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:
parent
e59f439101
commit
714352ec18
24 changed files with 570 additions and 103 deletions
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue