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}222223function collectDirectory(dir: string): Array<{ path: string; data: Buffer }> {224 return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {225 const full = join(dir, entry.name);226 if (entry.isDirectory()) return collectDirectory(full);227 return [{ path: relative(dir, full).split(sep).join("/"), data: readFileSync(full) }];228 });229}230231232233234235236237238export function packageChromeZip(version: string): string {239 const zipPath = join(ensureArtifactsDir(), `peach-chrome-${version}.zip`);240 const entries = collectDirectory(join(ROOT, "dist-chrome")).filter(241 (entry) => !isPackagingNoise(entry.path),242 );243 writeFileSync(zipPath, createDeterministicZip(entries));244 return zipPath;245}246247export function uploadChrome(248 version: string,249 runner: Runner = runCommand,250): void {251 const zipPath = packageChromeZip(version);252 const extensionId = requireEnv("CWS_EXTENSION_ID");253 runner(254 resolveBin("chrome-webstore-upload"),255 [256 "upload",257 "--source",258 zipPath,259 "--extension-id",260 extensionId,261 "--auto-publish",262 ],263 {264 env: cwsUploadEnv(),265 label: "Uploading and publishing to Chrome Web Store",266 },267 );268}269270export { packageSourceArchive } from "./package-source";271272export function signFirefox(273 sourceZip: string,274 runner: Runner = runCommand,275): void {276 runner(277 resolveBin("web-ext"),278 [279 "sign",280 "--source-dir",281 join(ROOT, "dist-firefox"),282 "--artifacts-dir",283 join(ROOT, "artifacts"),284 "--channel=listed",285 "--upload-source-code",286 sourceZip,287 ],288 {289 env: amoSignEnv(),290 label: "Signing and submitting to Firefox AMO",291 },292 );293}