storebound/cli/play/rollout.ts
2026-10-12 04:00:58 +08:00

51 lines
3.3 KiB
TypeScript

// Staged rollout of the release on a Play track (production by default).
// storebound play rollout --fraction 0.2 | --halt | --resume [--fraction 0.1] | --complete [--track production] [--dry] [--yes]
// Start a staged rollout with: storebound play release --version-code N --production inProgress --fraction 0.05
// Halting stops new installs of the update; it is NOT a rollback (users who updated keep it). To roll back, ship a
// fixed build with a higher versionCode. Production changes ask for confirmation unless --yes, except --halt.
// Not offered to agents through `storebound mcp`.
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log } from '../lib/log';
import { type PlayRelease, type RolloutAction, rolloutReleases } from '../lib/release';
import { playClient } from './client';
/** Same releases on the track, whatever their order and key order. Pure. */
export function sameReleases(a: PlayRelease[], b: PlayRelease[]): boolean {
const canon = (x: unknown): unknown => (Array.isArray(x) ? x.map(canon) : x && typeof x === 'object' ? Object.fromEntries(Object.entries(x).sort(([k], [l]) => k.localeCompare(l)).map(([k, v]) => [k, canon(v)])) : x);
const key = (rs: PlayRelease[]) => JSON.stringify(rs.map(r => JSON.stringify(canon(r))).sort());
return key(a) === key(b);
}
export function rolloutAction(a: Ctx['args']): RolloutAction {
const picked = ['halt', 'resume', 'complete'].filter(k => a.has(k));
if (picked.length > 1) throw new Error('pick one of --halt, --resume, --complete');
const f = a.get('fraction');
const value = f === undefined ? undefined : Number(f);
if (value !== undefined && Number.isNaN(value)) throw new Error('--fraction: a number like 0.2');
if (picked[0] === 'halt') return { kind: 'halt' };
if (picked[0] === 'complete') return { kind: 'complete' };
if (picked[0] === 'resume') return { kind: 'resume', value };
if (value === undefined) throw new Error('usage: play rollout --fraction 0.2 | --halt | --resume [--fraction x] | --complete [--track production]');
return { kind: 'fraction', value };
}
export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const action = rolloutAction(ctx.args);
const track = ctx.args.str('track', 'production');
const dry = ctx.args.has('dry');
const play = await playClient(root, need(config.play?.packageName, 'play.packageName'));
let confirmed = dry || action.kind === 'halt' || track !== 'production';
await play.edit(async (id, ctl) => {
const cur = await play.call<{ releases?: PlayRelease[] }>('GET', `${play.API}/edits/${id}/tracks/${track}`);
const next = rolloutReleases(cur.releases ?? [], action);
log('play', `${track}: ${next.summary}${dry ? ' (dry)' : ''}`);
// already like that (a re-run, or the same command twice): nothing is committed
if (sameReleases(next.releases, cur.releases ?? [])) { ctl.unchanged(); return; }
if (!confirmed) confirmed = await confirm(ctx, `${track}: ${next.summary}. Apply?`);
if (!confirmed) throw new Error('not confirmed, nothing changed');
await play.call('PUT', `${play.API}/edits/${id}/tracks/${track}`, { track, releases: next.releases });
}, { dry, retries: 1 });
done('play', dry ? 'rollout checked (dry run, edit discarded)' : 'rollout updated');
}