700030ce6645
replace shell-based release tools with argv runner, credential isolation, and online-resolution ban (PB-26-211, v5.0.67)
- time
- 2026-09-03 18:47 UTC · 1 month ago
- machine
- koh2
- agent
- muse-spark-1.3-contributor-free
- session
- PB-26-211 release tool hardening
- parent
- 40265d95b2a3
↓ peach-browser-700030ce.tar.gzatlas at this save
files changed10 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+5 −5scripts/ci.ts+8 −6scripts/release-firefox.ts+35 −93scripts/release-gate.test.ts+8 −3scripts/release-lib.test.ts+237 −0scripts/release-lib.ts+314 −0scripts/release.test.ts+18 −24scripts/release.ts+40 −129manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "5.0.66",4+ "version": "5.0.67",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "5.0.66",5+ "version": "5.0.67",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+5 −5
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "5.0.66",3+ "version": "5.0.67",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",@@ -57,19 +57,19 @@5757 "devDependencies": {5858 "@biomejs/biome": "^2.5.6",5959 "@crxjs/vite-plugin": "^2.4.0",60- "@playwright/test": "^1.59.1",60+ "@playwright/test": "1.59.1",6161 "@types/chrome": "^0.1.40",6262 "@types/node": "^22.0.0",6363 "@types/pako": "^2.0.4",6464 "@types/papaparse": "^5.5.2",65- "chrome-webstore-upload-cli": "^4.0.0",65+ "chrome-webstore-upload-cli": "4.0.1",6666 "geckodriver": "6.1.1",6767 "jsdom": "^29.0.2",68- "typescript": "^6.0.3",68+ "typescript": "6.0.3",6969 "vite": "^8.0.8",7070 "vite-plugin-solid": "^2.11.12",7171 "vitest": "^4.1.4",72- "web-ext": "^10.2.0"72+ "web-ext": "10.3.0"7373 },7474 "overrides": {7575 "rollup": "^4.0.0"scripts/ci.ts+8 −6
@@ -4,7 +4,8 @@44 // Usage: bun run scripts/ci.ts55 66 import { spawnSync } from 'bun';7-import { existsSync } from 'fs';7+import { existsSync } from 'node:fs';8+import { resolveBin } from './release-lib';89 910 const RESET = '\x1b[0m';1011 const GREEN = '\x1b[32m';@@ -20,7 +21,7 @@2021 2122 interface Step {2223 name: string;23- cmd: string[];24+ cmd: string[] | (() => string[]);2425 cwd?: string;2526 }2627 @@ -34,7 +35,7 @@3435 },3536 {3637 name: 'TypeScript',37- cmd: ['npx', 'tsc', '--noEmit'],38+ cmd: () => [resolveBin('tsc'), '--noEmit'],3839 },3940 {4041 name: 'Lint',@@ -69,7 +70,7 @@6970 ...(RUN_BROWSER_GATE7071 ? [{7172 name: 'Chrome release browser gate',72- cmd: ['npx', 'playwright', 'test', '--project=chrome-release'],73+ cmd: () => [resolveBin('playwright'), 'test', '--project=chrome-release'],7374 }]7475 : []),7576 // Firefox passkey matrix covers PB-26-209 (exportFunction privileged bridge).@@ -85,7 +86,8 @@8586 8687 function run(step: Step): { ok: boolean; output: string; duration: number } {8788 const start = Date.now();88- const result = spawnSync(step.cmd, {89+ const cmd = typeof step.cmd === 'function' ? step.cmd() : step.cmd;90+ const result = spawnSync(cmd, {8991 cwd: step.cwd ?? process.cwd(),9092 env: process.env,9193 stdout: 'pipe',@@ -126,7 +128,7 @@126128 if (RUN_BROWSER_GATE) {127129 console.log(`${DIM}Browser gate enabled — reporting runtime versions:${RESET}`);128130 const chromedriver = spawnSync({129- cmd: ['npx', 'playwright', '--version'],131+ cmd: [resolveBin('playwright'), '--version'],130132 stdout: 'pipe',131133 stderr: 'pipe',132134 });scripts/release-firefox.ts+35 −93
@@ -2,114 +2,56 @@22 // scripts/release-firefox.ts33 // One-command Firefox AMO release.44 // Usage: bun run scripts/release-firefox.ts5+//6+// Required environment variables:7+// AMO_JWT_ISSUER, AMO_JWT_SECRET8+//9+// PB-26-211: all tools are resolved from node_modules/.bin — run10+// `bun install --frozen-lockfile` before invoking release.511 6-import { execSync } from "child_process";7-import { readFileSync, existsSync, mkdirSync } from "fs";8-import { join } from "path";12+import {13+ preflight,14+ readVersion,15+ runCommand,16+ safeEnv,17+ packageFirefoxSourceZip,18+ signFirefox,19+} from "./release-lib";920 10-const ROOT = process.cwd();21+// ─── Preflight ───────────────────────────────────────────────────────────────1122 12-function run(cmd: string, label?: string, env?: Record<string, string>) {13- const display = label ?? cmd;14- console.log(`\n▶ ${display}`);15- execSync(cmd, { stdio: "inherit", cwd: ROOT, env: { ...process.env, ...env } });16-}17-18-function fail(msg: string): never {19- console.error(`\n✗ ${msg}`);20- process.exit(1);21-}22-23-// ── Verify environment ────────────────────────────────────────────────────────24-25-if (!process.env.AMO_JWT_ISSUER) {26- fail("AMO_JWT_ISSUER not set. Add it to your shell profile and reload.");27-}28-if (!process.env.AMO_JWT_SECRET) {29- fail("AMO_JWT_SECRET not set. Add it to your shell profile and reload.");30-}31-32-// ── Read version ──────────────────────────────────────────────────────────────33-34-const manifest = JSON.parse(35- readFileSync(join(ROOT, "manifest.firefox.json"), "utf8")36-);37-const version = manifest.version;38-if (!version) fail("Could not read version from manifest.firefox.json");23+preflight();24+const version = readVersion("manifest.firefox.json");3925 console.log(`\n🍑 Peach Firefox — releasing v${version}`);4026 41-// ── CI gate ───────────────────────────────────────────────────────────────────27+// ─── CI gate ───────────────────────────────────────────────────────────────────28+// PB-26-211: CI runs with a minimal safe environment — no store credentials.4229 43-run("npm run ci", "Running CI gate");30+runCommand("npm", ["run", "ci"], {31+ label: "Running CI gate",32+ env: safeEnv(),33+});4434 45-// ── Build ─────────────────────────────────────────────────────────────────────35+// ─── Build ─────────────────────────────────────────────────────────────────────4636 47-run("npm run build:firefox", `Building Firefox extension v${version}`);37+runCommand("npm", ["run", "build:firefox"], {38+ label: `Building Firefox extension v${version}`,39+ env: safeEnv(),40+});4841 49-// ── Package source archive ────────────────────────────────────────────────────42+// ─── Package source archive ────────────────────────────────────────────────────5043 // AMO requires source for any extension with minified/transpiled code.5144 // This zips everything a reviewer needs to build Peach from source.5245 53-// Keep the reviewer archive beside the signed artifact for traceability.54-if (!existsSync(join(ROOT, "artifacts"))) {55- mkdirSync(join(ROOT, "artifacts"));56-}57-const sourceZip = join(ROOT, "artifacts", `peach-source-${version}.zip`);58-run(59- [60- "zip -r",61- sourceZip,62- "src",63- "scripts",64- "public",65- // PB-26-004: the shipped .wasm is transpiled from crypto-core/src/lib.rs —66- // AMO reviewers must be able to inspect and rebuild it (npm run67- // build:noise-core). Build cache and generated output are excluded.68- "crypto-core",69- "manifest.json",70- "manifest.firefox.json",71- "package.json",72- "bun.lock",73- "tsconfig.json",74- "vite.config.ts",75- "--exclude 'node_modules/*'",76- "--exclude 'dist-*/*'",77- "--exclude 'crypto-core/target/*'",78- "--exclude 'crypto-core/generated/*'",79- "--exclude '*.zip'",80- "--exclude '.DS_Store'",81- "--exclude '__MACOSX/*'",82- ].join(" "),83- "Packaging source archive for AMO"84-);46+const sourceZip = packageFirefoxSourceZip(version);8547 86-// ── Ensure artifacts dir ──────────────────────────────────────────────────────48+// ─── Sign and submit ───────────────────────────────────────────────────────────49+// PB-26-211: AMO credentials are read and validated here, immediately before50+// signing. They are passed only to the web-ext child.8751 88-if (!existsSync(join(ROOT, "artifacts"))) {89- mkdirSync(join(ROOT, "artifacts"));90-}52+signFirefox(sourceZip);9153 92-// ── Sign and submit ───────────────────────────────────────────────────────────93-94-run(95- [96- "npx web-ext sign",97- "--source-dir dist-firefox",98- "--artifacts-dir artifacts",99- "--channel=listed",100- "--upload-source-code",101- sourceZip,102- ].join(" "),103- "Signing and submitting to AMO",104- // web-ext reads WEB_EXT_API_KEY/WEB_EXT_API_SECRET from env — pass via105- // child env so the JWT credentials are not visible in the process table.106- {107- WEB_EXT_API_KEY: process.env.AMO_JWT_ISSUER!,108- WEB_EXT_API_SECRET: process.env.AMO_JWT_SECRET!,109- }110-);111-112-// ── Done ──────────────────────────────────────────────────────────────────────54+// ─── Done ──────────────────────────────────────────────────────────────────────11355 11456 console.log(`\n✓ Peach Firefox v${version} submitted to AMO.`);11557 console.log(` Source archive retained at ${sourceZip}.`);scripts/release-gate.test.ts+8 −3
@@ -87,12 +87,17 @@8787 const RELEASE_SCRIPTS = ["scripts/release.ts", "scripts/release-firefox.ts"];8888 8989 for (const scriptPath of RELEASE_SCRIPTS) {90- it(`${scriptPath} calls "npm run ci" before any packaging or upload`, () => {90+ it(`${scriptPath} calls the CI gate before any packaging or upload`, () => {9191 const source = readFileSync(join(ROOT, scriptPath), "utf8");92- const ciIndex = source.indexOf('npm run ci');92+ // PB-26-211: CI is invoked via runCommand("npm", ["run", "ci"], ...).93+ // Look for the CI invocation pattern.94+ const ciIndex = source.indexOf('"run", "ci"');9395 expect(ciIndex).toBeGreaterThan(-1);9496 95- const packageIndex = source.search(/zip -r|web-ext sign|chrome-webstore-upload-cli upload/i);97+ // Packaging/upload calls appear after CI. PB-26-211: these are now98+ // function calls to uploadChrome / signFirefox / packageFirefoxSourceZip.99+ // Match calls (with paren), not import names.100+ const packageIndex = source.search(/uploadChrome\(|signFirefox\(|packageFirefoxSourceZip\(/);96101 if (packageIndex > -1) {97102 expect(ciIndex).toBeLessThan(packageIndex);98103 }scripts/release-lib.test.ts+237 −0
@@ -0,0 +1,237 @@1+// scripts/release-lib.test.ts2+// PB-26-211: release tool hardening — security tests for the argv-based runner.3+import { readFileSync } from "node:fs";4+import { fileURLToPath } from "node:url";5+import { dirname, join, resolve } from "node:path";6+import { afterEach, describe, expect, it } from "vitest";7+8+import {9+ buildCommand,10+ safeEnv,11+ cwsUploadEnv,12+ amoSignEnv,13+ resolveBin,14+ preflight,15+ packageChromeZip,16+ packageFirefoxSourceZip,17+ uploadChrome,18+ signFirefox,19+ CWS_SECRET_NAMES,20+ AMO_SECRET_NAMES,21+ type CommandSpec,22+ type Runner,23+} from "./release-lib";24+25+const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");26+const ALL_SECRET_NAMES = [...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES];27+28+const HELD_ENV: Record<string, string | undefined> = {};29+30+function setEnv(name: string, val: string) {31+ HELD_ENV[name] = process.env[name];32+ process.env[name] = val;33+}34+35+function restoreEnv() {36+ for (const [name, val] of Object.entries(HELD_ENV)) {37+ if (val === undefined) delete process.env[name];38+ else process.env[name] = val;39+ }40+ for (const key of Object.keys(HELD_ENV)) delete HELD_ENV[key];41+}42+43+afterEach(restoreEnv);44+45+// ─── 1. No shell, no npx ───────────────────────────────────────────────────46+47+describe("PB-26-211: argv-based runner — no shell, no npx", () => {48+ it("buildCommand returns a CommandSpec with array args, never a string", () => {49+ const spec = buildCommand("/bin/echo", ["hello", "world"]);50+ expect(Array.isArray(spec.args)).toBe(true);51+ expect(spec.args).toEqual(["hello", "world"]);52+ expect(typeof spec.executable).toBe("string");53+ });54+55+ it("no release/CI script source contains execSync or npx", () => {56+ const scripts = [57+ "scripts/release.ts",58+ "scripts/release-firefox.ts",59+ "scripts/release-lib.ts",60+ "scripts/ci.ts",61+ ];62+ for (const p of scripts) {63+ const src = readFileSync(join(ROOT, p), "utf8");64+ expect(src, `${p} must not use execSync`).not.toContain("execSync");65+ expect(src, `${p} must not invoke npx`).not.toMatch(/\bnpx\b/);66+ }67+ });68+69+ it("no release script builds commands via string concatenation (.join)", () => {70+ const scripts = [71+ "scripts/release.ts",72+ "scripts/release-firefox.ts",73+ "scripts/release-lib.ts",74+ ];75+ for (const p of scripts) {76+ const src = readFileSync(join(ROOT, p), "utf8");77+ expect(src, `${p} must not join command strings`).not.toContain("].join(");78+ }79+ });80+81+ it("fake runner captures argv — no captured spec uses a shell or npx", () => {82+ const captured: CommandSpec[] = [];83+ const fake: Runner = (exec, args, opts) => {84+ captured.push(buildCommand(exec, args, opts));85+ };86+87+ for (const name of ALL_SECRET_NAMES) setEnv(name, "secret-value");88+89+ packageChromeZip("9.9.9", fake);90+ packageFirefoxSourceZip("9.9.9", fake);91+ uploadChrome("9.9.9", fake);92+ signFirefox("/tmp/fake-source.zip", fake);93+94+ expect(captured.length).toBeGreaterThanOrEqual(4);95+ for (const spec of captured) {96+ expect(spec.executable).not.toBe("npx");97+ expect(spec.args).not.toContain("npx");98+ expect(Array.isArray(spec.args)).toBe(true);99+ }100+ });101+});102+103+// ─── 2. Child environment isolation ────────────────────────────────────────104+105+describe("PB-26-211: CI/build child environments contain no store credentials", () => {106+ it("safeEnv strips all CWS and AMO secret names", () => {107+ for (const name of ALL_SECRET_NAMES) setEnv(name, "leak-value");108+109+ const env = safeEnv();110+ for (const name of ALL_SECRET_NAMES) {111+ expect(env, `safeEnv must not contain ${name}`).not.toHaveProperty(name);112+ }113+ });114+115+ it("safeEnv preserves non-secret environment variables", () => {116+ setEnv("PATH", "/usr/bin:/bin");117+ setEnv("HOME", "/tmp/test-home");118+119+ const env = safeEnv();120+ expect(env.PATH).toBe("/usr/bin:/bin");121+ expect(env.HOME).toBe("/tmp/test-home");122+ });123+});124+125+// ─── 3. Credential disjointness ────────────────────────────────────────────126+127+describe("PB-26-211: Chrome and AMO credentials are disjoint", () => {128+ it("cwsUploadEnv contains CWS credentials but no AMO credentials", () => {129+ for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);130+131+ const env = cwsUploadEnv();132+133+ // CWS child env names are present134+ expect(env.CLIENT_ID).toBe("val-CWS_CLIENT_ID");135+ expect(env.CLIENT_SECRET).toBe("val-CWS_CLIENT_SECRET");136+ expect(env.REFRESH_TOKEN).toBe("val-CWS_REFRESH_TOKEN");137+ expect(env.PUBLISHER_ID).toBe("val-PUBLISHER_ID");138+139+ // AMO credentials are absent140+ expect(env).not.toHaveProperty("AMO_JWT_ISSUER");141+ expect(env).not.toHaveProperty("AMO_JWT_SECRET");142+ expect(env).not.toHaveProperty("WEB_EXT_API_KEY");143+ expect(env).not.toHaveProperty("WEB_EXT_API_SECRET");144+ });145+146+ it("amoSignEnv contains AMO credentials but no CWS credentials", () => {147+ for (const name of ALL_SECRET_NAMES) setEnv(name, "val-" + name);148+149+ const env = amoSignEnv();150+151+ // AMO child env names are present152+ expect(env.WEB_EXT_API_KEY).toBe("val-AMO_JWT_ISSUER");153+ expect(env.WEB_EXT_API_SECRET).toBe("val-AMO_JWT_SECRET");154+155+ // CWS credentials are absent156+ expect(env).not.toHaveProperty("CWS_CLIENT_ID");157+ expect(env).not.toHaveProperty("CWS_CLIENT_SECRET");158+ expect(env).not.toHaveProperty("CWS_REFRESH_TOKEN");159+ expect(env).not.toHaveProperty("CWS_EXTENSION_ID");160+ expect(env).not.toHaveProperty("PUBLISHER_ID");161+ expect(env).not.toHaveProperty("CLIENT_ID");162+ expect(env).not.toHaveProperty("CLIENT_SECRET");163+ expect(env).not.toHaveProperty("REFRESH_TOKEN");164+ });165+});166+167+// ─── 4. Shell metacharacter path safety ────────────────────────────────────168+169+describe("PB-26-211: paths with shell metacharacters remain one argv element", () => {170+ it("buildCommand treats a metacharacter path as a single arg", () => {171+ const evilPath = "/tmp/$(whoami); echo pwned";172+ const spec = buildCommand("/bin/echo", [evilPath]);173+ expect(spec.args).toHaveLength(1);174+ expect(spec.args[0]).toBe(evilPath);175+ });176+177+ it("spawnSync with argv arrays does not execute shell injections", () => {178+ const { spawnSync } = require("node:child_process");179+ // If a shell were used, $(whoami) would be replaced with the username.180+ const evilPath = "/tmp/$(whoami)";181+ const result = spawnSync("/bin/echo", [evilPath], {182+ stdout: "pipe",183+ stderr: "pipe",184+ shell: false,185+ });186+ const out = result.stdout?.toString().trim();187+ expect(out).toBe(evilPath);188+ // If command substitution ran, the output would contain a username, not $().189+ expect(out).toContain("$(whoami)");190+ });191+});192+193+// ─── 5. Missing binary failure ─────────────────────────────────────────────194+195+describe("PB-26-211: missing local binary fails before any network access", () => {196+ it("resolveBin throws when the tool is not in node_modules/.bin", () => {197+ expect(() => resolveBin("nonexistent-tool-xyz-12345")).toThrow(198+ /not found/,199+ );200+ });201+202+ it("resolveBin throws before returning a path, preventing network calls", () => {203+ let threw = false;204+ try {205+ resolveBin("another-missing-tool-67890");206+ } catch {207+ threw = true;208+ }209+ expect(threw).toBe(true);210+ });211+});212+213+// ─── 6. Lockfile / version preflight ───────────────────────────────────────214+215+describe("PB-26-211: preflight rejects non-exact release-tool versions", () => {216+ it("preflight passes when release-critical deps are exact", () => {217+ // After pinning, all release-critical deps have exact versions.218+ // This will fail RED until versions are pinned.219+ expect(() => preflight()).not.toThrow();220+ });221+222+ it("package.json has exact versions for release-critical dev deps", () => {223+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));224+ const devDeps = pkg.devDependencies ?? {};225+ const critical = [226+ "chrome-webstore-upload-cli",227+ "web-ext",228+ "typescript",229+ "@playwright/test",230+ ];231+ for (const dep of critical) {232+ const v = devDeps[dep];233+ expect(v, `${dep} must be in devDependencies`).toBeDefined();234+ expect(v, `${dep} must not use ^ or ~`).not.toMatch(/^[~^]/);235+ }236+ });237+});scripts/release-lib.ts+314 −0
@@ -0,0 +1,314 @@1+// scripts/release-lib.ts2+// Shared argv-based runner for release tooling (PB-26-211).3+// No shell, no online resolution, no inherited store credentials.4+5+import { spawnSync } from "node:child_process";6+import { existsSync, mkdirSync, readFileSync } from "node:fs";7+import { basename, join } from "node:path";8+9+const ROOT = process.cwd();10+const BIN_DIR = join(ROOT, "node_modules", ".bin");11+12+// ─── Secret identification ─────────────────────────────────────────────────13+// Parent env names + child env names for both stores. All must be stripped14+// from safe (build/CI) environments and only injected into the single upload15+// step that needs them.16+17+export const CWS_SECRET_NAMES = [18+ "CWS_CLIENT_ID",19+ "CWS_CLIENT_SECRET",20+ "CWS_REFRESH_TOKEN",21+ "CWS_EXTENSION_ID",22+ "PUBLISHER_ID",23+ "CLIENT_ID",24+ "CLIENT_SECRET",25+ "REFRESH_TOKEN",26+] as const;27+28+export const AMO_SECRET_NAMES = [29+ "AMO_JWT_ISSUER",30+ "AMO_JWT_SECRET",31+ "WEB_EXT_API_KEY",32+ "WEB_EXT_API_SECRET",33+] as const;34+35+const ALL_SECRET_SET = new Set<string>([36+ ...CWS_SECRET_NAMES,37+ ...AMO_SECRET_NAMES,38+]);39+40+// ─── Binary resolution ─────────────────────────────────────────────────────41+42+export function resolveBin(name: string): string {43+ const path = join(BIN_DIR, name);44+ if (!existsSync(path)) {45+ throw new Error(46+ `Required release tool not found: node_modules/.bin/${name}. ` +47+ `Run \`bun install --frozen-lockfile\` before release.`,48+ );49+ }50+ return path;51+}52+53+// ─── Environment management ────────────────────────────────────────────────54+55+function requireEnv(name: string): string {56+ const val = process.env[name];57+ if (!val) {58+ throw new Error(59+ `${name} not set. Export it from your secret store and reload your shell.`,60+ );61+ }62+ return val;63+}64+65+export function safeEnv(): Record<string, string> {66+ const env: Record<string, string> = {};67+ for (const [key, val] of Object.entries(process.env)) {68+ if (val === undefined) continue;69+ if (ALL_SECRET_SET.has(key)) continue;70+ env[key] = val;71+ }72+ return env;73+}74+75+export function cwsUploadEnv(): Record<string, string> {76+ return {77+ ...safeEnv(),78+ CLIENT_ID: requireEnv("CWS_CLIENT_ID"),79+ CLIENT_SECRET: requireEnv("CWS_CLIENT_SECRET"),80+ REFRESH_TOKEN: requireEnv("CWS_REFRESH_TOKEN"),81+ PUBLISHER_ID: requireEnv("PUBLISHER_ID"),82+ };83+}84+85+export function amoSignEnv(): Record<string, string> {86+ return {87+ ...safeEnv(),88+ WEB_EXT_API_KEY: requireEnv("AMO_JWT_ISSUER"),89+ WEB_EXT_API_SECRET: requireEnv("AMO_JWT_SECRET"),90+ };91+}92+93+// ─── Command runner ────────────────────────────────────────────────────────94+95+export interface RunOptions {96+ cwd?: string;97+ env?: Record<string, string>;98+ label?: string;99+}100+101+export interface CommandSpec {102+ executable: string;103+ args: string[];104+ env: Record<string, string>;105+ cwd: string;106+}107+108+export function buildCommand(109+ executable: string,110+ args: string[],111+ options: RunOptions = {},112+): CommandSpec {113+ return {114+ executable,115+ args: [...args],116+ env: options.env ?? safeEnv(),117+ cwd: options.cwd ?? ROOT,118+ };119+}120+121+export type Runner = (122+ executable: string,123+ args: string[],124+ options?: RunOptions,125+) => void;126+127+export function runCommand(128+ executable: string,129+ args: string[],130+ options: RunOptions = {},131+): void {132+ const spec = buildCommand(executable, args, options);133+ const display =134+ options.label ??135+ `${basename(spec.executable)} ${spec.args.join(" ")}`;136+ console.log(`\n▶ ${display}`);137+ const result = spawnSync(spec.executable, spec.args, {138+ cwd: spec.cwd,139+ env: spec.env,140+ stdio: "inherit",141+ shell: false,142+ });143+ if (result.status !== 0) {144+ throw new Error(145+ `Command failed (exit ${result.status}): ${basename(spec.executable)}`,146+ );147+ }148+}149+150+// ─── Preflight ─────────────────────────────────────────────────────────────151+152+const RELEASE_CRITICAL_DEPS = [153+ "chrome-webstore-upload-cli",154+ "web-ext",155+ "typescript",156+ "@playwright/test",157+];158+159+export function preflight(): void {160+ const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));161+ const devDeps: Record<string, string> = pkg.devDependencies ?? {};162+ const offenders: string[] = [];163+ for (const dep of RELEASE_CRITICAL_DEPS) {164+ const version = devDeps[dep];165+ if (!version) {166+ offenders.push(`${dep} (missing from devDependencies)`);167+ } else if (version.startsWith("^") || version.startsWith("~")) {168+ offenders.push(`${dep} (has "${version}", must be exact)`);169+ }170+ }171+ if (offenders.length > 0) {172+ throw new Error(173+ "Release preflight failed — release-critical dev dependencies must be pinned to exact versions:\n " +174+ offenders.join("\n "),175+ );176+ }177+}178+179+// ─── Shared packaging and signing ──────────────────────────────────────────180+181+export function readVersion(manifestPath: string): string {182+ const manifest = JSON.parse(readFileSync(join(ROOT, manifestPath), "utf8"));183+ if (!manifest.version) {184+ throw new Error(`Could not read version from ${manifestPath}`);185+ }186+ return manifest.version;187+}188+189+export function ensureArtifactsDir(): string {190+ const dir = join(ROOT, "artifacts");191+ if (!existsSync(dir)) mkdirSync(dir);192+ return dir;193+}194+195+export function packageChromeZip(196+ version: string,197+ runner: Runner = runCommand,198+): string {199+ const artifactsDir = ensureArtifactsDir();200+ const zipPath = join(artifactsDir, `peach-chrome-${version}.zip`);201+ if (existsSync(zipPath)) {202+ runner("trash", [zipPath], {203+ label: "Moving existing Chrome archive to Trash",204+ });205+ }206+ runner(207+ "zip",208+ [209+ "-r",210+ zipPath,211+ ".",212+ "--exclude",213+ "*.map",214+ "--exclude",215+ ".DS_Store",216+ "--exclude",217+ "__MACOSX/*",218+ ],219+ {220+ cwd: join(ROOT, "dist-chrome"),221+ label: `Packaging Chrome extension → peach-chrome-${version}.zip`,222+ },223+ );224+ return zipPath;225+}226+227+export function uploadChrome(228+ version: string,229+ runner: Runner = runCommand,230+): void {231+ const zipPath = packageChromeZip(version, runner);232+ const extensionId = requireEnv("CWS_EXTENSION_ID");233+ runner(234+ resolveBin("chrome-webstore-upload"),235+ [236+ "upload",237+ "--source",238+ zipPath,239+ "--extension-id",240+ extensionId,241+ "--auto-publish",242+ ],243+ {244+ env: cwsUploadEnv(),245+ label: "Uploading and publishing to Chrome Web Store",246+ },247+ );248+}249+250+export function packageFirefoxSourceZip(251+ version: string,252+ runner: Runner = runCommand,253+): string {254+ const artifactsDir = ensureArtifactsDir();255+ const sourceZip = join(artifactsDir, `peach-source-${version}.zip`);256+ runner(257+ "zip",258+ [259+ "-r",260+ sourceZip,261+ "src",262+ "scripts",263+ "public",264+ "crypto-core",265+ "manifest.json",266+ "manifest.firefox.json",267+ "package.json",268+ "bun.lock",269+ "tsconfig.json",270+ "vite.config.ts",271+ "--exclude",272+ "node_modules/*",273+ "--exclude",274+ "dist-*/*",275+ "--exclude",276+ "crypto-core/target/*",277+ "--exclude",278+ "crypto-core/generated/*",279+ "--exclude",280+ "*.zip",281+ "--exclude",282+ ".DS_Store",283+ "--exclude",284+ "__MACOSX/*",285+ ],286+ {287+ label: "Packaging source archive for AMO",288+ },289+ );290+ return sourceZip;291+}292+293+export function signFirefox(294+ sourceZip: string,295+ runner: Runner = runCommand,296+): void {297+ runner(298+ resolveBin("web-ext"),299+ [300+ "sign",301+ "--source-dir",302+ join(ROOT, "dist-firefox"),303+ "--artifacts-dir",304+ join(ROOT, "artifacts"),305+ "--channel=listed",306+ "--upload-source-code",307+ sourceZip,308+ ],309+ {310+ env: amoSignEnv(),311+ label: "Signing and submitting to Firefox AMO",312+ },313+ );314+}scripts/release.test.ts+18 −24
@@ -6,26 +6,21 @@66 import { describe, expect, it } from "vitest";77 88 const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");9-const RELEASE_SCRIPTS = ["scripts/release.ts", "scripts/release-firefox.ts"];9+// PB-26-211: packaging/signing logic moved to release-lib.ts. Both release10+// entry points import from it, so verifying release-lib.ts covers both.11+const RELEASE_LIB = "scripts/release-lib.ts";1012 11-function readReleaseScript(path: string): string {12- return readFileSync(join(ROOT, path), "utf8");13+function readReleaseLib(): string {14+ return readFileSync(join(ROOT, RELEASE_LIB), "utf8");1315 }1416 15-function signCommand(script: string): string {16- const start = script.indexOf('"npx web-ext sign"');17- const end = script.indexOf('].join(" ")', start);18- return script.slice(start, end);19-}20-2117 describe("Firefox release source submission", () => {22- it("passes the generated source archive to web-ext sign in both entry points", () => {23- for (const path of RELEASE_SCRIPTS) {24- const command = signCommand(readReleaseScript(path));25- expect(command).toContain('"--upload-source-code",');26- expect(command).toContain("sourceZip");27- expect(command.match(/--upload-source-code/g)).toHaveLength(1);28- }18+ it("passes the generated source archive to web-ext sign with --upload-source-code", () => {19+ const source = readReleaseLib();20+ // web-ext sign is called with --upload-source-code and the source zip path.21+ expect(source).toContain('"--upload-source-code"');22+ expect(source).toContain("sourceZip");23+ expect(source.match(/--upload-source-code/g)).toHaveLength(1);2924 });3025 3126 it("builds an archive containing the reviewed crypto source and Noise build script", () => {@@ -74,13 +69,12 @@7469 ]),7570 );7671 77- for (const path of RELEASE_SCRIPTS) {78- const script = readReleaseScript(path);79- expect(script).toContain('"crypto-core",');80- expect(existsSync(join(ROOT, "crypto-core/Cargo.toml"))).toBe(true);81- expect(existsSync(join(ROOT, "crypto-core/Cargo.lock"))).toBe(true);82- expect(existsSync(join(ROOT, "crypto-core/src/lib.rs"))).toBe(true);83- expect(existsSync(join(ROOT, "scripts/build-noise-core.mjs"))).toBe(true);84- }72+ // PB-26-211: verify release-lib.ts includes crypto-core in the source archive.73+ const source = readReleaseLib();74+ expect(source).toContain('"crypto-core"');75+ expect(existsSync(join(ROOT, "crypto-core/Cargo.toml"))).toBe(true);76+ expect(existsSync(join(ROOT, "crypto-core/Cargo.lock"))).toBe(true);77+ expect(existsSync(join(ROOT, "crypto-core/src/lib.rs"))).toBe(true);78+ expect(existsSync(join(ROOT, "scripts/build-noise-core.mjs"))).toBe(true);8579 });8680 });scripts/release.ts+40 −129
@@ -6,43 +6,35 @@66 //77 // Required environment variables (set from your secret store, then export88 // in the release shell — PB-26-013: never written to a shell profile):9-// CWS_CLIENT_ID, CWS_CLIENT_SECRET, CWS_REFRESH_TOKEN, CWS_EXTENSION_ID9+// CWS_CLIENT_ID, CWS_CLIENT_SECRET, CWS_REFRESH_TOKEN, CWS_EXTENSION_ID,10+// PUBLISHER_ID1011 // AMO_JWT_ISSUER, AMO_JWT_SECRET (not needed for --chrome-only)12+//13+// PB-26-211: all tools are resolved from node_modules/.bin — run14+// `bun install --frozen-lockfile` before invoking release.1115 12-import { execSync } from "child_process";13-import { readFileSync, existsSync, mkdirSync } from "fs";14-import { join } from "path";16+import {17+ preflight,18+ readVersion,19+ runCommand,20+ safeEnv,21+ uploadChrome,22+ packageFirefoxSourceZip,23+ signFirefox,24+} from "./release-lib";1525 16-const ROOT = process.cwd();1726 const CHROME_ONLY = process.argv.includes("--chrome-only");1827 1928 const BOLD = "\x1b[1m";2029 const DIM = "\x1b[2m";2130 const ACCENT = "\x1b[38;5;208m";2231 const GREEN = "\x1b[32m";23-const RED = "\x1b[31m";2432 const RESET = "\x1b[0m";2533 26-function run(cmd: string, label?: string, env?: Record<string, string>) {27- console.log(`\n${ACCENT}▶${RESET} ${label ?? cmd}`);28- execSync(cmd, { stdio: "inherit", cwd: ROOT, env: { ...process.env, ...env } });29-}30-3134 function success(msg: string) {3235 console.log(`${GREEN}✓ ${msg}${RESET}`);3336 }3437 35-function fail(msg: string): never {36- console.error(`${RED}✗ ${msg}${RESET}`);37- process.exit(1);38-}39-40-function check(name: string): string {41- const val = process.env[name];42- if (!val) fail(`${name} not set. Export it from your secret store and reload your shell.`);43- return val!;44-}45-4638 // ─── Header ──────────────────────────────────────────────────────────────────4739 4840 console.clear();@@ -50,138 +42,57 @@5042 console.log(`${ACCENT}${BOLD} ░░ peach release ░░${RESET}`);5143 console.log("");5244 53-// ─── Read version ─────────────────────────────────────────────────────────────45+// ─── Preflight ───────────────────────────────────────────────────────────────5446 55-const manifest = JSON.parse(readFileSync(join(ROOT, "manifest.json"), "utf8"));56-const version = manifest.version;57-if (!version) fail("Could not read version from manifest.json");47+preflight();48+const version = readVersion("manifest.json");5849 const targets = CHROME_ONLY ? "Chrome" : "Chrome + Firefox";5950 console.log(`${BOLD}Releasing Peach v${version} to ${targets}${RESET}`);6051 console.log(`${DIM}Stores update automatically for existing users.${RESET}`);6152 62-// ─── Verify environment ───────────────────────────────────────────────────────63-64-console.log(`\n${BOLD}Checking credentials…${RESET}`);65-66-const CWS_CLIENT_ID = check("CWS_CLIENT_ID");67-const CWS_CLIENT_SECRET = check("CWS_CLIENT_SECRET");68-const CWS_REFRESH_TOKEN = check("CWS_REFRESH_TOKEN");69-const CWS_EXTENSION_ID = check("CWS_EXTENSION_ID");70-const CWS_PUBLISHER_ID = check("PUBLISHER_ID");71-72-let AMO_JWT_ISSUER = "";73-let AMO_JWT_SECRET = "";74-if (!CHROME_ONLY) {75- AMO_JWT_ISSUER = check("AMO_JWT_ISSUER");76- AMO_JWT_SECRET = check("AMO_JWT_SECRET");77-}78-79-success("All credentials present");80-8153 // ─── CI gate ──────────────────────────────────────────────────────────────────54+// PB-26-211: CI runs with a minimal safe environment — no store credentials.8255 83-run("npm run ci", "Running CI gate");56+runCommand("npm", ["run", "ci"], {57+ label: "Running CI gate",58+ env: safeEnv(),59+});8460 success("All tests pass");8561 8662 // ─── Build ────────────────────────────────────────────────────────────────────63+// PB-26-211: build children receive no store credentials.8764 8865 if (CHROME_ONLY) {89- run("npm run build:chrome", "Building Chrome target");66+ runCommand("npm", ["run", "build:chrome"], {67+ label: "Building Chrome target",68+ env: safeEnv(),69+ });9070 } else {91- run("npm run build", "Building all targets (Chrome + Firefox)");71+ runCommand("npm", ["run", "build"], {72+ label: "Building all targets (Chrome + Firefox)",73+ env: safeEnv(),74+ });9275 }9376 success("Build complete");9477 95-// ─── Ensure artifacts dir ─────────────────────────────────────────────────────96-97-if (!existsSync(join(ROOT, "artifacts"))) {98- mkdirSync(join(ROOT, "artifacts"));99-}100-10178 // ─── Chrome release ───────────────────────────────────────────────────────────79+// PB-26-211: CWS credentials are read and validated here, immediately before80+// upload. They are passed only to the Chrome upload child, never to build or81+// CI children.10282 10383 console.log(`\n${ACCENT}${BOLD}Chrome Web Store${RESET}`);104-105-const chromeZip = join(ROOT, "artifacts", `peach-chrome-${version}.zip`);106-if (existsSync(chromeZip)) run(`trash "${chromeZip}"`, "Moving existing Chrome archive to Trash");107-108-run(109- `cd dist-chrome && zip -r "${chromeZip}" . --exclude '*.map' --exclude '.DS_Store' --exclude '__MACOSX/*'`,110- `Packaging Chrome extension → peach-chrome-${version}.zip`,111-);112-113-run(114- [115- "npx chrome-webstore-upload-cli upload",116- `--source "${chromeZip}"`,117- `--extension-id "${CWS_EXTENSION_ID}"`,118- "--auto-publish",119- ].join(" "),120- "Uploading and publishing to Chrome Web Store",121- // Credentials via child env, not inline command-line assignments, so they122- // are not visible in the process table. The CLI reads these from env.123- {124- CLIENT_ID: CWS_CLIENT_ID,125- CLIENT_SECRET: CWS_CLIENT_SECRET,126- REFRESH_TOKEN: CWS_REFRESH_TOKEN,127- PUBLISHER_ID: CWS_PUBLISHER_ID,128- },129-);130-84+uploadChrome(version);13185 success(`Chrome v${version} submitted — review typically takes minutes to hours`);13286 13387 // ─── Firefox release ──────────────────────────────────────────────────────────88+// PB-26-211: AMO credentials are read and validated here, immediately before89+// signing. They are passed only to the web-ext child, never to Chrome tooling90+// or build children.13491 13592 if (!CHROME_ONLY) {13693 console.log(`\n${ACCENT}${BOLD}Firefox Add-ons (AMO)${RESET}`);137-138- const sourceZip = join(ROOT, "artifacts", `peach-source-${version}.zip`);139- run(140- [141- "zip -r",142- sourceZip,143- "src",144- "scripts",145- "public",146- // PB-26-004: the shipped .wasm is transpiled from crypto-core/src/lib.rs147- // — AMO reviewers must be able to inspect and rebuild it (npm run148- // build:noise-core). Build cache and generated output are excluded.149- "crypto-core",150- "manifest.json",151- "manifest.firefox.json",152- "package.json",153- "bun.lock",154- "tsconfig.json",155- "vite.config.ts",156- "--exclude 'node_modules/*'",157- "--exclude 'dist-*/*'",158- "--exclude 'crypto-core/target/*'",159- "--exclude 'crypto-core/generated/*'",160- "--exclude '*.zip'",161- "--exclude '.DS_Store'",162- "--exclude '__MACOSX/*'",163- ].join(" "),164- "Packaging source archive for AMO",165- );166-167- run(168- [169- "npx web-ext sign",170- "--source-dir dist-firefox",171- "--artifacts-dir artifacts",172- "--channel=listed",173- "--upload-source-code",174- sourceZip,175- ].join(" "),176- "Signing and submitting to Firefox AMO",177- // web-ext reads WEB_EXT_API_KEY/WEB_EXT_API_SECRET from env — pass via178- // child env so the JWT credentials are not visible in the process table.179- {180- WEB_EXT_API_KEY: AMO_JWT_ISSUER,181- WEB_EXT_API_SECRET: AMO_JWT_SECRET,182- },183- );184-94+ const sourceZip = packageFirefoxSourceZip(version);95+ signFirefox(sourceZip);18596 success(`Firefox v${version} submitted with source archive retained in artifacts/`);18697 }18798