Ship-step contract for pipelines: uniform exit codes (0/1/2/3/4/7) in cli/lib/ship-step.ts, one final --json line, --result-file (atomic, also on failure), --progress json, --env-file with secret redaction, --version-from; idempotent play upload / apple upload / play release / apple submit re-runs end with exit 7; play upload takes --track production and --version-code
This commit is contained in:
parent
4eac8e8cd8
commit
06a0b424f5
14 changed files with 928 additions and 24 deletions
|
|
@ -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<void> {
|
|||
// (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 <file>\` (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<void> {
|
|||
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}`); }
|
||||
|
|
|
|||
|
|
@ -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<void> { 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}`);
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
if (!found[0]) throw new Error(`usage: storebound apple upload <app.ipa> (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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
}
|
||||
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 <name> --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)`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 [<app.aab>] [--track internal|alpha|beta] [--status completed|draft] [--name text] [--not-sent-for-review] [--dry] [--yes] [--json]"
|
||||
"storebound play upload [<app.aab>] [--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 <name|store id|folder>] [--json]"
|
||||
],
|
||||
"triage": [
|
||||
"storebound triage [--apple] [--play] [--days 14] [--build N] [--issues 10] [--comments 3] [--dry] [--json]"
|
||||
],
|
||||
|
|
|
|||
25
cli/index.ts
25
cli/index.ts
|
|
@ -83,12 +83,35 @@ async function main(): Promise<void> {
|
|||
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<void>): Promise<void> => (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<void> => {
|
||||
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');
|
||||
|
|
|
|||
|
|
@ -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/<dev>/app/<app>/...)
|
||||
|
|
|
|||
|
|
@ -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, { declined?: string } | null | undefined>)[Symbol.for('storebound.shipRun')];
|
||||
if (ship) ship.declined = reason;
|
||||
process.exitCode = DECLINED_EXIT;
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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, { detail?: unknown } | null | undefined>)[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');
|
||||
|
|
|
|||
412
cli/lib/ship-step.ts
Normal file
412
cli/lib/ship-step.ts
Normal file
|
|
@ -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<string, { step: string; store: 'apple' | 'play' }> = {
|
||||
'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<Pick<ShipResult, 'track' | 'version' | 'build' | 'state' | 'url' | 'artifact'>>;
|
||||
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<symbol, Run | null | undefined>;
|
||||
|
||||
/** 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<Run, 'declined' | 'detail' | 'store'>): { 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<string, unknown>;
|
||||
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<string, ConsolePage> = {
|
||||
'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<string, unknown> {
|
||||
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<string, unknown> : {};
|
||||
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<string, unknown>;
|
||||
}
|
||||
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<string>();
|
||||
|
||||
/** Parses KEY=VALUE lines (# comments, `export ` prefix, single or double quotes; \n in double quotes). */
|
||||
export function parseEnvFile(text: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string | undefined> = process.env): string[] {
|
||||
const vals = new Set<string>(extraSecrets);
|
||||
for (const [k, v] of Object.entries(env)) if (v && SECRET_NAME.test(k)) vals.add(v);
|
||||
const out = new Set<string>();
|
||||
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<string, unknown>;
|
||||
try { j = JSON.parse(text) as Record<string, unknown>; } 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<string, { version?: string; build?: string; intBuild?: boolean }> = {
|
||||
'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} <path> 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<string, string | undefined> = 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<void>): Promise<ShipExitCode> {
|
||||
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');
|
||||
}
|
||||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
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) {
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
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)
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
// Upload an Android App Bundle to a Google Play testing track.
|
||||
// storebound play upload [<app.aab>] [--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 [<app.aab>] [--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<void> {
|
||||
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 <app.aab> --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 <app.aab> --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<void> {
|
|||
}));
|
||||
// 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<void> {
|
|||
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<string, unknown> = { 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')
|
||||
|
|
|
|||
|
|
@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
315
tests/ship-step.test.ts
Normal file
315
tests/ship-step.test.ts
Normal file
|
|
@ -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<typeof parseArgs>) => Promise<void>): 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 <app.aab>'), '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<!-- play: en-US -->\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 = '<?xml version="1.0" encoding="UTF-8"?><plist version="1.0"><dict><key>CFBundleIdentifier</key><string>com.example.notes</string><key>CFBundleShortVersionString</key><string>1.2.0</string><key>CFBundleVersion</key><string>7</string></dict></plist>';
|
||||
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' }]);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue