12345import { spawnSync } from "node:child_process";6import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";7import { basename, join, relative, sep } from "node:path";89import { createDeterministicZip } from "./package-source";1011const ROOT = process.cwd();12const BIN_DIR = join(ROOT, "node_modules", ".bin");13141516171819export const CWS_SECRET_NAMES = [20 "CWS_CLIENT_ID",21 "CWS_CLIENT_SECRET",22 "CWS_REFRESH_TOKEN",23 "CWS_EXTENSION_ID",24 "PUBLISHER_ID",25 "CLIENT_ID",26 "CLIENT_SECRET",27 "REFRESH_TOKEN",28] as const;2930export const AMO_SECRET_NAMES = [31 "AMO_JWT_ISSUER",32 "AMO_JWT_SECRET",33 "WEB_EXT_API_KEY",34 "WEB_EXT_API_SECRET",35] as const;3637const ALL_SECRET_SET = new Set<string>([38 ...CWS_SECRET_NAMES,39 ...AMO_SECRET_NAMES,40]);4142const BUILD_BEHAVIOR_NAMES = new Set([43 "BUILD_TARGET",44 "CI",45 "DEBUG",46 "MODE",47 "NODE_ENV",48 "PEACH_DEBUG",49 "PEACH_E2E_FIREFOX",50 "PEACH_E2E_PASSKEYS",51 "PEACH_RELEASE_BROWSER_GATE",52]);5354function isBuildBehaviorEnv(name: string): boolean {55 return BUILD_BEHAVIOR_NAMES.has(name) || name.startsWith("VITE_");56}57585960export function resolveBin(name: string): string {61 const path = join(BIN_DIR, name);62 if (!existsSync(path)) {63 throw new Error(64 `Required release tool not found: node_modules/.bin/${name}. ` +65 `Run \`bun install --frozen-lockfile\` before release.`,66 );67 }68 return path;69}70717273function requireEnv(name: string): string {74 const val = process.env[name];75 if (!val) {76 throw new Error(77 `${name} not set. Export it from your secret store and reload your shell.`,78 );79 }80 return val;81}8283export function safeEnv(): Record<string, string> {84 const env: Record<string, string> = {};85 for (const [key, val] of Object.entries(process.env)) {86 if (val === undefined) continue;87 if (ALL_SECRET_SET.has(key) || isBuildBehaviorEnv(key)) continue;88 env[key] = val;89 }90 return env;91}9293export function cwsUploadEnv(): Record<string, string> {94 return {95 ...safeEnv(),96 CLIENT_ID: requireEnv("CWS_CLIENT_ID"),97 CLIENT_SECRET: requireEnv("CWS_CLIENT_SECRET"),98 REFRESH_TOKEN: requireEnv("CWS_REFRESH_TOKEN"),99 PUBLISHER_ID: requireEnv("PUBLISHER_ID"),100 };101}102103export function amoSignEnv(): Record<string, string> {104 return {105 ...safeEnv(),106 WEB_EXT_API_KEY: requireEnv("AMO_JWT_ISSUER"),107 WEB_EXT_API_SECRET: requireEnv("AMO_JWT_SECRET"),108 };109}110111112113export interface RunOptions {114 cwd?: string;115 env?: Record<string, string>;116 label?: string;117}118119export interface CommandSpec {120 executable: string;121 args: string[];122 env: Record<string, string>;123 cwd: string;124}125126export function buildCommand(127 executable: string,128 args: string[],129 options: RunOptions = {},130): CommandSpec {131 return {132 executable,133 args: [...args],134 env: options.env ?? safeEnv(),135 cwd: options.cwd ?? ROOT,136 };137}138139export type Runner = (140 executable: string,141 args: string[],142 options?: RunOptions,143) => void;144145export function runCommand(146 executable: string,147 args: string[],148 options: RunOptions = {},149): void {150 const spec = buildCommand(executable, args, options);151 const display =152 options.label ??153 `${basename(spec.executable)} ${spec.args.join(" ")}`;154 console.log(`\n▶ ${display}`);155 const result = spawnSync(spec.executable, spec.args, {156 cwd: spec.cwd,157 env: spec.env,158 stdio: "inherit",159 shell: false,160 });161 if (result.status !== 0) {162 throw new Error(163 `Command failed (exit ${result.status}): ${basename(spec.executable)}`,164 );165 }166}167168169170const RELEASE_CRITICAL_DEPS = [171 "chrome-webstore-upload-cli",172 "web-ext",173 "typescript",174 "@playwright/test",175];176177export function preflight(): void {178 const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));179 const devDeps: Record<string, string> = pkg.devDependencies ?? {};180 const offenders: string[] = [];181 for (const dep of RELEASE_CRITICAL_DEPS) {182 const version = devDeps[dep];183 if (!version) {184 offenders.push(`${dep} (missing from devDependencies)`);185 } else if (version.startsWith("^") || version.startsWith("~")) {186 offenders.push(`${dep} (has "${version}", must be exact)`);187 }188 }189 if (offenders.length > 0) {190 throw new Error(191 "Release preflight failed — release-critical dev dependencies must be pinned to exact versions:\n " +192 offenders.join("\n "),193 );194 }195}196197198199export function readVersion(manifestPath: string): string {200 const manifest = JSON.parse(readFileSync(join(ROOT, manifestPath), "utf8"));201 if (!manifest.version) {202 throw new Error(`Could not read version from ${manifestPath}`);203 }204 return manifest.version;205}206207export function ensureArtifactsDir(): string {208 const dir = join(ROOT, "artifacts");209 if (!existsSync(dir)) mkdirSync(dir);210 return dir;211}212213214function isPackagingNoise(relativePath: string): boolean {215 return (216 relativePath.endsWith(".map") ||217 basename(relativePath) === ".DS_Store" ||218 relativePath.startsWith(`__MACOSX${sep}`) ||219 relativePath.startsWith("__MACOSX/")220 );221}222223export function collectDirectory(224 dir: string,225 root = dir,226): Array<{ path: string; data: Buffer }> {227 return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {228 const full = join(dir, entry.name);229 if (entry.isDirectory()) return collectDirectory(full, root);230 return [{ path: relative(root, full).split(sep).join("/"), data: readFileSync(full) }];231 });232}233234235236237238239240241export function packageChromeZip(version: string): string {242 const zipPath = join(ensureArtifactsDir(), `peach-chrome-${version}.zip`);243 const entries = collectDirectory(join(ROOT, "dist-chrome")).filter(244 (entry) => !isPackagingNoise(entry.path),245 );246 writeFileSync(zipPath, createDeterministicZip(entries));247 return zipPath;248}249250export function uploadChrome(251 version: string,252 runner: Runner = runCommand,253): void {254 const zipPath = packageChromeZip(version);255 const extensionId = requireEnv("CWS_EXTENSION_ID");256 runner(257 resolveBin("chrome-webstore-upload"),258 [259 "upload",260 "--source",261 zipPath,262 "--extension-id",263 extensionId,264 "--auto-publish",265 ],266 {267 env: cwsUploadEnv(),268 label: "Uploading and publishing to Chrome Web Store",269 },270 );271}272273export { packageSourceArchive } from "./package-source";274275export function signFirefox(276 sourceZip: string,277 runner: Runner = runCommand,278): void {279 runner(280 resolveBin("web-ext"),281 [282 "sign",283 "--source-dir",284 join(ROOT, "dist-firefox"),285 "--artifacts-dir",286 join(ROOT, "artifacts"),287 "--channel=listed",288 "--upload-source-code",289 sourceZip,290 ],291 {292 env: amoSignEnv(),293 label: "Signing and submitting to Firefox AMO",294 },295 );296}