259 lines
16 KiB
TypeScript
259 lines
16 KiB
TypeScript
// 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<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 => {
|
|
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<!-- 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';
|
|
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 <versionCode> --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');
|
|
});
|
|
});
|