Play 'Live' only when the public store page proves it: play status checks https://play.google.com/store/apps/details?id=<pkg> (no auth, 4 s timeout, cached 5 min; publicPage in the JSON), a completed production release with a 404 page is 'Sent to Google (in review or not yet published)', 200 is live for the first release or when the page shows the versionName, otherwise 'Released to production (Google may still be reviewing)', offline is unknown; new phases sent / released in the desktop Releases tab, Portfolio, release status | plan; versions matched across the stores without trailing .0 parts (App Store 1.0 and Play 1.0.0 are one row and one train, each side shows its own string); tests/play-public-version-match.test.ts, desktop boot check release-state cases (page 404 and 200) (pitfall 56)

This commit is contained in:
CRTkafa 2026-10-13 20:46:48 +08:00
parent aa72204a06
commit be9ff8b788
18 changed files with 436 additions and 30 deletions

View file

@ -0,0 +1,119 @@
// A first release on both stores in the shapes they really answer: App Store "1.0" (build 3) waiting for review, Google
// Play production "1.0.0" (versionCode 3) completed with userFraction 1 while the app is not public yet (the store page
// answers 404). One row for 1.0 / 1.0.0, never "Live" on Play from the Developer API alone (pitfall 56).
import { afterEach, describe, expect, test } from 'bun:test';
import { APPLE_STATUS_FIRST_REVIEW, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures';
import { trainsOf } from '../cli/release';
import { clearPlayPublicCache, pageVersion, playPublicPage } from '../cli/lib/play-public';
import { appleReleases, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio';
import { laneOf, laneText, releasePlan, storeNow, versionHistory } from '../cli/lib/train';
const realFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = realFetch; delete process.env.STOREBOUND_PLAY_PUBLIC; clearPlayPublicCache(); });
describe('version matching across the stores', () => {
test('trailing zero parts do not make a different version', () => {
expect(versionKey('1.0')).toBe('1');
expect(versionKey('1.0.0')).toBe('1');
expect(versionKey('1')).toBe('1');
expect(versionKey('2.10.0')).toBe('2.10');
expect(versionKey('01.2.0')).toBe('1.2');
expect(versionKey('1.0-beta')).toBe('1.0-beta');
expect(sameVersion('1.0', '1.0.0')).toBe(true);
expect(sameVersion('2.1', '2.10')).toBe(false);
expect(sameVersion('1.0.1', '1.0')).toBe(false);
expect(sameVersion(null, '1')).toBe(false);
});
test('App Store 1.0 and Play 1.0.0 are one row and one train; each side keeps its own string', () => {
const ios = appleReleases(APPLE_STATUS_FIRST_REVIEW), android = playReleases(PLAY_STATUS_FIRST_404);
const all = [...ios, ...android];
const h = versionHistory(all);
expect(h).toHaveLength(1);
expect(h[0]!.version).toBe('1.0.0');
expect(h[0]!.ios).toMatchObject({ version: '1.0', build: '3', phase: 'review' });
expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'sent', track: 'production' });
const t = releaseTrains(ios, android);
expect(t.current).toBeNull(); // nothing reaches users yet
expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'sent' } });
// release status --versions: the one row is the next train
const s = { release: { source: 'live' as const, ...t, all }, storeErrors: {}, summary: null } as never;
expect(trainsOf(s, true).map(x => [x.which, x.train.version])).toEqual([['next', '1.0.0']]);
// release plan 1.0 finds the Play 1.0.0 release too
const p = releasePlan({ version: '1.0', platforms: { ios: true, android: true }, check: { problems: [], warnings: [] }, validate: [], releases: all });
expect(p.ios?.release?.version).toBe('1.0');
expect(p.android?.release?.version).toBe('1.0.0');
expect(p.done).toBe(false);
expect(p.android!.steps.map(x => x.text).join(' ')).toContain('sent to Google');
});
});
describe('Google Play "Live" needs the public store page', () => {
test('completed production + page 404 = sent to Google, never live', () => {
const [prod] = playReleases(PLAY_STATUS_FIRST_404);
expect(prod).toMatchObject({ phase: 'sent', state: 'completed', publicPage: 'notPublic', fraction: 1 });
expect(playReleaseLabel(prod!)).toBe('Sent to Google (in review or not yet published)');
const lane = laneOf('android', prod!);
expect(lane.at).toBe(2); // the review stage, not live / rollout
expect(lane.share).toBeNull();
expect(laneText(lane)).not.toMatch(/^live/);
expect(storeNow(playReleases(PLAY_STATUS_FIRST_404), 'android').now).toBeNull();
});
test('first release + page 200 = live', () => {
const [prod] = playReleases(PLAY_STATUS_FIRST_200);
expect(prod).toMatchObject({ phase: 'live', publicPage: 'public' });
expect(laneText(laneOf('android', prod!))).toBe('live');
const t = releaseTrains(appleReleases(APPLE_STATUS_FIRST_REVIEW), playReleases(PLAY_STATUS_FIRST_200));
expect(t.current).toMatchObject({ version: '1.0.0', android: { phase: 'live' }, ios: { phase: 'review' } });
});
test('an update after the first release: page 200 is not proof for the new versionCode unless the page shows it', () => {
const update = { publicPage: { state: 'public', versionName: null }, tracks: [
{ track: 'production', releases: [{ name: '1.1.0', versionCodes: ['5'], status: 'completed' }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
] };
const [prod] = playReleases(update);
expect(prod!.phase).toBe('released');
expect(playReleaseLabel(prod!)).toBe('Released to production (Google may still be reviewing)');
expect(laneText(laneOf('android', prod!))).toContain('Google may still be reviewing');
expect(playReleases({ ...update, publicPage: { state: 'public', versionName: '1.1' } })[0]!.phase).toBe('live');
});
test('page not read = unknown, shown as unknown', () => {
const [prod] = playReleases({ tracks: PLAY_STATUS_FIRST_404.tracks });
expect(prod).toMatchObject({ phase: 'released', publicPage: 'unknown' });
expect(playReleaseLabel(prod!)).toContain('unknown');
expect(laneOf('android', prod!).tone).toBe('neutral');
});
});
describe('public store page read', () => {
const answer = (status: number, body = '') => { const urls: string[] = []; globalThis.fetch = (async (u: string | URL | Request) => { urls.push(String(u)); return new Response(body, { status }); }) as unknown as typeof fetch; return urls; };
test('404 = not public yet, 200 = public, the url has no auth and asks for English', async () => {
const urls = answer(404);
expect((await playPublicPage('com.example.first404', { noCache: true })).state).toBe('notPublic');
expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en');
answer(200, '<script type="application/ld+json">{"softwareVersion":"1.0.0"}</script>');
expect(await playPublicPage('com.example.first200', { noCache: true })).toMatchObject({ state: 'public', http: 200, versionName: '1.0.0' });
});
test('offline or a server error = unknown; unknown is never cached', async () => {
globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch;
const r = await playPublicPage('com.example.offline', { noCache: true });
expect(r).toMatchObject({ state: 'unknown', http: null });
expect(r.error).toContain('ENOTFOUND');
answer(503);
expect((await playPublicPage('com.example.offline')).state).toBe('unknown');
answer(404);
expect((await playPublicPage('com.example.offline')).state).toBe('notPublic');
});
test('answers are cached for a few minutes', async () => {
const urls = answer(404);
const pkg = `com.example.cache${Date.now()}`;
await playPublicPage(pkg);
answer(200);
expect((await playPublicPage(pkg)).state).toBe('notPublic');
expect(urls).toHaveLength(1);
});
test('the page version is read only from structured data', () => {
expect(pageVersion('{"softwareVersion": "2.3.1"}')).toBe('2.3.1');
expect(pageVersion('{"softwareVersion":"Varies with device"}')).toBeNull();
expect(pageVersion('<div>Version 1.0</div>')).toBeNull();
});
});

View file

@ -24,6 +24,7 @@ const play = {
{ track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] },
],
products: [{ productId: 'coins' }, { productId: 'no_ads' }, { sku: 'android_only' }],
publicPage: { state: 'public' },
};
describe('portfolio (apps, release trains, catalog parity)', () => {
@ -67,7 +68,7 @@ describe('portfolio (apps, release trains, catalog parity)', () => {
const t = releaseTrains(appleReleases(apple), playReleases(play));
expect(t.current).toMatchObject({ version: '2.3.0', ios: { phase: 'review', build: '45' }, android: { phase: 'rollout', fraction: 0.2, build: '231' } });
expect(t.next).toMatchObject({ version: '2.4.0', ios: null, android: { phase: 'testing' } });
const done = releaseTrains(appleReleases(apple), playReleases({ tracks: [play.tracks[0]!.releases[0] && { track: 'production', releases: [play.tracks[0]!.releases[0]] }] }));
const done = releaseTrains(appleReleases(apple), playReleases({ publicPage: play.publicPage, tracks: [play.tracks[0]!.releases[0] && { track: 'production', releases: [play.tracks[0]!.releases[0]] }] }));
expect(done.current).toMatchObject({ version: '2.2.1', ios: { phase: 'live', build: '41' }, android: { phase: 'live', build: '220' } });
expect(done.next).toMatchObject({ version: '2.3.0', ios: { phase: 'review' }, android: null });
expect(releaseTrains([], [])).toEqual({ current: null, next: null });

View file

@ -22,6 +22,7 @@ const play = {
{ track: 'production', releases: [{ name: '2.3.0', versionCodes: ['230'], status: 'inProgress', userFraction: 0.1 }, { name: '2.2.0', versionCodes: ['220'], status: 'completed' }] },
{ track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] },
],
publicPage: { state: 'public' },
};
const all: StoreRelease[] = [...appleReleases(apple), ...playReleases(play)];