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 releaseGateEnv(): Record<string, string> {92 return { ...safeEnv(), PEACH_RELEASE_BROWSER_GATE: "1" };93}9495export function cwsUploadEnv(): Record<string, string> {96 return {97 ...safeEnv(),98 CLIENT_ID: requireEnv("CWS_CLIENT_ID"),99 CLIENT_SECRET: requireEnv("CWS_CLIENT_SECRET"),100 REFRESH_TOKEN: requireEnv("CWS_REFRESH_TOKEN"),101 PUBLISHER_ID: requireEnv("PUBLISHER_ID"),102 };103}104105export function amoSignEnv(): Record<string, string> {106 return {107 ...safeEnv(),108 WEB_EXT_API_KEY: requireEnv("AMO_JWT_ISSUER"),109 WEB_EXT_API_SECRET: requireEnv("AMO_JWT_SECRET"),110 };111}112113114115export interface RunOptions {116 cwd?: string;117 env?: Record<string, string>;118 label?: string;119}120121export interface CommandSpec {122 executable: string;123 args: string[];124 env: Record<string, string>;125 cwd: string;126}127128export function buildCommand(129 executable: string,130 args: string[],131 options: RunOptions = {},132): CommandSpec {133 return {134 executable,135 args: [...args],136 env: options.env ?? safeEnv(),137 cwd: options.cwd ?? ROOT,138 };139}140141export type Runner = (142 executable: string,143 args: string[],144 options?: RunOptions,145) => void;146147export function runCommand(148 executable: string,149 args: string[],150 options: RunOptions = {},151): void {152 const spec = buildCommand(executable, args, options);153 const display =154 options.label ??155 `${basename(spec.executable)} ${spec.args.join(" ")}`;156 console.log(`\n▶ ${display}`);157 const result = spawnSync(spec.executable, spec.args, {158 cwd: spec.cwd,159 env: spec.env,160 stdio: "inherit",161 shell: false,162 });163 if (result.status !== 0) {164 throw new Error(165 `Command failed (exit ${result.status}): ${basename(spec.executable)}`,166 );167 }168}169170171172const RELEASE_CRITICAL_DEPS = [173 "chrome-webstore-upload-cli",174 "web-ext",175 "typescript",176 "@playwright/test",177];178179export function preflight(): void {180 const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));181 const devDeps: Record<string, string> = pkg.devDependencies ?? {};182 const offenders: string[] = [];183 for (const dep of RELEASE_CRITICAL_DEPS) {184 const version = devDeps[dep];185 if (!version) {186 offenders.push(`${dep} (missing from devDependencies)`);187 } else if (version.startsWith("^") || version.startsWith("~")) {188 offenders.push(`${dep} (has "${version}", must be exact)`);189 }190 }191 if (offenders.length > 0) {192 throw new Error(193 "Release preflight failed — release-critical dev dependencies must be pinned to exact versions:\n " +194 offenders.join("\n "),195 );196 }197}198199200201export function readVersion(manifestPath: string): string {202 const manifest = JSON.parse(readFileSync(join(ROOT, manifestPath), "utf8"));203 if (!manifest.version) {204 throw new Error(`Could not read version from ${manifestPath}`);205 }206 return manifest.version;207}208209export function ensureArtifactsDir(): string {210 const dir = join(ROOT, "artifacts");211 if (!existsSync(dir)) mkdirSync(dir);212 return dir;213}214215export function packageChromeZip(216 version: string,217 runner: Runner = runCommand,218): string {219 const artifactsDir = ensureArtifactsDir();220 const zipPath = join(artifactsDir, `peach-chrome-${version}.zip`);221 if (existsSync(zipPath)) {222 runner("trash", [zipPath], {223 label: "Moving existing Chrome archive to Trash",224 });225 }226 runner(227 "zip",228 [229 "-r",230 zipPath,231 ".",232 "--exclude",233 "*.map",234 "--exclude",235 ".DS_Store",236 "--exclude",237 "__MACOSX/*",238 ],239 {240 cwd: join(ROOT, "dist-chrome"),241 label: `Packaging Chrome extension → peach-chrome-${version}.zip`,242 },243 );244 return zipPath;245}246247export function uploadChrome(248 version: string,249 runner: Runner = runCommand,250): void {251 const zipPath = packageChromeZip(version, runner);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}