/** * Promote a capture run into the Remotion asset set. * * The capture suite writes PNGs and manifest fragments to * `test-results/demo-captures/` — deliberately NOT into `demo/public/captures/`, * because a capture becomes a published frame only after someone has looked at * it. `demo/preflight.ts` enforces that gate: it refuses any capture whose * `audienceVerdict` is not `perfect`. * * Until now the copy-and-merge step between those two directories did not exist * — it was done by hand on 2026-08-23, which is exactly why the promoted set and * its manifest still pointed at `m12-admin-gate-20260823` a week and two image * rolls later, while `preflight` reported "audited perfect" about stale frames. * A verification artifact that silently describes an old deployment is worse than * no artifact. * * Usage: * node --experimental-strip-types demo/promote.ts * Copy + merge + stamp the live revision. Verdicts land as `pending`, so * `preflight` FAILS until the frames are audited. That failure is correct. * * node --experimental-strip-types demo/promote.ts --audited "" * Mark every promoted capture audited, recording the note and a timestamp. * Only pass this after actually opening the images at full resolution * against `demo/audience-brief.md`. */ import { createHash } from 'node:crypto'; import { execFile } from 'node:child_process'; import { copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { promisify } from 'node:util'; const execFileAsync = promisify(execFile); const SOURCE_DIR = join('test-results', 'demo-captures'); const TARGET_DIR = join('demo', 'public', 'captures'); const MANIFEST = join('demo', 'capture-manifest.json'); type CaptureRow = { id: string; file: string; contentHash: string; audienceVerdict: string; auditStatus: string; auditedAt?: string; auditNote?: string; [key: string]: unknown; }; type Manifest = { schemaVersion: string; buildRevision: string; verifiedImage: string; capturedEnvironment: string; viewport: { width: number; height: number }; captures: CaptureRow[]; }; const auditedIndex = process.argv.indexOf('--audited'); const audited = auditedIndex !== -1; const auditNote = audited ? (process.argv[auditedIndex + 1] ?? '').trim() : ''; if (audited && auditNote === '') { process.stderr.write( 'promote: --audited requires a note describing what was checked.\n' + 'An audit with no statement of what was looked at is not an audit.\n', ); process.exit(2); } const fragments = (await readdir(SOURCE_DIR)).filter( (name) => name.startsWith('manifest-') && name.endsWith('.json'), ); if (fragments.length === 0) { process.stderr.write( `promote: no manifest fragments in ${SOURCE_DIR}.\n` + `Run 'npm run test:demo' first — promotion cannot invent a capture run.\n`, ); process.exit(2); } const rows: CaptureRow[] = []; for (const fragment of fragments) { const parsed = JSON.parse(await readFile(join(SOURCE_DIR, fragment), 'utf8')) as CaptureRow[]; rows.push(...parsed); } rows.sort((left, right) => left.id.localeCompare(right.id)); const duplicates = rows.map((row) => row.id).filter((id, index, all) => all.indexOf(id) !== index); if (duplicates.length > 0) { process.stderr.write(`promote: duplicate capture ids across fragments: ${duplicates.join(', ')}\n`); process.exit(2); } // Stamp what was ACTUALLY verified, read from the cluster and the repo rather // than carried over from the previous manifest. A stale stamp is the whole defect // this script exists to close. const { stdout: revisionOut } = await execFileAsync('git', ['rev-parse', '--short', 'HEAD']); const { stdout: imageOut } = await execFileAsync('kubectl', [ '-n', process.env.E2E_NAMESPACE ?? 'tidaldb-cluster', 'get', 'statefulset', 'tidaldb', '-o', 'jsonpath={.spec.template.spec.containers[0].image}', ]); const previous = JSON.parse(await readFile(MANIFEST, 'utf8')) as Manifest; await mkdir(TARGET_DIR, { recursive: true }); // Remove any promoted file that this run did not produce. Preflight already // refuses an orphan, but leaving one here would make a re-run of THIS script the // thing that finally reports it, one step further from the cause. const promotedIds = new Set(rows.map((row) => row.id)); for (const existing of await readdir(TARGET_DIR)) { if (!existing.endsWith('.png')) continue; if (!promotedIds.has(existing.replace(/\.png$/, ''))) { process.stdout.write(` removing orphan ${existing} (no row in this capture run)\n`); await rm(join(TARGET_DIR, existing)); } } const stampedAt = new Date().toISOString(); for (const row of rows) { const name = `${row.id}.png`; const bytes = await readFile(join(SOURCE_DIR, name)); const hash = `sha256:${createHash('sha256').update(bytes).digest('hex')}`; if (hash !== row.contentHash) { process.stderr.write( `promote: ${row.id} hash mismatch — the fragment says ${row.contentHash} but the PNG ` + `hashes to ${hash}. The capture run and its manifest disagree; re-run the suite.\n`, ); process.exit(2); } await copyFile(join(SOURCE_DIR, name), join(TARGET_DIR, name)); if (audited) { row.auditStatus = 'pass'; row.audienceVerdict = 'perfect'; row.auditedAt = stampedAt; row.auditNote = auditNote; } } const manifest: Manifest = { schemaVersion: previous.schemaVersion, buildRevision: revisionOut.trim(), verifiedImage: imageOut.trim(), capturedEnvironment: previous.capturedEnvironment, viewport: previous.viewport, captures: rows, }; await writeFile(MANIFEST, `${JSON.stringify(manifest, null, 2)}\n`); process.stdout.write( `\npromoted ${rows.length} captures\n` + ` revision: ${manifest.buildRevision}\n` + ` image: ${manifest.verifiedImage}\n` + ` verdicts: ${audited ? `audited (${auditNote})` : 'PENDING — preflight will fail until audited'}\n\n`, );