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

@ -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) {

View file

@ -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)

View file

@ -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')