12345import { spawnSync } from "node:child_process";6import { existsSync, mkdirSync, readFileSync } from "node:fs";7import { basename, join } from "node:path";89const ROOT = process.cwd();10const BIN_DIR = join(ROOT, "node_modules", ".bin");11121314151617export 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;2728export 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;3435const ALL_SECRET_SET = new Set<string>([36 ...CWS_SECRET_NAMES,37 ...AMO_SECRET_NAMES,38]);39404142export 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}52535455function 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}6465export 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}7475export 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}8485export 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}92939495export interface RunOptions {96 cwd?: string;97 env?: Record<string, string>;98 label?: string;99}100101export interface CommandSpec {102 executable: string;103 args: string[];104 env: Record<string, string>;105 cwd: string;106}107108export 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}120121export type Runner = (122 executable: string,123 args: string[],124 options?: RunOptions,125) => void;126127export 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}149150151152const RELEASE_CRITICAL_DEPS = [153 "chrome-webstore-upload-cli",154 "web-ext",155 "typescript",156 "@playwright/test",157];158159export 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}178179180181export 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}188189export function ensureArtifactsDir(): string {190 const dir = join(ROOT, "artifacts");191 if (!existsSync(dir)) mkdirSync(dir);192 return dir;193}194195export 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}226227export 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}249250export 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}292293export 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}