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

@ -159,9 +159,9 @@ export interface AppIdentity {
}
/** Where a version is on one store. */
/** `sent`: a Google Play production release that is completed in the API while the public store page is 404 (sent to
* Google, in review or not yet published). `released`: completed in production and the app is public, but this
* version is not proven live (an update Google may still be reviewing, or the public page could not be read). */
/** Google Play production (completed in the API) is decided by the public store page: its version = `live`, 404 or an
* older live version = `review` (with `liveVersion` / `managed`), page never read (network) = `released` with
* publicPage unknown ("Couldn't check the store page"). `sent` is no longer produced (older cached reads). */
export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approved' | 'review' | 'sent' | 'rejected' | 'testing' | 'draft' | 'other';
export interface StoreRelease {
@ -180,6 +180,12 @@ export interface StoreRelease {
phased?: { state: string; day: number | null } | null;
/** Google Play production: the public store page (public = 200, notPublic = 404, unknown = not read). */
publicPage?: 'public' | 'notPublic' | 'unknown';
/** Google Play production in review while the store page shows an older live version: that version. */
liveVersion?: string | null;
/** Google Play production in review with managed publishing on (play.managedPublishing): approval is not readable. */
managed?: boolean;
/** The store page could not be read now: the phase is from the last known page read at this time. */
pageStaleAt?: string | null;
/** When the store record was made (iOS version createdDate), if the store says. */
date?: string | null;
}
@ -216,6 +222,8 @@ export interface AppStatus {
virtual?: boolean;
/** live only: per-platform read errors ("unknown, never false"). */
storeErrors: { ios?: string; android?: string };
/** A store that could not be read this time, shown from the last good read (its time): couldn't refresh. */
staleStores?: { ios?: string; android?: string };
errors: string[];
}

View file

@ -3,9 +3,10 @@
// same app at once and race each other into duplicates. Dry runs and reads take no lock.
// A lock whose process is gone (same machine) is taken over; a live one stops the run with who holds it. A lock
// left by a crash on another machine (shared folder) stays until --force-unlock or 12 hours.
import { hostname } from 'node:os';
import { mkdirSync, openSync, readFileSync, rmSync, writeSync, closeSync } from 'node:fs';
import { homedir, hostname } from 'node:os';
import { mkdirSync, openSync, readFileSync, rmSync, statSync, utimesSync, writeSync, closeSync } from 'node:fs';
import { join } from 'node:path';
import { AsyncLocalStorage } from 'node:async_hooks';
import type { ShipConfig } from './config';
import type { Args } from './args';
@ -58,6 +59,66 @@ export function acquireLock(root: string, key: string, command: string, o: { for
return () => { rmSync(path, { force: true }); held.delete(path); };
}
// ---- Machine-wide short locks (Play edits) ----
// Google Play keeps one open edit per app: a second edits.insert for the same package invalidates the first ("This
// Edit has been deleted"). Every storebound process that opens an edit (reads included) takes
// ~/.storebound/locks/<key>.lock first, so parallel reads of one package wait for each other; other packages run in
// parallel. The holder touches the file every few seconds (heartbeat); a file whose process is gone or that was not
// touched for STALE_SHORT_MS is taken over. Waiting has a timeout and logs who it waits for.
export const machineLockDir = (): string => process.env.STOREBOUND_LOCK_DIR || join(homedir(), '.storebound', 'locks');
const STALE_SHORT_MS = 60_000;
/** Whether a short lock may be taken over: unreadable, its process gone (same host) or not touched for a minute. */
export function staleShortLock(l: LockInfo | null, touchedAt: number, now = Date.now(), host = hostname(), isAlive = alive): boolean {
if (!l) return now - touchedAt > 2000; // half written: give the writer a moment
if (now - touchedAt > STALE_SHORT_MS) return true;
return l.host === host && !isAlive(l.pid);
}
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
/** The machine locks held by the current async call chain: a nested take of the same key runs inside the outer one
* (it would otherwise wait for itself), while a parallel caller in the same process still waits. */
const inside = new AsyncLocalStorage<Set<string>>();
/**
* Runs fn while holding the machine-wide lock `key` (e.g. `play-edit-<package>`). Waits up to `timeoutMs` for another
* holder (polling, a heartbeat line every `heartbeatMs` through `onWait`), then throws with the holder.
*/
export async function withMachineLock<T>(key: string, command: string, fn: () => Promise<T>,
o: { timeoutMs?: number; pollMs?: number; heartbeatMs?: number; onWait?: (msg: string) => void } = {}): Promise<T> {
if (inside.getStore()?.has(key)) return fn();
const dir = machineLockDir();
mkdirSync(dir, { recursive: true, mode: 0o700 });
const path = join(dir, `${key.replace(/[^A-Za-z0-9._-]+/g, '_')}.lock`);
const timeout = o.timeoutMs ?? 90_000, poll = o.pollMs ?? 100, beat = o.heartbeatMs ?? 10_000;
const start = Date.now();
let lastBeat = start;
for (;;) {
try {
const fd = openSync(path, 'wx', 0o600);
writeSync(fd, JSON.stringify({ pid: process.pid, host: hostname(), command, startedAt: new Date().toISOString() } satisfies LockInfo) + '\n');
closeSync(fd);
break;
} catch (e) {
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
let cur: LockInfo | null = null, touched = Date.now();
try { touched = statSync(path).mtimeMs; cur = JSON.parse(readFileSync(path, 'utf8')) as LockInfo; } catch { /* gone or half written */ }
if (staleShortLock(cur, touched)) { rmSync(path, { force: true }); continue; }
const waited = Date.now() - start;
const who = cur ? `\`storebound ${cur.command}\` (pid ${cur.pid})` : 'another storebound run';
if (waited > timeout) throw new Error(`timed out after ${Math.round(waited / 1000)} s waiting for ${key}: ${who} still holds it (Google Play allows one open edit per app). Try again when it is done.`);
if (Date.now() - lastBeat >= beat) { lastBeat = Date.now(); o.onWait?.(`waiting ${Math.round(waited / 1000)} s for ${key}, held by ${who}`); }
await sleep(poll);
}
}
held.add(path);
if (!hooked) { hooked = true; process.on('exit', () => { for (const p of held) rmSync(p, { force: true }); }); }
const hb = setInterval(() => { try { const t = new Date(); utimesSync(path, t, t); } catch { /* removed */ } }, 5000);
(hb as { unref?: () => void }).unref?.();
try { return await inside.run(new Set([...(inside.getStore() ?? []), key]), fn); } finally { clearInterval(hb); rmSync(path, { force: true }); held.delete(path); }
}
/** Read-only subcommands and flags: no lock. */
const READ_SUBS = new Set(['status', 'list', 'devices', 'diff', 'export', 'pull', 'wait', 'options', 'coverage', 'report', 'campaigns', 'check', 'help']);

View file

@ -1,7 +1,9 @@
// Is the app public on Google Play? The Play Developer API has no review state: a production release reads
// "completed" from the moment it is sent, while Google may still be reviewing it (a first release is not public
// until then). The public store page answers it: 200 = published, 404 = not public yet. No auth, a fast timeout,
// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it), "unknown" when offline.
// "completed" from the moment it is sent, also while Google reviews it. The public store page answers it: 404 = not
// public yet (a first release in review), 200 = published, and the page's "Version" (About this app) is the live
// versionName, so a completed release whose versionName is not the page's is in review. No auth, a fast timeout,
// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it). Only a network failure
// gives "unknown"; it then returns the last known answer (any age) marked `stale` with the time it was read.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@ -11,25 +13,45 @@ export interface PlayPublicPage {
state: PublicState;
/** HTTP status of the store page (null when it could not be read). */
http: number | null;
/** The versionName the page shows, when it shows one (usually it does not). */
/** The live versionName the page shows ("Version" in About this app); null when it shows none. */
versionName: string | null;
/** The page says "Varies with device" instead of a version (several APKs): the version cannot be compared. */
varies?: boolean;
/** A network failure: this is the last known answer from `checkedAt`, not a fresh one. */
stale?: boolean;
checkedAt: string;
url: string;
error?: string;
}
export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`;
export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en&gl=US`;
const TTL_MS = 5 * 60_000;
const mem = new Map<string, PlayPublicPage>();
const cacheFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.json`);
const lastFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.last.json`);
const fresh = (p: PlayPublicPage | undefined | null): p is PlayPublicPage => !!p && p.state !== 'unknown' && Date.now() - Date.parse(p.checkedAt) < TTL_MS;
/** The versionName in the page's structured data, when it has one ("softwareVersion"). */
export function pageVersion(html: string): string | null {
const m = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/);
return m && /\d/.test(m[1]!) ? m[1]! : null;
/**
* The live versionName of a Play store page. The page embeds "About this app" in its AF_initDataCallback data blobs
* (ds:5 in 2026-10) as `[[["0.8.1"]],[[[36]],[[[24,"7.0"]]]]]`: the version, then the target and minimum SDK with the
* Android version. "Varies with device" stands in for the version when the app ships several APKs. Older pages had
* schema.org "softwareVersion". Recorded shape: tests/fixtures/play-page-version.html.
*/
export function pageVersionInfo(html: string): { versionName: string | null; varies: boolean } {
const blobs = [...html.matchAll(/AF_initDataCallback\(\{key: '(ds:\d+)'[\s\S]*?<\/script>/g)].map(m => m[0]);
const about = /\[\[\["([^"\\]{1,60})"\]\],\[\[\[\d+\]\],\[\[\[\d+,"[^"\\]{0,20}"\]\]\]\]/;
for (const b of blobs.length ? blobs : [html]) {
const m = about.exec(b);
if (!m) continue;
if (/varies with device/i.test(m[1]!)) return { versionName: null, varies: true };
if (/\d/.test(m[1]!)) return { versionName: m[1]!, varies: false };
}
const ld = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/);
if (ld && /varies with device/i.test(ld[1]!)) return { versionName: null, varies: true };
return { versionName: ld && /\d/.test(ld[1]!) ? ld[1]! : null, varies: false };
}
export const pageVersion = (html: string): string | null => pageVersionInfo(html).versionName;
/** Reads the public page (cached 5 minutes; STOREBOUND_PLAY_PUBLIC=public|notPublic|unknown overrides it for tests). */
export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCache?: boolean } = {}): Promise<PlayPublicPage> {
@ -46,7 +68,7 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac
try {
const r = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(o.timeoutMs ?? 4000), headers: { 'User-Agent': 'Mozilla/5.0 storebound', 'Accept-Language': 'en' } });
const http = r.status;
if (http === 200) out = { state: 'public', http, versionName: pageVersion(await r.text()), checkedAt: new Date().toISOString(), url };
if (http === 200) { const v = pageVersionInfo(await r.text()); out = { state: 'public', http, versionName: v.versionName, ...(v.varies ? { varies: true } : {}), checkedAt: new Date().toISOString(), url }; }
else if (http === 404) out = { state: 'notPublic', http, versionName: null, checkedAt: new Date().toISOString(), url };
else out = { state: 'unknown', http, versionName: null, checkedAt: new Date().toISOString(), url, error: `HTTP ${http}` };
} catch (e) {
@ -54,8 +76,14 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac
}
if (out.state !== 'unknown') {
mem.set(pkg, out);
try { mkdirSync(join(tmpdir(), 'storebound-play-public'), { recursive: true }); writeFileSync(cacheFile(pkg), JSON.stringify(out)); } catch { /* cache is optional */ }
try { mkdirSync(join(tmpdir(), 'storebound-play-public'), { recursive: true }); writeFileSync(cacheFile(pkg), JSON.stringify(out)); writeFileSync(lastFile(pkg), JSON.stringify(out)); } catch { /* cache is optional */ }
return out;
}
// a network failure: the last known answer with its time, never a guess
try {
const last = JSON.parse(readFileSync(lastFile(pkg), 'utf8')) as PlayPublicPage;
if (last.url === url && last.state !== 'unknown') return { ...last, stale: true, error: out.error };
} catch { /* none known */ }
return out;
}

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 };

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':

