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

@ -2,6 +2,20 @@
## Unreleased
- **Parallel Play reads no longer break each other.** Google Play keeps one open edit per app, so two reads of one
app at once (the desktop's auto-read) deleted each other's edits ("This Edit has been deleted") and the Releases tab
then said "not on Google Play yet". Every Play edit of a package now runs under a machine-wide per-package lock
(`~/.storebound/locks`, wait with timeout and heartbeat; other packages stay parallel), and a read whose edit was
deleted is redone once with a new edit (writes are not). A store that failed to read shows "Couldn't read Google
Play" and keeps its last good releases with "couldn't refresh". When the stores' current versions differ, the card
header says "Current · Play 4.1.9 · App Store 4.1.2" and each lane shows its own state ("4.1.2 · Live (4.1.9 not
submitted)"); "Not on this store" only for an app with no record there (pitfall 58).
- **Google Play status is decided, never "maybe".** `play status`, the portfolio and the desktop read the live version
from the public store page ("Version" in About this app): the same version = Live, 404 = In review (managed
publishing: In review / ready to publish), an older one = In review (live: 1.0.0), Rolling out N% / Halted / Draft
from the API. Only a network failure says "Couldn't check the store page", and it keeps the last known answer with
its time. "In review or not yet published" and "Google may still be reviewing" are gone.
- **Desktop reads everything by itself.** No more "Not read yet" and a Read button on every page: on start, when the
window shows again and every 15 minutes while it is open (Settings: auto-read on / off, interval), every app's
status, releases and submission state, TestFlight, catalog, prices, listings, health, reviews, analytics and checks

View file

@ -71,7 +71,7 @@ const STORE_APPS: Record<string, StoreApp[]> = {
droid('com.example.nightnotes.free', 'Nightnotes Free', G.night), droid('com.example.moodgrid', 'Mood Grid', G.mood),
],
[A1]: [
ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.1.0', 'READY_FOR_SALE']),
ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.0.4', 'READY_FOR_SALE']),
ios('6400000012', 'com.example.blockgarden.ios', 'Block Garden', G.block, ['1.2.0', 'IN_REVIEW']), ios('6400000013', 'com.example.wordlantern', 'Word Lantern', G.lantern, ['2.0.1', 'READY_FOR_SALE']),
ios('6400000014', 'com.example.oldracer', 'Old Racer', G.racer, ['1.9.3', 'REMOVED_FROM_SALE']),
],
@ -166,7 +166,8 @@ function status(id: string, live: boolean): AppStatus {
}
/** Store-only app releases (fixture), by bundle id / package name. */
const STORE_RELEASES: Record<string, StoreRelease[]> = {
'com.example.orbithop': [rel('ios', '3.1.0', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })],
// the stores do not always move together: Play is on 3.1.0, the App Store still on 3.0.4
'com.example.orbithop': [rel('ios', '3.0.4', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })],
'com.example.blockgarden.ios': [rel('ios', '1.2.0', 'review', { build: '17', state: 'IN_REVIEW', date: iso(60 * 5) }), rel('ios', '1.1.2', 'live', { build: '15', state: 'READY_FOR_SALE', date: iso(60 * 24 * 40) })],
'com.example.blockgarden': [rel('android', '1.2.0', 'live', { build: '120', state: 'completed' })],
'com.example.wordlantern': [rel('ios', '2.0.1', 'live', { build: '41', state: 'READY_FOR_SALE', date: iso(60 * 24 * 18) })],

View file

