// Updates are agent steps, the first release is the owner's (cli/lib/release-kind.ts, docs/bypass.md "Updates are agent // steps"): the kind (update / first) is read from the store; an update of a live app ships with --yes in a pipeline // (no owner gate), a first-ever release with --yes is exit 3 OWNER_STEP unless approved (--first-release, bypass mode), // and a release that is on the store already is exit 7. Against the stateful App Store Connect and Google Play doubles. import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setDefaultTimeout, test } from 'bun:test'; import { mkdtempSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseArgs } from '../cli/lib/args'; import { appleReleaseKind, firstReleaseAllowed, playReleaseKind } from '../cli/lib/release-kind'; import { setBypass } from '../cli/lib/bypass'; import { runShipStep } from '../cli/lib/ship-step'; import { run as playUpload } from '../cli/play/upload'; import { run as playRelease } from '../cli/play/release'; import { run as appleSubmit } from '../cli/apple/submit'; import { run as appleRelease } from '../cli/apple/release'; import { makeAab } from './build-fixtures'; import { ascEnv, BASE, playEnv, project, restore } from './helpers/store-mock'; import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores'; const APP = '6400000101'; /** Runs a ship step in-process with stdout / stderr captured. */ async function ship(key: string, argv: string[], body: (a: ReturnType) => Promise): Promise<{ code: number; out: string; err: string }> { const out: string[] = [], err: string[] = []; const o = process.stdout.write, e = process.stderr.write, cl = console.log, ce = console.error, cw = console.warn, ci = console.info; process.stdout.write = ((s: string | Uint8Array) => { out.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stdout.write; process.stderr.write = ((s: string | Uint8Array) => { err.push(typeof s === 'string' ? s : new TextDecoder().decode(s)); return true; }) as typeof process.stderr.write; console.log = (...a: unknown[]) => { out.push(a.map(String).join(' ') + '\n'); }; console.info = console.log; console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ') + '\n'); }; console.warn = console.error; try { const code = await runShipStep(key, argv, parseArgs, async a => { 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; } } describe('the kind comes from the store (pure)', () => { test('Play: a completed / inProgress / halted production release means published; a draft or nothing means first', () => { expect(playReleaseKind([{ status: 'completed', versionCodes: ['41'] }]).kind).toBe('update'); expect(playReleaseKind([{ status: 'inProgress', versionCodes: ['42'] }, { status: 'completed', versionCodes: ['41'] }]).kind).toBe('update'); expect(playReleaseKind([{ status: 'halted', versionCodes: ['42'] }]).kind).toBe('update'); expect(playReleaseKind([{ status: 'draft', versionCodes: ['1'] }])).toMatchObject({ kind: 'first', why: expect.stringContaining('only draft') }); expect(playReleaseKind([]).kind).toBe('first'); expect(playReleaseKind(undefined).kind).toBe('first'); }); test('App Store: a version that is or was on sale means update; Prepare for Submission / in review / rejected means first', () => { expect(appleReleaseKind([{ versionString: '1.0.0', state: 'READY_FOR_SALE' }, { versionString: '1.1.0', state: 'PREPARE_FOR_SUBMISSION' }]).kind).toBe('update'); expect(appleReleaseKind([{ state: 'REPLACED_WITH_NEW_VERSION' }]).kind).toBe('update'); expect(appleReleaseKind([{ state: 'READY_FOR_DISTRIBUTION' }]).kind).toBe('update'); expect(appleReleaseKind([{ state: 'PREPARE_FOR_SUBMISSION' }, { state: 'WAITING_FOR_REVIEW' }, { state: 'REJECTED' }]).kind).toBe('first'); expect(appleReleaseKind([]).kind).toBe('first'); }); test('the policy: updates always; a first release only with a prompt, the owner flag or bypass mode', () => { const g = { yes: true, bypass: false, approved: false }; expect(firstReleaseAllowed({ ...g, kind: 'update' })).toBe(true); expect(firstReleaseAllowed({ ...g, kind: 'first' })).toBe(false); expect(firstReleaseAllowed({ ...g, kind: 'first', yes: false })).toBe(true); // the prompt is the owner's answer expect(firstReleaseAllowed({ ...g, kind: 'first', approved: true })).toBe(true); expect(firstReleaseAllowed({ ...g, kind: 'first', bypass: true })).toBe(true); }); }); describe('Google Play production (fake store)', () => { setDefaultTimeout(30_000); let s: Stores; beforeAll(() => { playEnv(); }); afterAll(restore); beforeEach(() => { s = installStores(); }); afterEach(() => { uninstallStores(); delete process.env.STOREBOUND_HOME; }); const LISTING = '# Play\n\n## English\n\n\n### Play short description\n```\nNotes.\n```\n\n### Play full description\n```\nNotes for everyone.\n```\n\n### What\'s new\n```\nFixes.\n```\n'; const published = (): void => { s.play.live.bundles = [41]; s.play.live.tracks = { production: { releases: [{ status: 'completed', versionCodes: ['41'] }] } }; }; const files = () => ({ 'store/listing.md': LISTING, 'build/android/app.aab': makeAab('com.example.notes', 42, '1.2.0') }); const upload = ['--json', '--yes', '--track', 'production']; test('play upload --track production --yes on a published app: no owner gate, exit 0, exit 7 on the re-run', async () => { published(); const p = project(BASE, files()); const first = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a })); expect(first.err).not.toContain('error:'); expect(first.code).toBe(0); expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'upload', track: 'production', build: '42', state: 'completed' }); expect(s.play.committed.length).toBe(1); const n = s.calls.length; const again = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a })); expect(again.code).toBe(7); expect(writes(s.calls.slice(n))).toEqual([]); }); test('play upload --track production --yes on a never-published app: exit 3 OWNER_STEP, nothing written', async () => { const p = project(BASE, files()); const r = await ship('play upload', upload, a => playUpload({ loaded: p.loaded, args: a })); expect(r.code).toBe(3); const j = JSON.parse(r.out); expect(j).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); expect(j.error.message).toContain('first release of this app'); expect(j.error.message).not.toContain('the owner sends it'); expect(j.error.consoleUrl).toContain('play.google.com/console'); expect(writes(s.calls)).toEqual([]); expect(s.play.committed).toEqual([]); }); test('the owner approved the first release (--first-release) or turned bypass mode on: the gate lets it through', async () => { const p = project(BASE, files()); const ok = await ship('play upload', [...upload, '--first-release'], a => playUpload({ loaded: p.loaded, args: a })); // Play takes only drafts on a never-published app: the gate passed, the store's own answer is the console step expect(ok.err).toContain('first release ('); }); test('bypass mode (the owner opted in) lets a first release through the gate', async () => { process.env.STOREBOUND_HOME = mkdtempSync(join(tmpdir(), 'sb-bypass-')); setBypass(true); const p2 = project(BASE, files()); const viaBypass = await ship('play upload', upload, a => playUpload({ loaded: p2.loaded, args: a })); expect(viaBypass.code).not.toBe(3); expect(viaBypass.err).toContain('bypass mode'); }); test('a plain run (no contract) of a first release is the usual declined exit 3', async () => { const p = project(BASE, files()); const before = process.exitCode; const err: string[] = []; const ce = console.error; console.error = (...a: unknown[]) => { err.push(a.map(String).join(' ')); }; try { await playUpload(p.ctx(['--yes', '--track', 'production'])); } finally { console.error = ce; } expect(process.exitCode).toBe(3); expect(err.join('\n')).toContain('declined: first release of this app'); process.exitCode = before; expect(writes(s.calls)).toEqual([]); }); test('play release --production completed --yes: an update goes through, then exit 7; a staged rollout is the same policy', async () => { published(); s.play.live.bundles = [41, 42]; const p = project(BASE, { 'store/listing.md': LISTING }); const argv = ['--json', '--yes', '--version-code', '42', '--production', 'completed']; const first = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a })); expect(first.err).not.toContain('error:'); expect(first.code).toBe(0); expect(JSON.parse(first.out)).toMatchObject({ ok: true, step: 'release', track: 'production', build: '42', state: 'completed', releaseKind: 'update' }); const again = await ship('play release', argv, a => playRelease({ loaded: p.loaded, args: a })); expect(again.code).toBe(7); const staged = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'inProgress', '--fraction', '0.1'], a => playRelease({ loaded: p.loaded, args: a })); expect(staged.code).toBe(0); expect(JSON.parse(staged.out).tracks[0]).toMatchObject({ status: 'inProgress', userFraction: 0.1 }); }); test('play release --production completed --yes on a never-published app: exit 3 OWNER_STEP; --production draft is not gated', async () => { s.play.live.bundles = [42]; const p = project(BASE, { 'store/listing.md': LISTING }); const refused = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'completed'], a => playRelease({ loaded: p.loaded, args: a })); expect(refused.code).toBe(3); expect(JSON.parse(refused.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); expect(s.play.committed).toEqual([]); const draft = await ship('play release', ['--json', '--yes', '--version-code', '42', '--production', 'draft'], a => playRelease({ loaded: p.loaded, args: a })); expect(draft.code).toBe(0); }); }); describe('App Store (fake store)', () => { setDefaultTimeout(30_000); let s: Stores; beforeAll(() => { ascEnv(); }); afterAll(restore); beforeEach(() => { s = installStores(); }); afterEach(() => { uninstallStores(); }); const version = (id: string, v: string, state: string): void => { s.asc.put({ type: 'appStoreVersions', id, attributes: { versionString: v, appVersionState: state, platform: 'IOS' }, rels: { app: APP } }); }; const build = (): void => { s.asc.put({ type: 'builds', id: 'b-8', attributes: { version: '8', processingState: 'VALID', usesNonExemptEncryption: false }, rels: { app: APP } }); }; test('apple submit --yes for an update of an app on sale: no owner gate, exit 0; the re-run is exit 7', async () => { version('ver-0', '1.0.0', 'READY_FOR_SALE'); version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); build(); const p = project(BASE); const argv = ['--json', '--yes', '--build', '8']; const r = await ship('apple submit', argv, a => appleSubmit({ loaded: p.loaded, args: a })); expect(r.err).not.toContain('error:'); expect(r.code).toBe(0); expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'submit', store: 'apple', build: '8', releaseKind: 'update' }); expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']); const n = s.calls.length; const again = await ship('apple submit', argv, a => appleSubmit({ loaded: p.loaded, args: a })); expect(again.code).toBe(7); expect(writes(s.calls.slice(n))).toEqual([]); }); test('apple submit --yes for the first version of an app: exit 3 OWNER_STEP, nothing written; --first-release sends it', async () => { version('ver-1', '1.0.0', 'PREPARE_FOR_SUBMISSION'); build(); const p = project(BASE); const r = await ship('apple submit', ['--json', '--yes', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); expect(r.code).toBe(3); const j = JSON.parse(r.out); expect(j).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); expect(j.error.message).toContain('first release of this app'); expect(writes(s.calls)).toEqual([]); expect(s.asc.all('reviewSubmissions')).toEqual([]); const ok = await ship('apple submit', ['--json', '--yes', '--build', '8', '--first-release'], a => appleSubmit({ loaded: p.loaded, args: a })); expect(ok.code).toBe(0); expect(s.asc.all('reviewSubmissions').map(x => x.attributes.state)).toEqual(['WAITING_FOR_REVIEW']); }); test('apple submit without --yes is a dry run that names the kind (no gate, exit 0)', async () => { version('ver-0', '1.0.0', 'READY_FOR_SALE'); version('ver-1', '1.1.0', 'PREPARE_FOR_SUBMISSION'); build(); const p = project(BASE); const r = await ship('apple submit', ['--json', '--build', '8'], a => appleSubmit({ loaded: p.loaded, args: a })); expect(r.code).toBe(0); expect(JSON.parse(r.out)).toMatchObject({ state: 'planned', releaseKind: 'update' }); expect(writes(s.calls)).toEqual([]); }); test('apple release --manual-release --yes: an approved update is released (exit 0), released already is exit 7', async () => { version('ver-0', '1.0.0', 'REPLACED_WITH_NEW_VERSION'); version('ver-1', '1.1.0', 'PENDING_DEVELOPER_RELEASE'); const p = project(BASE); const argv = ['--json', '--yes', '--manual-release', '--version', '1.1.0']; const r = await ship('apple release', argv, a => appleRelease({ loaded: p.loaded, args: a })); expect(r.err).not.toContain('error:'); expect(r.code).toBe(0); expect(JSON.parse(r.out)).toMatchObject({ ok: true, step: 'release', store: 'apple', version: '1.1.0', releaseKind: 'update' }); expect(writes(s.calls).map(c => `${c.method} ${c.path}`)).toEqual(['POST /v1/appStoreVersionReleaseRequests']); // the store moved it on: the same command again has nothing to release s.asc.all('appStoreVersions').find(v => v.id === 'ver-1')!.attributes.appVersionState = 'READY_FOR_SALE'; const n = s.calls.length; const again = await ship('apple release', argv, a => appleRelease({ loaded: p.loaded, args: a })); expect(again.code).toBe(7); expect(JSON.parse(again.out)).toMatchObject({ ok: true, state: 'READY_FOR_SALE' }); expect(writes(s.calls.slice(n))).toEqual([]); }); test('apple release --manual-release --yes for the first version ever: exit 3 OWNER_STEP, no release request', async () => { version('ver-1', '1.0.0', 'PENDING_DEVELOPER_RELEASE'); const p = project(BASE); const r = await ship('apple release', ['--json', '--yes', '--manual-release'], a => appleRelease({ loaded: p.loaded, args: a })); expect(r.code).toBe(3); expect(JSON.parse(r.out)).toMatchObject({ ok: false, error: { code: 'OWNER_STEP' } }); expect(writes(s.calls)).toEqual([]); }); }); describe('the policy is written down', () => { const read = (f: string): string => readFileSync(join(import.meta.dir, '..', f), 'utf8'); test('docs, skills and the agent rules say updates are agent steps and the first release is the owner\'s', () => { for (const f of ['docs/bypass.md', 'docs/ship-step.md', 'skills/release-management/SKILL.md', 'skills/play-first-release/SKILL.md', 'docs/agents-core.md']) { const t = read(f); expect(t, f).toContain('--first-release'); expect(t, f).toContain('OWNER_STEP'); } expect(read('skills/store-pitfalls/SKILL.md')).toContain('Updates are agent steps'); expect(read('CHANGELOG.md')).toContain('OWNER_STEP'); }); });