172 lines
11 KiB
TypeScript
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);
|
|
}
|