storebound/cli/release.ts

172 lines
11 KiB
TypeScript

// `storebound release`: release trains from the terminal, read-only. The same stages as the desktop's Releases tab
// (cli/lib/train.ts, cli/lib/app-status.ts are shared with it).
// release status [--app a,b] [--versions] [--json]
// per app: the version that reaches users and the next one, both stores on one train
// (build -> testing -> review -> live -> rollout %); --versions lists every version the stores know
// release plan <version> [--app a] [--no-validate] [--json]
// what is left for <version> on each store, in fix order: config problems (check), the version on the store,
// store blockers (validate), the next move (owner steps are named, never done), warnings
// In an app folder: that app (its own credentials). In a workspace (storebound.workspace.json above): every app, or
// --app a,b, with the credential profiles of the workspace.
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import type { Args } from './lib/args';
import { emptyStatus, fillAppStatus, lastLine, pool } from './lib/app-status';
import { CONFIG_NAMES } from './lib/config';
import type { AppStatus, ReleaseTrain } from './lib/desktop-protocol';
import { log, printJson, warn } from './lib/log';
import { runCommand, type RunResult } from './lib/runner';
import { textTable } from './lib/table';
import { laneOf, laneText, type PlanStep, releasePlan, type ReleasePlan, sameVersion, trackText, versionHistory } from './lib/train';
import type { Finding } from './lib/validate-rules';
import { findWorkspace, loadWorkspace, resolveApps, selectApps } from './lib/workspace';
import { appDir, appEnv, workspaceApps } from './workspace';
interface Target { name: string; dir: string; virtual: boolean; env: Record<string, string>; problems: string[] }
const USAGE = 'usage: storebound release status [--app a,b] [--versions] [--json] | release plan <version> [--app a] [--no-validate] [--json]';
function configDir(from: string): string | null {
for (let d = resolve(from); ; d = dirname(d)) {
if (CONFIG_NAMES.some(n => existsSync(join(d, n)))) return d;
if (dirname(d) === d) return null;
}
}
/** The apps to read: this app folder, or the workspace's apps (filtered by --app). */
async function targets(args: Args): Promise<{ list: Target[]; concurrency: number }> {
const cwd = process.cwd();
const here = configDir(cwd);
const wsFile = findWorkspace(cwd, args.get('workspace'));
// an app folder wins unless --app asks for the workspace (or the workspace file is closer than the config)
if (here && !args.get('app') && (!wsFile || dirname(wsFile).length <= here.length)) {
return { list: [{ name: here.split(/[\\/]/).pop() ?? here, dir: here, virtual: false, env: {}, problems: [] }], concurrency: 1 };
}
if (!wsFile) throw new Error('no storebound.config.ts here and no storebound.workspace.json above (run it in an app folder or a workspace)');
const l = await loadWorkspace(cwd, args.get('workspace'));
const n = Math.min(8, Math.max(1, l.ws.concurrency ?? 4));
const { apps: folders } = resolveApps(l);
const summaries = new Map<string, AppStatus['summary']>();
await pool(folders, n, async f => {
const r = await runCommand(['inspect', '--json'], { cwd: f.path, machine: true }).result;
if (r.code === 0 && r.data) summaries.set(f.id, r.data as AppStatus['summary']);
});
const { apps, accounts } = workspaceApps(l, summaries);
const { picked, unknown } = selectApps(apps.filter(a => !a.ignored), args.list('app'));
if (unknown.length) throw new Error(`--app: no app named ${unknown.join(', ')} (storebound ws list shows the names)`);
return { list: picked.map(a => { const { env, problems } = appEnv(a.unified, accounts); return { name: a.name, dir: appDir(a), virtual: a.virtual, env, problems }; }), concurrency: n };
}
const exec = (env: Record<string, string>) => (argv: string[], cwd: string): Promise<RunResult> => runCommand(argv, { cwd, env, machine: true }).result;
async function readLive(t: Target): Promise<AppStatus> {
const e = emptyStatus(t.dir);
if (t.virtual) e.virtual = true;
try { await fillAppStatus(e, t.dir, { live: true, virtual: t.virtual, exec: exec(t.env) }); } catch (err) { e.errors.push((err as Error).message); }
if (e.summary?.name) t.name = e.summary.name;
return e;
}
export interface TrainRow { app: string; version: string; which: 'current' | 'next' | 'other'; ios: string; android: string }
/** The trains of one app to show: current + next (the desktop's portfolio), or every version. */
export function trainsOf(s: AppStatus, all: boolean): { which: TrainRow['which']; train: ReleaseTrain }[] {
if (all) {
const cur = s.release.current?.version, next = s.release.next?.version;
return versionHistory(s.release.all ?? []).map(t => ({ which: sameVersion(t.version, cur) ? 'current' as const : sameVersion(t.version, next) ? 'next' as const : 'other' as const, train: t }));
}
return [...(s.release.current ? [{ which: 'current' as const, train: s.release.current }] : []), ...(s.release.next ? [{ which: 'next' as const, train: s.release.next }] : [])];
}
const cell = (store: 'ios' | 'android', t: ReleaseTrain, s: AppStatus): string => {
if (s.storeErrors[store]) return 'unreadable';
const on = store === 'ios' ? s.summary?.apple : s.summary?.play;
if (!on) return '';
const r = t[store];
if (!r) return '-';
const extra = [r.build ? `build ${r.build}` : '', store === 'android' && r.track && r.track !== 'production' ? r.track : ''].filter(Boolean).join(', ');
return `${laneText(laneOf(store, r))}${extra ? ` (${extra})` : ''}`;
};
async function status(args: Args): Promise<void> {
const { list, concurrency } = await targets(args);
const all = args.has('versions');
const read = await pool(list, concurrency, async t => ({ t, s: await readLive(t) }));
if (args.has('json')) {
printJson({ apps: read.map(({ t, s }) => ({
app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], warnings: s.warnings ?? [], storeErrors: s.storeErrors,
trains: trainsOf(s, all).map(({ which, train }) => ({ which, version: train.version, ios: train.ios, android: train.android,
lanes: { ios: laneOf('ios', train.ios), android: laneOf('android', train.android) } })),
})), at: new Date().toISOString() });
} else {
const rows: string[][] = [];
for (const { t, s } of read) {
const trains = trainsOf(s, all);
const unread = Object.keys(s.storeErrors).length > 0;
if (!trains.length) rows.push([t.name, '', '', s.errors[0] ? `error: ${s.errors[0]}` : unread ? 'not read (see the warnings)' : 'no version on either store yet', '']);
for (const { which, train } of trains) rows.push([t.name, train.version, which === 'other' ? '' : which, cell('ios', train, s), cell('android', train, s)]);
for (const w of s.warnings ?? []) warn('release', `${t.name}: ${w}`);
for (const p of [...t.problems, ...s.errors.slice(trains.length ? 0 : 1), ...Object.entries(s.storeErrors).map(([k, v]) => `${k}: ${v}`)]) warn('release', `${t.name}: ${p}`);
}
console.log(textTable(['app', 'version', 'train', 'App Store', 'Google Play'], rows));
log('release', 'stages: build > testing > review > live > rollout (%); owner steps (submit, release, rollout) stay with the owner');
}
if (read.some(({ s }) => s.errors.length || s.storeErrors.ios || s.storeErrors.android)) process.exitCode = 1;
}
const KIND: Record<PlanStep['kind'], string> = { config: 'CONFIG ', store: 'STORE ', blocker: 'BLOCKER', owner: 'OWNER ', wait: 'wait ', warning: 'warn ', done: 'done ' };
export function formatPlan(app: string, p: ReleasePlan): string {
const out: string[] = [`${app} ${p.version}${p.done ? ': live everywhere, nothing left' : ''}`];
let i = 0;
const line = (s: PlanStep) => ` ${String(++i).padStart(2)}. ${KIND[s.kind]} ${s.text}${s.fix ? `\n -> ${s.fix}` : ''}`;
if (p.shared.length) { out.push(' both stores (fix first):'); out.push(...p.shared.map(line)); }
for (const sp of [p.ios, p.android]) {
if (!sp) continue;
out.push(` ${sp.store === 'ios' ? 'App Store' : 'Google Play'}: ${sp.release ? `${laneText(sp.lane)} [${trackText(sp.lane)}]` : 'not on the store'}`);
out.push(...sp.steps.map(line));
}
for (const u of p.unknown) out.push(` unknown: ${u}`);
return out.join('\n');
}
async function plan(args: Args): Promise<void> {
const version = args._[1];
if (!version || !/^\d+(\.\d+){0,3}$/.test(version)) throw new Error('release plan <version>, e.g. release plan 2.4.0');
const { list, concurrency } = await targets(args);
const plans = await pool(list, concurrency, async t => {
const s = await readLive(t);
const check = s.check ? { problems: s.check.problems, warnings: s.check.warnings } : null;
let validate: Finding[] | null = null;
// validate only while something is not yet submitted on a store (it reads the editable version / the Play edit)
const pre = releasePlan({ version, platforms: { ios: !!s.summary?.apple, android: !!s.summary?.play }, check, validate: [], releases: s.release.all ?? null, storeErrors: s.storeErrors });
const needs = [pre.ios, pre.android].some(x => x && !x.done && (!x.release || ['draft', 'testing', 'rejected', 'other'].includes(x.release.phase)));
if (needs && !args.has('no-validate') && !t.virtual && s.summary) {
if (!args.has('json')) log('release', `${t.name}: validate (read-only, both stores)`);
const r = await exec(t.env)(['validate', '--json', '--no-health'], t.dir);
const d = r.data as { findings?: Finding[] } | undefined;
if (d?.findings) validate = d.findings;
else s.errors.push(`validate: ${lastLine(r.out) || `exit ${r.code}`}`);
} else if (!needs) validate = [];
const p = releasePlan({ version, platforms: { ios: !!s.summary?.apple, android: !!s.summary?.play }, check, validate, releases: s.summary ? s.release.all ?? null : null, storeErrors: s.storeErrors });
if (args.has('no-validate') && needs) p.unknown = p.unknown.map(u => (u.startsWith('store readiness') ? 'store readiness not checked (--no-validate)' : u));
return { t, s, p };
});
if (args.has('json')) printJson({ version, apps: plans.map(({ t, s, p }) => ({ app: t.name, folder: t.virtual ? null : t.dir, errors: [...t.problems, ...s.errors], plan: p })) });
else {
for (const { t, s, p } of plans) {
console.log(formatPlan(t.name, p));
for (const e of [...t.problems, ...s.errors]) warn('release', `${t.name}: ${e}`);
console.log('');
}
}
if (plans.some(({ s }) => s.errors.length)) process.exitCode = 1;
}
export async function run(args: Args): Promise<void> {
const sub = args._[0];
if (sub === 'status') return status(args);
if (sub === 'plan') return plan(args);
console.error(USAGE);
process.exit(2);
}