Play 'Live' only when the public store page proves it: play status checks https://play.google.com/store/apps/details?id=<pkg> (no auth, 4 s timeout, cached 5 min; publicPage in the JSON), a completed production release with a 404 page is 'Sent to Google (in review or not yet published)', 200 is live for the first release or when the page shows the versionName, otherwise 'Released to production (Google may still be reviewing)', offline is unknown; new phases sent / released in the desktop Releases tab, Portfolio, release status | plan; versions matched across the stores without trailing .0 parts (App Store 1.0 and Play 1.0.0 are one row and one train, each side shows its own string); tests/play-public-version-match.test.ts, desktop boot check release-state cases (page 404 and 200) (pitfall 56)

This commit is contained in:
CRTkafa 2026-10-13 20:46:48 +08:00
parent aa72204a06
commit be9ff8b788
18 changed files with 436 additions and 30 deletions

View file

@ -103,3 +103,23 @@ export const PLAY_STATUS = {
],
listings: [], products: [],
};
// 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.
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')],
builds: [build('b-3', '3', 'VALID', '2026-10-10T09:00:00Z', 'pre-10')], attachedBuilds: [], buildVersions: { 'b-3': '1.0' },
iaps: [], phased: {}, subs: [{ id: 'rs-10', attributes: { state: 'WAITING_FOR_REVIEW', submittedDate: '2026-10-10T10:00:00Z' } }], unresolved: [], emptyDrafts: [],
};
const playFirst = (state: 'public' | 'notPublic') => ({
...PLAY_STATUS,
tracks: [
{ 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' },
});
export const PLAY_STATUS_FIRST_404 = playFirst('notPublic');
export const PLAY_STATUS_FIRST_200 = playFirst('public');

View file

@ -100,6 +100,9 @@ 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.rejected': 'Rejected',
'phase.testing': 'Testing',
'phase.draft': 'Draft',
@ -211,6 +214,7 @@ export const en = {
'tl.next': 'Next',
'tl.live': 'Live',
'tl.released': 'Released',
'tl.store': 'Store',
'tl.created': 'Created {date}',
'tl.notOnStore': 'Not on this store',

View file

@ -102,6 +102,9 @@ export const tr: Record<keyof typeof en, string> = {
'phase.paused': 'Duraklatıldı',
'phase.approved': 'Onaylandı',
'phase.review': 'İncelemede',
'phase.sent': 'Google’a gönderildi (incelemede ya da henüz yayınlanmadı)',
'phase.released': 'Üretime çıktı (Google hâlâ inceliyor olabilir)',
'phase.releasedUnknown': 'Üretime çıktı (yayın durumu bilinmiyor: mağaza sayfası okunamadı)',
'phase.rejected': 'Reddedildi',
'phase.testing': 'Testte',
'phase.draft': 'Taslak',
@ -213,6 +216,7 @@ export const tr: Record<keyof typeof en, string> = {
'tl.next': 'Sonraki',
'tl.live': 'Yayında',
'tl.released': 'Üretimde',
'tl.store': 'Mağaza',
'tl.created': 'Oluşturma: {date}',
'tl.notOnStore': 'Bu mağazada yok',

View file

@ -1,6 +1,7 @@
// What deserves a desktop notification after a read (Settings > Notifications). Pure: compares the previous read with
// the new one and never fires on a first read (nothing to compare with = no flood).
import type { PortfolioResult } from '../api';
import { sameVersion } from '../../../../cli/lib/portfolio';
import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model';
export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string }
@ -8,14 +9,14 @@ export interface ReleaseChange { project: string; name: string; store: 'ios' | '
/** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */
export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] {
if (!prev) return [];
const WATCH = new Set(['review', 'approved', 'rejected', 'live', 'halted']);
const WATCH = new Set(['review', 'sent', 'released', 'approved', 'rejected', 'live', 'halted']);
const out: ReleaseChange[] = [];
for (const a of next.apps) {
const before = prev.apps.find(p => p.project === a.project);
if (!before || before.release.source !== 'live' || a.release.source !== 'live') continue;
for (const r of a.release.all ?? []) {
if (!r.version) continue;
const old = (before.release.all ?? []).find(x => x.store === r.store && x.version === r.version);
const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version)));
if (!old || old.phase === r.phase) continue;
if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase });
}

View file

@ -22,6 +22,7 @@ function stageCaption(l: Lane, i: number): string {
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');
return '';
}
@ -33,7 +34,7 @@ function LaneRow({ lane, version }: { lane: Lane; version: string }) {
<StoreGlyph store={lane.store} />
<div>
<div className="w1">{storeName(lane.store)}</div>
<div className="w2">{r ? `${version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}</div>
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}</div>
</div>
</div>
{lane.at < 0 ? <div className="tl-none">{t('tl.noVersion', { version, store: storeName(lane.store) })}</div> : STAGES.map((s, i) => {
@ -102,8 +103,10 @@ function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) {
function Train({ title, train, tf, tone, app }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp }) {
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;
return (
<Card pad={false} title={<span className="train-head"><span className="ver">{train.version}</span><Chip tone={tone}>{title}</Chip></span>}
<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>}
sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
<div className="tl" style={{ paddingTop: 12 }}>
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div>
@ -165,7 +168,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
<tbody>
{history.map(h => (
<tr key={h.version}>
<td className="strong num">{h.version}</td>
<td className="strong num">{[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')}</td>
<td>{h.ios ? <PhaseChip r={h.ios} /> : <span className="muted small">–</span>}</td>
<td className="num muted">{h.ios?.build ?? '–'}</td>
<td>{h.android ? <PhaseChip r={h.android} /> : <span className="muted small">–</span>}</td>

View file

@ -14,7 +14,7 @@ export function Chip({ tone = 'neutral', children, dot = true, title, icon }: {
return <span className={`chip ${tone}`} title={title}>{icon ? <Icon name={icon} /> : dot && tone !== 'outline' && <i />}{children}</span>;
}
const PHASE_TONE: Record<ReleasePhase, Tone> = { live: 'ok', rollout: 'accent', halted: 'bad', approved: 'ok', review: 'warn', rejected: 'bad', testing: 'info', draft: 'neutral', other: 'neutral' };
const PHASE_TONE: Record<ReleasePhase, Tone> = { live: 'ok', rollout: 'accent', halted: 'bad', released: 'info', approved: 'ok', review: 'warn', sent: 'warn', rejected: 'bad', testing: 'info', draft: 'neutral', other: 'neutral' };
/** Chip of a store release: "Live", "Rollout 20%", "In review" ... (iOS phased release: "Phased 50%", "Paused"). */
export function phaseLabel(r: StoreRelease | null | undefined): { text: string; tone: Tone } {
@ -22,6 +22,7 @@ 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' };
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] };
}