// Release trains: one version across the App Store and Google Play, on one track of stages // (build -> testing -> review -> live -> rollout). The desktop app's Releases tab (apps/desktop/src/lib/model.ts // 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 { CONSOLE_PAGES, consoleLink, consoleUrl, playTrackPage } from './console-links'; 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 }; export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'neutral' | 'accent'; /** Stages of one version on one store, left to right. */ export const STAGES = ['build', 'testing', 'review', 'live', 'rollout'] as const; export type Stage = (typeof STAGES)[number]; export interface Lane { store: 'ios' | 'android'; release: StoreRelease | null; /** Index of the stage it is at (-1: not on this store). */ at: number; tone: Tone; /** Rollout share 0-1 when live or rolling out (1 = everyone). */ share: number | null; /** The store's state word, for a tooltip. */ state: string; } /** Where a store release is on the track. Rollout counts as the last stage; a full release fills it. */ export function laneOf(store: 'ios' | 'android', r: StoreRelease | null, testflightReached = false): Lane { if (!r) return { store, release: null, at: -1, tone: 'neutral', share: null, state: '' }; const base = { store, release: r, state: r.state }; switch (r.phase) { case 'draft': return { ...base, at: testflightReached ? 1 : 0, tone: testflightReached ? 'info' : 'neutral', share: null }; case 'testing': return { ...base, at: 1, tone: 'info', share: null }; case 'review': return { ...base, at: 2, tone: 'warn', share: null }; case 'approved': return { ...base, at: 2, tone: 'ok', share: null }; case 'sent': return { ...base, at: 2, tone: 'warn', share: null }; case 'released': return { ...base, at: 3, tone: r.publicPage === 'unknown' ? 'neutral' : 'info', share: null }; case 'rejected': return { ...base, at: 2, tone: 'bad', share: null }; case 'live': return { ...base, at: 4, tone: 'ok', share: 1 }; case 'rollout': return { ...base, at: 4, tone: 'accent', share: r.fraction ?? null }; case 'halted': return { ...base, at: 4, tone: 'bad', share: r.fraction ?? null }; default: return { ...base, at: 0, tone: 'neutral', share: null }; } } /** TestFlight builds as `apple testflight status --json` lists them (only what the lanes need). */ export interface BetaBuilds { builds: { version: string | null; internal: string | null; external: string | null }[] } /** Both lanes of a train; iOS counts TestFlight as reached when a build of that version is in beta testing. */ export function trainLanes(t: ReleaseTrain, tf?: BetaBuilds | null): Lane[] { const inBeta = !!tf?.builds.some(b => sameVersion(b.version, t.version) && /IN_BETA_TESTING|BETA_APPROVED|READY_FOR_BETA_TESTING/.test(`${b.internal ?? ''} ${b.external ?? ''}`)); return [laneOf('ios', t.ios, inBeta), laneOf('android', t.android)]; } /** Every distinct version of the store releases, newest first, with both stores' release (best phase per store). */ export function versionHistory(all: StoreRelease[]): ReleaseTrain[] { const keys = new Map(); // one row per release: 1.0 (App Store) and 1.0.0 (Play) are the same for (const v of all.map(r => r.version).filter((x): x is string => !!x)) { const k = versionKey(v); keys.set(k, [...(keys.get(k) ?? []), v]); } const best = (store: 'ios' | 'android', k: string) => all.filter(r => r.store === store && !!r.version && versionKey(r.version) === k).sort((a, b) => (PHASE_RANK[a.phase] ?? 99) - (PHASE_RANK[b.phase] ?? 99))[0] ?? null; return [...keys].map(([k, vs]) => { const ios = best('ios', k), android = best('android', k); return { version: displayVersion([android?.version, ios?.version, ...vs]), ios, android }; }).sort((a, b) => cmpVersion(b.version, a.version)); } /** On one store: the highest version reaching users (live or rolling out) and the highest version above it. */ export function storeNow(all: StoreRelease[], store: 'ios' | 'android'): { now: StoreRelease | null; next: StoreRelease | null } { const rs = all.filter((r): r is StoreRelease & { version: string } => r.store === store && !!r.version); const desc = (a: { version: string }, b: { version: string }) => cmpVersion(b.version, a.version); const now = rs.filter(r => REACHING.has(r.phase)).sort(desc)[0] ?? null; const next = rs.filter(r => !now || cmpVersion(r.version, now.version) > 0).sort(desc)[0] ?? null; 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}%`); /** "review (approved)", "rollout 20%", "rollout (halted) 5%", "live", "-" for a store without this version. */ export function laneText(l: Lane): string { if (!l.release) return '-'; const stage = STAGES[l.at] ?? 'build'; const p = l.release.phase; if (p === 'live') return 'live'; // the whole track filled: everyone has it 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; } /** "build > testing > [review] > live > rollout" with the reached stage in brackets. */ export function trackText(l: Lane): string { return STAGES.map((s, i) => (i === l.at ? `[${s}]` : s)).join(' > '); } // ---- Release plan: what is left for one version on each store, in fix order ---- export type PlanKind = 'config' | 'store' | 'blocker' | 'owner' | 'wait' | 'warning' | 'done'; export interface PlanStep { kind: PlanKind; text: string; /** Absolute deep link to the console page the message points to (desktop / MCP render it as an Open link). */ url?: string; /** The command or console place that does it. Owner steps name what the owner runs; storebound never does them. */ fix?: string; code?: string; } export interface StorePlan { store: 'ios' | 'android'; /** The store's release of this version (best phase), or null when the version is not on the store yet. */ release: StoreRelease | null; lane: Lane; steps: PlanStep[]; done: boolean; } export interface ReleasePlan { version: string; /** Offline config problems (storebound check): both stores need them fixed first. */ shared: PlanStep[]; ios: StorePlan | null; android: StorePlan | null; /** Every store the app is on has the version live to everyone. */ done: boolean; /** What the plan could not read ("unknown, never false"). */ unknown: string[]; } export interface PlanInput { version: string; platforms: { ios: boolean; android: boolean }; /** `storebound check --json`, or null when it did not run. */ check: { problems: string[]; warnings: string[] } | null; /** `storebound validate --json` findings, or null when it did not run. Validate reads the editable App Store version * and the Play edit, so its blockers count for a version that is not yet in review. */ validate: Finding[] | null; /** Every store release read (`AppStatus.release.all`), or null when the stores were not read. */ releases: StoreRelease[] | null; storeErrors?: { ios?: string; android?: string }; } const STORE_NAME = { ios: 'App Store', android: 'Google Play' } as const; /** Phases where the store blockers of `validate` still matter (nothing is submitted yet). */ const BEFORE_REVIEW = new Set(['none', 'draft', 'testing', 'rejected', 'other']); function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null, findings: Finding[] | null): PlanStep[] { const steps: PlanStep[] = []; const phase = r?.phase ?? 'none'; const name = STORE_NAME[store]; // 1. the version itself on the store if (!r) { steps.push(store === 'ios' ? { kind: 'store', text: `${v} is not on ${name} yet: upload a build (Xcode / Transporter), then create the version with its texts`, fix: `storebound apple next-build; storebound apple listing --version ${v} --build --dry` } : { kind: 'store', text: `${v} is not on ${name} yet: upload the AAB to a testing track`, fix: 'storebound play next-version-code; storebound play release --internal completed --dry' }); } else if (phase === 'rejected') { steps.push({ kind: 'blocker', text: `${v} was rejected (${r.state}): read the reason, fix it, then resubmit`, fix: 'review-rejections skill', code: 'rejected' }); } // 2. store blockers (validate, already in fix order), only while nothing is submitted const tag = store === 'ios' ? 'apple' : 'play'; const mine = (findings ?? []).filter(f => f.store === tag); if (BEFORE_REVIEW.has(phase)) for (const f of mine.filter(x => x.level === 'blocker')) steps.push({ kind: 'blocker', text: f.msg, ...(f.fix ? { fix: f.fix } : {}), code: f.code }); // 3. the next move on the track switch (phase) { case 'none': case 'other': break; case 'draft': steps.push(store === 'ios' ? { kind: 'owner', text: `submit ${v} for App Review (owner)`, fix: consoleLink('asc.version', '> Add for Review'), url: consoleUrl('asc.version') } : r!.track && r!.track !== 'production' ? { kind: 'owner', text: `roll out the ${r!.track} draft of ${v} to its testers (owner)`, fix: `${CONSOLE_PAGES[playTrackPage(r!.track)].label} (${r!.track}): ${consoleUrl(playTrackPage(r!.track))}`, url: consoleUrl(playTrackPage(r!.track)) } : { kind: 'owner', text: `send the production draft of ${v} for review (owner)`, fix: consoleLink('play.publishing', '> Send for review'), url: consoleUrl('play.publishing') }); break; case 'testing': steps.push(store === 'ios' ? { kind: 'store', text: `${v} is in TestFlight (build ${r!.build ?? '?'}) but has no App Store version yet: create it with that build and its texts`, fix: `storebound apple listing --version ${v} --build ${r!.build ?? ''} --dry` } : { kind: 'owner', text: `promote ${v} from ${r!.track ?? 'testing'} to production (owner)`, fix: `${consoleLink('play.production')}, or \`storebound play release --production draft\` in a terminal`, url: consoleUrl('play.production') }); break; case 'rejected': steps.push({ kind: 'owner', text: `resubmit ${v} after the fix (owner)`, fix: store === 'ios' ? consoleLink('asc.app-review', '> reply / resubmit') : consoleLink('play.publishing'), url: consoleUrl(store === 'ios' ? 'asc.app-review' : 'play.publishing') }); break; case 'sent': case 'released': 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 ? `${consoleLink('play.publishing')} (publish once approved)` : 'storebound play status', ...(r!.managed ? { url: consoleUrl('play.publishing') } : {}) }); 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': steps.push({ kind: 'owner', text: `${v} is approved (${r!.state}): release it (owner)`, fix: store === 'ios' ? `${consoleLink('asc.version', '> Release this version')}, or \`storebound apple release --manual-release\` in a terminal` : consoleLink('play.publishing'), url: consoleUrl(store === 'ios' ? 'asc.version' : 'play.publishing') }); break; case 'rollout': steps.push({ kind: 'owner', text: `${v} reaches ${pctText(r!.fraction) || 'part'} of users: raise or complete the rollout when health is good (owner)`, fix: store === 'ios' ? 'storebound health; then `storebound apple release --phased complete` in a terminal' : 'storebound health; then `storebound play rollout --fraction ` or `--complete` in a terminal' }); break; case 'halted': steps.push({ kind: 'owner', text: `the rollout of ${v} is halted at ${pctText(r!.fraction) || '?'}: fix and resume, or ship a new version (owner)`, fix: store === 'ios' ? '`storebound apple release --phased resume` in a terminal' : '`storebound play rollout --resume` in a terminal' }); break; case 'live': steps.push({ kind: 'done', text: `${v} is live on ${name} for everyone` }); break; } // 4. warnings last (they do not block) if (BEFORE_REVIEW.has(phase)) for (const f of mine.filter(x => x.level === 'warn')) steps.push({ kind: 'warning', text: f.msg, ...(f.fix ? { fix: f.fix } : {}), code: f.code }); return steps; } /** * What is left for `version` on each store, in fix order: config problems (offline check) first, then per store the * version itself, the store blockers (validate), the next move on the track (owner steps are named, never done) and * the warnings. A store whose state could not be read is listed in `unknown`, never planned as "not there". */ export function releasePlan(i: PlanInput): ReleasePlan { const unknown: string[] = []; const shared: PlanStep[] = []; if (i.check) { for (const p of i.check.problems) shared.push({ kind: 'config', text: p, fix: 'storebound check' }); } else unknown.push('config check did not run'); const trains = i.releases ? versionHistory(i.releases) : []; const train = trains.find(t => cmpVersion(t.version, i.version) === 0 && t.version.split('.').length === i.version.split('.').length) ?? trains.find(t => cmpVersion(t.version, i.version) === 0) ?? null; const one = (store: 'ios' | 'android'): StorePlan | null => { if (!i.platforms[store]) return null; const err = i.storeErrors?.[store]; if (!i.releases || err) { unknown.push(`${STORE_NAME[store]} state not read${err ? `: ${err}` : ''}`); return { store, release: null, lane: laneOf(store, null), steps: [{ kind: 'store', text: `${STORE_NAME[store]} could not be read: the plan for this store is unknown`, fix: store === 'ios' ? 'storebound apple status' : 'storebound play status' }], done: false }; } const r = train?.[store] ?? null; const steps = storeSteps(store, i.version, r, i.validate); return { store, release: r, lane: laneOf(store, r), steps, done: r?.phase === 'live' }; }; if (!i.validate) unknown.push('store readiness (validate) did not run'); const ios = one('ios'), android = one('android'); const stores = [ios, android].filter((x): x is StorePlan => !!x); return { version: i.version, shared, ios, android, done: !shared.length && stores.length > 0 && stores.every(s => s.done), unknown }; }