@ -106,7 +106,7 @@ export const PLAY_STATUS = {
// A first release in the shapes the stores really answered (pitfall: App Store "1.0" in review while Google Play's
// production release of "1.0.0" reads "completed" but the app is not public yet). `play status --json` adds the public
// store page check (publicPage): 404 = not public yet, 200 = published.
// store page check (publicPage): 404 = not public yet, 200 = published with the page's live version.
export const APPLE_STATUS_FIRST_REVIEW = {
app: { id: '1000000002', attributes: { name: 'Prism Puddle', bundleId: 'com.example.prismpuddle' } },
versions: [version('ver-10', '1.0', 'WAITING_FOR_REVIEW', 'b-3')],
@ -119,7 +119,7 @@ const playFirst = (state: 'public' | 'notPublic') => ({
{ track: 'production', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed', userFraction: 1 }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
],
publicPage: { state, http: state === 'public' ? 200 : 404, versionName: null, checkedAt: '2026-10-13T08:00:00Z', url: 'https://play.google.com/store/apps/details?id=com.example.prismpuddle&hl=en' },
publicPage: { state, http: state === 'public' ? 200 : 404, versionName: state === 'public' ? '1.0.0' : null, checkedAt: '2026-10-13T08:00:00Z', url: 'https://play.google.com/store/apps/details?id=com.example.prismpuddle&hl=en' },
});
export const PLAY_STATUS_FIRST_404 = playFirst('notPublic');
export const PLAY_STATUS_FIRST_200 = playFirst('public');

View file

@ -118,9 +118,13 @@ export const en = {
'phase.paused': 'Paused',
'phase.approved': 'Approved',
'phase.review': 'In review',
'phase.sent': 'Sent to Google (in review or not yet published)',
'phase.released': 'Released to production (Google may still be reviewing)',
'phase.releasedUnknown': 'Released to production (live state unknown: the store page could not be read)',
'phase.sent': 'In review',
'phase.released': "Couldn't check the store page",
'phase.releasedUnknown': "Couldn't check the store page",
'phase.pageUnknown': "Couldn't check the store page",
'phase.pageStale': "couldn't check the store page, as of {when}",
'phase.reviewLive': 'In review (live: {version})',
'phase.reviewManaged': 'In review / ready to publish (managed publishing)',
'phase.rejected': 'Rejected',
'phase.testing': 'Testing',
'phase.draft': 'Draft',
@ -236,6 +240,12 @@ export const en = {
'tl.store': 'Store',
'tl.created': 'Created {date}',
'tl.notOnStore': 'Not on this store',
'tl.unknown': 'Unknown',
'tl.unreadable': "Couldn't read {store}: state unknown",
'tl.otherVersion': '{now} · {phase} ({version} not submitted)',
'tl.notThisVersion': 'Not this version',
'tl.current': 'Current',
'tl.bothVersions': 'Play {play} · App Store {ios}',
'tl.noVersion': '{version} is not on {store} yet',
'tl.everyone': 'Everyone',
'tl.day': 'day {day} of 7',
@ -245,6 +255,7 @@ export const en = {
'releases.notReadText': 'The timeline shows each version on both stores: build, TestFlight or internal testing, review, live and the rollout share. Read the stores once to fill it.',
'releases.noAccounts': 'Tip: map store accounts to this app in App folders first.',
'releases.storeUnreadable': '{store} could not be read, so its lane shows as unknown: {error}',
'releases.storeStale': "Couldn't refresh {store}: showing the read from {when}. {error}",
'releases.noVersions': 'No versions on the stores yet',
'releases.noVersionsText': 'When the first build is uploaded it shows here.',
'releases.history': 'Versions on the stores',

View file

@ -120,9 +120,13 @@ export const tr: Record<keyof typeof en, string> = {
'phase.paused': 'Duraklatıldı',
'phase.approved': 'Onaylandı',
'phase.review': 'İncelemede',
'phase.sent': 'Google’a gönderildi (incelemede ya da henüz yayınlanmadı)',
'phase.released': 'Üretime çıktı (Google hâlâ inceliyor olabilir)',
'phase.releasedUnknown': 'Üretime çıktı (yayın durumu bilinmiyor: mağaza sayfası okunamadı)',
'phase.sent': 'İncelemede',
'phase.released': 'Mağaza sayfası kontrol edilemedi',
'phase.releasedUnknown': 'Mağaza sayfası kontrol edilemedi',
'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi',
'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla',
'phase.reviewLive': 'İncelemede (yayında: {version})',
'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)',
'phase.rejected': 'Reddedildi',
'phase.testing': 'Testte',
'phase.draft': 'Taslak',
@ -238,6 +242,12 @@ export const tr: Record<keyof typeof en, string> = {
'tl.store': 'Mağaza',
'tl.created': 'Oluşturma: {date}',
'tl.notOnStore': 'Bu mağazada yok',
'tl.unknown': 'Bilinmiyor',
'tl.unreadable': '{store} okunamadı: durum bilinmiyor',
'tl.otherVersion': '{now} · {phase} ({version} gönderilmedi)',
'tl.notThisVersion': 'Bu sürüm değil',
'tl.current': 'Güncel',
'tl.bothVersions': 'Play {play} · App Store {ios}',
'tl.noVersion': '{version} henüz {store} mağazasında yok',
'tl.everyone': 'Herkes',
'tl.day': '7 günün {day}. günü',
@ -247,6 +257,7 @@ export const tr: Record<keyof typeof en, string> = {
'releases.notReadText': 'Zaman çizelgesi her sürümü iki mağazada birlikte gösterir: derleme, TestFlight ya da dahili test, inceleme, yayın ve dağıtım payı. Doldurmak için mağazaları bir kez okuyun.',
'releases.noAccounts': 'İpucu: önce Uygulama klasörleri ekranında bu uygulamaya mağaza hesaplarını bağlayın.',
'releases.storeUnreadable': '{store} okunamadı, bu yüzden şeridi bilinmiyor olarak gösteriliyor: {error}',
'releases.storeStale': '{store} yenilenemedi: {when} okunan durum gösteriliyor. {error}',
'releases.noVersions': 'Mağazalarda henüz sürüm yok',
'releases.noVersionsText': 'İlk derleme yüklendiğinde burada görünür.',
'releases.history': 'Mağazalardaki sürümler',

View file

@ -4,7 +4,8 @@
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
import { plainError } from '../../../../../cli/lib/google-errors';
import { useState } from 'react';
import type { ReleaseTrain, StoreRelease } from '../../api';
import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api';
import { laneGap, storesDiffer } from '../../../../../cli/lib/train';
import type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton';
import { type Key, t } from '../../i18n';
@ -13,7 +14,7 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory }
import { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons';
import { RolloutBar } from '../../ui/charts';
import { Card, Chip, Empty, Notice, PhaseChip, Skeleton, storeName } from '../../ui/kit';
import { Card, Chip, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
import { AppleSubmitCard, PlayProductionCard } from './Submit';
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
@ -22,23 +23,35 @@ function stageCaption(l: Lane, i: number): string {
if (i !== l.at || !l.release) return '';
const r = l.release;
if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing'));
if (i === 2) return t(`phase.${r.phase}` as Key);
if (i === 3 && r.phase === 'released') return t(r.publicPage === 'unknown' ? 'phase.releasedUnknown' : 'phase.released');
if (i === 2) return r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key);
if (i === 3 && r.phase === 'released') return phaseLabel(r).text;
return '';
}
function LaneRow({ lane, version }: { lane: Lane; version: string }) {
/** The text of a lane that does not carry this version: couldn't read, the store's own current version, not there yet,
* or (only without any record of the app on that store) "Not on this store". */
function gapText(lane: Lane, version: string, st: AppStatus): { who: string; line: string; kind: string } {
const g = laneGap(lane.store, st.release.all ?? [], st.storeErrors[lane.store]);
const store = storeName(lane.store);
if (g.kind === 'unreadable') return { kind: g.kind, who: t('tl.unknown'), line: t('tl.unreadable', { store }) };
if (g.kind === 'other') return { kind: g.kind, who: g.now.version, line: t('tl.otherVersion', { now: g.now.version, phase: t(`phase.${g.now.phase}` as Key), version }) };
if (g.kind === 'missing') return { kind: g.kind, who: t('tl.notThisVersion'), line: t('tl.noVersion', { version, store }) };
return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') };
}
function LaneRow({ lane, version, st }: { lane: Lane; version: string; st: AppStatus }) {
const r = lane.release;
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
return (
<div className="tl-lane">
<div className="tl-who">
<StoreGlyph store={lane.store} />
<div>
<div className="w1">{storeName(lane.store)}</div>
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}</div>
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
</div>
</div>
{lane.at < 0 ? <div className="tl-none">{t('tl.noVersion', { version, store: storeName(lane.store) })}</div> : STAGES.map((s, i) => {
{gap ? <div className="tl-none" data-gap={gap.kind}>{gap.line}</div> : STAGES.map((s, i) => {
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : '';
const last = s === 'rollout';
return (
@ -102,16 +115,19 @@ function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) {
return null;
}
function Train({ title, train, tf, tone, app }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp }) {
function Train({ title, train, tf, tone, app, st }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp; st: AppStatus }) {
const lanes = trainLanes(train, tf);
// "Live" only when a store has it live or rolling out; a Play release Google may still review is "Released"
const label = tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
// the stores' current versions differ (normal: they do not always move together): a neutral "Current" header
const differ = tone === 'ok' ? storesDiffer(st.release.all ?? []) : null;
// "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not
const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version;
return (
<Card pad={false} title={<span className="train-head"><span className="ver">{train.version}</span><Chip tone={tone === 'ok' && label !== title ? 'info' : tone}>{label}</Chip></span>}
<Card pad={false} title={<span className="train-head"><span className="ver">{ver}</span><Chip tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone}>{label}</Chip></span>}
sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
<div className="tl" style={{ paddingTop: 12 }}>
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div>
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} />)}
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} />)}
</div>
{lanes.some(l => l.release) && app.project && (
<div className="card-foot roll-foot">
@ -157,9 +173,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
const errs = Object.entries(l.storeErrors);
return (
<>
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>)}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} />}
{errs.map(([s, msg]) => {
const when = l.staleStores?.[s as 'ios'];
return <Notice key={s} tone="warn">{when ? t('releases.storeStale', { store: storeName(s as 'ios'), when: ago(when), error: plainError(msg) }) : t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>;
})}
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} st={l} />}
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} st={l} />}
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
<Card title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false}>

View file

@ -16,6 +16,7 @@ import { keepLines, progressOf } from './lib/progress';
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
import { fixHint } from './lib/views';
import { withLastGood } from '../../../cli/lib/train';
import { failureText, usable } from './lib/results';
export type ToastKind = 'job' | 'ok' | 'bad' | 'info';
@ -201,7 +202,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
try {
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
const r = await api.portfolio(liveRead, job, stores);
let r = await api.portfolio(liveRead, job, stores);
// a store that failed this time keeps its last good releases ("couldn't refresh"), never turns empty
const prevLive = liveRef.current;
if (liveRead && prevLive) r = { ...r, apps: r.apps.map(a => withLastGood(prevLive.value.apps.find(x => x.project === a.project), a, prevLive.at)) };
setPortfolio(r);
if (liveRead) {
const changes = releaseChanges(liveRef.current?.value, r);

View file

@ -22,7 +22,11 @@ export function phaseLabel(r: StoreRelease | null | undefined): { text: string;
const pct = r.fraction !== null && r.fraction !== undefined && r.fraction < 1 ? ` ${share(r.fraction)}` : '';
if (r.phase === 'rollout') return { text: (r.store === 'ios' ? t('phase.phased') : t('phase.rollout')) + pct, tone: 'accent' };
if (r.phase === 'halted') return { text: (r.store === 'ios' ? t('phase.paused') : t('phase.halted')) + pct, tone: 'bad' };
if (r.phase === 'released' && r.publicPage === 'unknown') return { text: t('phase.releasedUnknown'), tone: 'neutral' };
const stale = r.pageStaleAt ? ` · ${t('phase.pageStale', { when: ago(r.pageStaleAt) })}` : '';
if (r.store === 'android' && r.phase === 'released') return { text: t('phase.pageUnknown') + stale, tone: 'neutral' };
if (r.store === 'android' && (r.phase === 'review' || r.phase === 'sent'))
return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.review')) + stale, tone: 'warn' };
if (r.store === 'android' && r.phase === 'live' && stale) return { text: t('phase.live') + stale, tone: 'ok' };
if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' };
return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] };
}

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(' ')}`);
});

View file

@ -653,9 +653,11 @@ that fails without the fix (see CONTRIBUTING.md).
## 56. "Live on Play" while Google was still reviewing, and 1.0 / 1.0.0 shown as two versions
- **Do:** never call a Google Play production release live from the Developer API alone: a release reads `completed`
(userFraction 1) from the moment it is sent, also while Google reviews the first release. `play status` checks the
public store page (no auth, 4 s timeout, cached 5 minutes): 404 = "Sent to Google (in review or not yet
published)"; 200 = live for the first release (or when the page shows the same versionName), otherwise "Released to
production (Google may still be reviewing)"; offline = unknown, shown as unknown. Match versions across the stores
public store page (no auth, 4 s timeout, cached 5 minutes) and decides, never "maybe": the page's "Version" (About
this app, read from its AF_initDataCallback data) equals the release's versionName = "Live"; 404 = "In review" (with
play.managedPublishing: "In review / ready to publish (managed publishing)"); an older version on the page = "In
review (live: <older>)"; "Varies with device" = live. Only a network failure gives "Couldn't check the store page",
and then the last known page answer is used with its time. Match versions across the stores
by `versionKey` (trailing .0 parts dropped: 1 == 1.0 == 1.0.0); show each store's own string.
- **Guard:** `playPublicPage` (`cli/lib/play-public.ts`), `playReleases` / `versionKey` / `sameVersion`
(`cli/lib/portfolio.ts`), `versionHistory` (`cli/lib/train.ts`), `tests/play-public-version-match.test.ts`, the
@ -671,3 +673,18 @@ that fails without the fix (see CONTRIBUTING.md).
findings; a real failure shows the command's own error line, never a bare exit code.
- **Guard:** `cli/lib/google-errors.ts`, `outcomeOf` / `errorLine` (`cli/lib/desktop-protocol.ts`),
`tests/google-errors.test.ts`, the desktop boot check's `errors` cases (api-disabled, scan-findings, scan-failed).
## 58. Parallel Play reads deleted each other's edits, and a failed read said "not on Google Play yet"
- **Do:** Google Play keeps one open edit per app: a second `edits.insert` for the same package deletes the first, so a
read in it answers 400 "This Edit has been deleted". Every edit (read or write) of a package takes the machine-wide
lock `~/.storebound/locks/play-edit-<package>.lock` (short wait with a heartbeat log, 90 s timeout for reads, 10 min
for writes; stale when its process is gone or untouched for a minute); other packages stay parallel. A read whose
edit was deleted, committed elsewhere or expired is redone once with a new edit; a write is never redone that way
(it keeps its own invalidation retry). A store that could not be read is "couldn't read Google Play" (unknown),
never "not on Google Play yet"; its last good releases stay with "couldn't refresh". When the stores' current
versions differ (normal), each lane shows its own ("4.1.2 · Live (4.1.9 not submitted)") under a neutral "Current"
header; "Not on this store" only when the store has no record of the app.
- **Guard:** `withMachineLock` (`cli/lib/lock.ts`), `read()` / `edit()` (`cli/play/client.ts`),
`laneGap` / `storesDiffer` / `withLastGood` (`cli/lib/train.ts`), `tests/play-edit-lock.test.ts` (a fake Play where a
second insert invalidates the first), the desktop boot check's auto-read case (two Play reads of one app at once,
Orbit Hop on different versions per store).

7
tests/fixtures/play-page-version.html vendored Normal file
View file

@ -0,0 +1,7 @@
<!doctype html><html lang="en-US"><head><title>Example Game - Apps on Google Play</title></head><body>
<!-- Recorded 2026-10-13 from the store page of a live app (version 0.8.1),
trimmed: only the AF_initDataCallback blocks' shape around "About this app"; image URLs, other blobs and ids removed. -->
<script nonce="n">AF_initDataCallback({key: 'ds:0', hash: '1', data:[[["en-US"]],null,[["Varies"]]], sideChannel: {}});</script>
<script nonce="n">AF_initDataCallback({key: 'ds:3', hash: '7', data:[[[["1.0"]]],[null,[["Recent changes"]]]], sideChannel: {}});</script>
<script nonce="n">AF_initDataCallback({key: 'ds:5', hash: '12', data:[[[[]]],[null,null,[["Example Game"],null,null,null,[null,[null,"You can request that data be deleted"]],1],null,null,null,[[["0.8.1"]],[[[36]],[[[24,"7.0"]]]]],null,null,null,[null,[null,"Example Game 0.8.1 (Alpha): bug fixes and stability improvements."]],[["Sep 30, 2026",[1790776977,376000000]]],null,null,[0,4,0,0],null,null,null,null,null,[null,[[],null,11]]],{"12":[["com.example.demo"]]}], sideChannel: {}});</script>
</body></html>

View file

@ -13,6 +13,7 @@ process.env.STOREBOUND_LIST_SETTLE_MS ??= '0';
const machine = mkdtempSync(join(tmpdir(), 'storebound-machine-'));
process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials');
process.env.STOREBOUND_REGISTRY = join(machine, 'projects.json');
process.env.STOREBOUND_LOCK_DIR = join(machine, 'locks');
// Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation:
// DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code.

View file

@ -0,0 +1,135 @@
// Google Play keeps one open edit per app: a second edits.insert for the same package deletes the first, and a read in
// the first edit then answers 400 "This Edit has been deleted." (seen live when the desktop's auto-read ran several Play
// reads of one app at once). The fake below does exactly that; parallel reads of one package must all succeed, other
// packages must still run in parallel, and a read whose edit was replaced is redone once with a new edit.
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { mkdtempSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { playClient } from '../cli/play/client';
import { withMachineLock } from '../cli/lib/lock';
import { laneGap, storesDiffer, withLastGood } from '../cli/lib/train';
import type { AppStatus, StoreRelease } from '../cli/lib/desktop-protocol';
const realFetch = globalThis.fetch;
const saved = process.env.PLAY_SERVICE_ACCOUNT;
afterEach(() => { globalThis.fetch = realFetch; if (saved === undefined) delete process.env.PLAY_SERVICE_ACCOUNT; else process.env.PLAY_SERVICE_ACCOUNT = saved; });
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
const KEY = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
beforeEach(() => { process.env.PLAY_SERVICE_ACCOUNT = KEY; });
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
const DELETED = { error: { code: 400, message: 'This Edit has been deleted.', status: 'INVALID_ARGUMENT' } };
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
/** Fake androidpublisher: one open edit per package; an insert replaces it. Counts the edits open at once per package. */
function fakePlay(o: { delay?: number; replaceOnce?: string } = {}) {
const open = new Map<string, string>();
let n = 0;
const peak = { total: 0, now: 0 };
let replaced = false;
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
const url = String(input);
const method = init?.method ?? 'GET';
if (url.includes('oauth2')) return json({ access_token: 't', expires_in: 3600 });
const m = /applications\/([^/]+)\/edits(?:\/([^/:]+))?(\/.*)?$/.exec(url);
if (!m) return json({}, 404);
const [, pkg, id, rest] = m as unknown as [string, string, string | undefined, string | undefined];
if (method === 'POST' && !id) {
const e = `e${++n}`; open.set(pkg, e);
peak.now++; peak.total = Math.max(peak.total, peak.now);
return json({ id: e });
}
if (method === 'DELETE') { peak.now--; if (open.get(pkg) === id) open.delete(pkg); return json({}); }
await wait(o.delay ?? 15);
if (o.replaceOnce === pkg && !replaced) { replaced = true; open.set(pkg, `e${++n}`); } // e.g. a Console save
if (open.get(pkg) !== id) return json(DELETED, 400);
return json({ ok: rest ?? '' });
}) as typeof fetch;
return peak;
}
describe('Play edits: one open edit per package', () => {
test('the fake is faithful: two edits of one package without the lock kill the first', async () => {
fakePlay();
const ids: string[] = [];
const call = async (pkg: string) => { const r = await realFetchless(pkg); ids.push(r); };
async function realFetchless(pkg: string): Promise<string> {
const e = await (await fetch(`https://x/applications/${pkg}/edits`, { method: 'POST' })).json() as { id: string };
await wait(5);
const r = await fetch(`https://x/applications/${pkg}/edits/${e.id}/details`);
return String(r.status);
}
await Promise.all([call('com.example.a'), call('com.example.a')]);
expect(ids.sort()).toEqual(['200', '400']);
});
test('parallel reads of one package all succeed (serialized)', async () => {
const peak = fakePlay();
const play = [await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo')];
const out = await Promise.all(play.map(p => p.read(async id => {
const a = await p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`);
const b = await p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/tracks`);
return [a.ok, b.ok];
})));
expect(out).toEqual(Array(4).fill(['/details', '/tracks']));
expect(peak.total).toBe(1);
});
test('different packages still read in parallel', async () => {
const peak = fakePlay({ delay: 40 });
const pkgs = ['com.example.a', 'com.example.b', 'com.example.c'];
const started = Date.now();
const out = await Promise.all(pkgs.map(async pkg => { const p = await playClient('/nonexistent', pkg); return p.read(id => p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`)); }));
expect(out.map(o => o.ok)).toEqual(['/details', '/details', '/details']);
expect(peak.total).toBe(3);
expect(Date.now() - started).toBeLessThan(40 * 3);
});
test('a read whose edit was deleted is redone once with a new edit', async () => {
fakePlay({ replaceOnce: 'com.example.demo' });
const p = await playClient('/nonexistent', 'com.example.demo');
const seen: string[] = [];
const r = await p.read(async id => { seen.push(id); return p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`); });
expect(r.ok).toBe('/details');
expect(seen.length).toBe(2);
expect(seen[0]).not.toBe(seen[1]);
});
test('a lock held by another holder times out with who holds it', async () => {
const dir = mkdtempSync(join(tmpdir(), 'sb-lock-'));
const prev = process.env.STOREBOUND_LOCK_DIR;
process.env.STOREBOUND_LOCK_DIR = dir;
try {
const beats: string[] = [];
let release!: () => void;
const first = withMachineLock('play-edit-x', 'play read x', () => new Promise<void>(r => { release = r; }));
await wait(10);
await expect(withMachineLock('play-edit-x', 'play read x', async () => 1, { timeoutMs: 60, pollMs: 10, heartbeatMs: 20, onWait: m => beats.push(m) })).rejects.toThrow(/timed out .*play read x/);
expect(beats.length).toBeGreaterThan(0);
release(); await first;
expect(await withMachineLock('play-edit-x', 'play read x', async () => 2, { timeoutMs: 100 })).toBe(2);
} finally { if (prev === undefined) delete process.env.STOREBOUND_LOCK_DIR; else process.env.STOREBOUND_LOCK_DIR = prev; }
});
});
describe('a store that could not be read is unknown, never empty', () => {
const r = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase']): StoreRelease => ({ store, version, build: '1', state: 's', phase, fraction: null, track: store === 'android' ? 'production' : null });
test("laneGap: unreadable, the store's own other version, missing, none", () => {
expect(laneGap('android', [r('ios', '0.8.1', 'live')], '400 This Edit has been deleted.').kind).toBe('unreadable');
expect(laneGap('ios', [r('ios', '4.1.2', 'live'), r('android', '4.1.9', 'live')])).toMatchObject({ kind: 'other', now: { version: '4.1.2' } });
expect(laneGap('ios', [r('ios', '1.0', 'review')]).kind).toBe('missing');
expect(laneGap('ios', [r('android', '1.0', 'live')]).kind).toBe('none');
expect(storesDiffer([r('ios', '4.1.2', 'live'), r('android', '4.1.9', 'live')])).toEqual({ ios: '4.1.2', android: '4.1.9' });
expect(storesDiffer([r('ios', '4.1', 'live'), r('android', '4.1.0', 'live')])).toBeNull();
});
test('withLastGood keeps the last good Play releases with their time after a failed refresh', () => {
const prev = { release: { source: 'live', current: null, next: null, all: [r('ios', '0.8.1', 'live'), r('android', '0.8.1', 'live')] }, storeErrors: {} } as unknown as AppStatus;
const next = { release: { source: 'live', current: null, next: null, all: [r('ios', '0.8.1', 'live')] }, storeErrors: { android: 'GET /edits/x/details -> 400 This Edit has been deleted.' } } as unknown as AppStatus;
const m = withLastGood(prev, next, '2026-10-13T10:00:00Z');
expect(m.staleStores).toEqual({ android: '2026-10-13T10:00:00Z' });
expect(m.release.current).toMatchObject({ version: '0.8.1', android: { phase: 'live' } });
expect(withLastGood(undefined, next, 'x')).toBe(next);
});
});

