Play edits under a machine-wide per-package lock (parallel reads of one app no longer delete each other's edits; a read whose edit was deleted is redone once, writes are not); a store that failed to read is 'Couldn't read Google Play' and keeps its last good releases with 'couldn't refresh'; stores on different versions: neutral 'Current · Play x · App Store y' header and each lane its own state, 'Not on this store' only without a record; Play status decided from the public store page's live version (Live / In review / In review (live: x) / managed publishing / Rolling out / Halted / Draft; only a network failure says 'Couldn't check the store page', with the last known answer and its time); tests/play-edit-lock.test.ts, recorded page fixture, boot check auto-read cases (pitfall 58)

This commit is contained in:
CRTkafa 2026-10-14 00:14:32 +08:00
parent e59f439101
commit 714352ec18
24 changed files with 570 additions and 103 deletions

View file

@ -3,8 +3,8 @@
// re-exports these) and `storebound release status | plan` (cli/release.ts) share this file, so both show the same
// stage for the same store state. Pure; the store reads are `apple status --json` / `play status --json`, turned into
// StoreRelease rows by cli/lib/portfolio.ts.
import type { ReleaseTrain, StoreRelease } from './desktop-protocol';
import { cmpVersion, displayVersion, PHASE_RANK, REACHING, sameVersion, versionKey } from './portfolio';
import type { AppStatus, ReleaseTrain, StoreRelease } from './desktop-protocol';
import { cmpVersion, displayVersion, PHASE_RANK, playReleaseLabel, REACHING, releaseTrains, sameVersion, versionKey } from './portfolio';
import type { Finding } from './validate-rules';
export { cmpVersion, displayVersion, sameVersion, versionKey };
@ -75,6 +75,54 @@ export function storeNow(all: StoreRelease[], store: 'ios' | 'android'): { now:
return { now, next };
}
/**
* What a lane without this version shows, so a store is never called empty when it was not read or carries another
* version: `unreadable` (the read failed and nothing is known: "couldn't read Google Play"), `other` (the store's own
* current release is a different version: "4.1.2 · Live (4.1.9 not submitted)"), `missing` (the app is on the store,
* this version is not yet) and `none` (no record of the app on that store at all: "Not on this store"). Pure.
*/
export type LaneGap = { kind: 'unreadable' } | { kind: 'other'; now: StoreRelease & { version: string } } | { kind: 'missing' } | { kind: 'none' };
export function laneGap(store: 'ios' | 'android', all: StoreRelease[], storeError?: string | null): LaneGap {
const mine = all.filter(r => r.store === store);
if (!mine.length) return storeError ? { kind: 'unreadable' } : { kind: 'none' };
const { now } = storeNow(all, store);
if (now) return { kind: 'other', now: now as StoreRelease & { version: string } };
return { kind: 'missing' };
}
/** The stores' current (reaching users) versions when they differ: "Play 4.1.9 · App Store 4.1.2" for a neutral
* header. Null when only one store has one or both have the same version. Pure. */
export function storesDiffer(all: StoreRelease[]): { ios: string; android: string } | null {
const i = storeNow(all, 'ios').now?.version, a = storeNow(all, 'android').now?.version;
return i && a && !sameVersion(i, a) ? { ios: i, android: a } : null;
}
/**
* After a refresh in which a store could not be read, keep the store's releases from the previous good read (marked
* by `staleStores`), so the lanes show the last known state with "couldn't refresh" instead of turning empty. Pure.
*/
export function keepLastGood(prev: StoreRelease[] | undefined, next: StoreRelease[], errors: { ios?: string; android?: string }): { all: StoreRelease[]; stale: ('ios' | 'android')[] } {
const stale: ('ios' | 'android')[] = [];
let all = next;
for (const s of ['ios', 'android'] as const) {
if (!errors[s] || next.some(r => r.store === s)) continue;
const old = (prev ?? []).filter(r => r.store === s);
if (old.length) { all = [...all, ...old]; stale.push(s); }
}
return { all, stale };
}
/** keepLastGood over a whole status: the app's trains are rebuilt from the merged releases and `staleStores` names
* each store shown from the earlier read (with that read's time). Returns `next` itself when nothing is kept. */
export function withLastGood(prev: AppStatus | undefined, next: AppStatus, prevAt: string): AppStatus {
if (next.release.source !== 'live' || !prev?.release.all?.length) return next;
const { all, stale } = keepLastGood(prev.release.all, next.release.all ?? [], next.storeErrors);
if (!stale.length) return next;
const staleStores = { ...next.staleStores };
for (const s of stale) staleStores[s] = prev.staleStores?.[s] ?? prevAt;
return { ...next, release: { source: 'live', ...releaseTrains(all.filter(r => r.store === 'ios'), all.filter(r => r.store === 'android')), all }, staleStores };
}
// ---- Terminal view of a lane (release status) ----
const pctText = (x: number | null): string => (x === null ? '' : `${Math.round(x * 1000) / 10}%`);
@ -85,8 +133,7 @@ export function laneText(l: Lane): string {
const stage = STAGES[l.at] ?? 'build';
const p = l.release.phase;
if (p === 'live') return 'live'; // the whole track filled: everyone has it
if (p === 'sent') return 'review (sent to Google: in review or not yet published)';
if (p === 'released') return l.release.publicPage === 'unknown' ? 'released to production (public page not readable: live unknown)' : 'released to production (Google may still be reviewing)';
if (l.release.store === 'android' && (p === 'sent' || p === 'released' || (p === 'review' && l.release.track === 'production'))) return playReleaseLabel(l.release).replace(/^\w/, c => c.toLowerCase());
const word = p === stage || (p === 'draft' && stage === 'build') || (p === 'other' && stage === 'build') ? stage : `${stage} (${p})`;
if (p === 'rollout' || p === 'halted') return `${word} ${pctText(l.share)}`.trim();
return word;
@ -184,12 +231,14 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null,
steps.push({ kind: 'owner', text: `resubmit ${v} after the fix (owner)`, fix: store === 'ios' ? 'App Store Connect > App Review > reply / resubmit' : 'Play Console > Publishing overview' });
break;
case 'sent':
steps.push({ kind: 'wait', text: `${v} is sent to Google (production completed, the store page is not public yet): in review or not yet published`, fix: 'storebound play status (checks the public store page)' });
break;
case 'released':
steps.push({ kind: 'wait', text: r!.publicPage === 'unknown' ? `${v} is released to production; the public store page could not be read, so live is unknown` : `${v} is released to production; Google may still be reviewing it (the app is public, this version is not proven live)`, fix: 'storebound play status' });
steps.push({ kind: 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: 'storebound play status (reads the public store page)' });
break;
case 'review':
if (store === 'android' && r!.track === 'production') {
steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? 'Play Console > Publishing overview (publish once approved)' : 'storebound play status' });
break;
}
steps.push({ kind: 'wait', text: `${v} is in review (${r!.state}); nothing to do until the store answers`, fix: store === 'ios' ? 'storebound apple status --wait-review' : 'storebound play status' });
break;
case 'approved':