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]);3940const BUILD_BEHAVIOR_NAMES = new Set([41 "BUILD_TARGET",42 "CI",43 "DEBUG",44 "MODE",45 "NODE_ENV",46 "PEACH_DEBUG",47 "PEACH_E2E_FIREFOX",48 "PEACH_E2E_PASSKEYS",49 "PEACH_RELEASE_BROWSER_GATE",50]);5152function isBuildBehaviorEnv(name: string): boolean {53 return BUILD_BEHAVIOR_NAMES.has(name) || name.startsWith("VITE_");54}55565758export function resolveBin(name: string): string {59 const path = join(BIN_DIR, name);60 if (!existsSync(path)) {61 throw new Error(62 `Required release tool not found: node_modules/.bin/${name}. ` +63 `Run \`bun install --frozen-lockfile\` before release.`,64 );65 }66 return path;67}68697071function requireEnv(name: string): string {72 const val = process.env[name];73 if (!val) {74 throw new Error(75 `${name} not set. Export it from your secret store and reload your shell.`,76 );77 }78 return val;79}8081export function safeEnv(): Record<string, string> {82 const env: Record<string, string> = {};83 for (const [key, val] of Object.entries(process.env)) {84 if (val === undefined) continue;85 if (ALL_SECRET_SET.has(key) || isBuildBehaviorEnv(key)) continue;86 env[key] = val;87 }88 return env;89}9091export function cwsUploadEnv(): Record<string, string> {92 return {93 ...safeEnv(),94 CLIENT_ID: requireEnv("CWS_CLIENT_ID"),95 CLIENT_SECRET: requireEnv("CWS_CLIENT_SECRET"),96 REFRESH_TOKEN: requireEnv("CWS_REFRESH_TOKEN"),97 PUBLISHER_ID: requireEnv("PUBLISHER_ID"),98 };99}100101export function amoSignEnv(): Record<string, string> {102 return {103 ...safeEnv(),104 WEB_EXT_API_KEY: requireEnv("AMO_JWT_ISSUER"),105 WEB_EXT_API_SECRET: requireEnv("AMO_JWT_SECRET"),106 };107}108109110111export interface RunOptions {112 cwd?: string;113 env?: Record<string, string>;114 label?: string;115}116117export interface CommandSpec {118 executable: string;119 args: string[];120 env: Record<string, string>;121 cwd: string;122}123124export function buildCommand(125 executable: string,126 args: string[],127 options: RunOptions = {},128): CommandSpec {129 return {130 executable,131 args: [...args],132 env: options.env ?? safeEnv(),133 cwd: options.cwd ?? ROOT,134 };135}136137export type Runner = (138 executable: string,139 args: string[],140 options?: RunOptions,141) => void;142143export function runCommand(144 executable: string,145 args: string[],146 options: RunOptions = {},147): void {148 const spec = buildCommand(executable, args, options);149 const display =150 options.label ??151 `${basename(spec.executable)} ${spec.args.join(" ")}`;152 console.log(`\n▶ ${display}`);153 const result = spawnSync(spec.executable, spec.args, {154 cwd: spec.cwd,155 env: spec.env,156 stdio: "inherit",157 shell: false,158 });159 if (result.status !== 0) {160 throw new Error(161 `Command failed (exit ${result.status}): ${basename(spec.executable)}`,162 );163 }164}165166167168const RELEASE_CRITICAL_DEPS = [169 "chrome-webstore-upload-cli",170 "web-ext",171 "typescript",172 "@playwright/test",173];174175export function preflight(): void {176 const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));177 const devDeps: Record<string, string> = pkg.devDependencies ?? {};178 const offenders: string[] = [];179 for (const dep of RELEASE_CRITICAL_DEPS) {180 const version = devDeps[dep];181 if (!version) {182 offenders.push(`${dep} (missing from devDependencies)`);183 } else if (version.startsWith("^") || version.startsWith("~")) {184 offenders.push(`${dep} (has "${version}", must be exact)`);185 }186 }187 if (offenders.length > 0) {188 throw new Error(189 "Release preflight failed — release-critical dev dependencies must be pinned to exact versions:\n " +190 offenders.join("\n "),191 );192 }193}194195196197export function readVersion(manifestPath: string): string {198 const manifest = JSON.parse(readFileSync(join(ROOT, manifestPath), "utf8"));199 if (!manifest.version) {200 throw new Error(`Could not read version from ${manifestPath}`);201 }202 return manifest.version;203}204205export function ensureArtifactsDir(): string {206 const dir = join(ROOT, "artifacts");207 if (!existsSync(dir)) mkdirSync(dir);208 return dir;209}210211export function packageChromeZip(212 version: string,213 runner: Runner = runCommand,214): string {215 const artifactsDir = ensureArtifactsDir();216 const zipPath = join(artifactsDir, `peach-chrome-${version}.zip`);217 if (existsSync(zipPath)) {218 runner("trash", [zipPath], {219 label: "Moving existing Chrome archive to Trash",220 });221 }222 runner(223 "zip",224 [225 "-r",226 zipPath,227 ".",228 "--exclude",229 "*.map",230 "--exclude",231 ".DS_Store",232 "--exclude",233 "__MACOSX/*",234 ],235 {236 cwd: join(ROOT, "dist-chrome"),237 label: `Packaging Chrome extension → peach-chrome-${version}.zip`,238 },239 );240 return zipPath;241}242243export function uploadChrome(244 version: string,245 runner: Runner = runCommand,246): void {247 const zipPath = packageChromeZip(version, runner);248 const extensionId = requireEnv("CWS_EXTENSION_ID");249 runner(250 resolveBin("chrome-webstore-upload"),251 [252 "upload",253 "--source",254 zipPath,255 "--extension-id",256 extensionId,257 "--auto-publish",258 ],259 {260 env: cwsUploadEnv(),261 label: "Uploading and publishing to Chrome Web Store",262 },263 );264}265266export { packageSourceArchive } from "./package-source";267268export function signFirefox(269 sourceZip: string,270 runner: Runner = runCommand,271): void {272 runner(273 resolveBin("web-ext"),274 [275 "sign",276 "--source-dir",277 join(ROOT, "dist-firefox"),278 "--artifacts-dir",279 join(ROOT, "artifacts"),280 "--channel=listed",281 "--upload-source-code",282 sourceZip,283 ],284 {285 env: amoSignEnv(),286 label: "Signing and submitting to Firefox AMO",287 },288 );289}