View file

@ -6,6 +6,10 @@ import { errorBody } from '../lib/http';
import { noteGoogle } from '../lib/quota';
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt';
import { withMachineLock } from '../lib/lock';
/** A read's edit is gone: replaced by a newer edit of the app, committed elsewhere or expired. */
export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already been committed|edit.*(expired|not found)|outside of this Edit/i;
export type PlayClient = Awaited<ReturnType<typeof playClient>>;
@ -101,6 +105,11 @@ export async function playClient(root: string, packageName: string) {
}
}
/** Google keeps one open edit per app; every edit (read or write) of this package, in any storebound process on
* this machine, runs under one lock (cli/lib/lock.ts), so parallel reads never delete each other's edits. */
const locked = <T>(what: string, fn: () => Promise<T>, timeoutMs: number): Promise<T> =>
withMachineLock(`play-edit-${packageName}`, `play ${what} ${packageName}`, fn, { timeoutMs, onWait: m => log('play', m) });
return {
API, UPLOAD, packageName, call,
/**
@ -108,8 +117,16 @@ export async function playClient(root: string, packageName: string) {
* A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger).
*/
async read<T>(fn: (id: string) => Promise<T>): Promise<T> {
const id = await openOne();
try { return await fn(id); } finally { await closeOne(id, 'read-only'); }
return locked('read', async () => {
for (let attempt = 0; ; attempt++) {
const id = await openOne();
try { return await fn(id); } catch (err) {
// the edit was replaced by another writer (Console, another tool) or expired: a read is safe to redo once
if (attempt === 0 && EDIT_GONE.test(String(err))) { log('play', `read edit ${id} was invalidated, reading again with a new edit`); continue; }
throw err;
} finally { await closeOne(id, 'read-only'); }
}
}, 90_000);
},
/**
* Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit
@ -117,6 +134,7 @@ export async function playClient(root: string, packageName: string) {
* redone, up to `retries` times. Keep edits small (e.g. one per language for images) so a retry is cheap.
*/
async edit<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise<T> {
return locked(o.dry ? 'dry edit' : 'edit', async () => {
for (let left = o.retries ?? 3; ; left--) {
try { return await editOnce(fn, o); } catch (err) {
// redo only what never went live: an edit invalidated before or at its commit, or a request inside the edit
@ -127,6 +145,7 @@ export async function playClient(root: string, packageName: string) {
log('play', `${outside ? 'app changed outside the edit (Console?)' : 'a request got no clear answer; the edit was discarded'}, redoing it (${left} retries left)`);
}
}
}, 10 * 60_000);
},
};
}

