#!/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 YELLOW = '\x1b[33m'; const BOLD = '\x1b[1m'; const DIM = '\x1b[2m'; const RUN_FIREFOX_BROWSER_CHECKS = process.env.CI === 'true' || process.env.PEACH_E2E_FIREFOX === '1'; // PB-26-210: the release browser gate is mandatory in CI and opt-in for // local pre-release verification. When enabled, both Chrome and Firefox // browser tests MUST run and pass — they do not silently skip. const RUN_BROWSER_GATE = process.env.CI === 'true' || process.env.PEACH_RELEASE_BROWSER_GATE === '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'], }, { name: 'Tests', cmd: ['npm', 'test', '--', '--reporter=verbose'], }, { // 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'], }, ...(RUN_FIREFOX_BROWSER_CHECKS ? [{ name: 'Firefox sender context', cmd: ['npm', 'run', 'test:firefox:sender'], }] : []), // PB-26-210: mandatory release browser gate. // Chrome sentinels cover PB-26-201 (authorization) and PB-26-203 (storage isolation). ...(RUN_BROWSER_GATE ? [{ name: 'Chrome release browser gate', cmd: () => [resolveBin('playwright'), 'test', '--project=chrome-release'], }] : []), // Firefox passkey matrix covers PB-26-209 (exportFunction privileged bridge). // If Firefox/Marionette cannot launch, the script exits non-zero — the gate // fails rather than silently skipping. ...(RUN_BROWSER_GATE ? [{ name: 'Firefox release browser gate', cmd: ['node', 'scripts/test-firefox-passkeys.mjs'], }] : []), ]; 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 }[] = []; // PB-26-210: report browser executables/versions before the browser gate // so release hosts can verify their runtime dependencies. if (RUN_BROWSER_GATE) { console.log(`${DIM}Browser gate enabled — reporting runtime versions:${RESET}`); const chromedriver = spawnSync({ cmd: [resolveBin('playwright'), '--version'], stdout: 'pipe', stderr: 'pipe', }); if (chromedriver.stdout) console.log(`${DIM} Playwright: ${chromedriver.stdout.toString().trim()}${RESET}`); if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH) { console.log(`${DIM} Chrome: ${process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH}${RESET}`); } console.log(); } 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();