244 lines
15 KiB
TypeScript
244 lines
15 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 type { ReleaseTrain, StoreRelease } from './desktop-protocol';
|
|
import { cmpVersion, displayVersion, PHASE_RANK, REACHING, 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 };
|
|
}
|
|
|
|
// ---- 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 (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)';
|
|
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;
|
|
/** 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: 'App Store Connect > the version > Add for Review' }
|
|
: r!.track && r!.track !== 'production'
|
|
? { kind: 'owner', text: `roll out the ${r!.track} draft of ${v} to its testers (owner)`, fix: `Play Console > Test and release > ${r!.track}` }
|
|
: { kind: 'owner', text: `send the production draft of ${v} for review (owner)`, fix: 'Play Console > Publishing overview > Send for review' });
|
|
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: 'Play Console > Test and release > Production, or `storebound play release <aab> --production draft` in a terminal' });
|
|
break;
|
|
case 'rejected':
|
|
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' });
|
|
break;
|
|
case 'review':
|
|
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' ? 'App Store Connect > Release this version, or `storebound apple release --manual-release` in a terminal' : 'Play Console > Publishing overview' });
|
|
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 };
|
|
}
|