View file

@ -4,7 +4,9 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { APPLE_STATUS_FIRST_REVIEW, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures';
import { trainsOf } from '../cli/release';
import { clearPlayPublicCache, pageVersion, playPublicPage } from '../cli/lib/play-public';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { clearPlayPublicCache, pageVersion, pageVersionInfo, playPublicPage } from '../cli/lib/play-public';
import { appleReleases, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio';
import { laneOf, laneText, releasePlan, storeNow, versionHistory } from '../cli/lib/train';
@ -31,10 +33,10 @@ describe('version matching across the stores', () => {
expect(h).toHaveLength(1);
expect(h[0]!.version).toBe('1.0.0');
expect(h[0]!.ios).toMatchObject({ version: '1.0', build: '3', phase: 'review' });
expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'sent', track: 'production' });
expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'review', track: 'production' });
const t = releaseTrains(ios, android);
expect(t.current).toBeNull(); // nothing reaches users yet
expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'sent' } });
expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'review' } });
// release status --versions: the one row is the next train
const s = { release: { source: 'live' as const, ...t, all }, storeErrors: {}, summary: null } as never;
expect(trainsOf(s, true).map(x => [x.which, x.train.version])).toEqual([['next', '1.0.0']]);
@ -43,15 +45,15 @@ describe('version matching across the stores', () => {
expect(p.ios?.release?.version).toBe('1.0');
expect(p.android?.release?.version).toBe('1.0.0');
expect(p.done).toBe(false);
expect(p.android!.steps.map(x => x.text).join(' ')).toContain('sent to Google');
expect(p.android!.steps.map(x => x.text).join(' ')).toContain('1.0: In review');
});
});
describe('Google Play "Live" needs the public store page', () => {
test('completed production + page 404 = sent to Google, never live', () => {
test('completed production + page 404 = in review, never live', () => {
const [prod] = playReleases(PLAY_STATUS_FIRST_404);
expect(prod).toMatchObject({ phase: 'sent', state: 'completed', publicPage: 'notPublic', fraction: 1 });
expect(playReleaseLabel(prod!)).toBe('Sent to Google (in review or not yet published)');
expect(prod).toMatchObject({ phase: 'review', state: 'completed', publicPage: 'notPublic', fraction: 1 });
expect(playReleaseLabel(prod!)).toBe('In review');
const lane = laneOf('android', prod!);
expect(lane.at).toBe(2); // the review stage, not live / rollout
expect(lane.share).toBeNull();
@ -65,22 +67,46 @@ describe('Google Play "Live" needs the public store page', () => {
const t = releaseTrains(appleReleases(APPLE_STATUS_FIRST_REVIEW), playReleases(PLAY_STATUS_FIRST_200));
expect(t.current).toMatchObject({ version: '1.0.0', android: { phase: 'live' }, ios: { phase: 'review' } });
});
test('an update after the first release: page 200 is not proof for the new versionCode unless the page shows it', () => {
const update = { publicPage: { state: 'public', versionName: null }, tracks: [
{ track: 'production', releases: [{ name: '1.1.0', versionCodes: ['5'], status: 'completed' }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
] };
const [prod] = playReleases(update);
expect(prod!.phase).toBe('released');
expect(playReleaseLabel(prod!)).toBe('Released to production (Google may still be reviewing)');
expect(laneText(laneOf('android', prod!))).toContain('Google may still be reviewing');
expect(playReleases({ ...update, publicPage: { state: 'public', versionName: '1.1' } })[0]!.phase).toBe('live');
const prodRel = (page: Record<string, unknown> | undefined, name = '1.1.0', status = 'completed', extra: Record<string, unknown> = {}) => playReleases({ ...extra, publicPage: page, tracks: [
{ track: 'production', releases: [{ name, versionCodes: ['5'], status, userFraction: status === 'completed' ? undefined : 0.2 }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
] })[0]!;
test('rule: page version = the release version: Live', () => {
const r = prodRel({ state: 'public', versionName: '1.1' });
expect(r.phase).toBe('live');
expect(playReleaseLabel(r)).toBe('Live');
});
test('page not read = unknown, shown as unknown', () => {
const [prod] = playReleases({ tracks: PLAY_STATUS_FIRST_404.tracks });
expect(prod).toMatchObject({ phase: 'released', publicPage: 'unknown' });
expect(playReleaseLabel(prod!)).toContain('unknown');
expect(laneOf('android', prod!).tone).toBe('neutral');
test('rule: page 404, managed publishing off: In review; on: In review / ready to publish', () => {
expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }))).toBe('In review');
expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }, '1.1.0', 'completed', { managedPublishing: true }))).toBe('In review / ready to publish (managed publishing)');
});
test('rule: page live with an older version: In review (live: <older>)', () => {
const r = prodRel({ state: 'public', versionName: '1.0.0' });
expect(r).toMatchObject({ phase: 'review', liveVersion: '1.0.0' });
expect(playReleaseLabel(r)).toBe('In review (live: 1.0.0)');
expect(laneText(laneOf('android', r))).toBe('in review (live: 1.0.0)');
expect(storeNow([r], 'android').now).toBeNull();
});
test('rule: rollout, halted, draft', () => {
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'inProgress'))).toBe('Rolling out 20%');
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'halted'))).toBe('Halted');
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'draft'))).toBe('Draft');
});
test('rule: "Varies with device" = live (nothing to compare)', () => {
expect(prodRel({ state: 'public', versionName: null, varies: true }).phase).toBe('live');
});
test('rule: only a network failure is "Couldn\'t check the store page"; with a last known read it keeps that state and its time', () => {
const r = prodRel(undefined);
expect(r).toMatchObject({ phase: 'released', publicPage: 'unknown' });
expect(playReleaseLabel(r)).toBe("Couldn't check the store page");
expect(laneOf('android', r).tone).toBe('neutral');
const s = prodRel({ state: 'public', versionName: '1.1.0', stale: true, checkedAt: '2026-10-12T09:30:00Z' });
expect(s).toMatchObject({ phase: 'live', pageStaleAt: '2026-10-12T09:30:00Z' });
expect(playReleaseLabel(s)).toBe("Live (couldn't check the store page now; as of 2026-10-12 09:30 UTC)");
});
test('no uncertain wording is left', () => {
for (const r of [prodRel(undefined), prodRel({ state: 'notPublic' }), prodRel({ state: 'public', versionName: '1.0' })])
expect(`${playReleaseLabel(r)} ${laneText(laneOf('android', r))}`).not.toMatch(/not yet published|may still be reviewing|Sent to Google/);
});
});
@ -89,10 +115,19 @@ describe('public store page read', () => {
test('404 = not public yet, 200 = public, the url has no auth and asks for English', async () => {
const urls = answer(404);
expect((await playPublicPage('com.example.first404', { noCache: true })).state).toBe('notPublic');
expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en');
expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en&gl=US');
answer(200, '<script type="application/ld+json">{"softwareVersion":"1.0.0"}</script>');
expect(await playPublicPage('com.example.first200', { noCache: true })).toMatchObject({ state: 'public', http: 200, versionName: '1.0.0' });
});
test('a network failure returns the last known answer with its time', async () => {
const pkg = `com.example.lastknown${Date.now()}`;
answer(200, readFileSync(join(import.meta.dir, 'fixtures/play-page-version.html'), 'utf8'));
const ok = await playPublicPage(pkg, { noCache: true });
expect(ok).toMatchObject({ state: 'public', versionName: '0.8.1' });
globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch;
const r = await playPublicPage(pkg, { noCache: true });
expect(r).toMatchObject({ state: 'public', versionName: '0.8.1', stale: true, checkedAt: ok.checkedAt });
});
test('offline or a server error = unknown; unknown is never cached', async () => {
const off = `com.example.offline${Date.now()}`;
globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch;
@ -112,9 +147,12 @@ describe('public store page read', () => {
expect((await playPublicPage(pkg)).state).toBe('notPublic');
expect(urls).toHaveLength(1);
});
test('the page version is read only from structured data', () => {
test('the page version is read from the recorded live page shape (About this app in the ds blobs)', () => {
const html = readFileSync(join(import.meta.dir, 'fixtures/play-page-version.html'), 'utf8');
expect(pageVersionInfo(html)).toEqual({ versionName: '0.8.1', varies: false });
expect(pageVersionInfo(html.replace('[[["0.8.1"]]', '[[["Varies with device"]]'))).toEqual({ versionName: null, varies: true });
expect(pageVersion('{"softwareVersion": "2.3.1"}')).toBe('2.3.1');
expect(pageVersion('{"softwareVersion":"Varies with device"}')).toBeNull();
expect(pageVersionInfo('{"softwareVersion":"Varies with device"}')).toEqual({ versionName: null, varies: true });
expect(pageVersion('<div>Version 1.0</div>')).toBeNull();
});
});

View file

@ -24,7 +24,7 @@ const play = {
{ track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] },
],
products: [{ productId: 'coins' }, { productId: 'no_ads' }, { sku: 'android_only' }],
publicPage: { state: 'public' },
publicPage: { state: 'public', versionName: '2.2.1' },
};
describe('portfolio (apps, release trains, catalog parity)', () => {

View file

@ -22,7 +22,7 @@ const play = {
{ track: 'production', releases: [{ name: '2.3.0', versionCodes: ['230'], status: 'inProgress', userFraction: 0.1 }, { name: '2.2.0', versionCodes: ['220'], status: 'completed' }] },
{ track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] },
],
publicPage: { state: 'public' },
publicPage: { state: 'public', versionName: '2.2.0' },
};
const all: StoreRelease[] = [...appleReleases(apple), ...playReleases(play)];

View file

@ -254,7 +254,7 @@ describe('serve --stdio: session', () => {
const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: summary }
: argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } }
: argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' }
: argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }], publicPage: { state: 'public' } } } : {});
: argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }], publicPage: { state: 'public', versionName: '1.2.0' } } } : {});
const r = resOf(await call('portfolio.status', { projects: [DEMO, '/nope/game'], live: true })) as { apps: J[] };
expect(r.apps[0]).toMatchObject({ project: DEMO, app: { id: DEMO, name: 'Demo', platforms: { ios: { bundleId: 'com.example.demo' }, android: { packageName: 'com.example.demo' } } },
check: { ok: true, warnings: ['w'] }, storeErrors: { ios: 'ASC_KEY_ID and ASC_ISSUER_ID are not set' }, ok: false });

