// scripts/release-lib.ts // Shared argv-based runner for release tooling (PB-26-211). // No shell, no online resolution, no inherited store credentials. import { spawnSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import { basename, join, relative, sep } from "node:path"; import { createDeterministicZip } from "./package-source"; const ROOT = process.cwd(); const BIN_DIR = join(ROOT, "node_modules", ".bin"); // ─── Secret identification ───────────────────────────────────────────────── // Parent env names + child env names for both stores. All must be stripped // from safe (build/CI) environments and only injected into the single upload // step that needs them. export const CWS_SECRET_NAMES = [ "CWS_CLIENT_ID", "CWS_CLIENT_SECRET", "CWS_REFRESH_TOKEN", "CWS_EXTENSION_ID", "PUBLISHER_ID", "CLIENT_ID", "CLIENT_SECRET", "REFRESH_TOKEN", ] as const; export const AMO_SECRET_NAMES = [ "AMO_JWT_ISSUER", "AMO_JWT_SECRET", "WEB_EXT_API_KEY", "WEB_EXT_API_SECRET", ] as const; const ALL_SECRET_SET = new Set([ ...CWS_SECRET_NAMES, ...AMO_SECRET_NAMES, ]); const BUILD_BEHAVIOR_NAMES = new Set([ "BUILD_TARGET", "CI", "DEBUG", "MODE", "NODE_ENV", "PEACH_DEBUG", "PEACH_E2E_FIREFOX", "PEACH_E2E_PASSKEYS", "PEACH_RELEASE_BROWSER_GATE", ]); function isBuildBehaviorEnv(name: string): boolean { return BUILD_BEHAVIOR_NAMES.has(name) || name.startsWith("VITE_"); } // ─── Binary resolution ────────────────────────────────────────────────────────── export function resolveBin(name: string): string { const path = join(BIN_DIR, name); if (!existsSync(path)) { throw new Error( `Required release tool not found: node_modules/.bin/${name}. ` + `Run \`bun install --frozen-lockfile\` before release.`, ); } return path; } // ─── Environment management ──────────────────────────────────────────────── function requireEnv(name: string): string { const val = process.env[name]; if (!val) { throw new Error( `${name} not set. Export it from your secret store and reload your shell.`, ); } return val; } export function safeEnv(): Record { const env: Record = {}; for (const [key, val] of Object.entries(process.env)) { if (val === undefined) continue; if (ALL_SECRET_SET.has(key) || isBuildBehaviorEnv(key)) continue; env[key] = val; } return env; } export function cwsUploadEnv(): Record { return { ...safeEnv(), CLIENT_ID: requireEnv("CWS_CLIENT_ID"), CLIENT_SECRET: requireEnv("CWS_CLIENT_SECRET"), REFRESH_TOKEN: requireEnv("CWS_REFRESH_TOKEN"), PUBLISHER_ID: requireEnv("PUBLISHER_ID"), }; } export function amoSignEnv(): Record { return { ...safeEnv(), WEB_EXT_API_KEY: requireEnv("AMO_JWT_ISSUER"), WEB_EXT_API_SECRET: requireEnv("AMO_JWT_SECRET"), }; } // ─── Command runner ──────────────────────────────────────────────────────── export interface RunOptions { cwd?: string; env?: Record; label?: string; } export interface CommandSpec { executable: string; args: string[]; env: Record; cwd: string; } export function buildCommand( executable: string, args: string[], options: RunOptions = {}, ): CommandSpec { return { executable, args: [...args], env: options.env ?? safeEnv(), cwd: options.cwd ?? ROOT, }; } export type Runner = ( executable: string, args: string[], options?: RunOptions, ) => void; export function runCommand( executable: string, args: string[], options: RunOptions = {}, ): void { const spec = buildCommand(executable, args, options); const display = options.label ?? `${basename(spec.executable)} ${spec.args.join(" ")}`; console.log(`\n▶ ${display}`); const result = spawnSync(spec.executable, spec.args, { cwd: spec.cwd, env: spec.env, stdio: "inherit", shell: false, }); if (result.status !== 0) { throw new Error( `Command failed (exit ${result.status}): ${basename(spec.executable)}`, ); } } // ─── Preflight ───────────────────────────────────────────────────────────── const RELEASE_CRITICAL_DEPS = [ "chrome-webstore-upload-cli", "web-ext", "typescript", "@playwright/test", ]; export function preflight(): void { const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); const devDeps: Record = pkg.devDependencies ?? {}; const offenders: string[] = []; for (const dep of RELEASE_CRITICAL_DEPS) { const version = devDeps[dep]; if (!version) { offenders.push(`${dep} (missing from devDependencies)`); } else if (version.startsWith("^") || version.startsWith("~")) { offenders.push(`${dep} (has "${version}", must be exact)`); } } if (offenders.length > 0) { throw new Error( "Release preflight failed — release-critical dev dependencies must be pinned to exact versions:\n " + offenders.join("\n "), ); } } // ─── Shared packaging and signing ────────────────────────────────────────── export function readVersion(manifestPath: string): string { const manifest = JSON.parse(readFileSync(join(ROOT, manifestPath), "utf8")); if (!manifest.version) { throw new Error(`Could not read version from ${manifestPath}`); } return manifest.version; } export function ensureArtifactsDir(): string { const dir = join(ROOT, "artifacts"); if (!existsSync(dir)) mkdirSync(dir); return dir; } /** Matches the exclusions the previous system-`zip` call applied. */ function isPackagingNoise(relativePath: string): boolean { return ( relativePath.endsWith(".map") || basename(relativePath) === ".DS_Store" || relativePath.startsWith(`__MACOSX${sep}`) || relativePath.startsWith("__MACOSX/") ); } export function collectDirectory( dir: string, root = dir, ): Array<{ path: string; data: Buffer }> { return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { const full = join(dir, entry.name); if (entry.isDirectory()) return collectDirectory(full, root); return [{ path: relative(root, full).split(sep).join("/"), data: readFileSync(full) }]; }); } /** * PB-26-611: the system `zip` embedded filesystem timestamps, so two builds of * the same commit produced different archives and the shipped Chrome artifact * could not be verified by hash. Reuses the deterministic writer the AMO * source archive already uses — fixed DOS timestamps, deflate level 9, sorted * entries. */ export function packageChromeZip(version: string): string { const zipPath = join(ensureArtifactsDir(), `peach-chrome-${version}.zip`); const entries = collectDirectory(join(ROOT, "dist-chrome")).filter( (entry) => !isPackagingNoise(entry.path), ); writeFileSync(zipPath, createDeterministicZip(entries)); return zipPath; } export function uploadChrome( version: string, runner: Runner = runCommand, ): void { const zipPath = packageChromeZip(version); const extensionId = requireEnv("CWS_EXTENSION_ID"); runner( resolveBin("chrome-webstore-upload"), [ "upload", "--source", zipPath, "--extension-id", extensionId, "--auto-publish", ], { env: cwsUploadEnv(), label: "Uploading and publishing to Chrome Web Store", }, ); } export { packageSourceArchive } from "./package-source"; export function signFirefox( sourceZip: string, runner: Runner = runCommand, ): void { runner( resolveBin("web-ext"), [ "sign", "--source-dir", join(ROOT, "dist-firefox"), "--artifacts-dir", join(ROOT, "artifacts"), "--channel=listed", "--upload-source-code", sourceZip, ], { env: amoSignEnv(), label: "Signing and submitting to Firefox AMO", }, ); }