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:
parent
aa72204a06
commit
be9ff8b788
18 changed files with 436 additions and 30 deletions
|
|
@ -115,7 +115,10 @@ export interface AppIdentity {
|
|||
}
|
||||
|
||||
/** Where a version is on one store. */
|
||||
export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'approved' | 'review' | 'rejected' | 'testing' | 'draft' | 'other';
|
||||
/** `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). */
|
||||
export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approved' | 'review' | 'sent' | 'rejected' | 'testing' | 'draft' | 'other';
|
||||
|
||||
export interface StoreRelease {
|
||||
store: 'ios' | 'android';
|
||||
|
|
@ -131,6 +134,8 @@ export interface StoreRelease {
|
|||
track: string | null;
|
||||
/** iOS phased release: day 1-7 and its state (ACTIVE, PAUSED, COMPLETE); null without one. */
|
||||
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';
|
||||
/** When the store record was made (iOS version createdDate), if the store says. */
|
||||
date?: string | null;
|
||||
}
|
||||
|
|
|
|||
63
cli/lib/play-public.ts
Normal file
63
cli/lib/play-public.ts
Normal 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(); }
|
||||
|
|
@ -23,6 +23,25 @@ export function cmpVersion(a: string, b: string): number {
|
|||
return 0;
|
||||
}
|
||||
|
||||
/** Matching key of a version: numeric parts without trailing zero parts, so 1 == 1.0 == 1.0.0 ("1"), 2.10 != 2.1.
|
||||
* A version with non-numeric parts keeps its own spelling. The stores' strings are shown as they are; this only joins. */
|
||||
export function versionKey(v: string): string {
|
||||
const parts = v.trim().split('.');
|
||||
if (!parts.every(p => /^\d+$/.test(p))) return v.trim();
|
||||
const n = parts.map(p => String(parseInt(p, 10)));
|
||||
while (n.length > 1 && n.at(-1) === '0') n.pop();
|
||||
return n.join('.');
|
||||
}
|
||||
|
||||
/** The same release on both stores (App Store "1.0" and Play "1.0.0"). */
|
||||
export const sameVersion = (a: string | null | undefined, b: string | null | undefined): boolean => !!a && !!b && versionKey(a) === versionKey(b);
|
||||
|
||||
/** The spelling a joined row shows: the longest of the stores' strings (1.0.0 over 1.0), the first one on a tie. */
|
||||
export function displayVersion(vs: (string | null | undefined)[]): string {
|
||||
const xs = vs.filter((x): x is string => !!x);
|
||||
return xs.reduce((a, b) => (b.split('.').length > a.split('.').length ? b : a), xs[0] ?? '');
|
||||
}
|
||||
|
||||
/** App Store version states (appStoreState / appVersionState) -> phase. */
|
||||
export function applePhase(state: string): ReleasePhase {
|
||||
if (/^(READY_FOR_SALE|READY_FOR_DISTRIBUTION)$/.test(state)) return 'live';
|
||||
|
|
@ -85,22 +104,56 @@ export function testflightReleases(builds: J[], buildVersions: Record<string, st
|
|||
/** Leading dotted version of a Play release name ("2.3.0 (45)" -> "2.3.0"). */
|
||||
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.
|
||||
*/
|
||||
export function playReleases(data: unknown): StoreRelease[] {
|
||||
const out: StoreRelease[] = [];
|
||||
for (const t of ((data as J)?.tracks ?? []) as J[]) {
|
||||
for (const r of (t?.releases ?? []) as J[]) {
|
||||
const codes = ((r.versionCodes ?? []) as unknown[]).map(Number).filter(Number.isFinite);
|
||||
const page = (data as J)?.publicPage as { state?: string; versionName?: string | null } | undefined;
|
||||
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);
|
||||
const production = t.track === 'production';
|
||||
const status = String(r.status ?? 'unknown');
|
||||
const phase: ReleasePhase = !production ? (status === 'draft' ? 'draft' : 'testing')
|
||||
: status === 'completed' ? 'live' : status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other';
|
||||
out.push({ store: 'android', version: versionOfName(r.name), build: codes.length ? String(Math.max(...codes)) : null, state: status, phase, fraction: typeof r.userFraction === 'number' ? r.userFraction : null, track: String(t.track) });
|
||||
const version = versionOfName(r.name);
|
||||
const rel: StoreRelease = { store: 'android', version, build: codes.length ? String(Math.max(...codes)) : null, state: status, phase: 'other', fraction: typeof r.userFraction === 'number' ? r.userFraction : null, track: String(t.track) };
|
||||
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';
|
||||
rel.publicPage = pageState;
|
||||
}
|
||||
out.push(rel);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const PHASE_RANK: Record<ReleasePhase, number> = { live: 0, rollout: 1, halted: 2, approved: 3, review: 4, rejected: 5, testing: 6, draft: 7, other: 8 };
|
||||
/** Plain words for a Play production release's phase (CLI text; the desktop has the same words in i18n). */
|
||||
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;
|
||||
}
|
||||
|
||||
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 };
|
||||
|
||||
/** Phases where the version reaches (or may reach) users. */
|
||||
export const REACHING: ReadonlySet<ReleasePhase> = new Set<ReleasePhase>(['live', 'rollout', 'halted', 'released']);
|
||||
const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) => PHASE_RANK[a.phase] - PHASE_RANK[b.phase])[0] ?? null;
|
||||
|
||||
/**
|
||||
|
|
@ -110,8 +163,11 @@ const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) =>
|
|||
*/
|
||||
export function releaseTrains(ios: StoreRelease[], android: StoreRelease[]): { current: ReleaseTrain | null; next: ReleaseTrain | null } {
|
||||
const all = [...ios, ...android].filter((r): r is StoreRelease & { version: string } => !!r.version);
|
||||
const train = (v: string): ReleaseTrain => ({ version: v, ios: pick(all.filter(r => r.store === 'ios' && r.version === v)), android: pick(all.filter(r => r.store === 'android' && r.version === v)) });
|
||||
const live = all.filter(r => r.phase === 'live' || r.phase === 'rollout' || r.phase === 'halted').map(r => r.version).sort(cmpVersion).at(-1) ?? null;
|
||||
const train = (v: string): ReleaseTrain => {
|
||||
const i = pick(all.filter(r => r.store === 'ios' && sameVersion(r.version, v))), a = pick(all.filter(r => r.store === 'android' && sameVersion(r.version, v)));
|
||||
return { version: displayVersion([a?.version, i?.version, v]), ios: i, android: a };
|
||||
};
|
||||
const live = all.filter(r => REACHING.has(r.phase)).map(r => r.version).sort(cmpVersion).at(-1) ?? null;
|
||||
const above = all.filter(r => !live || cmpVersion(r.version, live) > 0).map(r => r.version).sort(cmpVersion).at(-1) ?? null;
|
||||
return { current: live ? train(live) : null, next: above ? train(above) : null };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,10 @@
|
|||
// 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 } from './portfolio';
|
||||
import { cmpVersion, displayVersion, PHASE_RANK, REACHING, sameVersion, versionKey } from './portfolio';
|
||||
import type { Finding } from './validate-rules';
|
||||
|
||||
export { cmpVersion };
|
||||
export { cmpVersion, displayVersion, sameVersion, versionKey };
|
||||
|
||||
export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'neutral' | 'accent';
|
||||
|
||||
|
|
@ -36,6 +36,8 @@ export function laneOf(store: 'ios' | 'android', r: StoreRelease | null, testfli
|
|||
case 'testing': return { ...base, at: 1, tone: 'info', share: null };
|
||||
case 'review': return { ...base, at: 2, tone: 'warn', share: null };
|
||||
case 'approved': return { ...base, at: 2, tone: 'ok', share: null };
|
||||
case 'sent': return { ...base, at: 2, tone: 'warn', share: null };
|
||||
case 'released': return { ...base, at: 3, tone: r.publicPage === 'unknown' ? 'neutral' : 'info', share: null };
|
||||
case 'rejected': return { ...base, at: 2, tone: 'bad', share: null };
|
||||
case 'live': return { ...base, at: 4, tone: 'ok', share: 1 };
|
||||
case 'rollout': return { ...base, at: 4, tone: 'accent', share: r.fraction ?? null };
|
||||
|
|
@ -49,23 +51,26 @@ export interface BetaBuilds { builds: { version: string | null; internal: string
|
|||
|
||||
/** Both lanes of a train; iOS counts TestFlight as reached when a build of that version is in beta testing. */
|
||||
export function trainLanes(t: ReleaseTrain, tf?: BetaBuilds | null): Lane[] {
|
||||
const inBeta = !!tf?.builds.some(b => b.version === t.version && /IN_BETA_TESTING|BETA_APPROVED|READY_FOR_BETA_TESTING/.test(`${b.internal ?? ''} ${b.external ?? ''}`));
|
||||
const inBeta = !!tf?.builds.some(b => sameVersion(b.version, t.version) && /IN_BETA_TESTING|BETA_APPROVED|READY_FOR_BETA_TESTING/.test(`${b.internal ?? ''} ${b.external ?? ''}`));
|
||||
return [laneOf('ios', t.ios, inBeta), laneOf('android', t.android)];
|
||||
}
|
||||
|
||||
/** Every distinct version of the store releases, newest first, with both stores' release (best phase per store). */
|
||||
export function versionHistory(all: StoreRelease[]): ReleaseTrain[] {
|
||||
const rank: Record<string, number> = { live: 0, rollout: 1, halted: 2, approved: 3, review: 4, rejected: 5, testing: 6, draft: 7, other: 8 };
|
||||
const versions = [...new Set(all.map(r => r.version).filter((v): v is string => !!v))].sort(cmpVersion).reverse();
|
||||
const best = (store: 'ios' | 'android', v: string) => all.filter(r => r.store === store && r.version === v).sort((a, b) => (rank[a.phase] ?? 9) - (rank[b.phase] ?? 9))[0] ?? null;
|
||||
return versions.map(v => ({ version: v, ios: best('ios', v), android: best('android', v) }));
|
||||
const keys = new Map<string, string[]>(); // one row per release: 1.0 (App Store) and 1.0.0 (Play) are the same
|
||||
for (const v of all.map(r => r.version).filter((x): x is string => !!x)) { const k = versionKey(v); keys.set(k, [...(keys.get(k) ?? []), v]); }
|
||||
const best = (store: 'ios' | 'android', k: string) => all.filter(r => r.store === store && !!r.version && versionKey(r.version) === k).sort((a, b) => (PHASE_RANK[a.phase] ?? 99) - (PHASE_RANK[b.phase] ?? 99))[0] ?? null;
|
||||
return [...keys].map(([k, vs]) => {
|
||||
const ios = best('ios', k), android = best('android', k);
|
||||
return { version: displayVersion([android?.version, ios?.version, ...vs]), ios, android };
|
||||
}).sort((a, b) => cmpVersion(b.version, a.version));
|
||||
}
|
||||
|
||||
/** On one store: the highest version reaching users (live or rolling out) and the highest version above it. */
|
||||
export function storeNow(all: StoreRelease[], store: 'ios' | 'android'): { now: StoreRelease | null; next: StoreRelease | null } {
|
||||
const rs = all.filter((r): r is StoreRelease & { version: string } => r.store === store && !!r.version);
|
||||
const desc = (a: { version: string }, b: { version: string }) => cmpVersion(b.version, a.version);
|
||||
const now = rs.filter(r => r.phase === 'live' || r.phase === 'rollout' || r.phase === 'halted').sort(desc)[0] ?? null;
|
||||
const now = rs.filter(r => REACHING.has(r.phase)).sort(desc)[0] ?? null;
|
||||
const next = rs.filter(r => !now || cmpVersion(r.version, now.version) > 0).sort(desc)[0] ?? null;
|
||||
return { now, next };
|
||||
}
|
||||
|
|
@ -80,6 +85,8 @@ 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)';
|
||||
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;
|
||||
|
|
@ -176,6 +183,12 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null,
|
|||
case 'rejected':
|
||||
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' });
|
||||
break;
|
||||
case 'review':
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue