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

@ -105,20 +105,22 @@ export function testflightReleases(builds: J[], buildVersions: Record<string, st
export const versionOfName = (name: unknown): string | null => (typeof name === 'string' ? name.match(/\d+(?:\.\d+){0,3}/)?.[0] ?? null : null);
/**
* Play tracks (`play status --json`) -> releases. The Developer API has no review state: a completed production release
* is only "live" when the public store page proves it (`publicPage` in the status JSON): 404 = sent to Google (in
* review or not yet published); 200 = live for the first release or when the page shows this versionName, otherwise
* "released" (an update Google may still be reviewing); page not read = "released" with publicPage unknown.
* Play tracks (`play status --json`) -> releases. The Developer API has no review state, so a completed production
* release is decided by the public store page (`publicPage` in the status JSON): the page's version is this versionName
* (or newer, or "Varies with device") = live; 404 = in review (with managed publishing: in review or ready to publish);
* an older version on the page = in review, with the live one named. Only a network failure without any earlier page
* read leaves it undecided ("released", publicPage unknown: "Couldn't check the store page"); with an earlier read the
* phase comes from that read and `pageStaleAt` says when.
*/
export function playReleases(data: unknown): StoreRelease[] {
const out: StoreRelease[] = [];
const page = (data as J)?.publicPage as { state?: string; versionName?: string | null } | undefined;
const page = (data as J)?.publicPage as { state?: string; versionName?: string | null; varies?: boolean; stale?: boolean; checkedAt?: string } | undefined;
const managed = (data as J)?.managedPublishing === true;
const pageState = page?.state === 'public' || page?.state === 'notPublic' ? page.state : 'unknown';
const raw = (data as J)?.tracks;
const tracks: J[] = Array.isArray(raw) ? raw : [];
const codesOf = (r: J): number[] => (Array.isArray(r?.versionCodes) ? (r.versionCodes as unknown[]) : []).map(Number).filter(Number.isFinite);
const rels = (t: J): J[] => (Array.isArray(t?.releases) ? t.releases : []);
const allCodes = tracks.flatMap(t => rels(t).flatMap(codesOf));
for (const t of tracks) {
for (const r of rels(t)) {
const codes = codesOf(r);
@ -129,12 +131,17 @@ export function playReleases(data: unknown): StoreRelease[] {
rel.phase = !production ? (status === 'draft' ? 'draft' : 'testing')
: status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other';
if (production && status === 'completed') {
const low = codes.length ? Math.min(...codes) : Infinity;
const first = !allCodes.some(c => c < low); // nothing older on any track: the app's first release
rel.phase = pageState === 'notPublic' ? 'sent'
: pageState === 'public' && (first || sameVersion(page?.versionName, version)) ? 'live'
: 'released';
const pv = page?.versionName ?? null;
if (pageState === 'notPublic') { rel.phase = 'review'; if (managed) rel.managed = true; }
else if (pageState === 'public') {
// its version (or newer: this is an earlier one), or "Varies with device" (nothing to compare) = live; no
// version found on a public page means the page changed shape: not checked, never guessed
if (page?.varies || (pv && version && cmpVersion(pv, version) >= 0)) rel.phase = 'live';
else if (!pv || !version) rel.phase = 'released';
else { rel.phase = 'review'; rel.liveVersion = pv; if (managed) rel.managed = true; }
} else rel.phase = 'released';
rel.publicPage = pageState;
if (page?.stale && pageState !== 'unknown') rel.pageStaleAt = page.checkedAt ?? null;
}
out.push(rel);
}
@ -142,12 +149,19 @@ export function playReleases(data: unknown): StoreRelease[] {
return out;
}
/** Plain words for a Play production release's phase (CLI text; the desktop has the same words in i18n). */
/** Plain words for a Play release's phase, decided from the store page (CLI text; the desktop has the same words in
* i18n, apps/desktop/src/ui/kit.tsx phaseLabel). */
export function playReleaseLabel(r: StoreRelease): string {
if (r.phase === 'sent') return 'Sent to Google (in review or not yet published)';
if (r.phase === 'released') return r.publicPage === 'unknown' ? 'Released to production (public store page not readable: live state unknown)' : 'Released to production (Google may still be reviewing)';
if (r.phase === 'live') return 'Live';
return r.state;
const base = ((): string => {
if (r.phase === 'review' || r.phase === 'sent') return r.managed ? 'In review / ready to publish (managed publishing)' : r.liveVersion ? `In review (live: ${r.liveVersion})` : 'In review';
if (r.phase === 'released') return "Couldn't check the store page";
if (r.phase === 'live') return 'Live';
if (r.phase === 'rollout') return `Rolling out ${Math.round((r.fraction ?? 0) * 1000) / 10}%`;
if (r.phase === 'halted') return 'Halted';
if (r.phase === 'draft') return 'Draft';
return r.state;
})();
return r.pageStaleAt ? `${base} (couldn't check the store page now; as of ${r.pageStaleAt.slice(0, 16).replace('T', ' ')} UTC)` : base;
}
export const PHASE_RANK: Record<ReleasePhase, number> = { live: 0, rollout: 1, halted: 2, released: 3, approved: 4, review: 5, sent: 6, rejected: 7, testing: 8, draft: 9, other: 10 };