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
10 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 −129
manifest.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": {

view file

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",

view file

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"

view file

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     });

view file

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}.`);

view file

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       }

view file

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+});

view file

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+}

view file

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 });

view file

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 

view file