View file

@ -22,7 +22,7 @@ export async function run(ctx: Ctx): Promise<void> {
// the App category is not in the API (edits.details has no category field): a console step
const cat = playCategoryOf(config);
const category = { config: cat?.category ?? null, source: cat?.source ?? null, api: false, plan: 'storebound console plan play-store-settings' };
if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage }); return; }
if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null }); return; }
console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`);
console.log(` category: ${cat ? `${cat.category} (${cat.source})` : 'not in the config (play.category)'} · not readable by the API: Store settings is a console step (${category.plan})`);
console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`);
@ -30,8 +30,8 @@ export async function run(ctx: Ctx): Promise<void> {
const rs = (t.releases ?? []).map(r => `${r.name ?? '?'} [${r.versionCodes?.join(',') ?? '-'}] ${r.status}${r.userFraction ? ' ' + r.userFraction * 100 + '%' : ''}`);
console.log(` track ${t.track.padEnd(11)} ${rs.join(' | ') || '-'}`);
}
console.log(` public store page: ${publicPage.state === 'public' ? 'published (200)' : publicPage.state === 'notPublic' ? 'not public yet (404)' : `unknown (${publicPage.error ?? 'not read'})`}`);
for (const r of playReleases({ tracks, publicPage }).filter(x => x.track === 'production' && x.state === 'completed')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`);
console.log(` public store page: ${publicPage.state === 'public' ? `published (200), live version ${publicPage.versionName ?? (publicPage.varies ? 'varies with device' : 'not shown')}` : publicPage.state === 'notPublic' ? 'not public yet (404)' : `couldn't check it (${publicPage.error ?? 'not read'})`}${publicPage.stale ? ` (last known, ${publicPage.checkedAt.slice(0, 16).replace('T', ' ')} UTC)` : ''}`);
for (const r of playReleases({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }).filter(x => x.track === 'production')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`);
console.log(` products (${products.length}):`);
for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`);
});