// 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, readFileSync } from "node:fs"; import { basename, join } from "node:path"; 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; } export function packageChromeZip( version: string, runner: Runner = runCommand, ): string { const artifactsDir = ensureArtifactsDir(); const zipPath = join(artifactsDir, `peach-chrome-${version}.zip`); if (existsSync(zipPath)) { runner("trash", [zipPath], { label: "Moving existing Chrome archive to Trash", }); } runner( "zip", [ "-r", zipPath, ".", "--exclude", "*.map", "--exclude", ".DS_Store", "--exclude", "__MACOSX/*", ], { cwd: join(ROOT, "dist-chrome"), label: `Packaging Chrome extension → peach-chrome-${version}.zip`, }, ); return zipPath; } export function uploadChrome( version: string, runner: Runner = runCommand, ): void { const zipPath = packageChromeZip(version, runner); 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", }, ); }