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 ## 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 - **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 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 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), droid('com.example.nightnotes.free', 'Nightnotes Free', G.night), droid('com.example.moodgrid', 'Mood Grid', G.mood),
], ],
[A1]: [ [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('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']), 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. */ /** Store-only app releases (fixture), by bundle id / package name. */
const STORE_RELEASES: Record<string, StoreRelease[]> = { 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.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.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) })], '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 // 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 // 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 = { export const APPLE_STATUS_FIRST_REVIEW = {
app: { id: '1000000002', attributes: { name: 'Prism Puddle', bundleId: 'com.example.prismpuddle' } }, app: { id: '1000000002', attributes: { name: 'Prism Puddle', bundleId: 'com.example.prismpuddle' } },
versions: [version('ver-10', '1.0', 'WAITING_FOR_REVIEW', 'b-3')], 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: 'production', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed', userFraction: 1 }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] }, { 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_404 = playFirst('notPublic');
export const PLAY_STATUS_FIRST_200 = playFirst('public'); export const PLAY_STATUS_FIRST_200 = playFirst('public');

View file

@ -118,9 +118,13 @@ export const en = {
'phase.paused': 'Paused', 'phase.paused': 'Paused',
'phase.approved': 'Approved', 'phase.approved': 'Approved',
'phase.review': 'In review', 'phase.review': 'In review',
'phase.sent': 'Sent to Google (in review or not yet published)', 'phase.sent': 'In review',
'phase.released': 'Released to production (Google may still be reviewing)', 'phase.released': "Couldn't check the store page",
'phase.releasedUnknown': 'Released to production (live state unknown: the store page could not be read)', '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.rejected': 'Rejected',
'phase.testing': 'Testing', 'phase.testing': 'Testing',
'phase.draft': 'Draft', 'phase.draft': 'Draft',
@ -236,6 +240,12 @@ export const en = {
'tl.store': 'Store', 'tl.store': 'Store',
'tl.created': 'Created {date}', 'tl.created': 'Created {date}',
'tl.notOnStore': 'Not on this store', '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.noVersion': '{version} is not on {store} yet',
'tl.everyone': 'Everyone', 'tl.everyone': 'Everyone',
'tl.day': 'day {day} of 7', '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.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.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.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.noVersions': 'No versions on the stores yet',
'releases.noVersionsText': 'When the first build is uploaded it shows here.', 'releases.noVersionsText': 'When the first build is uploaded it shows here.',
'releases.history': 'Versions on the stores', '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.paused': 'Duraklatıldı',
'phase.approved': 'Onaylandı', 'phase.approved': 'Onaylandı',
'phase.review': 'İncelemede', 'phase.review': 'İncelemede',
'phase.sent': 'Google’a gönderildi (incelemede ya da henüz yayınlanmadı)', 'phase.sent': 'İncelemede',
'phase.released': 'Üretime çıktı (Google hâlâ inceliyor olabilir)', 'phase.released': 'Mağaza sayfası kontrol edilemedi',
'phase.releasedUnknown': 'Üretime çıktı (yayın durumu bilinmiyor: mağaza sayfası okunamadı)', '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.rejected': 'Reddedildi',
'phase.testing': 'Testte', 'phase.testing': 'Testte',
'phase.draft': 'Taslak', 'phase.draft': 'Taslak',
@ -238,6 +242,12 @@ export const tr: Record<keyof typeof en, string> = {
'tl.store': 'Mağaza', 'tl.store': 'Mağaza',
'tl.created': 'Oluşturma: {date}', 'tl.created': 'Oluşturma: {date}',
'tl.notOnStore': 'Bu mağazada yok', '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.noVersion': '{version} henüz {store} mağazasında yok',
'tl.everyone': 'Herkes', 'tl.everyone': 'Herkes',
'tl.day': '7 günün {day}. günü', '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.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.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.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.noVersions': 'Mağazalarda henüz sürüm yok',
'releases.noVersionsText': 'İlk derleme yüklendiğinde burada görünür.', 'releases.noVersionsText': 'İlk derleme yüklendiğinde burada görünür.',
'releases.history': 'Mağazalardaki sürümler', '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. // as console plans. Releasing an approved version (manual release) stays in App Store Connect.
import { plainError } from '../../../../../cli/lib/google-errors'; import { plainError } from '../../../../../cli/lib/google-errors';
import { useState } from 'react'; 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 type { UnifiedApp } from '../../lib/identity';
import { WriteButton } from '../../ui/WriteButton'; import { WriteButton } from '../../ui/WriteButton';
import { type Key, t } from '../../i18n'; 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 { useData, useRead, useWrite } from '../../store';
import { Icon, StoreGlyph } from '../../ui/icons'; import { Icon, StoreGlyph } from '../../ui/icons';
import { RolloutBar } from '../../ui/charts'; 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'; 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' }; 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 ''; if (i !== l.at || !l.release) return '';
const r = l.release; const r = l.release;
if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing')); 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 === 2) return r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key);
if (i === 3 && r.phase === 'released') return t(r.publicPage === 'unknown' ? 'phase.releasedUnknown' : 'phase.released'); if (i === 3 && r.phase === 'released') return phaseLabel(r).text;
return ''; 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 r = lane.release;
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
return ( return (
<div className="tl-lane"> <div className="tl-lane">
<div className="tl-who"> <div className="tl-who">
<StoreGlyph store={lane.store} /> <StoreGlyph store={lane.store} />
<div> <div>
<div className="w1">{storeName(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>
</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 cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : '';
const last = s === 'rollout'; const last = s === 'rollout';
return ( return (
@ -102,16 +115,19 @@ function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) {
return null; 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); 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" // the stores' current versions differ (normal: they do not always move together): a neutral "Current" header
const label = tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title; 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 ( 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}> 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" style={{ paddingTop: 12 }}>
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div> <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> </div>
{lanes.some(l => l.release) && app.project && ( {lanes.some(l => l.release) && app.project && (
<div className="card-foot roll-foot"> <div className="card-foot roll-foot">
@ -157,9 +173,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
const errs = Object.entries(l.storeErrors); const errs = Object.entries(l.storeErrors);
return ( return (
<> <>
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>)} {errs.map(([s, msg]) => {
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} />} const when = l.staleStores?.[s as 'ios'];
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} />} 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>} {!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}> <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 { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler'; import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
import { fixHint } from './lib/views'; import { fixHint } from './lib/views';
import { withLastGood } from '../../../cli/lib/train';
import { failureText, usable } from './lib/results'; import { failureText, usable } from './lib/results';
export type ToastKind = 'job' | 'ok' | 'bad' | 'info'; 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; const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
try { try {
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : []; 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); setPortfolio(r);
if (liveRead) { if (liveRead) {
const changes = releaseChanges(liveRef.current?.value, r); 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)}` : ''; 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 === '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 === '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' }; 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] }; 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. */ /** 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 Play production (completed in the API) is decided by the public store page: its version = `live`, 404 or an
* Google, in review or not yet published). `released`: completed in production and the app is public, but this * older live version = `review` (with `liveVersion` / `managed`), page never read (network) = `released` with
* version is not proven live (an update Google may still be reviewing, or the public page could not be read). */ * 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 type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approved' | 'review' | 'sent' | 'rejected' | 'testing' | 'draft' | 'other';
export interface StoreRelease { export interface StoreRelease {
@ -180,6 +180,12 @@ export interface StoreRelease {
phased?: { state: string; day: number | null } | null; phased?: { state: string; day: number | null } | null;
/** Google Play production: the public store page (public = 200, notPublic = 404, unknown = not read). */ /** Google Play production: the public store page (public = 200, notPublic = 404, unknown = not read). */
publicPage?: 'public' | 'notPublic' | 'unknown'; 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. */ /** When the store record was made (iOS version createdDate), if the store says. */
date?: string | null; date?: string | null;
} }
@ -216,6 +222,8 @@ export interface AppStatus {
virtual?: boolean; virtual?: boolean;
/** live only: per-platform read errors ("unknown, never false"). */ /** live only: per-platform read errors ("unknown, never false"). */
storeErrors: { ios?: string; android?: string }; 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[]; 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. // 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 // 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. // left by a crash on another machine (shared folder) stays until --force-unlock or 12 hours.
import { hostname } from 'node:os'; import { homedir, hostname } from 'node:os';
import { mkdirSync, openSync, readFileSync, rmSync, writeSync, closeSync } from 'node:fs'; import { mkdirSync, openSync, readFileSync, rmSync, statSync, utimesSync, writeSync, closeSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { AsyncLocalStorage } from 'node:async_hooks';
import type { ShipConfig } from './config'; import type { ShipConfig } from './config';
import type { Args } from './args'; 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); }; 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. */ /** 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']); 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 // 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 // "completed" from the moment it is sent, also while Google reviews it. The public store page answers it: 404 = not
// until then). The public store page answers it: 200 = published, 404 = not public yet. No auth, a fast timeout, // public yet (a first release in review), 200 = published, and the page's "Version" (About this app) is the live
// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it), "unknown" when offline. // 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 { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { join } from 'node:path'; import { join } from 'node:path';
@ -11,25 +13,45 @@ export interface PlayPublicPage {
state: PublicState; state: PublicState;
/** HTTP status of the store page (null when it could not be read). */ /** HTTP status of the store page (null when it could not be read). */
http: number | null; 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; 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; checkedAt: string;
url: string; url: string;
error?: 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 TTL_MS = 5 * 60_000;
const mem = new Map<string, PlayPublicPage>(); const mem = new Map<string, PlayPublicPage>();
const cacheFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.json`); 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; 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 { * The live versionName of a Play store page. The page embeds "About this app" in its AF_initDataCallback data blobs
const m = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/); * (ds:5 in 2026-10) as `[[["0.8.1"]],[[[36]],[[[24,"7.0"]]]]]`: the version, then the target and minimum SDK with the
return m && /\d/.test(m[1]!) ? m[1]! : null; * 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). */ /** 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> { 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 { 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 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; 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 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}` }; else out = { state: 'unknown', http, versionName: null, checkedAt: new Date().toISOString(), url, error: `HTTP ${http}` };
} catch (e) { } catch (e) {
@ -54,8 +76,14 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac
} }
if (out.state !== 'unknown') { if (out.state !== 'unknown') {
mem.set(pkg, out); 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; 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); 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 * Play tracks (`play status --json`) -> releases. The Developer API has no review state, so a completed production
* is only "live" when the public store page proves it (`publicPage` in the status JSON): 404 = sent to Google (in * release is decided by the public store page (`publicPage` in the status JSON): the page's version is this versionName
* review or not yet published); 200 = live for the first release or when the page shows this versionName, otherwise * (or newer, or "Varies with device") = live; 404 = in review (with managed publishing: in review or ready to publish);
* "released" (an update Google may still be reviewing); page not read = "released" with publicPage unknown. * 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[] { export function playReleases(data: unknown): StoreRelease[] {
const out: 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 pageState = page?.state === 'public' || page?.state === 'notPublic' ? page.state : 'unknown';
const raw = (data as J)?.tracks; const raw = (data as J)?.tracks;
const tracks: J[] = Array.isArray(raw) ? raw : []; 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 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 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 t of tracks) {
for (const r of rels(t)) { for (const r of rels(t)) {
const codes = codesOf(r); const codes = codesOf(r);
@ -129,12 +131,17 @@ export function playReleases(data: unknown): StoreRelease[] {
rel.phase = !production ? (status === 'draft' ? 'draft' : 'testing') rel.phase = !production ? (status === 'draft' ? 'draft' : 'testing')
: status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other'; : status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other';
if (production && status === 'completed') { if (production && status === 'completed') {
const low = codes.length ? Math.min(...codes) : Infinity; const pv = page?.versionName ?? null;
const first = !allCodes.some(c => c < low); // nothing older on any track: the app's first release if (pageState === 'notPublic') { rel.phase = 'review'; if (managed) rel.managed = true; }
rel.phase = pageState === 'notPublic' ? 'sent' else if (pageState === 'public') {
: pageState === 'public' && (first || sameVersion(page?.versionName, version)) ? 'live' // its version (or newer: this is an earlier one), or "Varies with device" (nothing to compare) = live; no
: 'released'; // 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; rel.publicPage = pageState;
if (page?.stale && pageState !== 'unknown') rel.pageStaleAt = page.checkedAt ?? null;
} }
out.push(rel); out.push(rel);
} }
@ -142,12 +149,19 @@ export function playReleases(data: unknown): StoreRelease[] {
return out; 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 { export function playReleaseLabel(r: StoreRelease): string {
if (r.phase === 'sent') return 'Sent to Google (in review or not yet published)'; const base = ((): string => {
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 === '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 === 'live') return 'Live'; if (r.phase === 'released') return "Couldn't check the store page";
return r.state; 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 }; 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 // 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 // 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. // StoreRelease rows by cli/lib/portfolio.ts.
import type { ReleaseTrain, StoreRelease } from './desktop-protocol'; import type { AppStatus, ReleaseTrain, StoreRelease } from './desktop-protocol';
import { cmpVersion, displayVersion, PHASE_RANK, REACHING, sameVersion, versionKey } from './portfolio'; import { cmpVersion, displayVersion, PHASE_RANK, playReleaseLabel, REACHING, releaseTrains, sameVersion, versionKey } from './portfolio';
import type { Finding } from './validate-rules'; import type { Finding } from './validate-rules';
export { cmpVersion, displayVersion, sameVersion, versionKey }; export { cmpVersion, displayVersion, sameVersion, versionKey };
@ -75,6 +75,54 @@ export function storeNow(all: StoreRelease[], store: 'ios' | 'android'): { now:
return { now, next }; 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) ---- // ---- Terminal view of a lane (release status) ----
const pctText = (x: number | null): string => (x === null ? '' : `${Math.round(x * 1000) / 10}%`); 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 stage = STAGES[l.at] ?? 'build';
const p = l.release.phase; const p = l.release.phase;
if (p === 'live') return 'live'; // the whole track filled: everyone has it 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 (l.release.store === 'android' && (p === 'sent' || p === 'released' || (p === 'review' && l.release.track === 'production'))) return playReleaseLabel(l.release).replace(/^\w/, c => c.toLowerCase());
if (p === 'released') return l.release.publicPage === 'unknown' ? 'released to production (public page not readable: live unknown)' : 'released to production (Google may still be reviewing)';
const word = p === stage || (p === 'draft' && stage === 'build') || (p === 'other' && stage === 'build') ? stage : `${stage} (${p})`; 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(); if (p === 'rollout' || p === 'halted') return `${word} ${pctText(l.share)}`.trim();
return word; 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' }); 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; break;
case 'sent': 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': 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; break;
case 'review': 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' }); 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; break;
case 'approved': case 'approved':

View file

@ -6,6 +6,10 @@ import { errorBody } from '../lib/http';
import { noteGoogle } from '../lib/quota'; import { noteGoogle } from '../lib/quota';
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert'; import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
import * as rt from '../lib/rt'; 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>>; 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 { return {
API, UPLOAD, packageName, call, 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). * 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> { async read<T>(fn: (id: string) => Promise<T>): Promise<T> {
const id = await openOne(); return locked('read', async () => {
try { return await fn(id); } finally { await closeOne(id, 'read-only'); } 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 * 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. * 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> { 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--) { for (let left = o.retries ?? 3; ; left--) {
try { return await editOnce(fn, o); } catch (err) { 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 // 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)`); 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 // the App category is not in the API (edits.details has no category field): a console step
const cat = playCategoryOf(config); const cat = playCategoryOf(config);
const category = { config: cat?.category ?? null, source: cat?.source ?? null, api: false, plan: 'storebound console plan play-store-settings' }; 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(`${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(` 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(', ') || '-'}`); 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 + '%' : ''}`); 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(` 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'})`}`); 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 }).filter(x => x.track === 'production' && x.state === 'completed')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`); 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}):`); 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(' ')}`); 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 ## 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` - **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 (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 public store page (no auth, 4 s timeout, cached 5 minutes) and decides, never "maybe": the page's "Version" (About
published)"; 200 = live for the first release (or when the page shows the same versionName), otherwise "Released to this app, read from its AF_initDataCallback data) equals the release's versionName = "Live"; 404 = "In review" (with
production (Google may still be reviewing)"; offline = unknown, shown as unknown. Match versions across the stores 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. 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` - **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 (`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. 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`), - **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). `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-')); const machine = mkdtempSync(join(tmpdir(), 'storebound-machine-'));
process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials'); process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials');
process.env.STOREBOUND_REGISTRY = join(machine, 'projects.json'); 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: // 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. // 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 { 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 { 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 { 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 { appleReleases, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio';
import { laneOf, laneText, releasePlan, storeNow, versionHistory } from '../cli/lib/train'; 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).toHaveLength(1);
expect(h[0]!.version).toBe('1.0.0'); expect(h[0]!.version).toBe('1.0.0');
expect(h[0]!.ios).toMatchObject({ version: '1.0', build: '3', phase: 'review' }); 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); const t = releaseTrains(ios, android);
expect(t.current).toBeNull(); // nothing reaches users yet 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 // release status --versions: the one row is the next train
const s = { release: { source: 'live' as const, ...t, all }, storeErrors: {}, summary: null } as never; 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']]); 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.ios?.release?.version).toBe('1.0');
expect(p.android?.release?.version).toBe('1.0.0'); expect(p.android?.release?.version).toBe('1.0.0');
expect(p.done).toBe(false); 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', () => { 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); const [prod] = playReleases(PLAY_STATUS_FIRST_404);
expect(prod).toMatchObject({ phase: 'sent', state: 'completed', publicPage: 'notPublic', fraction: 1 }); expect(prod).toMatchObject({ phase: 'review', state: 'completed', publicPage: 'notPublic', fraction: 1 });
expect(playReleaseLabel(prod!)).toBe('Sent to Google (in review or not yet published)'); expect(playReleaseLabel(prod!)).toBe('In review');
const lane = laneOf('android', prod!); const lane = laneOf('android', prod!);
expect(lane.at).toBe(2); // the review stage, not live / rollout expect(lane.at).toBe(2); // the review stage, not live / rollout
expect(lane.share).toBeNull(); 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)); 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' } }); 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 prodRel = (page: Record<string, unknown> | undefined, name = '1.1.0', status = 'completed', extra: Record<string, unknown> = {}) => playReleases({ ...extra, publicPage: page, tracks: [
const update = { publicPage: { state: 'public', versionName: null }, tracks: [ { track: 'production', releases: [{ name, versionCodes: ['5'], status, userFraction: status === 'completed' ? undefined : 0.2 }] },
{ track: 'production', releases: [{ name: '1.1.0', versionCodes: ['5'], status: 'completed' }] }, { track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] }, ] })[0]!;
] }; test('rule: page version = the release version: Live', () => {
const [prod] = playReleases(update); const r = prodRel({ state: 'public', versionName: '1.1' });
expect(prod!.phase).toBe('released'); expect(r.phase).toBe('live');
expect(playReleaseLabel(prod!)).toBe('Released to production (Google may still be reviewing)'); expect(playReleaseLabel(r)).toBe('Live');
expect(laneText(laneOf('android', prod!))).toContain('Google may still be reviewing');
expect(playReleases({ ...update, publicPage: { state: 'public', versionName: '1.1' } })[0]!.phase).toBe('live');
}); });
test('page not read = unknown, shown as unknown', () => { test('rule: page 404, managed publishing off: In review; on: In review / ready to publish', () => {
const [prod] = playReleases({ tracks: PLAY_STATUS_FIRST_404.tracks }); expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }))).toBe('In review');
expect(prod).toMatchObject({ phase: 'released', publicPage: 'unknown' }); expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }, '1.1.0', 'completed', { managedPublishing: true }))).toBe('In review / ready to publish (managed publishing)');
expect(playReleaseLabel(prod!)).toContain('unknown'); });
expect(laneOf('android', prod!).tone).toBe('neutral'); 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 () => { test('404 = not public yet, 200 = public, the url has no auth and asks for English', async () => {
const urls = answer(404); const urls = answer(404);
expect((await playPublicPage('com.example.first404', { noCache: true })).state).toBe('notPublic'); 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>'); 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' }); 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 () => { test('offline or a server error = unknown; unknown is never cached', async () => {
const off = `com.example.offline${Date.now()}`; const off = `com.example.offline${Date.now()}`;
globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch; 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((await playPublicPage(pkg)).state).toBe('notPublic');
expect(urls).toHaveLength(1); 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": "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(); 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' }] }, { track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] },
], ],
products: [{ productId: 'coins' }, { productId: 'no_ads' }, { sku: 'android_only' }], 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)', () => { 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: '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' }] }, { 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)]; 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 } const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: summary }
: argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } } : 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] === '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[] }; 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' } } }, 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 }); 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; let visited = 0;
const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' '); 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 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 { try {
await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' }); 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)')); 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[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'}")`); if (row[3] !== (page404 ? SENT : 'Live')) problems.push(`Releases: Google Play cell "${row[3]}" (want "${page404 ? SENT : 'Live'}")`);
const rel = await text(); 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)'); 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}"`); 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) { if (page404) {
@ -609,6 +610,8 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
const notes: string[] = []; const notes: string[] = [];
const logged: string[] = []; const logged: string[] = [];
let reads = 0; 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 // 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 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> => { 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); const method = String(a.method);
if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a)); if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a));
reads++; 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) ?? {})); 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)); default: return clean(await fx.mockInvoke(cmd, a));
@ -664,10 +676,24 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
await settle(page); await settle(page);
await crashCheck(`${appName} ${id}`); await crashCheck(`${appName} ${id}`);
const tx = await text(); 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`); 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++; 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 // 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 page.locator('nav.side .nav-app', { hasText: 'Prism Puddle' }).first().click({ timeout: WAIT_MS });
await settle(page); await settle(page);