diff --git a/cli/play/release.ts b/cli/play/release.ts index 5e99e16..b1715e2 100644 --- a/cli/play/release.ts +++ b/cli/play/release.ts @@ -57,17 +57,10 @@ export async function run(ctx: Ctx): Promise { const gated = tracks.find(t => t.track === 'production' && (t.status === 'completed' || t.status === 'inProgress')); const notes = readPlayCopy(ctx).filter(c => c.whatsNew).map(c => ({ language: c.lang, text: c.whatsNew })); refusePlayNotes(ctx, notes, `play release notes${a.has('dry') ? ' (dry run)' : ''}`); - // update of a published app: no owner step; the first production release: the owner's (exit 3 OWNER_STEP with --yes) - let early: Awaited> | undefined; - const client = async (): Promise>> => (early ??= await playClient(root, pkg)); - let kind: ReturnType | null = null; - if (gated) { - const p0 = await client(); - const prod = await p0.read(async id => (await p0.call<{ releases?: PlayTrackRelease[] }>('GET', `${p0.API}/edits/${id}/tracks/production`).catch(() => ({ releases: [] }))).releases ?? []); - kind = playReleaseKind(prod); - if (a.has('dry')) log('play', `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`); - else if (!enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) return; - } + // update of a published app: no owner step; the first production release: the owner's (exit 3 OWNER_STEP with --yes). + // The kind is read inside the release's own edit (no extra edit), before anything is uploaded. + const seen: { kind?: ReturnType } = {}; + let refused = false; if (live && !a.has('dry') && !(await confirm(ctx, `Release to PRODUCTION as "${live.status}" (public once Google approves)?`))) return; if (aab) { @@ -76,11 +69,17 @@ export async function run(ctx: Ctx): Promise { 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 client(); + const play = await playClient(root, pkg); let released = fixed ?? ''; let nothing = false; try { await play.edit(async (id, ctl) => { + if (gated) { + const prod = (await play.call<{ releases?: PlayTrackRelease[] }>('GET', `${play.API}/edits/${id}/tracks/production`).catch(() => ({ releases: [] }))).releases ?? []; + const kind = (seen.kind = playReleaseKind(prod)); + if (a.has('dry')) log('play', `${kind.kind === 'update' ? 'update of a published app' : 'FIRST production release (the owner approves it once: --first-release)'}: ${kind.why}`); + else if (!enforceFirstRelease(ctx, kind, { store: 'play', what: 'the first production release', consoleUrl: consoleUrl('play.publishing') })) { refused = true; ctl.unchanged(); return; } + } let code = fixed ?? ''; let uploaded = false; if (aab) { @@ -123,6 +122,7 @@ export async function run(ctx: Ctx): Promise { throw new Error(`${(e as Error).message}\n\nThis app has never been published: the API may only create DRAFT releases. Re-run with --production draft, then send it from the Console (skill play-first-release).`); throw e; } + if (refused) return; // 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'); @@ -133,6 +133,6 @@ export async function run(ctx: Ctx): Promise { } // machine-readable result for CI scripts (a Jenkins stage reads it instead of calling the Play API itself) if (a.has('json')) printJson({ versionCode: released ? Number(released) : null, sha256: aab ? createHash('sha256').update(readFileSync(aab)).digest('hex') : null, name: a.get('name') ?? null, - tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry'), ...(kind ? { releaseKind: kind.kind } : {}) }); + tracks: tracks.map(t => ({ track: t.track, status: t.status, userFraction: t.status === 'inProgress' ? Number(fraction ?? 0.1) : null })), dry: a.has('dry'), ...(seen.kind ? { releaseKind: seen.kind.kind } : {}) }); done('play', 'release done'); } diff --git a/tests/play-not-sent-for-review.test.ts b/tests/play-not-sent-for-review.test.ts index 842d858..836e8cf 100644 --- a/tests/play-not-sent-for-review.test.ts +++ b/tests/play-not-sent-for-review.test.ts @@ -24,7 +24,7 @@ afterEach(() => { uninstallStores(); }); const edits = (what: string): string[] => s.calls.filter(c => c.method === 'POST' && c.path.includes(`:${what}`)).map(c => c.path.replace(/^.*\/edits\//, '')); const release = (p: ReturnType, extra: string[] = []) => - capture(() => playRelease(p.ctx(['--version-code', '5', '--internal', 'completed', '--production', 'completed', '--name', '1.0.1 (5)', '--yes', ...extra]))); + capture(() => playRelease(p.ctx(['--version-code', '5', '--internal', 'completed', '--production', 'completed', '--name', '1.0.1 (5)', '--yes', '--first-release', ...extra]))); describe('Play refuses the automatic send for review', () => { test('the 400 is recognised by its message (and a bad-request reason), nothing else', () => {