296 lines
18 KiB
TypeScript
296 lines
18 KiB
TypeScript
// 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<string, string[]>(); // 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 <N> --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 <aab> --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 ?? '<N>'} --dry` }
|
|
: { kind: 'owner', text: `promote ${v} from ${r!.track ?? 'testing'} to production (owner)`, fix: `${consoleLink('play.production')}, or \`storebound play release <aab> --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 <x>` 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 };
|
|
}
|