#!/usr/bin/env bun // peach-browser/scripts/ci.ts // Run at the end of a work session to verify everything is solid. // Usage: bun run scripts/ci.ts import { spawnSync } from 'bun'; import { existsSync } from 'node:fs'; import { resolveBin } from './release-lib'; const RESET = '\x1b[0m'; const GREEN = '\x1b[32m'; const RED = '\x1b[31m'; const BOLD = '\x1b[1m'; const DIM = '\x1b[2m'; const RUN_FIREFOX_SENDER_ONLY = process.env.PEACH_E2E_FIREFOX === '1'; interface Step { name: string; cmd: string[] | (() => string[]); cwd?: string; } const steps: Step[] = [ { // LV-6: standardize on bun + a frozen lockfile. --frozen-lockfile exits // non-zero if bun.lock is stale relative to package.json, so a forgotten // lockfile update fails CI instead of drifting silently. name: 'Lockfile freshness', cmd: ['bun', 'install', '--frozen-lockfile'], }, { name: 'TypeScript', cmd: () => [resolveBin('tsc'), '--noEmit'], }, { name: 'Lint', cmd: ['npm', 'run', 'lint'], }, { // PB-26-003: rebuild the Noise WASM from crypto-core and fail if the // committed blob drifts — "reviewed source → shipped binary" enforced. name: 'Noise WASM provenance', cmd: ['node', 'scripts/check-noise-core.mjs'], }, { name: 'Chrome build', cmd: ['npm', 'run', 'build:chrome'], }, { name: 'Firefox build', cmd: ['npm', 'run', 'build:firefox'], }, { // Runs after both builds: the browser gate exercises the shipped targets. name: 'Release browser gate', cmd: ['bun', 'run', 'scripts/run-release-browser-gate.ts'], }, { name: 'Tests', cmd: ['npm', 'test', '--', '--reporter=verbose'], }, ...(RUN_FIREFOX_SENDER_ONLY ? [{ name: 'Firefox sender context', cmd: ['npm', 'run', 'test:firefox:sender'], }] : []), ]; function run(step: Step): { ok: boolean; output: string; duration: number } { const start = Date.now(); const cmd = typeof step.cmd === 'function' ? step.cmd() : step.cmd; const result = spawnSync(cmd, { cwd: step.cwd ?? process.cwd(), env: process.env, stdout: 'pipe', stderr: 'pipe', }); const duration = Date.now() - start; const ok = result.exitCode === 0; const output = [ result.stdout?.toString().trim(), result.stderr?.toString().trim(), ].filter(Boolean).join('\n'); return { ok, output, duration }; } function formatDuration(ms: number): string { if (ms < 1000) return `${ms}ms`; return `${(ms / 1000).toFixed(1)}s`; } function printStep(name: string, ok: boolean, duration: number) { const icon = ok ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`; const time = `${DIM}${formatDuration(duration)}${RESET}`; console.log(` ${icon} ${name.padEnd(20)} ${time}`); } async function main() { console.log(); console.log(`${BOLD}peach-browser CI${RESET}`); console.log(`${DIM}${new Date().toLocaleTimeString()}${RESET}`); console.log(); let failed = false; const failures: { name: string; output: string }[] = []; for (const step of steps) { process.stdout.write(` ${DIM}…${RESET} ${step.name.padEnd(20)}\r`); const result = run(step); printStep(step.name, result.ok, result.duration); if (!result.ok) { failed = true; failures.push({ name: step.name, output: result.output }); break; // fail fast } } console.log(); if (!failed) { console.log(`${GREEN}${BOLD}All checks passed.${RESET}`); console.log(); // Show build artifact sizes const chromeManifest = 'dist-chrome/manifest.json'; if (existsSync(chromeManifest)) { const manifest = JSON.parse(await Bun.file(chromeManifest).text()); console.log(`${DIM}Version: ${manifest.version}${RESET}`); } } else { console.log(`${RED}${BOLD}Failed: ${failures[0].name}${RESET}`); console.log(); // Print the relevant failure output const output = failures[0].output; if (output) { // Show last 30 lines of output — most errors are at the end const lines = output.split('\n'); const relevant = lines.slice(-30).join('\n'); console.log(`${DIM}${relevant}${RESET}`); } console.log(); process.exit(1); } } main();