diff --git a/cli/apple/build.ts b/cli/apple/build.ts index ba282fd..277bfce 100644 --- a/cli/apple/build.ts +++ b/cli/apple/build.ts @@ -28,6 +28,7 @@ import { bundleTrackingIssues, ipaManifests } from '../lib/privacy-files'; import { ascClient } from './asc'; import { findBundleId } from './signing'; import * as rt from '../lib/rt'; +import { shipPhase } from '../lib/ship-step'; const TAG = 'build'; @@ -172,6 +173,7 @@ export async function run(ctx: Ctx): Promise { // (Embed Pods Frameworks). Stop before the web build and the archive (pitfall 101). const remote = remoteBuildSession(process.env, rt.spawnSync(['launchctl', 'managername'], { timeout: 5000 }).stdout.toString().trim()); if (remote && !a.has('allow-ssh')) throw new Error(`apple build: ${remote}; codesign there fails with errSecInternalComponent (locked keychain). Run it in the Mac's logged-in session: a Jenkins / Forgejo agent started in that session, or from SSH: write the command into a .command file and \`open -a Terminal \` (it runs in the GUI session; unset SSH_CONNECTION SSH_CLIENT SSH_TTY inside). --allow-ssh skips this check after \`security unlock-keychain\`.`); + shipPhase('prebuild', 'web build / prebuild'); const web = await webStep({ ...webOpts, dry: false }); for (const n of target.notes) log(TAG, n); const rsync = foreignRsync(process.env.PATH); @@ -182,12 +184,15 @@ export async function run(ctx: Ctx): Promise { const realAuth: AscAuth | null = key ? { keyPath: key.keyPath, keyId: key.keyId, issuerId: key.issuerId } : null; try { if (target.framework === 'flutter') await runLogged(flutterConfigArgs(build!, version), root, join(out, 'flutter-config.log'), showCmd(flutterConfigArgs(build!, version), null)); + shipPhase('build', `xcodebuild archive, build ${build}`); const arch = archiveArgs({ target, root, configuration, archivePath, version, build: build!, teamId: teamId ?? undefined, auth: realAuth }); await runLogged(arch, root, join(out, 'xcodebuild-archive.log'), showCmd(arch, realAuth)); + shipPhase('sign', 'xcodebuild -exportArchive (signing)'); await exportIpa({ root, out, archivePath, exportPath, auth: realAuth }); } finally { key?.cleanup(); } const ipa = readdirSync(exportPath).find(f => f.endsWith('.ipa'))!; const info = ipaInfo(join(exportPath, ipa)); + shipPhase('verify', `${info.version} (${info.build})`); if (info.bundleId !== bundleId) warn(TAG, `the IPA's bundle id is ${info.bundleId}, the config says ${bundleId}`); const vwhy = ipaVersionProblem(info.version, version, 'the requested --version'); if (vwhy) { if (a.has('allow-version-mismatch')) warn(TAG, vwhy); else throw new Error(`${relative(root, join(exportPath, ipa))}: ${vwhy}`); } diff --git a/cli/apple/resubmit.ts b/cli/apple/resubmit.ts index ef834a5..9b23312 100644 --- a/cli/apple/resubmit.ts +++ b/cli/apple/resubmit.ts @@ -28,11 +28,15 @@ import { readSubmitState, submitPlanned } from './iap-submit'; import { explain409, isScreenshotProcessing409, pendingScreenshots, updateLedger, versionScreenshotRows } from './screenshot-health'; import { openSubmissions } from './submission'; import { ensureGcSwitch, gameCenterInUse, gcSwitchPlan, readGcSwitch } from './game-center'; +import { shipNoop, shipPhase, shipReport, shipRun } from '../lib/ship-step'; +import { consoleUrl } from '../lib/console-links'; import { allVersions, parsePlatform, pickPlatform, platformLabel, platformOf, versionsPath } from './platform'; /** App Store version states a first submission may send: the editable version (Prepare for Submission, a rejected * one, or one already Ready for Review in a draft). */ export const SUBMITTABLE_APP = new Set(['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', ...REJECTED_APP]); +/** App Store version states after a submission went through: a re-run of `apple submit` with the same build has nothing to do. */ +export const SUBMITTED_APP = new Set(['WAITING_FOR_REVIEW', 'IN_REVIEW', 'PENDING_DEVELOPER_RELEASE', 'PENDING_APPLE_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'READY_FOR_DISTRIBUTION', 'READY_FOR_SALE']); export async function run(ctx: Ctx): Promise { await submitVersion(ctx, 'resubmit'); } @@ -45,6 +49,24 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom if (!/^\d+(\.\d+)*$/.test(number)) throw new Error(`--build ${number}: a build number like 42 or 1.0.3 (the one you just uploaded, never "the latest")`); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); + shipReport({ build: number, version: a.get('version'), url: consoleUrl('asc.app-review') }); + shipPhase('verify', `reading the App Store versions and build ${number}`); + // the ship-step contract: the version already went to App Review with this build: nothing to do (exit 7) + if (mode === 'submit' && shipRun()) { + const want0 = a.get('version'); + const flag0 = parsePlatform(a.get('platform')); + const sent = (await allVersions(c, appId)).filter((x: J) => (!want0 || x.attributes.versionString === want0) && (!flag0 || platformOf(x) === flag0) + && SUBMITTED_APP.has(String(x.attributes.appVersionState ?? x.attributes.appStoreState))); + for (const x of sent) { + const att = (await c.maybe(`/v1/appStoreVersions/${x.id}/build`))?.data; + if (att && String(att.attributes?.version ?? '') === number) { + const st = String(x.attributes.appVersionState ?? x.attributes.appStoreState); + shipNoop({ version: String(x.attributes.versionString), build: number, state: st }); + done(TAG, `version ${String(x.attributes.versionString)} with build ${number} is ${st} already: nothing sent`); + return; + } + } + } const ids = [...(config.products ?? []).filter(p => sellsOn(config, p, 'apple')).map(p => p.id), ...allSubscriptions(config.subscriptions).filter(x => sellsOn(config, x.sub, 'apple')).map(x => x.sub.id)]; // the platform: --platform, else the one whose version (or UNRESOLVED_ISSUES submission) this needs; both -> ask @@ -100,6 +122,7 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom // the desktop's owner dialog shows the output text: the plan table goes with the result there if (process.env.STOREBOUND_MACHINE === '1') { console.log(formatRows(rows)); for (const x of blockers) warn(TAG, x); } if (blockers.length) process.exitCode = 1; + else if (state.choice?.action === 'busy') shipNoop({ version: versionString, state: stateOf(v) }); if (dry || blockers.length || state.choice?.action === 'busy') return; } else { console.log(formatRows(rows)); @@ -108,12 +131,13 @@ export async function submitVersion(ctx: Ctx, mode: 'submit' | 'resubmit'): Prom for (const x of blockers) warn(TAG, x); } if (blockers.length) { process.exitCode = 1; done(TAG, 'nothing sent: fix the blockers above'); return; } - if (state.choice?.action === 'busy') { done(TAG, `nothing to ${mode}: ${state.choice.why}`); return; } + if (state.choice?.action === 'busy') { shipNoop({ version: versionString, state: stateOf(v) }); done(TAG, `nothing to ${mode}: ${state.choice.why}`); return; } if (dry) { done(TAG, a.has('dry') ? 'dry run: nothing sent' : `dry run (no --yes): nothing sent. ${mode === 'submit' ? 'Submitting' : 'Resubmitting'} is the owner's step: after the owner's yes, run it again with --yes`); return; } + shipPhase('submit', `${mode === 'submit' ? 'submitting' : 'resubmitting'} ${versionString} with build ${number}`); if (build.action === 'attach') { await c.asc('PATCH', `/v1/appStoreVersions/${v.id}/relationships/build`, { data: { type: 'builds', id: build.id } }); log(TAG, `build ${number} attached to ${versionString}`); diff --git a/cli/apple/upload.ts b/cli/apple/upload.ts index 0f4f522..6868d25 100644 --- a/cli/apple/upload.ts +++ b/cli/apple/upload.ts @@ -35,6 +35,14 @@ import { elapsed, heartbeat, type HeartbeatOptions, mb, share, withHeartbeat } f import { pump } from '../lib/runner'; import * as rt from '../lib/rt'; import { partHeaders } from './screenshot-health'; +import { SHIP_EXIT, ShipStepError, shipNoop, shipPhase, shipReport, shipRun } from '../lib/ship-step'; +import { consoleUrl } from '../lib/console-links'; + +/** Ends the run with a legacy exit code; under the ship-step contract a ShipStepError with the uniform code instead. */ +function stop(legacy: number, message: string): never { + if (shipRun()) throw legacy === BUILD_WAIT_EXIT.timeout ? new ShipStepError(message, SHIP_EXIT.failure, 'TIMEOUT') : new ShipStepError(message, SHIP_EXIT.refused, 'BUILD_INVALID', consoleUrl('asc.testflight')); + process.exit(legacy); +} const TAG = 'upload'; @@ -124,6 +132,7 @@ export async function sendParts(o: SendPartsOptions): Promise { await sleep(2000 * attempt); } sent += op.length; + shipPhase('upload', `${i + 1}/${n} parts`, (sent / o.size) * 100); if (n < 20 || (i + 1) % 10 === 0 || i === n - 1) say(`${i + 1}/${n} parts, ${share(sent, o.size)}, ${elapsed(now() - started)}`); } } finally { beat.stop(); } @@ -230,7 +239,9 @@ export async function run(ctx: Ctx): Promise { if (!found[0]) throw new Error(`usage: storebound apple upload (no ${want ? `build ${want} ` : ''}IPA in ${relative(root, dir)}; storebound apple build makes one)`); ipa = found[0]; } else ipa = resolve(ipa); + shipPhase('verify', `checking ${relative(root, ipa)}`); const info = ipaInfo(ipa); + shipReport({ version: info.version, build: info.build, url: consoleUrl('asc.testflight') }); if (want && info.build !== want) throw new Error(`${relative(root, ipa)} is build ${info.build}, not ${want} (--build): did the last apple build fail?`); if (info.bundleId !== bundleId) throw new Error(`${relative(root, ipa)} is ${info.bundleId}, the config says ${bundleId}`); // App Store Connect refuses IPAs from an Xcode / SDK older than its current minimum (cli/lib/store-minimums.ts) @@ -260,6 +271,14 @@ export async function run(ctx: Ctx): Promise { const already = via === 'api' && !dry ? (await uploadsOf(c, appId, info).catch(() => [])).find((u: J) => u.attributes?.state?.state === 'COMPLETE') : undefined; const onAsc = builds.find(b => String(b.attributes.version) === info.build); const decision = existingBuild({ ipa: relative(root, ipa), build: info.build, onAsc: !!onAsc, state: onAsc?.attributes?.processingState, uploadComplete: !!already, allowExisting: a.has('allow-existing'), next }); + // the ship-step contract: a build number App Store Connect has already is "nothing to do" (exit 7), never a second upload + if (shipRun() && (onAsc || already)) { + const state = String(onAsc?.attributes?.processingState ?? 'UPLOADED'); + shipNoop({ state, url: consoleUrl('asc.testflight') }); + if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: state }); + done(TAG, `build ${info.version} (${info.build}) is on App Store Connect already (${state}): nothing uploaded`); + return; + } if (decision instanceof Error) throw decision; if (decision === 'recheck') { log(TAG, `build ${info.version} (${info.build}) is on App Store Connect already${already ? ` (build upload ${already.id})` : ''}; --allow-existing: not sending it again`); @@ -267,7 +286,7 @@ export async function run(ctx: Ctx): Promise { const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: timeout, tag: TAG }); if (json) printJson({ ipa: relative(root, ipa), version: info.version, build: info.build, uploaded: true, alreadyUploaded: true, processingState: r.seen.processingState ?? null }); if (r.outcome === 'valid') { done(TAG, waitMessage(info.build, r)); return; } - process.exit(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout); + stop(r.outcome === 'failed' ? BUILD_WAIT_EXIT.failed : BUILD_WAIT_EXIT.timeout, waitMessage(info.build, r)); } if (numbers.length && compareBuild(info.build, next) < 0) warn(TAG, `build ${info.build} is lower than the highest uploaded one (next free: ${next}); Apple accepts it only if this version train has no higher build`); const reqs = buildUploadReqs(appId, info, basename(ipa), size); @@ -285,19 +304,21 @@ export async function run(ctx: Ctx): Promise { } if (!(await confirm(ctx, `Upload ${info.bundleId} ${info.version} (${info.build}) to App Store Connect? It shows up in TestFlight once processed.`))) return; const deadline = Date.now() + timeout; + shipPhase('upload', `uploading ${mb(size)} via ${via}`, 0); log(TAG, `${relative(root, ipa)}: ${info.bundleId} ${info.version} (${info.build}), ${mb(size)}, via ${via}, timeout ${a.str('timeout', '60m')}`); if (via === 'api') { - try { await apiUpload(c, appId, ipa, info, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); process.exit((e as { exit: number }).exit); } throw e; } + try { await apiUpload(c, appId, ipa, info, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } else { - try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); process.exit((e as { exit: number }).exit); } throw e; } + try { await altoolUpload(root, ipa, deadline); } catch (e) { if ((e as { exit?: number }).exit) { warn(TAG, (e as Error).message); stop((e as { exit: number }).exit, (e as Error).message); } throw e; } } if (!wait) { if (json) printJson({ ...summary, uploaded: true }); done(TAG, `build ${info.build} uploaded; storebound apple status shows when it is VALID`); return; } // the same poll as `storebound apple testflight wait`, within what is left of --timeout + shipPhase('processing', `waiting for App Store Connect to process build ${info.build}`); const r = await waitForBuild(c, appId, info.build, { marketing: info.version, timeoutMs: Math.max(0, deadline - Date.now()), tag: TAG }); const state = r.timedOut || (r.outcome !== 'valid' && r.outcome !== 'failed') ? 'TIMEOUT' : r.seen.processingState ?? 'UNKNOWN'; if (json) printJson({ ...summary, uploaded: true, processingState: state }); if (r.outcome === 'valid') { done(TAG, `${waitMessage(info.build, r)}. Then: apple testflight distribute --build ${info.build} --group --dry`); return; } - if (state === 'TIMEOUT') { warn(TAG, `build ${info.build} not processed after ${a.str('timeout', '60m')}; keep waiting with storebound apple testflight wait --build ${info.build} --version ${info.version}`); process.exit(BUILD_WAIT_EXIT.timeout); } + if (state === 'TIMEOUT') { warn(TAG, `build ${info.build} not processed after ${a.str('timeout', '60m')}; keep waiting with storebound apple testflight wait --build ${info.build} --version ${info.version}`); stop(BUILD_WAIT_EXIT.timeout, `build ${info.build} not processed after ${a.str('timeout', '60m')}`); } warn(TAG, `${waitMessage(info.build, r)} (Apple emails the ITMS-xxxxx reason to the team)`); - process.exit(BUILD_WAIT_EXIT.failed); + stop(BUILD_WAIT_EXIT.failed, `${waitMessage(info.build, r)} (Apple emails the ITMS-xxxxx reason to the team)`); } diff --git a/cli/data/flags.json b/cli/data/flags.json index 3d26219..0cde295 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -71,13 +71,17 @@ "allow-version-mismatch", "build-number", "configuration", + "env-file", "keychain", "no-install", "no-prebuild", "out", + "progress", "project", + "result-file", "scheme", "version", + "version-from", "workspace" ], "apple bundle": [ @@ -215,13 +219,17 @@ ], "apple resubmit": [ "build", + "env-file", "keychain", "new-version", "no-install", "no-submit", "platform", + "progress", "resolve-iaps", + "result-file", "version", + "version-from", "with-version" ], "apple review-details": [ @@ -253,13 +261,17 @@ ], "apple submit": [ "build", + "env-file", "keychain", "new-version", "no-install", "no-submit", "platform", + "progress", "resolve-iaps", + "result-file", "version", + "version-from", "with-version" ], "apple subscriptions": [ @@ -308,6 +320,7 @@ "allow-version-mismatch", "build", "csv", + "env-file", "group", "internal-only", "invite", @@ -315,11 +328,14 @@ "locale", "no-wait", "older-than", + "progress", "remove", + "result-file", "submit-review", "text", "timeout", "version", + "version-from", "via" ], "apple win-back": [ @@ -649,16 +665,20 @@ "play build": [ "allow-stale-web", "dir", + "env-file", "flavor", "include-drafts", "module", "no-prebuild", "not-sent-for-review", "out", + "progress", "prune", + "result-file", "src", "variant", "version-code", + "version-from", "version-name", "write" ], @@ -690,16 +710,20 @@ "play release": [ "alpha", "beta", + "env-file", "fraction", "include-drafts", "internal", "name", "not-sent-for-review", "production", + "progress", "prune", + "result-file", "src", "variant", "version-code", + "version-from", "write" ], "play rollout": [ @@ -746,14 +770,19 @@ "write" ], "play upload": [ + "env-file", "include-drafts", "name", "not-sent-for-review", + "progress", "prune", + "result-file", "src", "status", "track", "variant", + "version-code", + "version-from", "write" ], "prices": [ @@ -941,6 +970,25 @@ "seconds", "target" ], + "status": [ + "all", + "app", + "apple", + "apply", + "apps", + "concurrency", + "force", + "merge", + "no-health", + "no-validate", + "offline", + "out", + "play", + "state", + "stdio", + "versions", + "workspace" + ], "triage": [ "apple", "build", @@ -1079,6 +1127,7 @@ "download", "enable", "end", + "env-file", "every", "experiment", "expo", @@ -1185,6 +1234,7 @@ "poster", "production", "profile", + "progress", "project", "propose", "prune", @@ -1200,6 +1250,7 @@ "render", "repair-stuck", "resolve-iaps", + "result-file", "resume", "review-sent", "root", @@ -1253,6 +1304,7 @@ "verify", "version", "version-code", + "version-from", "version-name", "versions", "via", @@ -1559,7 +1611,7 @@ "storebound play subscriptions [--dry] [--only id1,id2] [--csv store/prices.csv] [--yes] [--allow-price-changes]" ], "play upload": [ - "storebound play upload [] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]" + "storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]" ], "prices": [ "storebound prices export [--out store/prices.csv] [--store apple|play] [--only ids] [--with-automatic] [--no-subscriptions] [--dry] [--json]", @@ -1628,6 +1680,9 @@ "shots video": [ "storebound shots video [--lang en,tr] [--target iphone,ipad,youtube] [--seconds 25] [--no-captions] [--dry]" ], + "status": [ + "storebound status [--app ] [--json]" + ], "triage": [ "storebound triage [--apple] [--play] [--days 14] [--build N] [--issues 10] [--comments 3] [--dry] [--json]" ], diff --git a/cli/index.ts b/cli/index.ts index 7ba9efd..b1e1386 100755 --- a/cli/index.ts +++ b/cli/index.ts @@ -83,12 +83,35 @@ async function main(): Promise { if (accepted) { const ok = new Set([...FLAGS.global, ...accepted]); const bad = [...new Set(rest.filter(x => /^--[^-]/.test(x)).map(x => x.slice(2).split('=')[0]!).filter(f => !ok.has(f)))]; - if (bad.length) { console.error(`unknown option${bad.length > 1 ? 's' : ''} for storebound ${key}: ${bad.map(f => '--' + f).join(', ')}\n\n${commandUsage(key)}`); process.exit(2); } + if (bad.length) { + const why = `unknown option${bad.length > 1 ? 's' : ''} for storebound ${key}: ${bad.map(f => '--' + f).join(', ')}`; + console.error(`${why}\n\n${commandUsage(key)}`); + // a ship step still answers with its one JSON line / result file (exit 2) + const { SHIP_STEPS, usageResult } = await import('./lib/ship-step'); + if (SHIP_STEPS[key]) usageResult(key, rest, why); + process.exit(2); + } } // --json: stdout is only the JSON, every log line goes to stderr (lib/log.ts withJsonOutput) const json = (f: () => Promise): Promise => (rest.includes('--json') && key !== 'mcp' && key !== 'serve' ? withJsonOutput(f) : f()); if (PLAIN[key]) { const plain = PLAIN[key]![0]; return json(async () => (await plain()).run(parseArgs(rest))); } + // the ship-step contract (cli/lib/ship-step.ts, docs/ship-step.md): one final JSON line / result file, uniform exit codes + const { SHIP_STEPS, runShipStep, shipOptions, usesContract } = await import('./lib/ship-step'); + if (SHIP_STEPS[key] && (() => { try { return usesContract(shipOptions(parseArgs(rest))); } catch { return true; } })()) { + const code = await runShipStep(key, rest, parseArgs, async args => { + const go = async (): Promise => { + const { loadConfig } = await import('./lib/config'); + const loaded = await loadConfig(args.get('config')); + const { acquireLock, lockKeys } = await import('./lib/lock'); + for (const k of lockKeys(key, args, loaded.config)) acquireLock(loaded.root, k, argv.filter(x => x !== '--force-unlock').join(' '), { force: args.has('force-unlock') }); + await (await COMMANDS[key]![0]()).run({ loaded, args }); + }; + await (args.has('json') ? withJsonOutput(go) : go()); + }); + process.exitCode = code; + return; + } const args = parseArgs(rest); await json(async () => { const { loadConfig } = await import('./lib/config'); diff --git a/cli/lib/console-links.ts b/cli/lib/console-links.ts index 3a2958a..8159d60 100644 --- a/cli/lib/console-links.ts +++ b/cli/lib/console-links.ts @@ -44,6 +44,7 @@ export const CONSOLE_PAGES = { 'asc.ppo': { store: 'apple', label: 'App Store Connect > Product Page Optimization', scope: 'app', path: '/distribution/product-page-optimization' }, // App Store Connect, account 'asc.payments': { store: 'apple', label: 'App Store Connect > Payments and Financial Reports', scope: 'account', path: '/itc/payments_and_financial_reports' }, + 'asc.agreements': { store: 'apple', label: 'App Store Connect > Business > Agreements', scope: 'account', path: '/business' }, 'asc.users': { store: 'apple', label: 'App Store Connect > Users and Access', scope: 'account', path: '/access/users' }, 'asc.keys': { store: 'apple', label: 'App Store Connect > Users and Access > Integrations > App Store Connect API', scope: 'account', path: '/access/integrations/api' }, // Play Console, per app (/u/0/developers//app//...) diff --git a/cli/lib/ctx.ts b/cli/lib/ctx.ts index 17218ad..6991235 100644 --- a/cli/lib/ctx.ts +++ b/cli/lib/ctx.ts @@ -57,6 +57,9 @@ export const DECLINED_MARK = 'declined: '; /** A declined confirmation: the reason on stderr, a nonzero exit code; returns false for confirm(). */ export function declined(reason: string): false { console.error(`${DECLINED_MARK}${reason}`); + // a ship step (cli/lib/ship-step.ts) reports the reason in error.message of its exit 3 + const ship = (globalThis as unknown as Record)[Symbol.for('storebound.shipRun')]; + if (ship) ship.declined = reason; process.exitCode = DECLINED_EXIT; return false; } diff --git a/cli/lib/log.ts b/cli/lib/log.ts index 34bb78a..9bc6756 100644 --- a/cli/lib/log.ts +++ b/cli/lib/log.ts @@ -104,6 +104,9 @@ export const RESULT_MARK = '\u001estorebound-result '; * `storebound serve` (STOREBOUND_MACHINE=1) it is one marked line, so log lines printed before it never break parsing. */ export function printJson(data: unknown): void { + // a ship step (cli/lib/ship-step.ts) keeps the data for its one final line instead of printing it + const ship = (globalThis as unknown as Record)[Symbol.for('storebound.shipRun')]; + if (ship) { ship.detail = data; return; } if (process.env.STOREBOUND_MACHINE === '1') process.stdout.write(RESULT_MARK + JSON.stringify(data) + '\n'); // during a --json run console.log goes to stderr, so the JSON goes to stdout itself else if (jsonOutput()) process.stdout.write(JSON.stringify(data, null, 2) + '\n'); diff --git a/cli/lib/ship-step.ts b/cli/lib/ship-step.ts new file mode 100644 index 0000000..147e93a --- /dev/null +++ b/cli/lib/ship-step.ts @@ -0,0 +1,412 @@ +// The "ship step" contract (docs/ship-step.md): how an external release pipeline (a Jenkins node, a VOX Ship job) +// calls `play build | play upload | play release | apple build | apple upload | apple submit` without a person: +// exit codes 0 ok, 1 failure, 2 usage, 3 needs the owner, 4 the store refused it, 7 nothing to do (SHIP_EXIT) +// --json exactly one final JSON line on stdout (ShipResult); every log line goes to stderr +// --result-file the same JSON written atomically to a file, also on failure +// --progress json one JSON line per phase on stderr: {"phase":"upload","pct":40,"message":"..."} +// --env-file KEY=VALUE secrets loaded before the credentials resolve (POSIX: mode 600 or stricter); every known +// secret value is scrubbed from stdout, stderr and the result file +// --version-from version.json: the version and build for the step ({version, build} | {versionName, versionCode} | +// {marketing, build}) +// The commands keep their own logic; they report what they did with shipReport / shipNoop / shipPhase, and printJson +// hands its data here during a ship run. Everything else (classifying a thrown error, the final line, the file) is +// in this one module, so every ship command answers the same way. +import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'; +import { dirname, join, basename } from 'node:path'; +import type { Args } from './args'; +import { type ConsolePage, consoleUrl } from './console-links'; + +/** The exit codes of a ship step (the same for every ship command). */ +export const SHIP_EXIT = { ok: 0, failure: 1, usage: 2, owner: 3, refused: 4, noop: 7 } as const; +export type ShipExitCode = (typeof SHIP_EXIT)[keyof typeof SHIP_EXIT]; + +/** The commands that follow the contract: command key -> step name and store. */ +export const SHIP_STEPS: Record = { + 'play build': { step: 'build', store: 'play' }, + 'play upload': { step: 'upload', store: 'play' }, + 'play release': { step: 'release', store: 'play' }, + 'apple build': { step: 'build', store: 'apple' }, + 'apple upload': { step: 'upload', store: 'apple' }, + 'apple submit': { step: 'submit', store: 'apple' }, + 'apple resubmit': { step: 'resubmit', store: 'apple' }, +}; + +/** error.code of an exit 3: what the owner has to do. */ +export const OWNER_CODES = ['AGREEMENT_PENDING', 'SIGN_IN_REQUIRED', 'COMPLIANCE_ANSWER_MISSING', 'CONSOLE_STEP', 'CONFIRMATION_REQUIRED'] as const; + +export interface ShipErrorInfo { code: string; message: string; consoleUrl?: string } +export interface ShipResult { + ok: boolean; + step: string; + store: 'apple' | 'play'; + track: string | null; + version: string | null; + build: string | null; + state: string | null; + url: string | null; + warnings: string[]; + /** The file a build step wrote (AAB / IPA); absent for other steps. */ + artifact?: string; + error?: ShipErrorInfo; +} + +/** An error that knows its ship exit code (thrown by commands where the generic classification would guess). */ +export class ShipStepError extends Error { + constructor(message: string, readonly exit: ShipExitCode, readonly code: string, readonly consoleUrl?: string) { super(message); } +} + +type Fields = Partial>; +interface Run { + key: string; step: string; store: 'apple' | 'play'; + progress: boolean; + fields: Fields; noop: boolean; warnings: string[]; detail?: unknown; declined?: string; +} +const RUN = Symbol.for('storebound.shipRun'); +const g = globalThis as unknown as Record; + +/** The ship run going on, or null (a normal command run). */ +export const shipRun = (): Run | null => g[RUN] ?? null; + +/** What the command did: fields of the final result (later calls overwrite earlier ones). */ +export function shipReport(f: Fields): void { const r = shipRun(); if (r) Object.assign(r.fields, Object.fromEntries(Object.entries(f).filter(([, v]) => v !== undefined))); } +/** Nothing to do (the build is on the store already, the release is there): exit 7 with what is there. */ +export function shipNoop(f: Fields = {}): void { const r = shipRun(); if (r) { r.noop = true; shipReport(f); } } +/** A warning for the result's warnings[] (the log line is the command's own). */ +export function shipWarn(msg: string): void { const r = shipRun(); if (r && !r.warnings.includes(msg)) r.warnings.push(msg); } +// printJson (log.ts) puts its data in run.detail and ctx.declined puts its reason in run.declined, through the same +// globalThis key (no import cycle with log.ts / ctx.ts) + +/** One progress line on stderr ({"phase","pct"?,"message"}) when the run asked for --progress json. */ +export function shipPhase(phase: string, message: string, pct?: number): void { + const r = shipRun(); + if (!r?.progress) return; + process.stderr.write(JSON.stringify({ phase, ...(pct === undefined ? {} : { pct: Math.max(0, Math.min(100, Math.round(pct))) }), message }) + '\n'); +} + +// ---- error classification ---- + +const page = (p: ConsolePage): string => consoleUrl(p); + +/** The ship exit code, error code and console deep link of an error a ship command threw. Pure but for the console + * ids of the loaded config (console-links). */ +export function classifyError(e: unknown, store: 'apple' | 'play'): { exit: ShipExitCode; error: ShipErrorInfo } { + if (e instanceof ShipStepError) return { exit: e.exit, error: { code: e.code, message: e.message, ...(e.consoleUrl ? { consoleUrl: e.consoleUrl } : {}) } }; + const message = e instanceof Error ? e.message : String(e); + const m = message; + const apple = store === 'apple'; + const owner = (code: string, url: string): { exit: ShipExitCode; error: ShipErrorInfo } => ({ exit: SHIP_EXIT.owner, error: { code, message, consoleUrl: url } }); + // usage: a flag or argument problem (the dispatcher's own unknown-option exit 2 happens before this) + if (/^usage:|^--[a-z][\w-]*[ :<=]|is required\b|^unknown (option|command)/i.test(m)) return { exit: SHIP_EXIT.usage, error: { code: 'USAGE', message } }; + // the owner: agreements, sign-in / key access, compliance answers, console-only steps + if (/REQUIRED_AGREEMENTS|agreement|Program License|terms of service|must (accept|agree)/i.test(m)) return owner('AGREEMENT_PENDING', apple ? page('asc.agreements') : page('play.app-list')); + if (/-> 401\b|NOT_AUTHORIZED|NOT_AUTHENTICATED|unauthori[sz]ed|invalid_grant|Google token:|two-factor|2FA|sign[ -]?in required|are not set \(env|is not set \(env|key not found|not a Google service account|-> 403\b|does not have permission|PERMISSION_DENIED|FORBIDDEN_ERROR/i.test(m)) + return owner('SIGN_IN_REQUIRED', apple ? page('asc.keys') : page('play.api-access')); + if (/never been published|draft app|status draft|from the Console|in the Console|console plan|Play Console UI|create the app record in the web UI|sent for review from the Google Play Console/i.test(m)) + return owner('CONSOLE_STEP', apple ? (/app record/i.test(m) ? page('asc.new-app') : page('asc.version')) : page('play.publishing')); + if (/export compliance|usesNonExemptEncryption|Missing Compliance|encryption answer/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('asc.encryption')); + if (/data safety/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('play.data-safety')); + if (/app privacy/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('asc.app-privacy')); + if (/content rating|IARC/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('play.content-rating')); + if (/age rating/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('asc.age-rating')); + if (/target audience/i.test(m)) return owner('COMPLIANCE_ANSWER_MISSING', page('play.target-audience')); + // the store said no: a 4xx answer, Apple's ITMS / state errors, a rejected upload or an invalid build + if (/-> (400|404|409|413|422)\b|ENTITY_ERROR|STATE_ERROR|ITMS-\d+|INVALID_ARGUMENT|FAILED_PRECONDITION|upload rejected|processingState (INVALID|FAILED)|is INVALID|Invalid Binary/i.test(m)) + return { exit: SHIP_EXIT.refused, error: { code: 'STORE_REFUSED', message, consoleUrl: apple ? page('asc.testflight') : page('play.releases') } }; + return { exit: SHIP_EXIT.failure, error: { code: 'FAILED', message } }; +} + +/** A legacy exit code a command (or a library) passed to process.exit / process.exitCode during a ship run. */ +export function classifyExit(code: number, run: Pick): { exit: ShipExitCode; error?: ShipErrorInfo } { + if (code === 0) return { exit: SHIP_EXIT.ok }; + if (code === 2) return { exit: SHIP_EXIT.usage, error: { code: 'USAGE', message: 'usage error (see stderr)' } }; + if (code === 3) return { exit: SHIP_EXIT.owner, error: { code: 'CONFIRMATION_REQUIRED', message: run.declined ?? 'a confirmation was declined or could not be asked (pass --yes in a pipeline)' } }; + // a command that reported blockers (apple submit) and set exit 1: classify the blockers' text + const blockers = (run.detail as { blockers?: unknown } | undefined)?.blockers; + if (Array.isArray(blockers) && blockers.length) { + const c = classifyError(new Error(blockers.map(String).join('; ')), run.store); + return { exit: c.exit === SHIP_EXIT.usage ? SHIP_EXIT.failure : c.exit, error: c.exit === SHIP_EXIT.failure ? { ...c.error, code: 'BLOCKERS' } : c.error }; + } + if (code === 7) return { exit: SHIP_EXIT.failure, error: { code: 'TIMEOUT', message: 'the step stopped at its timeout (see stderr); run it again to go on' } }; + return { exit: SHIP_EXIT.failure, error: { code: 'FAILED', message: `exit ${code} (see stderr)` } }; +} + +// ---- the result ---- + +const str = (v: unknown): string | null => (v === undefined || v === null || v === '' ? null : String(v)); + +/** Fields of the result read from the data a command printed with --json (when it did not shipReport them). */ +export function fieldsFromDetail(key: string, d: unknown): Fields { + if (!d || typeof d !== 'object') return {}; + const x = d as Record; + const dry = x.dry === true || x.dryRun === true; + switch (key) { + case 'play build': return { version: str(x.versionName), build: str(x.versionCode), state: x.aab ? 'built' : 'planned', ...(x.aab ? { artifact: String(x.aab) } : {}) }; + case 'apple build': return { version: x.ipa ? str(x.version) : null, build: x.ipa ? str(x.build) : null, state: x.ipa ? 'built' : 'planned', ...(x.ipa ? { artifact: String(x.ipa) } : {}) }; + case 'play upload': return { version: str(x.versionName), build: str(x.versionCode), track: str(x.track), state: x.uploaded ? str(x.status) : 'planned' }; + case 'play release': { + const tracks = Array.isArray(x.tracks) ? x.tracks as { track: string; status: string }[] : []; + return { build: str(x.versionCode), version: str(x.name), track: tracks.map(t => t.track).join(',') || null, state: dry ? 'planned' : tracks.map(t => t.status).join(',') || null }; + } + case 'apple upload': return { version: str(x.version), build: str(x.build), state: x.uploaded ? (str(x.processingState) ?? 'uploaded') : 'planned' }; + case 'apple submit': case 'apple resubmit': { + const v = x.version as { versionString?: string; state?: string } | undefined; + const b = x.build as { number?: string } | undefined; + return { version: str(v?.versionString), build: str(b?.number), state: dry ? 'planned' : 'WAITING_FOR_REVIEW' }; + } + default: return {}; + } +} + +const DEFAULT_URL: Record = { + 'play build': 'play.bundle-explorer', 'play upload': 'play.releases', 'play release': 'play.releases', + 'apple build': 'asc.testflight', 'apple upload': 'asc.testflight', 'apple submit': 'asc.app-review', 'apple resubmit': 'asc.app-review', +}; + +/** The final result: the contract keys, then the command's own --json fields that do not clash with them (so readers + * of the old output, like a stage reading versionCode / sha256 of `play release --json`, keep working). */ +export function buildResult(run: Run, exit: ShipExitCode, error?: ShipErrorInfo): ShipResult & Record { + const f = { ...fieldsFromDetail(run.key, run.detail), ...run.fields }; + let url: string | null = f.url ?? null; + if (!url) { try { url = consoleUrl(DEFAULT_URL[run.key] ?? (run.store === 'apple' ? 'asc.apps' : 'play.app-list')); } catch { url = null; } } + const own = run.detail && typeof run.detail === 'object' && !Array.isArray(run.detail) ? run.detail as Record : {}; + const out: ShipResult = { + ok: exit === SHIP_EXIT.ok || exit === SHIP_EXIT.noop, + step: run.step, store: run.store, + track: f.track ?? null, version: f.version ?? null, build: f.build ?? null, + state: exit === SHIP_EXIT.noop ? (f.state ?? 'unchanged') : error ? (f.state && f.state !== 'planned' ? f.state : 'failed') : f.state ?? null, + url, warnings: run.warnings, + ...(f.artifact ? { artifact: f.artifact } : {}), + ...(error ? { error } : {}), + }; + return { ...out, ...Object.fromEntries(Object.entries(own).filter(([k]) => !(k in out) && !CONTRACT_KEYS.has(k))) } as ShipResult & Record; +} +const CONTRACT_KEYS = new Set(['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings', 'artifact', 'error']); + +// ---- secrets: --env-file and redaction ---- + +const SECRET_NAME = /(SECRET|TOKEN|PASSWORD|PASSWD|PASS$|_KEY$|^ASC_P8$|P8|PRIVATE|CREDENTIAL|SERVICE_ACCOUNT|KEYSTORE_PASSWORD|AUTH)/i; +const extraSecrets = new Set(); + +/** Parses KEY=VALUE lines (# comments, `export ` prefix, single or double quotes; \n in double quotes). */ +export function parseEnvFile(text: string): Record { + const out: Record = {}; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith('#')) continue; + const m = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line); + if (!m) throw new Error(`--env-file: not KEY=VALUE: "${line.split('=')[0]!.slice(0, 40)}..."`); + let v = m[2]!; + if (v.length >= 2 && v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + else if (v.length >= 2 && v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1); + else v = v.replace(/\s+#.*$/, ''); + out[m[1]!] = v; + } + return out; +} + +/** Why an env file may not be read (group / world readable on POSIX), or null. Windows: no mode check. */ +export function envFileProblem(path: string, platform: string = process.platform): string | null { + if (!existsSync(path)) return `--env-file ${path}: no such file`; + if (platform === 'win32') return null; + const mode = statSync(path).mode & 0o777; + if (mode & 0o077) return `--env-file ${path}: mode ${mode.toString(8).padStart(3, '0')} lets others read it; chmod 600 ${path}`; + return null; +} + +/** Loads an env file into process.env (it wins over the environment); its values are scrubbed from every output. */ +export function loadEnvFile(path: string): string[] { + const why = envFileProblem(path); + if (why) throw new ShipStepError(why, SHIP_EXIT.usage, 'ENV_FILE'); + const vars = parseEnvFile(readFileSync(path, 'utf8')); + for (const [k, v] of Object.entries(vars)) { process.env[k] = v; if (v) extraSecrets.add(v); } + return Object.keys(vars); +} + +/** The secret values to scrub: every --env-file value and every env var whose name says secret. */ +export function secretValues(env: Record = process.env): string[] { + const vals = new Set(extraSecrets); + for (const [k, v] of Object.entries(env)) if (v && SECRET_NAME.test(k)) vals.add(v); + const out = new Set(); + for (const v of vals) { + if (v.length < 6) continue; // "true", "1": too short to be a secret, too common to scrub + out.add(v); + const esc = JSON.stringify(v).slice(1, -1); + if (esc !== v) out.add(esc); + if (v.includes('\n')) for (const l of v.split(/\r?\n/)) if (l.trim().length >= 16) out.add(l.trim()); + } + return [...out].sort((a, b) => b.length - a.length); +} + +/** Replaces every secret value in the text with [redacted]. */ +export function redact(text: string, secrets: string[] = secretValues()): string { + let t = text; + for (const s of secrets) if (t.includes(s)) t = t.split(s).join('[redacted]'); + return t; +} + +/** Scrubs secrets from everything written to stdout / stderr until the returned function is called. */ +export function installRedaction(): () => void { + const secrets = secretValues(); + if (!secrets.length) return () => {}; + const wrap = (s: NodeJS.WriteStream): (() => void) => { + const orig = s.write.bind(s); + s.write = ((chunk: unknown, ...rest: unknown[]) => { + const text = typeof chunk === 'string' ? chunk : chunk instanceof Uint8Array ? new TextDecoder().decode(chunk) : String(chunk); + return (orig as (c: string, ...r: unknown[]) => boolean)(redact(text, secrets), ...rest); + }) as typeof s.write; + return () => { s.write = orig as typeof s.write; }; + }; + const a = wrap(process.stdout), b = wrap(process.stderr); + // console.* in Bun writes to the fd directly, not through process.stdout.write: route it through the wrapped streams + const keep = { log: console.log, info: console.info, warn: console.warn, error: console.error }; + const fmt = (args: unknown[]): string => args.map(x => (typeof x === 'string' ? x : x instanceof Error ? x.stack ?? x.message : (() => { try { return JSON.stringify(x); } catch { return String(x); } })())).join(' ') + '\n'; + console.log = (...x: unknown[]) => { process.stdout.write(fmt(x)); }; + console.info = (...x: unknown[]) => { process.stdout.write(fmt(x)); }; + console.warn = (...x: unknown[]) => { process.stderr.write(fmt(x)); }; + console.error = (...x: unknown[]) => { process.stderr.write(fmt(x)); }; + return () => { Object.assign(console, keep); a(); b(); }; +} + +// ---- --version-from ---- + +export interface VersionFrom { version: string | null; build: string | null; shape: string } + +/** version.json shapes: {version, build} | {versionName, versionCode} | {marketing, build} (also marketingVersion / + * buildNumber); the build may be a number or a string. */ +export function parseVersionFile(text: string, where = 'version.json'): VersionFrom { + let j: Record; + try { j = JSON.parse(text) as Record; } catch (e) { throw new ShipStepError(`--version-from ${where}: not JSON (${(e as Error).message})`, SHIP_EXIT.usage, 'USAGE'); } + if (!j || typeof j !== 'object' || Array.isArray(j)) throw new ShipStepError(`--version-from ${where}: a JSON object is needed`, SHIP_EXIT.usage, 'USAGE'); + const pick = (...k: string[]): [string, string] | null => { for (const n of k) if (j[n] !== undefined && j[n] !== null && j[n] !== '') return [n, String(j[n])]; return null; }; + const v = pick('version', 'versionName', 'marketing', 'marketingVersion'); + const b = pick('build', 'versionCode', 'buildNumber'); + if (!v && !b) throw new ShipStepError(`--version-from ${where}: no version / build ({version, build}, {versionName, versionCode} or {marketing, build})`, SHIP_EXIT.usage, 'USAGE'); + if (v && !/^\d+(\.\d+){0,3}$/.test(v[1])) throw new ShipStepError(`--version-from ${where}: ${v[0]} "${v[1]}" is not x.y.z`, SHIP_EXIT.usage, 'USAGE'); + if (b && !/^\d+(\.\d+)*$/.test(b[1])) throw new ShipStepError(`--version-from ${where}: ${b[0]} "${b[1]}" is not a build number`, SHIP_EXIT.usage, 'USAGE'); + return { version: v?.[1] ?? null, build: b?.[1] ?? null, shape: `{${[v?.[0], b?.[0]].filter(Boolean).join(', ')}}` }; +} + +/** The flags --version-from stands for, per command. */ +const VERSION_FLAGS: Record = { + 'play build': { version: 'version-name', build: 'version-code', intBuild: true }, + 'play upload': { build: 'version-code', intBuild: true }, + 'play release': { build: 'version-code', intBuild: true }, + 'apple build': { version: 'version', build: 'build-number' }, + 'apple upload': { build: 'build' }, + 'apple submit': { version: 'version', build: 'build' }, + 'apple resubmit': { version: 'version', build: 'build' }, +}; + +/** The argv with the flags of --version-from added (a flag given by hand must agree with the file). */ +export function versionArgv(key: string, argv: string[], hand: Args, v: VersionFrom): string[] { + const map = VERSION_FLAGS[key]; + if (!map) return argv; + const out = [...argv]; + const add = (flag: string | undefined, value: string | null, int = false): void => { + if (!flag || value === null) return; + if (int && !/^\d+$/.test(value)) throw new ShipStepError(`--version-from: build "${value}" is not an Android versionCode (an integer)`, SHIP_EXIT.usage, 'USAGE'); + const given = hand.get(flag); + if (given !== undefined && given !== 'true') { if (given !== value) throw new ShipStepError(`--${flag} ${given} disagrees with --version-from (${value})`, SHIP_EXIT.usage, 'USAGE'); return; } + // play release with an .aab: the bundle carries its versionCode + if (key === 'play release' && flag === 'version-code' && hand._.some(x => x.endsWith('.aab'))) return; + out.push(`--${flag}`, value); + }; + add(map.version, v.version); + add(map.build, v.build, map.intBuild); + return out; +} + +// ---- the run ---- + +export interface ShipOptions { json: boolean; resultFile?: string; progress: boolean; envFile?: string; versionFrom?: string } + +/** The ship flags of a run (also lists them for tools/gen-flags.ts: every ship command imports this module). */ +export function shipOptions(a: Args): ShipOptions { + const p = a.get('progress'); + if (p !== undefined && p !== 'json' && p !== 'text') throw new ShipStepError('--progress json | text', SHIP_EXIT.usage, 'USAGE'); + const file = (n: string, v: string | undefined): string | undefined => { if (v === 'true') throw new ShipStepError(`--${n} needs a path`, SHIP_EXIT.usage, 'USAGE'); return v; }; + return { json: a.has('json'), resultFile: file('result-file', a.get('result-file')), progress: p === 'json', envFile: file('env-file', a.get('env-file')), versionFrom: file('version-from', a.get('version-from')) }; +} + +/** True when this run uses the contract (any ship flag): plain runs keep their old output and exit codes. A --json run + * under `storebound serve` / flows (STOREBOUND_MACHINE=1: the desktop reads the command's own result line) keeps + * the old result unless another ship flag asks for the contract. */ +export const usesContract = (o: ShipOptions, env: Record = process.env): boolean => + (o.json && env.STOREBOUND_MACHINE !== '1') || !!o.resultFile || o.progress || !!o.envFile || !!o.versionFrom; + +/** Writes the result file atomically (temp file in the same folder, then rename). */ +export function writeResultFile(path: string, data: string): void { + const tmp = join(dirname(path), `.${basename(path)}.${process.pid}.tmp`); + writeFileSync(tmp, data, { mode: 0o600 }); + renameSync(tmp, path); +} + +class ExitSignal extends Error { constructor(readonly code: number) { super(`exit ${code}`); } } + +/** + * Runs one ship command under the contract. `prepare` turns the raw argv into the command's Args (after --env-file + * and --version-from); `body` runs the command. Returns the ship exit code; prints the final line / writes the file. + */ +export async function runShipStep(key: string, rest: string[], parse: (argv: string[]) => Args, body: (args: Args) => Promise): Promise { + const meta = SHIP_STEPS[key]!; + const run: Run = { key, step: meta.step, store: meta.store, progress: false, fields: {}, noop: false, warnings: [] }; + const prevRun = g[RUN]; + g[RUN] = run; + let opts: ShipOptions = { json: rest.includes('--json'), progress: false }; + let unredact = (): void => {}; + const exitOrig = process.exit; + let exit: ShipExitCode = SHIP_EXIT.ok; + let error: ShipErrorInfo | undefined; + const before = process.exitCode; + process.exitCode = 0; + try { + let args = parse(rest); + opts = shipOptions(args); + run.progress = opts.progress; + if (opts.envFile) loadEnvFile(opts.envFile); + unredact = installRedaction(); + if (opts.versionFrom) { + if (!existsSync(opts.versionFrom)) throw new ShipStepError(`--version-from ${opts.versionFrom}: no such file`, SHIP_EXIT.usage, 'USAGE'); + const v = parseVersionFile(readFileSync(opts.versionFrom, 'utf8'), opts.versionFrom); + args = parse(versionArgv(key, rest, args, v)); + shipReport({ version: v.version ?? undefined, build: v.build ?? undefined }); + } + shipPhase('start', `storebound ${key}`); + process.exit = ((c?: number) => { throw new ExitSignal(c ?? 0); }) as typeof process.exit; + await body(args); + const c = Number(process.exitCode ?? 0); + const k = classifyExit(c, run); + exit = k.exit; error = k.error; + if (exit === SHIP_EXIT.ok && run.noop) exit = SHIP_EXIT.noop; + } catch (e) { + if (e instanceof ExitSignal) { const k = classifyExit(e.code, run); exit = k.exit; error = k.error; if (exit === SHIP_EXIT.ok && run.noop) exit = SHIP_EXIT.noop; } + else { + const k = classifyError(e, run.store); + exit = k.exit; error = k.error; + console.error(`\nerror: ${(e as Error).message ?? String(e)}`); + } + } finally { process.exit = exitOrig; } + const result = buildResult(run, exit, error); + shipPhase('done', exit === SHIP_EXIT.ok ? 'ok' : exit === SHIP_EXIT.noop ? 'nothing to do' : `${error?.code ?? 'failed'}`); + const line = redact(JSON.stringify(result)); + try { + if (opts.resultFile) writeResultFile(opts.resultFile, line + '\n'); + } catch (e) { console.error(`--result-file ${opts.resultFile}: ${(e as Error).message}`); if (exit === SHIP_EXIT.ok) exit = SHIP_EXIT.failure; } + if (opts.json) process.stdout.write(line + '\n'); + unredact(); + g[RUN] = prevRun ?? null; + process.exitCode = before ?? 0; + return exit; +} + +/** The answer of a ship step stopped by the dispatcher (an unknown flag): the usage result line and file, exit 2. */ +export function usageResult(key: string, argv: string[], message: string): void { + const meta = SHIP_STEPS[key]; + if (!meta) return; + const run: Run = { key, step: meta.step, store: meta.store, progress: false, fields: {}, noop: false, warnings: [] }; + const line = JSON.stringify(buildResult(run, SHIP_EXIT.usage, { code: 'USAGE', message })); + const at = argv.findIndex(x => x === '--result-file' || x.startsWith('--result-file=')); + const file = at < 0 ? undefined : argv[at]!.includes('=') ? argv[at]!.slice(argv[at]!.indexOf('=') + 1) : argv[at + 1]; + if (file && !file.startsWith('--')) { try { writeResultFile(file, line + '\n'); } catch { /* the exit code still says usage */ } } + if (argv.includes('--json')) process.stdout.write(line + '\n'); +} diff --git a/cli/play/build.ts b/cli/play/build.ts index 8fd4295..01e9155 100644 --- a/cli/play/build.ts +++ b/cli/play/build.ts @@ -29,6 +29,7 @@ import { playClient } from './client'; import { readPlayCopy, refusePlayNotes } from './listing'; import { usedVersionCodes } from './next-version-code'; import * as rt from '../lib/rt'; +import { shipPhase } from '../lib/ship-step'; const TAG = 'build'; @@ -97,12 +98,14 @@ export async function run(ctx: Ctx): Promise { if (!sdk.path) throw new Error(androidSdkMissing(sdk)); notesGuard(ctx); log(TAG, `Android SDK: ${sdk.path} (from ${sdk.source})`); + shipPhase('prebuild', 'web build / prebuild'); const web = await webStep({ ...webOpts, dry: false }); if (signing && !existsSync(signing.storeFile)) throw new Error('ANDROID_KEYSTORE: the keystore file does not exist'); if (!signing) warn(TAG, 'no ANDROID_KEYSTORE: the project\'s own release signingConfig must sign, else Play refuses the bundle'); for (const n of t.notes) log(TAG, n); for (const w of versionWarnings) warn(TAG, w); log(TAG, `$ ${plan.command}`); + shipPhase('build', `${plan.task}, versionCode ${code || '?'}`); const started = Date.now(); mkdirSync(out, { recursive: true }); const p = rt.spawn(argv, { cwd, stdout: 'pipe', stderr: 'pipe', env: { ...process.env, ...androidSdkEnv(sdk), ...signingEnv(signing) } }); @@ -118,9 +121,11 @@ export async function run(ctx: Ctx): Promise { if (exit !== 0) throw new Error(`build failed (exit ${exit}); last lines:\n${lines.slice(-20).join('\n')}\nfull log: ${relative(root, logFile)}`); const aab = findBundles([join(t.dir, t.module, 'build', 'outputs', 'bundle'), join(root, 'build', 'app', 'outputs', 'bundle')], started - 1000)[0]; if (!aab) throw new Error(`build finished but no new .aab under ${relative(root, t.dir)}/${t.module}/build/outputs/bundle`); + shipPhase('sign', 'checking the bundle signature'); const info = aabInfo(aab); // Play refuses an unsigned bundle (403) only at upload: stop here, naming the fix assertAabSigned(aab, relative(root, aab)); + shipPhase('verify', `versionCode ${info.versionCode}, ${info.packageName}`); if (info.packageName !== pkg) warn(TAG, `the AAB's package is ${info.packageName}, the config says ${pkg}`); // the hard stop: an AAB with another versionCode than planned is never copied to the output folder if (code && info.versionCode !== code) { diff --git a/cli/play/release.ts b/cli/play/release.ts index 71797f3..53fd29b 100644 --- a/cli/play/release.ts +++ b/cli/play/release.ts @@ -17,6 +17,8 @@ import { playTargetSdkProblem, targetSdkFile } from '../lib/store-minimums'; import { playClient } from './client'; import { usedVersionCodes } from './next-version-code'; import { readPlayCopy, refusePlayNotes } from './listing'; +import { shipNoop, shipPhase, shipReport } from '../lib/ship-step'; +import { consoleUrl, playTrackPage } from '../lib/console-links'; const TRACKS = ['internal', 'alpha', 'beta', 'production']; @@ -56,8 +58,10 @@ export async function run(ctx: Ctx): Promise { const sdkWhy = playTargetSdkProblem(aabInfo(aab).targetSdk, { where: targetSdkFile(root) }); if (sdkWhy) throw new Error(`${aab}: not released, ${sdkWhy}`); } + shipReport({ track: tracks.map(t => t.track).join(','), url: consoleUrl(playTrackPage(tracks.length === 1 ? tracks[0]!.track : undefined)) }); const play = await playClient(root, pkg); let released = fixed ?? ''; + let nothing = false; try { await play.edit(async (id, ctl) => { let code = fixed ?? ''; @@ -69,11 +73,13 @@ export async function run(ctx: Ctx): Promise { code = String(info.versionCode); log('play', `versionCode ${code} is on Play already: not uploaded again`); } else { + shipPhase('upload', `uploading ${aab}`, 0); log('play', `uploading ${aab} (${(statSync(aab).size / 1048576).toFixed(1)} MB), this can take minutes`); const b = await play.call<{ versionCode: number }>('POST', `${play.UPLOAD}/edits/${id}/bundles?uploadType=media`, new Uint8Array(readFileSync(aab)), { 'Content-Type': 'application/octet-stream' }); code = String(b.versionCode); uploaded = true; + shipPhase('upload', `uploaded versionCode ${code}`, 100); log('play', `uploaded versionCode ${code}`); } } @@ -90,7 +96,8 @@ export async function run(ctx: Ctx): Promise { changed++; log('play', `${t.track} <- ${code} (${t.status}, notes in ${notes.length} languages)`); } - if (!changed && !uploaded) ctl.unchanged(); + if (!changed && !uploaded) { nothing = true; ctl.unchanged(); } + else shipPhase('commit', `${changed} track(s) changed`); }, { dry: a.has('dry'), retries: aab ? 0 : 3, notSentForReview: a.has('not-sent-for-review'), // the staged rollout share is part of the plan (it was applied but not shown in the dry run) summary: `release ${a.get('name') ?? (aab ?? `versionCode ${fixed}`)}: ${tracks.map(t => `${t.track} ${t.status}${t.status === 'inProgress' ? ` ${Math.round(Number(fraction ?? 0.1) * 100)}%` : ''}`).join(', ')}` }); @@ -99,6 +106,8 @@ export async function run(ctx: Ctx): Promise { throw new Error(`${(e as Error).message}\n\nThis app has never been published: the API may only create DRAFT releases. Re-run with --production draft, then send it from the Console (skill play-first-release).`); throw e; } + // the ship-step contract: every track holds this release already (and no upload): nothing to do, exit 7 + if (nothing && !a.has('dry')) shipNoop({ build: released || undefined, state: tracks.map(t => t.status).join(',') }); const prod = tracks.find(t => t.track === 'production'); if (prod) { // Play's default: an approved release goes live by itself (managed publishing is a console-only setting) diff --git a/cli/play/upload.ts b/cli/play/upload.ts index dd630df..d7c93a1 100644 --- a/cli/play/upload.ts +++ b/cli/play/upload.ts @@ -1,10 +1,12 @@ // Upload an Android App Bundle to a Google Play testing track. -// storebound play upload [] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json] -// Default AAB: the newest in build/android (storebound play build). Package and versionCode are read from the bundle; -// the upload is refused when Play already has that versionCode. Release notes: the "What's new" of every listing -// language. --dry is read-only (one throwaway edit: version codes and the track are read, the edit is deleted). -// A real run asks first (--yes in scripts). Production is not offered here: `storebound play release --production -// draft` (and the play-first-release / release-management skills) keep that the owner's step. +// storebound play upload [] [--track internal|alpha|beta|production] [--status completed|draft] [--version-code N] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json] +// Default AAB: the newest in build/android (storebound play build), or with --version-code N the one with that +// versionCode. Package and versionCode are read from the bundle; the upload is refused when Play already has that +// versionCode (exit 1), and under the ship-step contract (--json / --result-file, docs/ship-step.md) it is exit 7 +// "nothing to do" with the track and state that are there: never a second upload. Release notes: the "What's new" of +// every listing language. --dry is read-only (one throwaway edit: version codes and the track are read, the edit is +// deleted). A real run asks first (--yes in scripts). --track production sends the release to Google's review (a +// pipeline's update step); --status draft keeps it for the Console. import { readdirSync, readFileSync, statSync } from 'node:fs'; import { join, relative } from 'node:path'; import type { Ctx } from '../lib/ctx'; @@ -16,29 +18,36 @@ import { nextVersionCode } from '../lib/release'; import { playClient } from './client'; import { readPlayCopy, refusePlayNotes } from './listing'; import { usedVersionCodes } from './next-version-code'; +import { shipNoop, shipPhase, shipReport, shipRun, shipWarn } from '../lib/ship-step'; +import { consoleUrl, playTrackPage } from '../lib/console-links'; const TAG = 'upload'; -export const UPLOAD_TRACKS = ['internal', 'alpha', 'beta'] as const; +export const UPLOAD_TRACKS = ['internal', 'alpha', 'beta', 'production'] as const; export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const a = ctx.args; const pkg = need(config.play?.packageName, 'play.packageName'); const track = a.str('track', 'internal'); - if (track === 'production') throw new Error('play upload is for testing tracks; production goes through storebound play release --production draft (the owner sends it)'); if (!(UPLOAD_TRACKS as readonly string[]).includes(track)) throw new Error(`--track: ${UPLOAD_TRACKS.join(' | ')}`); const status = a.str('status', 'completed'); if (!['completed', 'draft'].includes(status)) throw new Error('--status: completed | draft'); + const wantCode = a.get('version-code'); + if (wantCode !== undefined && !/^\d+$/.test(wantCode)) throw new Error('--version-code: an integer (the versionCode of the AAB to upload)'); let aab = a._.find(x => x.endsWith('.aab')); if (aab) aab = resolve(aab); else { const dir = resolve('build/android'); let found: string[] = []; try { found = readdirSync(dir).filter(f => f.endsWith('.aab')).map(f => join(dir, f)).sort((x, y) => statSync(y).mtimeMs - statSync(x).mtimeMs); } catch { /* none */ } - if (!found[0]) throw new Error('usage: storebound play upload --track internal (nothing in build/android; storebound play build makes one)'); + if (wantCode) found = found.filter(f => { try { return aabInfo(f).versionCode === Number(wantCode); } catch { return false; } }); + if (!found[0]) throw new Error(`usage: storebound play upload --track internal (no ${wantCode ? `versionCode ${wantCode} ` : ''}AAB in build/android; storebound play build makes one)`); aab = found[0]; } + shipPhase('verify', `checking ${relative(root, aab)}`); const info = aabInfo(aab); + if (wantCode && info.versionCode !== Number(wantCode)) throw new Error(`${relative(root, aab)} is versionCode ${info.versionCode}, not ${wantCode} (--version-code)`); + shipReport({ track, version: info.versionName ?? undefined, build: String(info.versionCode), url: consoleUrl(playTrackPage(track)) }); if (info.packageName !== pkg) throw new Error(`${relative(root, aab)} is ${info.packageName}, the config says ${pkg}`); // Play refuses bundles below its current target API level (cli/lib/store-minimums.ts): stop before any network call const sdkWhy = playTargetSdkProblem(info.targetSdk, { where: targetSdkFile(root) }); @@ -56,10 +65,21 @@ export async function run(ctx: Ctx): Promise { })); // an earlier run did it already (the bundle is on Play and on this track): nothing to send again if (used.includes(info.versionCode) && current.some(r => (r.versionCodes ?? []).includes(String(info.versionCode)) && r.status === status)) { - if (json) { printJson({ aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, track, status, uploaded: true, alreadyThere: true }); return; } + shipNoop({ state: status }); + if (json) { printJson({ aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, track, status, uploaded: true, alreadyThere: true }); return; } done(TAG, `versionCode ${info.versionCode} is on ${track} (${status}) already: nothing uploaded`); return; } + if (used.includes(info.versionCode) && !dry) { + // the ship-step contract: the versionCode is on Play already, nothing is uploaded again (exit 7 with what is there) + const on = await play.read(async id => (await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[] }[] }[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? []).catch(() => []); + const where = on.flatMap(t => (t.releases ?? []).filter(r => (r.versionCodes ?? []).includes(String(info.versionCode))).map(r => ({ track: t.track, status: r.status }))); + const here = where.find(w => w.track === track) ?? where[0]; + if (here) shipNoop({ track: here.track, state: here.status, url: consoleUrl(playTrackPage(here.track)) }); + else shipNoop({ state: 'uploaded' }); + if (!where.some(w => w.track === track)) shipWarn(`versionCode ${info.versionCode} is on Google Play${where.length ? ` (${where.map(w => `${w.track} ${w.status}`).join(', ')})` : ' (in the bundle library, on no track)'} but not on ${track}: storebound play release --version-code ${info.versionCode} --${track} ${status} puts it there`); + if (shipRun()) { done(TAG, `versionCode ${info.versionCode} is on Google Play already: nothing uploaded`); return; } + } if (used.includes(info.versionCode)) throw new Error(`versionCode ${info.versionCode} is already on Google Play: rebuild with --version-code ${nextVersionCode(used)} (storebound play build)`); const summary = { aab: relative(root, aab), packageName: info.packageName, versionCode: info.versionCode, versionName: info.versionName ?? null, sizeMB: Math.round(size / 104857.6) / 10, track, status, notes: notes.map(n => n.language), replaces: current.map(r => `${r.status} ${(r.versionCodes ?? []).join(',')}`) }; @@ -70,15 +90,18 @@ export async function run(ctx: Ctx): Promise { done(TAG, 'upload planned (dry run, read-only)'); return; } - if (!(await confirm(ctx, `Upload ${info.packageName} versionCode ${info.versionCode} and put it on the ${track} track (${status})? Testers of that track get it.`))) return; + if (!(await confirm(ctx, `Upload ${info.packageName} versionCode ${info.versionCode} and put it on the ${track} track (${status})? ${track === 'production' ? 'It goes to Google review and then to every user.' : 'Testers of that track get it.'}`))) return; try { await play.edit(async id => { log(TAG, `uploading ${summary.sizeMB} MB, this can take minutes`); + shipPhase('upload', `uploading ${summary.sizeMB} MB`, 0); const b = await play.call<{ versionCode: number }>('POST', `${play.UPLOAD}/edits/${id}/bundles?uploadType=media`, new Uint8Array(readFileSync(aab!)), { 'Content-Type': 'application/octet-stream' }); log(TAG, `uploaded versionCode ${b.versionCode}`); + shipPhase('upload', `uploaded versionCode ${b.versionCode}`, 100); const release: Record = { versionCodes: [String(b.versionCode)], status, ...(notes.length ? { releaseNotes: notes } : {}), ...(a.get('name') ? { name: a.get('name') } : {}) }; await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, { track, releases: [release] }); log(TAG, `${track} <- ${b.versionCode} (${status})`); + shipPhase('commit', `${track} <- ${b.versionCode} (${status})`); }, { retries: 0, notSentForReview: ctx.args.has('not-sent-for-review'), summary: `upload versionCode ${info.versionCode} to ${track} ${status}` }); } catch (e) { if (/draft app|status draft/i.test(String(e)) && status !== 'draft') diff --git a/tests/build-sign.test.ts b/tests/build-sign.test.ts index 20e8327..9bd1b4a 100644 --- a/tests/build-sign.test.ts +++ b/tests/build-sign.test.ts @@ -471,12 +471,17 @@ describe('CLI dry runs (no store access)', () => { expect(r.stderr.toString()).toContain("Use in defaultConfig of android/app/build.gradle: versionCode((project.findProperty('versionCode') ?: 1) as Integer)"); expect(existsSync(join(d, 'build/android/com.example.game-1.aab'))).toBe(false); }); - test('play upload refuses production', () => { + test('play upload: an unknown track is a usage error; under the ship contract exit 2 with one JSON line', () => { const d = project('cli-up'); writeFileSync(join(d, 'app.aab'), makeAab('com.example.game', 3)); - const r = cli(d, ['play', 'upload', 'app.aab', '--track', 'production', '--dry']); - expect(r.exitCode).toBe(1); - expect(r.stderr.toString()).toContain('testing tracks'); + const plain = cli(d, ['play', 'upload', 'app.aab', '--track', 'nightly', '--dry']); + expect(plain.exitCode).toBe(1); + expect(plain.stderr.toString()).toContain('--track: internal | alpha | beta | production'); + const r = cli(d, ['play', 'upload', 'app.aab', '--track', 'nightly', '--dry', '--json']); + expect(r.exitCode).toBe(2); + const lines = r.stdout.toString().trim().split('\n'); + expect(lines).toHaveLength(1); + expect(JSON.parse(lines[0]!)).toMatchObject({ ok: false, step: 'upload', store: 'play', error: { code: 'USAGE' } }); }); }); diff --git a/tests/ship-step.test.ts b/tests/ship-step.test.ts new file mode 100644 index 0000000..3da79de --- /dev/null +++ b/tests/ship-step.test.ts @@ -0,0 +1,315 @@ +// The ship-step contract (cli/lib/ship-step.ts, docs/ship-step.md): exit codes, the one final JSON line, the result +// file (also on failure), --progress json lines, idempotent re-uploads against the fake stores, --env-file with its +// permission check and redaction, --version-from shapes and the status read. +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; +import { chmodSync, existsSync, mkdtempSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseArgs } from '../cli/lib/args'; +import { log, printJson } from '../cli/lib/log'; +import { declined } from '../cli/lib/ctx'; +import { classifyError, classifyExit, envFileProblem, parseEnvFile, parseVersionFile, redact, runShipStep, SHIP_EXIT, shipNoop, shipPhase, shipReport, ShipStepError, usageResult, versionArgv } from '../cli/lib/ship-step'; +import { setConsoleIds } from '../cli/lib/console-links'; +import { platformStatus } from '../cli/status'; +import type { AppStatus } from '../cli/lib/desktop-protocol'; +import { run as playUpload } from '../cli/play/upload'; +import { run as appleUpload } from '../cli/apple/upload'; +import { run as playRelease } from '../cli/play/release'; +import { makeAab, makeIpa } from './build-fixtures'; +import { ascEnv, BASE, playEnv, project, restore } from './helpers/store-mock'; +import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores'; + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'sb-ship-')); + +/** Runs a ship step in-process with stdout / stderr captured. */ +async function ship(key: string, argv: string[], body: (a: ReturnType) => Promise): Promise<{ code: number; out: string; err: string }> { + const out: string[] = [], err: string[] = []; + const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info; + process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write; + process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); }; + console.info = console.log; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); }; + console.warn = console.error; + try { + const code = await runShipStep(key, argv, parseArgs, async a => { + // the dispatcher's --json: console.log goes to stderr (log.ts withJsonOutput) + const keep = console.log; + if (a.has('json')) console.log = (...x: unknown[]) => console.error(...x); + try { await body(a); } finally { console.log = keep; } + }); + return { code, out: out.join(''), err: err.join('') }; + } finally { process.stdout.write = o; process.stderr.write = e; console.log = cl; console.error = ce; console.warn = cw; console.info = ci; } +} + +const KEYS = ['ok', 'step', 'store', 'track', 'version', 'build', 'state', 'url', 'warnings']; + +describe('exit codes and error classification', () => { + afterEach(() => setConsoleIds({})); + test('the uniform codes', () => { + expect(SHIP_EXIT).toEqual({ ok: 0, failure: 1, usage: 2, owner: 3, refused: 4, noop: 7 }); + }); + test('owner steps carry a code and the console deep link', () => { + setConsoleIds({ appleAppId: '1000000001', playDeveloperId: '5550000000000000001', playAppId: '4970000000000000002' }); + const agree = classifyError(new Error('GET /v1/apps -> 403 FORBIDDEN.REQUIRED_AGREEMENTS_MISSING_OR_EXPIRED'), 'apple'); + expect(agree.exit).toBe(3); + expect(agree.error.code).toBe('AGREEMENT_PENDING'); + expect(agree.error.consoleUrl).toBe('https://appstoreconnect.apple.com/business'); + const sign = classifyError(new Error('GET /v1/apps -> 401 NOT_AUTHORIZED'), 'apple'); + expect([sign.exit, sign.error.code, sign.error.consoleUrl]).toEqual([3, 'SIGN_IN_REQUIRED', 'https://appstoreconnect.apple.com/access/integrations/api']); + expect(classifyError(new Error('PLAY_SERVICE_ACCOUNT is not set (env, ...)'), 'play').error.code).toBe('SIGN_IN_REQUIRED'); + const draft = classifyError(new Error('POST edits:commit -> 400 Only releases with status draft may be created on draft app.\n\nThis app has never been published'), 'play'); + expect([draft.exit, draft.error.code]).toEqual([3, 'CONSOLE_STEP']); + expect(draft.error.consoleUrl).toContain('/app/4970000000000000002/publishing'); + const ds = classifyError(new Error('the Data safety form is not answered'), 'play'); + expect([ds.exit, ds.error.code]).toEqual([3, 'COMPLIANCE_ANSWER_MISSING']); + expect(ds.error.consoleUrl).toContain('data-privacy-security'); + expect(classifyError(new Error('build 7 has no export compliance answer yet'), 'apple').error.consoleUrl).toContain('/distribution/info'); + }); + test('store refusals, usage, failures', () => { + expect(classifyError(new Error('PATCH /v1/reviewSubmissions/1 -> 409 STATE_ERROR'), 'apple')).toMatchObject({ exit: 4, error: { code: 'STORE_REFUSED' } }); + expect(classifyError(new Error('upload rejected: ITMS-90725'), 'apple').exit).toBe(4); + expect(classifyError(new Error('--track: internal | alpha'), 'play')).toMatchObject({ exit: 2, error: { code: 'USAGE' } }); + expect(classifyError(new Error('usage: storebound play upload '), 'play').exit).toBe(2); + expect(classifyError(new Error('build failed (exit 1)'), 'play')).toMatchObject({ exit: 1, error: { code: 'FAILED' } }); + expect(classifyError(new ShipStepError('x', 4, 'BUILD_INVALID', 'https://example.com'), 'apple')).toEqual({ exit: 4, error: { code: 'BUILD_INVALID', message: 'x', consoleUrl: 'https://example.com' } }); + }); + test('legacy exit codes: a declined confirmation is exit 3, blockers are classified', () => { + expect(classifyExit(3, { declined: 'not a terminal and no --yes', store: 'play' })).toEqual({ exit: 3, error: { code: 'CONFIRMATION_REQUIRED', message: 'not a terminal and no --yes' } }); + expect(classifyExit(1, { store: 'apple', detail: { blockers: ['PATCH x -> 409 ENTITY_ERROR'] } }).exit).toBe(4); + expect(classifyExit(1, { store: 'apple', detail: { blockers: ['screenshots missing'] } }).error?.code).toBe('BLOCKERS'); + expect(classifyExit(7, { store: 'apple' }).exit).toBe(1); + }); +}); + +describe('the final JSON line, the result file and progress', () => { + test('--json: exactly one JSON line on stdout, logs on stderr', async () => { + const r = await ship('play build', ['--json'], async () => { + log('build', 'a log line'); + printJson({ aab: '/x/app-42.aab', packageName: 'com.example.notes', versionCode: 42, versionName: '1.2.0' }); + }); + expect(r.code).toBe(0); + const lines = r.out.trim().split('\n'); + expect(lines).toHaveLength(1); + const j = JSON.parse(lines[0]!); + for (const k of KEYS) expect(j).toHaveProperty(k); + expect(j).toMatchObject({ ok: true, step: 'build', store: 'play', version: '1.2.0', build: '42', state: 'built', artifact: '/x/app-42.aab', warnings: [] }); + expect(j.error).toBeUndefined(); + expect(r.err).toContain('a log line'); + }); + test('--result-file: written atomically on failure too, with the error', async () => { + const d = tmp(); + const file = join(d, 'result.json'); + const r = await ship('apple submit', ['--result-file', file], async () => { + shipReport({ version: '1.2.0', build: '7' }); + throw new Error('PATCH /v1/reviewSubmissions/x -> 409 ENTITY_ERROR.ATTRIBUTE.INVALID'); + }); + expect(r.code).toBe(4); + expect(r.out).toBe(''); // no --json: nothing on stdout but the logs + const j = JSON.parse(readFileSync(file, 'utf8')); + expect(j).toMatchObject({ ok: false, step: 'submit', store: 'apple', version: '1.2.0', build: '7', state: 'failed', error: { code: 'STORE_REFUSED' } }); + expect(j.error.message).toContain('409'); + expect(readdirSync(d)).toEqual(['result.json']); // no temp file left + }); + test('a declined confirmation: exit 3 CONFIRMATION_REQUIRED', async () => { + const r = await ship('play upload', ['--json'], async () => { declined('not a terminal and no --yes (re-run with --yes to confirm)'); }); + expect(r.code).toBe(3); + expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'CONFIRMATION_REQUIRED' } }); + }); + test('process.exit inside a command still gives the line and the file', async () => { + const file = join(tmp(), 'r.json'); + const r = await ship('play release', ['--json', '--result-file', file], async () => { process.exit(2); }); + expect(r.code).toBe(2); + expect(JSON.parse(r.out).error.code).toBe('USAGE'); + expect(JSON.parse(readFileSync(file, 'utf8')).error.code).toBe('USAGE'); + }); + test('nothing to do: exit 7, ok true', async () => { + const r = await ship('play upload', ['--json'], async () => { shipNoop({ track: 'production', build: '42', state: 'completed' }); }); + expect(r.code).toBe(7); + expect(JSON.parse(r.out)).toMatchObject({ ok: true, track: 'production', build: '42', state: 'completed' }); + }); + test('--progress json: one JSON line per phase on stderr', async () => { + const r = await ship('apple upload', ['--progress', 'json'], async () => { shipPhase('upload', 'parts', 40); shipPhase('processing', 'waiting'); }); + const lines = r.err.split('\n').filter(l => l.startsWith('{')).map(l => JSON.parse(l) as { phase: string; pct?: number; message: string }); + expect(lines.map(l => l.phase)).toEqual(['start', 'upload', 'processing', 'done']); + expect(lines[1]).toEqual({ phase: 'upload', pct: 40, message: 'parts' }); + }); + test('--progress with another value: usage', async () => { + const r = await ship('apple upload', ['--progress', 'xml', '--json'], async () => {}); + expect(r.code).toBe(2); + }); + test('an unknown flag (stopped by the dispatcher): the usage line and file', () => { + const file = join(tmp(), 'u.json'); + const out: string[] = []; + const o = process.stdout.write; + process.stdout.write = ((s: string) => { out.push(s); return true; }) as typeof process.stdout.write; + try { usageResult('play upload', ['--json', '--result-file', file, '--trak', 'x'], 'unknown option for storebound play upload: --trak'); } finally { process.stdout.write = o; } + expect(JSON.parse(out.join(''))).toMatchObject({ ok: false, step: 'upload', error: { code: 'USAGE' } }); + expect(existsSync(file)).toBe(true); + }); +}); + +describe('--env-file and redaction', () => { + test('KEY=VALUE parsing', () => { + expect(parseEnvFile('# c\nexport A=1\nB="x y\\nz"\nC=\'q\'\nD=plain # note\n')).toEqual({ A: '1', B: 'x y\nz', C: 'q', D: 'plain' }); + expect(() => parseEnvFile('not a pair')).toThrow('KEY=VALUE'); + }); + test.skipIf(process.platform === 'win32')('POSIX: group / world readable files are refused', () => { + const f = join(tmp(), '.env'); + writeFileSync(f, 'A=1\n'); + chmodSync(f, 0o644); + expect(envFileProblem(f)).toContain('chmod 600'); + chmodSync(f, 0o600); + expect(envFileProblem(f)).toBeNull(); + }); + test('Windows: no mode check; a missing file is refused', () => { + expect(envFileProblem(join(tmp(), 'none'), 'win32')).toContain('no such file'); + const f = join(tmp(), '.env'); + writeFileSync(f, 'A=1\n'); + expect(envFileProblem(f, 'win32')).toBeNull(); + }); + test('values are loaded before the command and scrubbed from stdout, stderr and the result file', async () => { + const d = tmp(); + const env = join(d, 'ship.env'); + writeFileSync(env, 'SB_TEST_UPLOAD_TOKEN=s3cr3t-value-123456\n', { mode: 0o600 }); + const file = join(d, 'r.json'); + try { + const r = await ship('play upload', ['--json', '--env-file', env, '--result-file', file], async () => { + expect(process.env.SB_TEST_UPLOAD_TOKEN).toBe('s3cr3t-value-123456'); + console.error(`token is ${process.env.SB_TEST_UPLOAD_TOKEN}`); + throw new Error(`refused with s3cr3t-value-123456`); + }); + expect(r.err).not.toContain('s3cr3t-value-123456'); + expect(r.err).toContain('[redacted]'); + expect(r.out).not.toContain('s3cr3t-value-123456'); + expect(readFileSync(file, 'utf8')).not.toContain('s3cr3t-value-123456'); + expect(JSON.parse(r.out).error.message).toContain('[redacted]'); + } finally { delete process.env.SB_TEST_UPLOAD_TOKEN; } + }); + test('redact: multi-line keys line by line, JSON-escaped forms', () => { + const pem = '-----BEGIN PRIVATE KEY-----\nMIGHAgEAMBMGByqGSM49AgEGCCqGSM49\n-----END PRIVATE KEY-----'; + const s = [pem, JSON.stringify(pem).slice(1, -1)]; + expect(redact(`a ${pem} b`, s)).toBe('a [redacted] b'); + expect(redact(JSON.stringify({ k: pem }), s)).not.toContain('MIGHAgEA'); + }); +}); + +describe('--version-from', () => { + test('the three shapes', () => { + expect(parseVersionFile('{"version":"1.2.0","build":42}')).toMatchObject({ version: '1.2.0', build: '42' }); + expect(parseVersionFile('{"versionName":"1.2.0","versionCode":42}')).toMatchObject({ version: '1.2.0', build: '42' }); + expect(parseVersionFile('{"marketing":"1.2","build":"1.2.42"}')).toMatchObject({ version: '1.2', build: '1.2.42' }); + expect(() => parseVersionFile('{"name":"x"}')).toThrow('no version'); + expect(() => parseVersionFile('{"version":"one"}')).toThrow('not x.y.z'); + expect(() => parseVersionFile('nope')).toThrow('not JSON'); + }); + test('the flags each command gets; a hand flag must agree', () => { + const v = { version: '1.2.0', build: '42', shape: '' }; + expect(versionArgv('play build', ['--json'], parseArgs(['--json']), v)).toEqual(['--json', '--version-name', '1.2.0', '--version-code', '42']); + expect(versionArgv('apple build', [], parseArgs([]), v)).toEqual(['--version', '1.2.0', '--build-number', '42']); + expect(versionArgv('apple upload', [], parseArgs([]), v)).toEqual(['--build', '42']); + expect(versionArgv('apple submit', ['--yes'], parseArgs(['--yes']), v)).toEqual(['--yes', '--version', '1.2.0', '--build', '42']); + expect(versionArgv('play upload', [], parseArgs([]), v)).toEqual(['--version-code', '42']); + expect(versionArgv('play release', ['a.aab'], parseArgs(['a.aab']), v)).toEqual(['a.aab']); + expect(versionArgv('apple upload', ['--build', '42'], parseArgs(['--build', '42']), v)).toEqual(['--build', '42']); + expect(() => versionArgv('apple upload', ['--build', '41'], parseArgs(['--build', '41']), v)).toThrow('disagrees'); + expect(() => versionArgv('play build', [], parseArgs([]), { ...v, build: '1.2.42' })).toThrow('versionCode'); + }); + test('a run reads the file and the result carries the version', async () => { + const f = join(tmp(), 'version.json'); + writeFileSync(f, '{"versionName":"2.0.0","versionCode":77}'); + let seen = ''; + const r = await ship('play upload', ['--json', '--version-from', f], async a => { seen = a.str('version-code', ''); }); + expect(seen).toBe('77'); + expect(JSON.parse(r.out)).toMatchObject({ version: '2.0.0', build: '77' }); + }); +}); + +describe('idempotent re-upload (fake stores)', () => { + setDefaultTimeout(30_000); + let s: Stores; + beforeAll(() => { ascEnv(); playEnv(); }); + afterAll(restore); + beforeEach(() => { s = installStores(); }); + afterEach(() => { uninstallStores(); }); + const LISTING = '# Play\n\n## English\n\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n'; + + test('play upload: first run uploads (exit 0), the re-run is exit 7 with the existing release, no second upload', async () => { + const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') }); + const first = await ship('play upload', ['--json', '--yes', '--track', 'production'], a => playUpload({ loaded: p.loaded, args: a })); + expect(first.code).toBe(0); + expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'upload', store: 'play', track: 'production', version: '1.2.0', build: '42', state: 'completed' }); + const n = s.calls.length; + const again = await ship('play upload', ['--json', '--yes', '--track', 'production'], a => playUpload({ loaded: p.loaded, args: a })); + expect(again.code).toBe(7); + expect(JSON.parse(again.out)).toMatchObject({ ok: true, track: 'production', build: '42', state: 'completed' }); + expect(writes(s.calls.slice(n))).toEqual([]); + }); + + test('play upload: the versionCode is in the bundle list but on another track: exit 7 with a warning, nothing sent', async () => { + s.play.live.bundles = [42]; + s.play.live.tracks = { internal: { releases: [{ status: 'completed', versionCodes: ['42'] }] } } as never; + const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42) }); + const r = await ship('play upload', ['--json', '--yes', '--track', 'production'], a => playUpload({ loaded: p.loaded, args: a })); + expect(r.code).toBe(7); + const j = JSON.parse(r.out); + expect(j).toMatchObject({ track: 'internal', state: 'completed', build: '42' }); + expect(j.warnings[0]).toContain('not on production'); + expect(writes(s.calls)).toEqual([]); + }); + + test('play release: the same release on the track again is exit 7, no commit', async () => { + s.play.live.bundles = [42]; + const p = project(BASE, { 'store/listing.md': LISTING }); + const argv = ['--json', '--yes', '--version-code', '42', '--internal', 'completed']; + const first = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a })); + expect(first.code).toBe(0); + expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'release', track: 'internal', build: '42', state: 'completed', versionCode: 42 }); + const n = s.play.committed.length; + const again = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a })); + expect(again.code).toBe(7); + expect(s.play.committed.length).toBe(n); + }); + + test('play upload without the contract keeps its old exit 1 for a used versionCode', async () => { + s.play.live.bundles = [42]; + const p = project(BASE, { 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42) }); + await expect(playUpload(p.ctx(['--yes']))).rejects.toThrow('already on Google Play'); + }); + + test('apple upload: a build number App Store Connect has is exit 7 with its state, no build upload', async () => { + s.asc.put({ type: 'builds', id: 'b-7', attributes: { version: '7', processingState: 'VALID' }, rels: { app: '6400000101' } }); + const plist = 'CFBundleIdentifiercom.example.notesCFBundleShortVersionString1.2.0CFBundleVersion7'; + const p = project(BASE, { 'build/ios/export/Example.ipa': makeIpa(plist) }); + const r = await ship('apple upload', ['--json', '--yes'], a => appleUpload({ loaded: p.loaded, args: a })); + expect(r.err).not.toContain('error:'); + expect(r.code).toBe(7); + expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'upload', store: 'apple', version: '1.2.0', build: '7', state: 'VALID' }); + expect(writes(s.calls).filter(c => /buildUploads/.test(c.path))).toEqual([]); + }); +}); + +describe('status', () => { + test('per platform: live and next with build, track, review state, rollout %', () => { + const s: AppStatus = { + project: '/x', ok: true, app: null, check: null, catalog: null, storeErrors: {}, errors: [], + summary: { apple: { bundleId: 'com.example.notes', appId: '1' }, play: { packageName: 'com.example.notes' } } as never, + release: { source: 'live', current: null, next: null, all: [ + { store: 'ios', version: '1.1.0', build: '6', state: 'READY_FOR_SALE', phase: 'live', fraction: null, track: null }, + { store: 'ios', version: '1.2.0', build: '7', state: 'WAITING_FOR_REVIEW', phase: 'sent', fraction: null, track: null }, + { store: 'android', version: '1.2.0', build: '42', state: 'inProgress', phase: 'rollout', fraction: 0.2, track: 'production' }, + ] }, + }; + const p = platformStatus(s); + expect(p).toEqual([ + { platform: 'ios', store: 'apple', live: { version: '1.1.0', build: '6', track: 'app-store', state: 'READY_FOR_SALE', phase: 'live', reviewState: null, rolloutPct: null }, + next: { version: '1.2.0', build: '7', track: 'app-store', state: 'WAITING_FOR_REVIEW', phase: 'sent', reviewState: 'WAITING_FOR_REVIEW', rolloutPct: null } }, + { platform: 'android', store: 'play', live: { version: '1.2.0', build: '42', track: 'production', state: 'inProgress', phase: 'rollout', reviewState: null, rolloutPct: 20 }, next: null }, + ]); + }); + test('an unreadable store is listed with its error', () => { + const s = { project: '/x', ok: false, app: null, check: null, catalog: null, errors: [], storeErrors: { android: 'GET -> 401' }, + summary: { apple: null, play: { packageName: 'com.example.notes' } }, release: { source: 'live', current: null, next: null, all: [] } } as unknown as AppStatus; + expect(platformStatus(s)).toEqual([{ platform: 'android', store: 'play', live: null, next: null, error: 'GET -> 401' }]); + }); +});