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
|
|
@ -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