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

63
cli/lib/play-public.ts Normal file
View file

@ -0,0 +1,63 @@
// 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.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
export type PublicState = 'public' | 'notPublic' | 'unknown';
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). */
versionName: string | null;
checkedAt: string;
url: string;
error?: string;
}
export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`;
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 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;
}
/** 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> {
const url = playPageUrl(pkg);
const forced = process.env.STOREBOUND_PLAY_PUBLIC;
if (forced === 'public' || forced === 'notPublic' || forced === 'unknown')
return { state: forced, http: forced === 'public' ? 200 : forced === 'notPublic' ? 404 : null, versionName: process.env.STOREBOUND_PLAY_PUBLIC_VERSION ?? null, checkedAt: new Date().toISOString(), url };
if (!o.noCache) {
const m = mem.get(pkg);
if (fresh(m)) return m;
try { const f = JSON.parse(readFileSync(cacheFile(pkg), 'utf8')) as PlayPublicPage; if (fresh(f) && f.url === url) { mem.set(pkg, f); return f; } } catch { /* no cache */ }
}
let out: PlayPublicPage;
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 };
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) {
out = { state: 'unknown', http: null, versionName: null, checkedAt: new Date().toISOString(), url, error: (e as Error).message || 'offline' };
}
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 */ }
}
return out;
}
/** Forget the cached answers (tests). */
export function clearPlayPublicCache(): void { mem.clear(); }