// 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 { 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'); }