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:
CRTkafa 2026-10-23 13:13:21 +08:00
parent 4eac8e8cd8
commit 06a0b424f5
14 changed files with 928 additions and 24 deletions

View file

@ -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}`); }

View file

@ -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}`);

View file

@ -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)`);
}