View file

@ -541,7 +541,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
let visited = 0;
const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' ');
const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); };
const SENT = 'Sent to Google (in review or not yet published)';
const SENT = 'In review'; // decided from the store page: 404 = in review (no uncertain wording)
try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' });
await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)'));
@ -568,6 +568,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
if (row[1] !== 'In review') problems.push(`Releases: App Store cell "${row[1]}" (want "In review")`);
if (row[3] !== (page404 ? SENT : 'Live')) problems.push(`Releases: Google Play cell "${row[3]}" (want "${page404 ? SENT : 'Live'}")`);
const rel = await text();
if (/not yet published|may still be reviewing|Sent to Google/.test(rel + pf)) problems.push('uncertain Play wording on screen');
if (/is not on (App Store|Google Play) yet/.test(rel)) problems.push('Releases: a lane says the version is not on a store (1.0 and 1.0.0 not joined)');
for (const w of ['1.0 (3)', '1.0.0 (3)']) if (!rel.includes(w)) problems.push(`Releases: the lane does not show the store's own "${w}"`);
if (page404) {
@ -609,6 +610,8 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
const notes: string[] = [];
const logged: string[] = [];
let reads = 0;
const playOpen = new Map<string, number>();
let playPeak = 0;
// fixture art is file: URLs (blocked by the CSP): icons are left out
const clean = (v: unknown): unknown => (v === undefined ? null : JSON.parse(JSON.stringify(v), (_k, x) => (typeof x === 'string' && x.startsWith('file:') ? null : x)));
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
@ -621,6 +624,15 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
const method = String(a.method);
if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a));
reads++;
if (method.startsWith('play.') || ['health', 'reviews.list', 'catalog.diff', 'prices.current', 'validate'].includes(method)) {
// Play reads of one app may run at once (the scheduler's 4 per store, starts 250 ms apart per key): count the
// overlap and hold each read long enough for it to happen
const k = String(a.projectId);
const n = (playOpen.get(k) ?? 0) + 1;
playOpen.set(k, n); playPeak = Math.max(playPeak, n);
await new Promise(r => setTimeout(r, 400));
playOpen.set(k, playOpen.get(k)! - 1);
}
return clean(fx.fixtureRead(method, String(a.projectId), ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {}));
}
default: return clean(await fx.mockInvoke(cmd, a));
@ -664,10 +676,24 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
await settle(page);
await crashCheck(`${appName} ${id}`);
const tx = await text();
// a Play read that lost its edit to a parallel read (pitfall), or a store that failed called empty
for (const bad of [/Edit has been deleted/i, /is not on Google Play yet/i]) if (bad.test(tx)) problems.push(`${appName} ${id}: says "${(bad.exec(tx) ?? [''])[0]}"`);
if (/not read yet|Read the stores to see this/i.test(tx)) problems.push(`${appName} ${id}: says "${(/not read yet|Read the stores to see this/i.exec(tx) ?? [''])[0]}" after the background read`);
visited++;
}
}
notes.push(`Play reads of one app at once: up to ${playPeak}`);
if (playPeak < 2) problems.push(`no two Play reads of one app ran at once (peak ${playPeak}): the case does not exercise parallel reads`);
// the stores' current versions differ (Play 3.1.0, App Store 3.0.4): a neutral header, each store its own state
await page.locator('nav.side .nav-app', { hasText: 'Orbit Hop' }).first().click({ timeout: WAIT_MS });
await settle(page);
await page.locator('[role="tab"]#tab-releases').click({ timeout: WAIT_MS });
await settle(page);
const rel = await text();
for (const w of ['Current', 'Play 3.1.0 · App Store 3.0.4', '3.0.4 · Live (3.1.0 not submitted)']) if (!rel.includes(w)) problems.push(`Orbit Hop Releases: missing "${w}"`);
for (const w of ['Not on this store', 'is not on App Store yet', '3.1.0 Released']) if (rel.includes(w)) problems.push(`Orbit Hop Releases: says "${w}"`);
notes.push(`Orbit Hop releases: ${rel.slice(0, 200)}`);
await crashCheck('Orbit Hop Releases');
// Health: the first top issue opens with its full title, the sample stack and Copy for AI
await page.locator('nav.side .nav-app', { hasText: 'Prism Puddle' }).first().click({ timeout: WAIT_MS });
await settle(page);