From 714352ec18e9b0b0105f1b021b05ae3fe2af367b Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 00:14:32 +0800 Subject: [PATCH] =?UTF-8?q?Play=20edits=20under=20a=20machine-wide=20per-p?= =?UTF-8?q?ackage=20lock=20(parallel=20reads=20of=20one=20app=20no=20longe?= =?UTF-8?q?r=20delete=20each=20other's=20edits;=20a=20read=20whose=20edit?= =?UTF-8?q?=20was=20deleted=20is=20redone=20once,=20writes=20are=20not);?= =?UTF-8?q?=20a=20store=20that=20failed=20to=20read=20is=20'Couldn't=20rea?= =?UTF-8?q?d=20Google=20Play'=20and=20keeps=20its=20last=20good=20releases?= =?UTF-8?q?=20with=20'couldn't=20refresh';=20stores=20on=20different=20ver?= =?UTF-8?q?sions:=20neutral=20'Current=20=C2=B7=20Play=20x=20=C2=B7=20App?= =?UTF-8?q?=20Store=20y'=20header=20and=20each=20lane=20its=20own=20state,?= =?UTF-8?q?=20'Not=20on=20this=20store'=20only=20without=20a=20record;=20P?= =?UTF-8?q?lay=20status=20decided=20from=20the=20public=20store=20page's?= =?UTF-8?q?=20live=20version=20(Live=20/=20In=20review=20/=20In=20review?= =?UTF-8?q?=20(live:=20x)=20/=20managed=20publishing=20/=20Rolling=20out?= =?UTF-8?q?=20/=20Halted=20/=20Draft;=20only=20a=20network=20failure=20say?= =?UTF-8?q?s=20'Couldn't=20check=20the=20store=20page',=20with=20the=20las?= =?UTF-8?q?t=20known=20answer=20and=20its=20time);=20tests/play-edit-lock.?= =?UTF-8?q?test.ts,=20recorded=20page=20fixture,=20boot=20check=20auto-rea?= =?UTF-8?q?d=20cases=20(pitfall=2058)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 14 +++ apps/desktop/src/dev/fixtures.ts | 5 +- apps/desktop/src/dev/submit-fixtures.ts | 4 +- apps/desktop/src/i18n/en.ts | 17 ++- apps/desktop/src/i18n/tr.ts | 17 ++- apps/desktop/src/screens/tabs/Releases.tsx | 49 +++++--- apps/desktop/src/store.tsx | 6 +- apps/desktop/src/ui/kit.tsx | 6 +- cli/lib/desktop-protocol.ts | 14 ++- cli/lib/lock.ts | 65 +++++++++- cli/lib/play-public.ts | 50 ++++++-- cli/lib/portfolio.ts | 46 ++++--- cli/lib/train.ts | 63 ++++++++-- cli/play/client.ts | 23 +++- cli/play/status.ts | 6 +- skills/store-pitfalls/SKILL.md | 23 +++- tests/fixtures/play-page-version.html | 7 ++ tests/helpers/preload.ts | 1 + tests/play-edit-lock.test.ts | 135 +++++++++++++++++++++ tests/play-public-version-match.test.ts | 88 ++++++++++---- tests/portfolio.test.ts | 2 +- tests/release-train.test.ts | 2 +- tests/serve.test.ts | 2 +- tools/desktop-boot-check.ts | 28 ++++- 24 files changed, 570 insertions(+), 103 deletions(-) create mode 100644 tests/fixtures/play-page-version.html create mode 100644 tests/play-edit-lock.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d82704e..0030543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ ## Unreleased +- **Parallel Play reads no longer break each other.** Google Play keeps one open edit per app, so two reads of one + app at once (the desktop's auto-read) deleted each other's edits ("This Edit has been deleted") and the Releases tab + then said "not on Google Play yet". Every Play edit of a package now runs under a machine-wide per-package lock + (`~/.storebound/locks`, wait with timeout and heartbeat; other packages stay parallel), and a read whose edit was + deleted is redone once with a new edit (writes are not). A store that failed to read shows "Couldn't read Google + Play" and keeps its last good releases with "couldn't refresh". When the stores' current versions differ, the card + header says "Current · Play 4.1.9 · App Store 4.1.2" and each lane shows its own state ("4.1.2 · Live (4.1.9 not + submitted)"); "Not on this store" only for an app with no record there (pitfall 58). +- **Google Play status is decided, never "maybe".** `play status`, the portfolio and the desktop read the live version + from the public store page ("Version" in About this app): the same version = Live, 404 = In review (managed + publishing: In review / ready to publish), an older one = In review (live: 1.0.0), Rolling out N% / Halted / Draft + from the API. Only a network failure says "Couldn't check the store page", and it keeps the last known answer with + its time. "In review or not yet published" and "Google may still be reviewing" are gone. + - **Desktop reads everything by itself.** No more "Not read yet" and a Read button on every page: on start, when the window shows again and every 15 minutes while it is open (Settings: auto-read on / off, interval), every app's status, releases and submission state, TestFlight, catalog, prices, listings, health, reviews, analytics and checks diff --git a/apps/desktop/src/dev/fixtures.ts b/apps/desktop/src/dev/fixtures.ts index 5e1a63a..406b2f4 100644 --- a/apps/desktop/src/dev/fixtures.ts +++ b/apps/desktop/src/dev/fixtures.ts @@ -71,7 +71,7 @@ const STORE_APPS: Record = { droid('com.example.nightnotes.free', 'Nightnotes Free', G.night), droid('com.example.moodgrid', 'Mood Grid', G.mood), ], [A1]: [ - ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.1.0', 'READY_FOR_SALE']), + ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.0.4', 'READY_FOR_SALE']), ios('6400000012', 'com.example.blockgarden.ios', 'Block Garden', G.block, ['1.2.0', 'IN_REVIEW']), ios('6400000013', 'com.example.wordlantern', 'Word Lantern', G.lantern, ['2.0.1', 'READY_FOR_SALE']), ios('6400000014', 'com.example.oldracer', 'Old Racer', G.racer, ['1.9.3', 'REMOVED_FROM_SALE']), ], @@ -166,7 +166,8 @@ function status(id: string, live: boolean): AppStatus { } /** Store-only app releases (fixture), by bundle id / package name. */ const STORE_RELEASES: Record = { - 'com.example.orbithop': [rel('ios', '3.1.0', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })], + // the stores do not always move together: Play is on 3.1.0, the App Store still on 3.0.4 + 'com.example.orbithop': [rel('ios', '3.0.4', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })], 'com.example.blockgarden.ios': [rel('ios', '1.2.0', 'review', { build: '17', state: 'IN_REVIEW', date: iso(60 * 5) }), rel('ios', '1.1.2', 'live', { build: '15', state: 'READY_FOR_SALE', date: iso(60 * 24 * 40) })], 'com.example.blockgarden': [rel('android', '1.2.0', 'live', { build: '120', state: 'completed' })], 'com.example.wordlantern': [rel('ios', '2.0.1', 'live', { build: '41', state: 'READY_FOR_SALE', date: iso(60 * 24 * 18) })], diff --git a/apps/desktop/src/dev/submit-fixtures.ts b/apps/desktop/src/dev/submit-fixtures.ts index cc7cf1f..9b29a0f 100644 --- a/apps/desktop/src/dev/submit-fixtures.ts +++ b/apps/desktop/src/dev/submit-fixtures.ts @@ -106,7 +106,7 @@ export const PLAY_STATUS = { // A first release in the shapes the stores really answered (pitfall: App Store "1.0" in review while Google Play's // production release of "1.0.0" reads "completed" but the app is not public yet). `play status --json` adds the public -// store page check (publicPage): 404 = not public yet, 200 = published. +// store page check (publicPage): 404 = not public yet, 200 = published with the page's live version. export const APPLE_STATUS_FIRST_REVIEW = { app: { id: '1000000002', attributes: { name: 'Prism Puddle', bundleId: 'com.example.prismpuddle' } }, versions: [version('ver-10', '1.0', 'WAITING_FOR_REVIEW', 'b-3')], @@ -119,7 +119,7 @@ const playFirst = (state: 'public' | 'notPublic') => ({ { track: 'production', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed', userFraction: 1 }] }, { track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] }, ], - publicPage: { state, http: state === 'public' ? 200 : 404, versionName: null, checkedAt: '2026-10-13T08:00:00Z', url: 'https://play.google.com/store/apps/details?id=com.example.prismpuddle&hl=en' }, + publicPage: { state, http: state === 'public' ? 200 : 404, versionName: state === 'public' ? '1.0.0' : null, checkedAt: '2026-10-13T08:00:00Z', url: 'https://play.google.com/store/apps/details?id=com.example.prismpuddle&hl=en' }, }); export const PLAY_STATUS_FIRST_404 = playFirst('notPublic'); export const PLAY_STATUS_FIRST_200 = playFirst('public'); diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 9013ee9..3cad6a2 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -118,9 +118,13 @@ export const en = { 'phase.paused': 'Paused', 'phase.approved': 'Approved', 'phase.review': 'In review', - 'phase.sent': 'Sent to Google (in review or not yet published)', - 'phase.released': 'Released to production (Google may still be reviewing)', - 'phase.releasedUnknown': 'Released to production (live state unknown: the store page could not be read)', + 'phase.sent': 'In review', + 'phase.released': "Couldn't check the store page", + 'phase.releasedUnknown': "Couldn't check the store page", + 'phase.pageUnknown': "Couldn't check the store page", + 'phase.pageStale': "couldn't check the store page, as of {when}", + 'phase.reviewLive': 'In review (live: {version})', + 'phase.reviewManaged': 'In review / ready to publish (managed publishing)', 'phase.rejected': 'Rejected', 'phase.testing': 'Testing', 'phase.draft': 'Draft', @@ -236,6 +240,12 @@ export const en = { 'tl.store': 'Store', 'tl.created': 'Created {date}', 'tl.notOnStore': 'Not on this store', + 'tl.unknown': 'Unknown', + 'tl.unreadable': "Couldn't read {store}: state unknown", + 'tl.otherVersion': '{now} · {phase} ({version} not submitted)', + 'tl.notThisVersion': 'Not this version', + 'tl.current': 'Current', + 'tl.bothVersions': 'Play {play} · App Store {ios}', 'tl.noVersion': '{version} is not on {store} yet', 'tl.everyone': 'Everyone', 'tl.day': 'day {day} of 7', @@ -245,6 +255,7 @@ export const en = { 'releases.notReadText': 'The timeline shows each version on both stores: build, TestFlight or internal testing, review, live and the rollout share. Read the stores once to fill it.', 'releases.noAccounts': 'Tip: map store accounts to this app in App folders first.', 'releases.storeUnreadable': '{store} could not be read, so its lane shows as unknown: {error}', + 'releases.storeStale': "Couldn't refresh {store}: showing the read from {when}. {error}", 'releases.noVersions': 'No versions on the stores yet', 'releases.noVersionsText': 'When the first build is uploaded it shows here.', 'releases.history': 'Versions on the stores', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 703ba4a..1efb833 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -120,9 +120,13 @@ export const tr: Record = { 'phase.paused': 'Duraklatıldı', 'phase.approved': 'Onaylandı', 'phase.review': 'İncelemede', - 'phase.sent': 'Google’a gönderildi (incelemede ya da henüz yayınlanmadı)', - 'phase.released': 'Üretime çıktı (Google hâlâ inceliyor olabilir)', - 'phase.releasedUnknown': 'Üretime çıktı (yayın durumu bilinmiyor: mağaza sayfası okunamadı)', + 'phase.sent': 'İncelemede', + 'phase.released': 'Mağaza sayfası kontrol edilemedi', + 'phase.releasedUnknown': 'Mağaza sayfası kontrol edilemedi', + 'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi', + 'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla', + 'phase.reviewLive': 'İncelemede (yayında: {version})', + 'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)', 'phase.rejected': 'Reddedildi', 'phase.testing': 'Testte', 'phase.draft': 'Taslak', @@ -238,6 +242,12 @@ export const tr: Record = { 'tl.store': 'Mağaza', 'tl.created': 'Oluşturma: {date}', 'tl.notOnStore': 'Bu mağazada yok', + 'tl.unknown': 'Bilinmiyor', + 'tl.unreadable': '{store} okunamadı: durum bilinmiyor', + 'tl.otherVersion': '{now} · {phase} ({version} gönderilmedi)', + 'tl.notThisVersion': 'Bu sürüm değil', + 'tl.current': 'Güncel', + 'tl.bothVersions': 'Play {play} · App Store {ios}', 'tl.noVersion': '{version} henüz {store} mağazasında yok', 'tl.everyone': 'Herkes', 'tl.day': '7 günün {day}. günü', @@ -247,6 +257,7 @@ export const tr: Record = { 'releases.notReadText': 'Zaman çizelgesi her sürümü iki mağazada birlikte gösterir: derleme, TestFlight ya da dahili test, inceleme, yayın ve dağıtım payı. Doldurmak için mağazaları bir kez okuyun.', 'releases.noAccounts': 'İpucu: önce Uygulama klasörleri ekranında bu uygulamaya mağaza hesaplarını bağlayın.', 'releases.storeUnreadable': '{store} okunamadı, bu yüzden şeridi bilinmiyor olarak gösteriliyor: {error}', + 'releases.storeStale': '{store} yenilenemedi: {when} okunan durum gösteriliyor. {error}', 'releases.noVersions': 'Mağazalarda henüz sürüm yok', 'releases.noVersionsText': 'İlk derleme yüklendiğinde burada görünür.', 'releases.history': 'Mağazalardaki sürümler', diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index a480227..29a4c53 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -4,7 +4,8 @@ // as console plans. Releasing an approved version (manual release) stays in App Store Connect. import { plainError } from '../../../../../cli/lib/google-errors'; import { useState } from 'react'; -import type { ReleaseTrain, StoreRelease } from '../../api'; +import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api'; +import { laneGap, storesDiffer } from '../../../../../cli/lib/train'; import type { UnifiedApp } from '../../lib/identity'; import { WriteButton } from '../../ui/WriteButton'; import { type Key, t } from '../../i18n'; @@ -13,7 +14,7 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory } import { useData, useRead, useWrite } from '../../store'; import { Icon, StoreGlyph } from '../../ui/icons'; import { RolloutBar } from '../../ui/charts'; -import { Card, Chip, Empty, Notice, PhaseChip, Skeleton, storeName } from '../../ui/kit'; +import { Card, Chip, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit'; import { AppleSubmitCard, PlayProductionCard } from './Submit'; const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' }; @@ -22,23 +23,35 @@ function stageCaption(l: Lane, i: number): string { if (i !== l.at || !l.release) return ''; const r = l.release; if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing')); - if (i === 2) return t(`phase.${r.phase}` as Key); - if (i === 3 && r.phase === 'released') return t(r.publicPage === 'unknown' ? 'phase.releasedUnknown' : 'phase.released'); + if (i === 2) return r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key); + if (i === 3 && r.phase === 'released') return phaseLabel(r).text; return ''; } -function LaneRow({ lane, version }: { lane: Lane; version: string }) { +/** The text of a lane that does not carry this version: couldn't read, the store's own current version, not there yet, + * or (only without any record of the app on that store) "Not on this store". */ +function gapText(lane: Lane, version: string, st: AppStatus): { who: string; line: string; kind: string } { + const g = laneGap(lane.store, st.release.all ?? [], st.storeErrors[lane.store]); + const store = storeName(lane.store); + if (g.kind === 'unreadable') return { kind: g.kind, who: t('tl.unknown'), line: t('tl.unreadable', { store }) }; + if (g.kind === 'other') return { kind: g.kind, who: g.now.version, line: t('tl.otherVersion', { now: g.now.version, phase: t(`phase.${g.now.phase}` as Key), version }) }; + if (g.kind === 'missing') return { kind: g.kind, who: t('tl.notThisVersion'), line: t('tl.noVersion', { version, store }) }; + return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') }; +} + +function LaneRow({ lane, version, st }: { lane: Lane; version: string; st: AppStatus }) { const r = lane.release; + const gap = lane.at < 0 ? gapText(lane, version, st) : null; return (
{storeName(lane.store)}
-
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}
+
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}
- {lane.at < 0 ?
{t('tl.noVersion', { version, store: storeName(lane.store) })}
: STAGES.map((s, i) => { + {gap ?
{gap.line}
: STAGES.map((s, i) => { const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : ''; const last = s === 'rollout'; return ( @@ -102,16 +115,19 @@ function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) { return null; } -function Train({ title, train, tf, tone, app }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp }) { +function Train({ title, train, tf, tone, app, st }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp; st: AppStatus }) { const lanes = trainLanes(train, tf); - // "Live" only when a store has it live or rolling out; a Play release Google may still review is "Released" - const label = tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title; + // the stores' current versions differ (normal: they do not always move together): a neutral "Current" header + const differ = tone === 'ok' ? storesDiffer(st.release.all ?? []) : null; + // "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not + const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title; + const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version; return ( - {train.version}{label}} + {ver}{label}} sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
{t('tl.store')}{STAGES.map(s => {t(STAGE_KEY[s])})}
- {lanes.map(l => )} + {lanes.map(l => )}
{lanes.some(l => l.release) && app.project && (
@@ -157,9 +173,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { const errs = Object.entries(l.storeErrors); return ( <> - {errs.map(([s, msg]) => {t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })})} - {l.release.next && } - {l.release.current && } + {errs.map(([s, msg]) => { + const when = l.staleStores?.[s as 'ios']; + return {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) })}; + })} + {l.release.next && } + {l.release.current && } {!l.release.next && !l.release.current && {t('releases.noVersionsText')}} diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index 5055326..38c8bbd 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -16,6 +16,7 @@ import { keepLines, progressOf } from './lib/progress'; import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness'; import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler'; import { fixHint } from './lib/views'; +import { withLastGood } from '../../../cli/lib/train'; import { failureText, usable } from './lib/results'; export type ToastKind = 'job' | 'ok' | 'bad' | 'info'; @@ -201,7 +202,10 @@ export function DataProvider({ children }: { children: ReactNode }) { const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null; try { const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : []; - const r = await api.portfolio(liveRead, job, stores); + let r = await api.portfolio(liveRead, job, stores); + // a store that failed this time keeps its last good releases ("couldn't refresh"), never turns empty + const prevLive = liveRef.current; + if (liveRead && prevLive) r = { ...r, apps: r.apps.map(a => withLastGood(prevLive.value.apps.find(x => x.project === a.project), a, prevLive.at)) }; setPortfolio(r); if (liveRead) { const changes = releaseChanges(liveRef.current?.value, r); diff --git a/apps/desktop/src/ui/kit.tsx b/apps/desktop/src/ui/kit.tsx index f65ea4e..62c5106 100644 --- a/apps/desktop/src/ui/kit.tsx +++ b/apps/desktop/src/ui/kit.tsx @@ -22,7 +22,11 @@ export function phaseLabel(r: StoreRelease | null | undefined): { text: string; const pct = r.fraction !== null && r.fraction !== undefined && r.fraction < 1 ? ` ${share(r.fraction)}` : ''; if (r.phase === 'rollout') return { text: (r.store === 'ios' ? t('phase.phased') : t('phase.rollout')) + pct, tone: 'accent' }; if (r.phase === 'halted') return { text: (r.store === 'ios' ? t('phase.paused') : t('phase.halted')) + pct, tone: 'bad' }; - if (r.phase === 'released' && r.publicPage === 'unknown') return { text: t('phase.releasedUnknown'), tone: 'neutral' }; + const stale = r.pageStaleAt ? ` · ${t('phase.pageStale', { when: ago(r.pageStaleAt) })}` : ''; + if (r.store === 'android' && r.phase === 'released') return { text: t('phase.pageUnknown') + stale, tone: 'neutral' }; + if (r.store === 'android' && (r.phase === 'review' || r.phase === 'sent')) + return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.review')) + stale, tone: 'warn' }; + if (r.store === 'android' && r.phase === 'live' && stale) return { text: t('phase.live') + stale, tone: 'ok' }; if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' }; return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] }; } diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index edda93f..7f824a2 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -159,9 +159,9 @@ export interface AppIdentity { } /** Where a version is on one store. */ -/** `sent`: a Google Play production release that is completed in the API while the public store page is 404 (sent to - * Google, in review or not yet published). `released`: completed in production and the app is public, but this - * version is not proven live (an update Google may still be reviewing, or the public page could not be read). */ +/** Google Play production (completed in the API) is decided by the public store page: its version = `live`, 404 or an + * older live version = `review` (with `liveVersion` / `managed`), page never read (network) = `released` with + * publicPage unknown ("Couldn't check the store page"). `sent` is no longer produced (older cached reads). */ export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approved' | 'review' | 'sent' | 'rejected' | 'testing' | 'draft' | 'other'; export interface StoreRelease { @@ -180,6 +180,12 @@ export interface StoreRelease { phased?: { state: string; day: number | null } | null; /** Google Play production: the public store page (public = 200, notPublic = 404, unknown = not read). */ publicPage?: 'public' | 'notPublic' | 'unknown'; + /** Google Play production in review while the store page shows an older live version: that version. */ + liveVersion?: string | null; + /** Google Play production in review with managed publishing on (play.managedPublishing): approval is not readable. */ + managed?: boolean; + /** The store page could not be read now: the phase is from the last known page read at this time. */ + pageStaleAt?: string | null; /** When the store record was made (iOS version createdDate), if the store says. */ date?: string | null; } @@ -216,6 +222,8 @@ export interface AppStatus { virtual?: boolean; /** live only: per-platform read errors ("unknown, never false"). */ storeErrors: { ios?: string; android?: string }; + /** A store that could not be read this time, shown from the last good read (its time): couldn't refresh. */ + staleStores?: { ios?: string; android?: string }; errors: string[]; } diff --git a/cli/lib/lock.ts b/cli/lib/lock.ts index a3a1970..a51031c 100644 --- a/cli/lib/lock.ts +++ b/cli/lib/lock.ts @@ -3,9 +3,10 @@ // same app at once and race each other into duplicates. Dry runs and reads take no lock. // A lock whose process is gone (same machine) is taken over; a live one stops the run with who holds it. A lock // left by a crash on another machine (shared folder) stays until --force-unlock or 12 hours. -import { hostname } from 'node:os'; -import { mkdirSync, openSync, readFileSync, rmSync, writeSync, closeSync } from 'node:fs'; +import { homedir, hostname } from 'node:os'; +import { mkdirSync, openSync, readFileSync, rmSync, statSync, utimesSync, writeSync, closeSync } from 'node:fs'; import { join } from 'node:path'; +import { AsyncLocalStorage } from 'node:async_hooks'; import type { ShipConfig } from './config'; import type { Args } from './args'; @@ -58,6 +59,66 @@ export function acquireLock(root: string, key: string, command: string, o: { for return () => { rmSync(path, { force: true }); held.delete(path); }; } +// ---- Machine-wide short locks (Play edits) ---- +// Google Play keeps one open edit per app: a second edits.insert for the same package invalidates the first ("This +// Edit has been deleted"). Every storebound process that opens an edit (reads included) takes +// ~/.storebound/locks/.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(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>(); + +/** + * Runs fn while holding the machine-wide lock `key` (e.g. `play-edit-`). Waits up to `timeoutMs` for another + * holder (polling, a heartbeat line every `heartbeatMs` through `onWait`), then throws with the holder. + */ +export async function withMachineLock(key: string, command: string, fn: () => Promise, + o: { timeoutMs?: number; pollMs?: number; heartbeatMs?: number; onWait?: (msg: string) => void } = {}): Promise { + if (inside.getStore()?.has(key)) return fn(); + const dir = machineLockDir(); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const path = join(dir, `${key.replace(/[^A-Za-z0-9._-]+/g, '_')}.lock`); + const timeout = o.timeoutMs ?? 90_000, poll = o.pollMs ?? 100, beat = o.heartbeatMs ?? 10_000; + const start = Date.now(); + let lastBeat = start; + for (;;) { + try { + const fd = openSync(path, 'wx', 0o600); + writeSync(fd, JSON.stringify({ pid: process.pid, host: hostname(), command, startedAt: new Date().toISOString() } satisfies LockInfo) + '\n'); + closeSync(fd); + break; + } catch (e) { + if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e; + let cur: LockInfo | null = null, touched = Date.now(); + try { touched = statSync(path).mtimeMs; cur = JSON.parse(readFileSync(path, 'utf8')) as LockInfo; } catch { /* gone or half written */ } + if (staleShortLock(cur, touched)) { rmSync(path, { force: true }); continue; } + const waited = Date.now() - start; + const who = cur ? `\`storebound ${cur.command}\` (pid ${cur.pid})` : 'another storebound run'; + if (waited > timeout) throw new Error(`timed out after ${Math.round(waited / 1000)} s waiting for ${key}: ${who} still holds it (Google Play allows one open edit per app). Try again when it is done.`); + if (Date.now() - lastBeat >= beat) { lastBeat = Date.now(); o.onWait?.(`waiting ${Math.round(waited / 1000)} s for ${key}, held by ${who}`); } + await sleep(poll); + } + } + held.add(path); + if (!hooked) { hooked = true; process.on('exit', () => { for (const p of held) rmSync(p, { force: true }); }); } + const hb = setInterval(() => { try { const t = new Date(); utimesSync(path, t, t); } catch { /* removed */ } }, 5000); + (hb as { unref?: () => void }).unref?.(); + try { return await inside.run(new Set([...(inside.getStore() ?? []), key]), fn); } finally { clearInterval(hb); rmSync(path, { force: true }); held.delete(path); } +} + /** Read-only subcommands and flags: no lock. */ const READ_SUBS = new Set(['status', 'list', 'devices', 'diff', 'export', 'pull', 'wait', 'options', 'coverage', 'report', 'campaigns', 'check', 'help']); diff --git a/cli/lib/play-public.ts b/cli/lib/play-public.ts index 36f0440..3f8efe2 100644 --- a/cli/lib/play-public.ts +++ b/cli/lib/play-public.ts @@ -1,7 +1,9 @@ // Is the app public on Google Play? The Play Developer API has no review state: a production release reads -// "completed" from the moment it is sent, while Google may still be reviewing it (a first release is not public -// until then). The public store page answers it: 200 = published, 404 = not public yet. No auth, a fast timeout, -// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it), "unknown" when offline. +// "completed" from the moment it is sent, also while Google reviews it. The public store page answers it: 404 = not +// public yet (a first release in review), 200 = published, and the page's "Version" (About this app) is the live +// versionName, so a completed release whose versionName is not the page's is in review. No auth, a fast timeout, +// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it). Only a network failure +// gives "unknown"; it then returns the last known answer (any age) marked `stale` with the time it was read. import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -11,25 +13,45 @@ export interface PlayPublicPage { state: PublicState; /** HTTP status of the store page (null when it could not be read). */ http: number | null; - /** The versionName the page shows, when it shows one (usually it does not). */ + /** The live versionName the page shows ("Version" in About this app); null when it shows none. */ versionName: string | null; + /** The page says "Varies with device" instead of a version (several APKs): the version cannot be compared. */ + varies?: boolean; + /** A network failure: this is the last known answer from `checkedAt`, not a fresh one. */ + stale?: boolean; checkedAt: string; url: string; error?: string; } -export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`; +export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en&gl=US`; const TTL_MS = 5 * 60_000; const mem = new Map(); const cacheFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.json`); +const lastFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.last.json`); const fresh = (p: PlayPublicPage | undefined | null): p is PlayPublicPage => !!p && p.state !== 'unknown' && Date.now() - Date.parse(p.checkedAt) < TTL_MS; -/** The versionName in the page's structured data, when it has one ("softwareVersion"). */ -export function pageVersion(html: string): string | null { - const m = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/); - return m && /\d/.test(m[1]!) ? m[1]! : null; +/** + * The live versionName of a Play store page. The page embeds "About this app" in its AF_initDataCallback data blobs + * (ds:5 in 2026-10) as `[[["0.8.1"]],[[[36]],[[[24,"7.0"]]]]]`: the version, then the target and minimum SDK with the + * Android version. "Varies with device" stands in for the version when the app ships several APKs. Older pages had + * schema.org "softwareVersion". Recorded shape: tests/fixtures/play-page-version.html. + */ +export function pageVersionInfo(html: string): { versionName: string | null; varies: boolean } { + const blobs = [...html.matchAll(/AF_initDataCallback\(\{key: '(ds:\d+)'[\s\S]*?<\/script>/g)].map(m => m[0]); + const about = /\[\[\["([^"\\]{1,60})"\]\],\[\[\[\d+\]\],\[\[\[\d+,"[^"\\]{0,20}"\]\]\]\]/; + for (const b of blobs.length ? blobs : [html]) { + const m = about.exec(b); + if (!m) continue; + if (/varies with device/i.test(m[1]!)) return { versionName: null, varies: true }; + if (/\d/.test(m[1]!)) return { versionName: m[1]!, varies: false }; + } + const ld = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/); + if (ld && /varies with device/i.test(ld[1]!)) return { versionName: null, varies: true }; + return { versionName: ld && /\d/.test(ld[1]!) ? ld[1]! : null, varies: false }; } +export const pageVersion = (html: string): string | null => pageVersionInfo(html).versionName; /** Reads the public page (cached 5 minutes; STOREBOUND_PLAY_PUBLIC=public|notPublic|unknown overrides it for tests). */ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCache?: boolean } = {}): Promise { @@ -46,7 +68,7 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac try { const r = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(o.timeoutMs ?? 4000), headers: { 'User-Agent': 'Mozilla/5.0 storebound', 'Accept-Language': 'en' } }); const http = r.status; - if (http === 200) out = { state: 'public', http, versionName: pageVersion(await r.text()), checkedAt: new Date().toISOString(), url }; + if (http === 200) { const v = pageVersionInfo(await r.text()); out = { state: 'public', http, versionName: v.versionName, ...(v.varies ? { varies: true } : {}), checkedAt: new Date().toISOString(), url }; } else if (http === 404) out = { state: 'notPublic', http, versionName: null, checkedAt: new Date().toISOString(), url }; else out = { state: 'unknown', http, versionName: null, checkedAt: new Date().toISOString(), url, error: `HTTP ${http}` }; } catch (e) { @@ -54,8 +76,14 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac } if (out.state !== 'unknown') { mem.set(pkg, out); - try { mkdirSync(join(tmpdir(), 'storebound-play-public'), { recursive: true }); writeFileSync(cacheFile(pkg), JSON.stringify(out)); } catch { /* cache is optional */ } + try { mkdirSync(join(tmpdir(), 'storebound-play-public'), { recursive: true }); writeFileSync(cacheFile(pkg), JSON.stringify(out)); writeFileSync(lastFile(pkg), JSON.stringify(out)); } catch { /* cache is optional */ } + return out; } + // a network failure: the last known answer with its time, never a guess + try { + const last = JSON.parse(readFileSync(lastFile(pkg), 'utf8')) as PlayPublicPage; + if (last.url === url && last.state !== 'unknown') return { ...last, stale: true, error: out.error }; + } catch { /* none known */ } return out; } diff --git a/cli/lib/portfolio.ts b/cli/lib/portfolio.ts index 5995344..38bf4a1 100644 --- a/cli/lib/portfolio.ts +++ b/cli/lib/portfolio.ts @@ -105,20 +105,22 @@ export function testflightReleases(builds: J[], buildVersions: Record (typeof name === 'string' ? name.match(/\d+(?:\.\d+){0,3}/)?.[0] ?? null : null); /** - * Play tracks (`play status --json`) -> releases. The Developer API has no review state: a completed production release - * is only "live" when the public store page proves it (`publicPage` in the status JSON): 404 = sent to Google (in - * review or not yet published); 200 = live for the first release or when the page shows this versionName, otherwise - * "released" (an update Google may still be reviewing); page not read = "released" with publicPage unknown. + * Play tracks (`play status --json`) -> releases. The Developer API has no review state, so a completed production + * release is decided by the public store page (`publicPage` in the status JSON): the page's version is this versionName + * (or newer, or "Varies with device") = live; 404 = in review (with managed publishing: in review or ready to publish); + * an older version on the page = in review, with the live one named. Only a network failure without any earlier page + * read leaves it undecided ("released", publicPage unknown: "Couldn't check the store page"); with an earlier read the + * phase comes from that read and `pageStaleAt` says when. */ export function playReleases(data: unknown): StoreRelease[] { const out: StoreRelease[] = []; - const page = (data as J)?.publicPage as { state?: string; versionName?: string | null } | undefined; + const page = (data as J)?.publicPage as { state?: string; versionName?: string | null; varies?: boolean; stale?: boolean; checkedAt?: string } | undefined; + const managed = (data as J)?.managedPublishing === true; const pageState = page?.state === 'public' || page?.state === 'notPublic' ? page.state : 'unknown'; const raw = (data as J)?.tracks; const tracks: J[] = Array.isArray(raw) ? raw : []; const codesOf = (r: J): number[] => (Array.isArray(r?.versionCodes) ? (r.versionCodes as unknown[]) : []).map(Number).filter(Number.isFinite); const rels = (t: J): J[] => (Array.isArray(t?.releases) ? t.releases : []); - const allCodes = tracks.flatMap(t => rels(t).flatMap(codesOf)); for (const t of tracks) { for (const r of rels(t)) { const codes = codesOf(r); @@ -129,12 +131,17 @@ export function playReleases(data: unknown): StoreRelease[] { rel.phase = !production ? (status === 'draft' ? 'draft' : 'testing') : status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other'; if (production && status === 'completed') { - const low = codes.length ? Math.min(...codes) : Infinity; - const first = !allCodes.some(c => c < low); // nothing older on any track: the app's first release - rel.phase = pageState === 'notPublic' ? 'sent' - : pageState === 'public' && (first || sameVersion(page?.versionName, version)) ? 'live' - : 'released'; + const pv = page?.versionName ?? null; + if (pageState === 'notPublic') { rel.phase = 'review'; if (managed) rel.managed = true; } + else if (pageState === 'public') { + // its version (or newer: this is an earlier one), or "Varies with device" (nothing to compare) = live; no + // version found on a public page means the page changed shape: not checked, never guessed + if (page?.varies || (pv && version && cmpVersion(pv, version) >= 0)) rel.phase = 'live'; + else if (!pv || !version) rel.phase = 'released'; + else { rel.phase = 'review'; rel.liveVersion = pv; if (managed) rel.managed = true; } + } else rel.phase = 'released'; rel.publicPage = pageState; + if (page?.stale && pageState !== 'unknown') rel.pageStaleAt = page.checkedAt ?? null; } out.push(rel); } @@ -142,12 +149,19 @@ export function playReleases(data: unknown): StoreRelease[] { return out; } -/** Plain words for a Play production release's phase (CLI text; the desktop has the same words in i18n). */ +/** Plain words for a Play release's phase, decided from the store page (CLI text; the desktop has the same words in + * i18n, apps/desktop/src/ui/kit.tsx phaseLabel). */ export function playReleaseLabel(r: StoreRelease): string { - if (r.phase === 'sent') return 'Sent to Google (in review or not yet published)'; - if (r.phase === 'released') return r.publicPage === 'unknown' ? 'Released to production (public store page not readable: live state unknown)' : 'Released to production (Google may still be reviewing)'; - if (r.phase === 'live') return 'Live'; - return r.state; + const base = ((): string => { + if (r.phase === 'review' || r.phase === 'sent') return r.managed ? 'In review / ready to publish (managed publishing)' : r.liveVersion ? `In review (live: ${r.liveVersion})` : 'In review'; + if (r.phase === 'released') return "Couldn't check the store page"; + if (r.phase === 'live') return 'Live'; + if (r.phase === 'rollout') return `Rolling out ${Math.round((r.fraction ?? 0) * 1000) / 10}%`; + if (r.phase === 'halted') return 'Halted'; + if (r.phase === 'draft') return 'Draft'; + return r.state; + })(); + return r.pageStaleAt ? `${base} (couldn't check the store page now; as of ${r.pageStaleAt.slice(0, 16).replace('T', ' ')} UTC)` : base; } export const PHASE_RANK: Record = { live: 0, rollout: 1, halted: 2, released: 3, approved: 4, review: 5, sent: 6, rejected: 7, testing: 8, draft: 9, other: 10 }; diff --git a/cli/lib/train.ts b/cli/lib/train.ts index 0cb0f3b..7ed5bae 100644 --- a/cli/lib/train.ts +++ b/cli/lib/train.ts @@ -3,8 +3,8 @@ // re-exports these) and `storebound release status | plan` (cli/release.ts) share this file, so both show the same // stage for the same store state. Pure; the store reads are `apple status --json` / `play status --json`, turned into // StoreRelease rows by cli/lib/portfolio.ts. -import type { ReleaseTrain, StoreRelease } from './desktop-protocol'; -import { cmpVersion, displayVersion, PHASE_RANK, REACHING, sameVersion, versionKey } from './portfolio'; +import type { AppStatus, ReleaseTrain, StoreRelease } from './desktop-protocol'; +import { cmpVersion, displayVersion, PHASE_RANK, playReleaseLabel, REACHING, releaseTrains, sameVersion, versionKey } from './portfolio'; import type { Finding } from './validate-rules'; export { cmpVersion, displayVersion, sameVersion, versionKey }; @@ -75,6 +75,54 @@ export function storeNow(all: StoreRelease[], store: 'ios' | 'android'): { now: return { now, next }; } +/** + * What a lane without this version shows, so a store is never called empty when it was not read or carries another + * version: `unreadable` (the read failed and nothing is known: "couldn't read Google Play"), `other` (the store's own + * current release is a different version: "4.1.2 · Live (4.1.9 not submitted)"), `missing` (the app is on the store, + * this version is not yet) and `none` (no record of the app on that store at all: "Not on this store"). Pure. + */ +export type LaneGap = { kind: 'unreadable' } | { kind: 'other'; now: StoreRelease & { version: string } } | { kind: 'missing' } | { kind: 'none' }; +export function laneGap(store: 'ios' | 'android', all: StoreRelease[], storeError?: string | null): LaneGap { + const mine = all.filter(r => r.store === store); + if (!mine.length) return storeError ? { kind: 'unreadable' } : { kind: 'none' }; + const { now } = storeNow(all, store); + if (now) return { kind: 'other', now: now as StoreRelease & { version: string } }; + return { kind: 'missing' }; +} + +/** The stores' current (reaching users) versions when they differ: "Play 4.1.9 · App Store 4.1.2" for a neutral + * header. Null when only one store has one or both have the same version. Pure. */ +export function storesDiffer(all: StoreRelease[]): { ios: string; android: string } | null { + const i = storeNow(all, 'ios').now?.version, a = storeNow(all, 'android').now?.version; + return i && a && !sameVersion(i, a) ? { ios: i, android: a } : null; +} + +/** + * After a refresh in which a store could not be read, keep the store's releases from the previous good read (marked + * by `staleStores`), so the lanes show the last known state with "couldn't refresh" instead of turning empty. Pure. + */ +export function keepLastGood(prev: StoreRelease[] | undefined, next: StoreRelease[], errors: { ios?: string; android?: string }): { all: StoreRelease[]; stale: ('ios' | 'android')[] } { + const stale: ('ios' | 'android')[] = []; + let all = next; + for (const s of ['ios', 'android'] as const) { + if (!errors[s] || next.some(r => r.store === s)) continue; + const old = (prev ?? []).filter(r => r.store === s); + if (old.length) { all = [...all, ...old]; stale.push(s); } + } + return { all, stale }; +} + +/** keepLastGood over a whole status: the app's trains are rebuilt from the merged releases and `staleStores` names + * each store shown from the earlier read (with that read's time). Returns `next` itself when nothing is kept. */ +export function withLastGood(prev: AppStatus | undefined, next: AppStatus, prevAt: string): AppStatus { + if (next.release.source !== 'live' || !prev?.release.all?.length) return next; + const { all, stale } = keepLastGood(prev.release.all, next.release.all ?? [], next.storeErrors); + if (!stale.length) return next; + const staleStores = { ...next.staleStores }; + for (const s of stale) staleStores[s] = prev.staleStores?.[s] ?? prevAt; + return { ...next, release: { source: 'live', ...releaseTrains(all.filter(r => r.store === 'ios'), all.filter(r => r.store === 'android')), all }, staleStores }; +} + // ---- Terminal view of a lane (release status) ---- const pctText = (x: number | null): string => (x === null ? '' : `${Math.round(x * 1000) / 10}%`); @@ -85,8 +133,7 @@ export function laneText(l: Lane): string { const stage = STAGES[l.at] ?? 'build'; const p = l.release.phase; if (p === 'live') return 'live'; // the whole track filled: everyone has it - if (p === 'sent') return 'review (sent to Google: in review or not yet published)'; - if (p === 'released') return l.release.publicPage === 'unknown' ? 'released to production (public page not readable: live unknown)' : 'released to production (Google may still be reviewing)'; + if (l.release.store === 'android' && (p === 'sent' || p === 'released' || (p === 'review' && l.release.track === 'production'))) return playReleaseLabel(l.release).replace(/^\w/, c => c.toLowerCase()); const word = p === stage || (p === 'draft' && stage === 'build') || (p === 'other' && stage === 'build') ? stage : `${stage} (${p})`; if (p === 'rollout' || p === 'halted') return `${word} ${pctText(l.share)}`.trim(); return word; @@ -184,12 +231,14 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null, steps.push({ kind: 'owner', text: `resubmit ${v} after the fix (owner)`, fix: store === 'ios' ? 'App Store Connect > App Review > reply / resubmit' : 'Play Console > Publishing overview' }); break; case 'sent': - steps.push({ kind: 'wait', text: `${v} is sent to Google (production completed, the store page is not public yet): in review or not yet published`, fix: 'storebound play status (checks the public store page)' }); - break; case 'released': - steps.push({ kind: 'wait', text: r!.publicPage === 'unknown' ? `${v} is released to production; the public store page could not be read, so live is unknown` : `${v} is released to production; Google may still be reviewing it (the app is public, this version is not proven live)`, fix: 'storebound play status' }); + steps.push({ kind: 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: 'storebound play status (reads the public store page)' }); break; case 'review': + if (store === 'android' && r!.track === 'production') { + steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? 'Play Console > Publishing overview (publish once approved)' : 'storebound play status' }); + break; + } steps.push({ kind: 'wait', text: `${v} is in review (${r!.state}); nothing to do until the store answers`, fix: store === 'ios' ? 'storebound apple status --wait-review' : 'storebound play status' }); break; case 'approved': diff --git a/cli/play/client.ts b/cli/play/client.ts index 219060f..2b4fae7 100644 --- a/cli/play/client.ts +++ b/cli/play/client.ts @@ -6,6 +6,10 @@ import { errorBody } from '../lib/http'; import { noteGoogle } from '../lib/quota'; import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert'; import * as rt from '../lib/rt'; +import { withMachineLock } from '../lib/lock'; + +/** A read's edit is gone: replaced by a newer edit of the app, committed elsewhere or expired. */ +export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already been committed|edit.*(expired|not found)|outside of this Edit/i; export type PlayClient = Awaited>; @@ -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 = (what: string, fn: () => Promise, timeoutMs: number): Promise => + withMachineLock(`play-edit-${packageName}`, `play ${what} ${packageName}`, fn, { timeoutMs, onWait: m => log('play', m) }); + return { API, UPLOAD, packageName, call, /** @@ -108,8 +117,16 @@ export async function playClient(root: string, packageName: string) { * A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger). */ async read(fn: (id: string) => Promise): Promise { - const id = await openOne(); - try { return await fn(id); } finally { await closeOne(id, 'read-only'); } + return locked('read', async () => { + for (let attempt = 0; ; attempt++) { + const id = await openOne(); + try { return await fn(id); } catch (err) { + // the edit was replaced by another writer (Console, another tool) or expired: a read is safe to redo once + if (attempt === 0 && EDIT_GONE.test(String(err))) { log('play', `read edit ${id} was invalidated, reading again with a new edit`); continue; } + throw err; + } finally { await closeOne(id, 'read-only'); } + } + }, 90_000); }, /** * Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit @@ -117,6 +134,7 @@ export async function playClient(root: string, packageName: string) { * redone, up to `retries` times. Keep edits small (e.g. one per language for images) so a retry is cheap. */ async edit(fn: (id: string, ctl: EditCtl) => Promise, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise { + return locked(o.dry ? 'dry edit' : 'edit', async () => { for (let left = o.retries ?? 3; ; left--) { try { return await editOnce(fn, o); } catch (err) { // redo only what never went live: an edit invalidated before or at its commit, or a request inside the edit @@ -127,6 +145,7 @@ export async function playClient(root: string, packageName: string) { log('play', `${outside ? 'app changed outside the edit (Console?)' : 'a request got no clear answer; the edit was discarded'}, redoing it (${left} retries left)`); } } + }, 10 * 60_000); }, }; } diff --git a/cli/play/status.ts b/cli/play/status.ts index cfba96d..435c1f4 100644 --- a/cli/play/status.ts +++ b/cli/play/status.ts @@ -22,7 +22,7 @@ export async function run(ctx: Ctx): Promise { // the App category is not in the API (edits.details has no category field): a console step const cat = playCategoryOf(config); const category = { config: cat?.category ?? null, source: cat?.source ?? null, api: false, plan: 'storebound console plan play-store-settings' }; - if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage }); return; } + if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null }); return; } console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`); console.log(` category: ${cat ? `${cat.category} (${cat.source})` : 'not in the config (play.category)'} · not readable by the API: Store settings is a console step (${category.plan})`); console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`); @@ -30,8 +30,8 @@ export async function run(ctx: Ctx): Promise { const rs = (t.releases ?? []).map(r => `${r.name ?? '?'} [${r.versionCodes?.join(',') ?? '-'}] ${r.status}${r.userFraction ? ' ' + r.userFraction * 100 + '%' : ''}`); console.log(` track ${t.track.padEnd(11)} ${rs.join(' | ') || '-'}`); } - console.log(` public store page: ${publicPage.state === 'public' ? 'published (200)' : publicPage.state === 'notPublic' ? 'not public yet (404)' : `unknown (${publicPage.error ?? 'not read'})`}`); - for (const r of playReleases({ tracks, publicPage }).filter(x => x.track === 'production' && x.state === 'completed')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`); + console.log(` public store page: ${publicPage.state === 'public' ? `published (200), live version ${publicPage.versionName ?? (publicPage.varies ? 'varies with device' : 'not shown')}` : publicPage.state === 'notPublic' ? 'not public yet (404)' : `couldn't check it (${publicPage.error ?? 'not read'})`}${publicPage.stale ? ` (last known, ${publicPage.checkedAt.slice(0, 16).replace('T', ' ')} UTC)` : ''}`); + for (const r of playReleases({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }).filter(x => x.track === 'production')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`); console.log(` products (${products.length}):`); for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`); }); diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index aceceb3..8c42949 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -653,9 +653,11 @@ that fails without the fix (see CONTRIBUTING.md). ## 56. "Live on Play" while Google was still reviewing, and 1.0 / 1.0.0 shown as two versions - **Do:** never call a Google Play production release live from the Developer API alone: a release reads `completed` (userFraction 1) from the moment it is sent, also while Google reviews the first release. `play status` checks the - public store page (no auth, 4 s timeout, cached 5 minutes): 404 = "Sent to Google (in review or not yet - published)"; 200 = live for the first release (or when the page shows the same versionName), otherwise "Released to - production (Google may still be reviewing)"; offline = unknown, shown as unknown. Match versions across the stores + public store page (no auth, 4 s timeout, cached 5 minutes) and decides, never "maybe": the page's "Version" (About + this app, read from its AF_initDataCallback data) equals the release's versionName = "Live"; 404 = "In review" (with + play.managedPublishing: "In review / ready to publish (managed publishing)"); an older version on the page = "In + review (live: )"; "Varies with device" = live. Only a network failure gives "Couldn't check the store page", + and then the last known page answer is used with its time. Match versions across the stores by `versionKey` (trailing .0 parts dropped: 1 == 1.0 == 1.0.0); show each store's own string. - **Guard:** `playPublicPage` (`cli/lib/play-public.ts`), `playReleases` / `versionKey` / `sameVersion` (`cli/lib/portfolio.ts`), `versionHistory` (`cli/lib/train.ts`), `tests/play-public-version-match.test.ts`, the @@ -671,3 +673,18 @@ that fails without the fix (see CONTRIBUTING.md). findings; a real failure shows the command's own error line, never a bare exit code. - **Guard:** `cli/lib/google-errors.ts`, `outcomeOf` / `errorLine` (`cli/lib/desktop-protocol.ts`), `tests/google-errors.test.ts`, the desktop boot check's `errors` cases (api-disabled, scan-findings, scan-failed). + +## 58. Parallel Play reads deleted each other's edits, and a failed read said "not on Google Play yet" +- **Do:** Google Play keeps one open edit per app: a second `edits.insert` for the same package deletes the first, so a + read in it answers 400 "This Edit has been deleted". Every edit (read or write) of a package takes the machine-wide + lock `~/.storebound/locks/play-edit-.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). diff --git a/tests/fixtures/play-page-version.html b/tests/fixtures/play-page-version.html new file mode 100644 index 0000000..e659a0c --- /dev/null +++ b/tests/fixtures/play-page-version.html @@ -0,0 +1,7 @@ +Example Game - Apps on Google Play + + + + + diff --git a/tests/helpers/preload.ts b/tests/helpers/preload.ts index 63e4f07..9fe5d67 100644 --- a/tests/helpers/preload.ts +++ b/tests/helpers/preload.ts @@ -13,6 +13,7 @@ process.env.STOREBOUND_LIST_SETTLE_MS ??= '0'; const machine = mkdtempSync(join(tmpdir(), 'storebound-machine-')); process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials'); process.env.STOREBOUND_REGISTRY = join(machine, 'projects.json'); +process.env.STOREBOUND_LOCK_DIR = join(machine, 'locks'); // Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation: // DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code. diff --git a/tests/play-edit-lock.test.ts b/tests/play-edit-lock.test.ts new file mode 100644 index 0000000..bd6653b --- /dev/null +++ b/tests/play-edit-lock.test.ts @@ -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(); + 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 { + 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(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); + }); +}); diff --git a/tests/play-public-version-match.test.ts b/tests/play-public-version-match.test.ts index 7321826..647850e 100644 --- a/tests/play-public-version-match.test.ts +++ b/tests/play-public-version-match.test.ts @@ -4,7 +4,9 @@ import { afterEach, describe, expect, test } from 'bun:test'; import { APPLE_STATUS_FIRST_REVIEW, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures'; import { trainsOf } from '../cli/release'; -import { clearPlayPublicCache, pageVersion, playPublicPage } from '../cli/lib/play-public'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { clearPlayPublicCache, pageVersion, pageVersionInfo, playPublicPage } from '../cli/lib/play-public'; import { appleReleases, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio'; import { laneOf, laneText, releasePlan, storeNow, versionHistory } from '../cli/lib/train'; @@ -31,10 +33,10 @@ describe('version matching across the stores', () => { expect(h).toHaveLength(1); expect(h[0]!.version).toBe('1.0.0'); expect(h[0]!.ios).toMatchObject({ version: '1.0', build: '3', phase: 'review' }); - expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'sent', track: 'production' }); + expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'review', track: 'production' }); const t = releaseTrains(ios, android); expect(t.current).toBeNull(); // nothing reaches users yet - expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'sent' } }); + expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'review' } }); // release status --versions: the one row is the next train const s = { release: { source: 'live' as const, ...t, all }, storeErrors: {}, summary: null } as never; expect(trainsOf(s, true).map(x => [x.which, x.train.version])).toEqual([['next', '1.0.0']]); @@ -43,15 +45,15 @@ describe('version matching across the stores', () => { expect(p.ios?.release?.version).toBe('1.0'); expect(p.android?.release?.version).toBe('1.0.0'); expect(p.done).toBe(false); - expect(p.android!.steps.map(x => x.text).join(' ')).toContain('sent to Google'); + expect(p.android!.steps.map(x => x.text).join(' ')).toContain('1.0: In review'); }); }); describe('Google Play "Live" needs the public store page', () => { - test('completed production + page 404 = sent to Google, never live', () => { + test('completed production + page 404 = in review, never live', () => { const [prod] = playReleases(PLAY_STATUS_FIRST_404); - expect(prod).toMatchObject({ phase: 'sent', state: 'completed', publicPage: 'notPublic', fraction: 1 }); - expect(playReleaseLabel(prod!)).toBe('Sent to Google (in review or not yet published)'); + expect(prod).toMatchObject({ phase: 'review', state: 'completed', publicPage: 'notPublic', fraction: 1 }); + expect(playReleaseLabel(prod!)).toBe('In review'); const lane = laneOf('android', prod!); expect(lane.at).toBe(2); // the review stage, not live / rollout expect(lane.share).toBeNull(); @@ -65,22 +67,46 @@ describe('Google Play "Live" needs the public store page', () => { const t = releaseTrains(appleReleases(APPLE_STATUS_FIRST_REVIEW), playReleases(PLAY_STATUS_FIRST_200)); expect(t.current).toMatchObject({ version: '1.0.0', android: { phase: 'live' }, ios: { phase: 'review' } }); }); - test('an update after the first release: page 200 is not proof for the new versionCode unless the page shows it', () => { - const update = { publicPage: { state: 'public', versionName: null }, tracks: [ - { track: 'production', releases: [{ name: '1.1.0', versionCodes: ['5'], status: 'completed' }] }, - { track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] }, - ] }; - const [prod] = playReleases(update); - expect(prod!.phase).toBe('released'); - expect(playReleaseLabel(prod!)).toBe('Released to production (Google may still be reviewing)'); - expect(laneText(laneOf('android', prod!))).toContain('Google may still be reviewing'); - expect(playReleases({ ...update, publicPage: { state: 'public', versionName: '1.1' } })[0]!.phase).toBe('live'); + const prodRel = (page: Record | undefined, name = '1.1.0', status = 'completed', extra: Record = {}) => playReleases({ ...extra, publicPage: page, tracks: [ + { track: 'production', releases: [{ name, versionCodes: ['5'], status, userFraction: status === 'completed' ? undefined : 0.2 }] }, + { track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] }, + ] })[0]!; + test('rule: page version = the release version: Live', () => { + const r = prodRel({ state: 'public', versionName: '1.1' }); + expect(r.phase).toBe('live'); + expect(playReleaseLabel(r)).toBe('Live'); }); - test('page not read = unknown, shown as unknown', () => { - const [prod] = playReleases({ tracks: PLAY_STATUS_FIRST_404.tracks }); - expect(prod).toMatchObject({ phase: 'released', publicPage: 'unknown' }); - expect(playReleaseLabel(prod!)).toContain('unknown'); - expect(laneOf('android', prod!).tone).toBe('neutral'); + test('rule: page 404, managed publishing off: In review; on: In review / ready to publish', () => { + expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }))).toBe('In review'); + expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }, '1.1.0', 'completed', { managedPublishing: true }))).toBe('In review / ready to publish (managed publishing)'); + }); + test('rule: page live with an older version: In review (live: )', () => { + const r = prodRel({ state: 'public', versionName: '1.0.0' }); + expect(r).toMatchObject({ phase: 'review', liveVersion: '1.0.0' }); + expect(playReleaseLabel(r)).toBe('In review (live: 1.0.0)'); + expect(laneText(laneOf('android', r))).toBe('in review (live: 1.0.0)'); + expect(storeNow([r], 'android').now).toBeNull(); + }); + test('rule: rollout, halted, draft', () => { + expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'inProgress'))).toBe('Rolling out 20%'); + expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'halted'))).toBe('Halted'); + expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'draft'))).toBe('Draft'); + }); + test('rule: "Varies with device" = live (nothing to compare)', () => { + expect(prodRel({ state: 'public', versionName: null, varies: true }).phase).toBe('live'); + }); + test('rule: only a network failure is "Couldn\'t check the store page"; with a last known read it keeps that state and its time', () => { + const r = prodRel(undefined); + expect(r).toMatchObject({ phase: 'released', publicPage: 'unknown' }); + expect(playReleaseLabel(r)).toBe("Couldn't check the store page"); + expect(laneOf('android', r).tone).toBe('neutral'); + const s = prodRel({ state: 'public', versionName: '1.1.0', stale: true, checkedAt: '2026-10-12T09:30:00Z' }); + expect(s).toMatchObject({ phase: 'live', pageStaleAt: '2026-10-12T09:30:00Z' }); + expect(playReleaseLabel(s)).toBe("Live (couldn't check the store page now; as of 2026-10-12 09:30 UTC)"); + }); + test('no uncertain wording is left', () => { + for (const r of [prodRel(undefined), prodRel({ state: 'notPublic' }), prodRel({ state: 'public', versionName: '1.0' })]) + expect(`${playReleaseLabel(r)} ${laneText(laneOf('android', r))}`).not.toMatch(/not yet published|may still be reviewing|Sent to Google/); }); }); @@ -89,10 +115,19 @@ describe('public store page read', () => { test('404 = not public yet, 200 = public, the url has no auth and asks for English', async () => { const urls = answer(404); expect((await playPublicPage('com.example.first404', { noCache: true })).state).toBe('notPublic'); - expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en'); + expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en&gl=US'); answer(200, ''); expect(await playPublicPage('com.example.first200', { noCache: true })).toMatchObject({ state: 'public', http: 200, versionName: '1.0.0' }); }); + test('a network failure returns the last known answer with its time', async () => { + const pkg = `com.example.lastknown${Date.now()}`; + answer(200, readFileSync(join(import.meta.dir, 'fixtures/play-page-version.html'), 'utf8')); + const ok = await playPublicPage(pkg, { noCache: true }); + expect(ok).toMatchObject({ state: 'public', versionName: '0.8.1' }); + globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch; + const r = await playPublicPage(pkg, { noCache: true }); + expect(r).toMatchObject({ state: 'public', versionName: '0.8.1', stale: true, checkedAt: ok.checkedAt }); + }); test('offline or a server error = unknown; unknown is never cached', async () => { const off = `com.example.offline${Date.now()}`; globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch; @@ -112,9 +147,12 @@ describe('public store page read', () => { expect((await playPublicPage(pkg)).state).toBe('notPublic'); expect(urls).toHaveLength(1); }); - test('the page version is read only from structured data', () => { + test('the page version is read from the recorded live page shape (About this app in the ds blobs)', () => { + const html = readFileSync(join(import.meta.dir, 'fixtures/play-page-version.html'), 'utf8'); + expect(pageVersionInfo(html)).toEqual({ versionName: '0.8.1', varies: false }); + expect(pageVersionInfo(html.replace('[[["0.8.1"]]', '[[["Varies with device"]]'))).toEqual({ versionName: null, varies: true }); expect(pageVersion('{"softwareVersion": "2.3.1"}')).toBe('2.3.1'); - expect(pageVersion('{"softwareVersion":"Varies with device"}')).toBeNull(); + expect(pageVersionInfo('{"softwareVersion":"Varies with device"}')).toEqual({ versionName: null, varies: true }); expect(pageVersion('
Version 1.0
')).toBeNull(); }); }); diff --git a/tests/portfolio.test.ts b/tests/portfolio.test.ts index c0a52d6..1bb045c 100644 --- a/tests/portfolio.test.ts +++ b/tests/portfolio.test.ts @@ -24,7 +24,7 @@ const play = { { track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] }, ], products: [{ productId: 'coins' }, { productId: 'no_ads' }, { sku: 'android_only' }], - publicPage: { state: 'public' }, + publicPage: { state: 'public', versionName: '2.2.1' }, }; describe('portfolio (apps, release trains, catalog parity)', () => { diff --git a/tests/release-train.test.ts b/tests/release-train.test.ts index b10a034..09caee0 100644 --- a/tests/release-train.test.ts +++ b/tests/release-train.test.ts @@ -22,7 +22,7 @@ const play = { { track: 'production', releases: [{ name: '2.3.0', versionCodes: ['230'], status: 'inProgress', userFraction: 0.1 }, { name: '2.2.0', versionCodes: ['220'], status: 'completed' }] }, { track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] }, ], - publicPage: { state: 'public' }, + publicPage: { state: 'public', versionName: '2.2.0' }, }; const all: StoreRelease[] = [...appleReleases(apple), ...playReleases(play)]; diff --git a/tests/serve.test.ts b/tests/serve.test.ts index 319d5bf..14e6d29 100644 --- a/tests/serve.test.ts +++ b/tests/serve.test.ts @@ -254,7 +254,7 @@ describe('serve --stdio: session', () => { const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: summary } : argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } } : argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' } - : argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }], publicPage: { state: 'public' } } } : {}); + : argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }], publicPage: { state: 'public', versionName: '1.2.0' } } } : {}); const r = resOf(await call('portfolio.status', { projects: [DEMO, '/nope/game'], live: true })) as { apps: J[] }; expect(r.apps[0]).toMatchObject({ project: DEMO, app: { id: DEMO, name: 'Demo', platforms: { ios: { bundleId: 'com.example.demo' }, android: { packageName: 'com.example.demo' } } }, check: { ok: true, warnings: ['w'] }, storeErrors: { ios: 'ASC_KEY_ID and ASC_ISSUER_ID are not set' }, ok: false }); diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 617db52..6e90829 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -541,7 +541,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404: let visited = 0; const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' '); const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); }; - const SENT = 'Sent to Google (in review or not yet published)'; + const SENT = 'In review'; // decided from the store page: 404 = in review (no uncertain wording) try { await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' }); await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)')); @@ -568,6 +568,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404: if (row[1] !== 'In review') problems.push(`Releases: App Store cell "${row[1]}" (want "In review")`); if (row[3] !== (page404 ? SENT : 'Live')) problems.push(`Releases: Google Play cell "${row[3]}" (want "${page404 ? SENT : 'Live'}")`); const rel = await text(); + if (/not yet published|may still be reviewing|Sent to Google/.test(rel + pf)) problems.push('uncertain Play wording on screen'); if (/is not on (App Store|Google Play) yet/.test(rel)) problems.push('Releases: a lane says the version is not on a store (1.0 and 1.0.0 not joined)'); for (const w of ['1.0 (3)', '1.0.0 (3)']) if (!rel.includes(w)) problems.push(`Releases: the lane does not show the store's own "${w}"`); if (page404) { @@ -609,6 +610,8 @@ async function runAutoReadCase(browser: Browser): Promise { const notes: string[] = []; const logged: string[] = []; let reads = 0; + const playOpen = new Map(); + let playPeak = 0; // fixture art is file: URLs (blocked by the CSP): icons are left out const clean = (v: unknown): unknown => (v === undefined ? null : JSON.parse(JSON.stringify(v), (_k, x) => (typeof x === 'string' && x.startsWith('file:') ? null : x))); const answer = async (cmd: string, a: Record): Promise => { @@ -621,6 +624,15 @@ async function runAutoReadCase(browser: Browser): Promise { const method = String(a.method); if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a)); reads++; + if (method.startsWith('play.') || ['health', 'reviews.list', 'catalog.diff', 'prices.current', 'validate'].includes(method)) { + // Play reads of one app may run at once (the scheduler's 4 per store, starts 250 ms apart per key): count the + // overlap and hold each read long enough for it to happen + const k = String(a.projectId); + const n = (playOpen.get(k) ?? 0) + 1; + playOpen.set(k, n); playPeak = Math.max(playPeak, n); + await new Promise(r => setTimeout(r, 400)); + playOpen.set(k, playOpen.get(k)! - 1); + } return clean(fx.fixtureRead(method, String(a.projectId), ((a.params as { args?: Record } | null)?.args) ?? {})); } default: return clean(await fx.mockInvoke(cmd, a)); @@ -664,10 +676,24 @@ async function runAutoReadCase(browser: Browser): Promise { await settle(page); await crashCheck(`${appName} ${id}`); const tx = await text(); + // a Play read that lost its edit to a parallel read (pitfall), or a store that failed called empty + for (const bad of [/Edit has been deleted/i, /is not on Google Play yet/i]) if (bad.test(tx)) problems.push(`${appName} ${id}: says "${(bad.exec(tx) ?? [''])[0]}"`); if (/not read yet|Read the stores to see this/i.test(tx)) problems.push(`${appName} ${id}: says "${(/not read yet|Read the stores to see this/i.exec(tx) ?? [''])[0]}" after the background read`); visited++; } } + notes.push(`Play reads of one app at once: up to ${playPeak}`); + if (playPeak < 2) problems.push(`no two Play reads of one app ran at once (peak ${playPeak}): the case does not exercise parallel reads`); + // the stores' current versions differ (Play 3.1.0, App Store 3.0.4): a neutral header, each store its own state + await page.locator('nav.side .nav-app', { hasText: 'Orbit Hop' }).first().click({ timeout: WAIT_MS }); + await settle(page); + await page.locator('[role="tab"]#tab-releases').click({ timeout: WAIT_MS }); + await settle(page); + const rel = await text(); + for (const w of ['Current', 'Play 3.1.0 · App Store 3.0.4', '3.0.4 · Live (3.1.0 not submitted)']) if (!rel.includes(w)) problems.push(`Orbit Hop Releases: missing "${w}"`); + for (const w of ['Not on this store', 'is not on App Store yet', '3.1.0 Released']) if (rel.includes(w)) problems.push(`Orbit Hop Releases: says "${w}"`); + notes.push(`Orbit Hop releases: ${rel.slice(0, 200)}`); + await crashCheck('Orbit Hop Releases'); // Health: the first top issue opens with its full title, the sample stack and Copy for AI await page.locator('nav.side .nav-app', { hasText: 'Prism Puddle' }).first().click({ timeout: WAIT_MS }); await settle(page);