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 releaseGateEnv(): Record<string, string> {76 return { ...safeEnv(), PEACH_RELEASE_BROWSER_GATE: "1" };77}7879export function cwsUploadEnv(): Record<string, string> {80 return {81 ...safeEnv(),82 CLIENT_ID: requireEnv("CWS_CLIENT_ID"),83 CLIENT_SECRET: requireEnv("CWS_CLIENT_SECRET"),84 REFRESH_TOKEN: requireEnv("CWS_REFRESH_TOKEN"),85 PUBLISHER_ID: requireEnv("PUBLISHER_ID"),86 };87}8889export function amoSignEnv(): Record<string, string> {90 return {91 ...safeEnv(),92 WEB_EXT_API_KEY: requireEnv("AMO_JWT_ISSUER"),93 WEB_EXT_API_SECRET: requireEnv("AMO_JWT_SECRET"),94 };95}96979899export interface RunOptions {100 cwd?: string;101 env?: Record<string, string>;102 label?: string;103}104105export interface CommandSpec {106 executable: string;107 args: string[];108 env: Record<string, string>;109 cwd: string;110}111112export function buildCommand(113 executable: string,114 args: string[],115 options: RunOptions = {},116): CommandSpec {117 return {118 executable,119 args: [...args],120 env: options.env ?? safeEnv(),121 cwd: options.cwd ?? ROOT,122 };123}124125export type Runner = (126 executable: string,127 args: string[],128 options?: RunOptions,129) => void;130131export function runCommand(132 executable: string,133 args: string[],134 options: RunOptions = {},135): void {136 const spec = buildCommand(executable, args, options);137 const display =138 options.label ??139 `${basename(spec.executable)} ${spec.args.join(" ")}`;140 console.log(`\n▶ ${display}`);141 const result = spawnSync(spec.executable, spec.args, {142 cwd: spec.cwd,143 env: spec.env,144 stdio: "inherit",145 shell: false,146 });147 if (result.status !== 0) {148 throw new Error(149 `Command failed (exit ${result.status}): ${basename(spec.executable)}`,150 );151 }152}153154155156const RELEASE_CRITICAL_DEPS = [157 "chrome-webstore-upload-cli",158 "web-ext",159 "typescript",160 "@playwright/test",161];162163export function preflight(): void {164 const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));165 const devDeps: Record<string, string> = pkg.devDependencies ?? {};166 const offenders: string[] = [];167 for (const dep of RELEASE_CRITICAL_DEPS) {168 const version = devDeps[dep];169 if (!version) {170 offenders.push(`${dep} (missing from devDependencies)`);171 } else if (version.startsWith("^") || version.startsWith("~")) {172 offenders.push(`${dep} (has "${version}", must be exact)`);173 }174 }175 if (offenders.length > 0) {176 throw new Error(177 "Release preflight failed — release-critical dev dependencies must be pinned to exact versions:\n " +178 offenders.join("\n "),179 );180 }181}182183184185export function readVersion(manifestPath: string): string {186 const manifest = JSON.parse(readFileSync(join(ROOT, manifestPath), "utf8"));187 if (!manifest.version) {188 throw new Error(`Could not read version from ${manifestPath}`);189 }190 return manifest.version;191}192193export function ensureArtifactsDir(): string {194 const dir = join(ROOT, "artifacts");195 if (!existsSync(dir)) mkdirSync(dir);196 return dir;197}198199export function packageChromeZip(200 version: string,201 runner: Runner = runCommand,202): string {203 const artifactsDir = ensureArtifactsDir();204 const zipPath = join(artifactsDir, `peach-chrome-${version}.zip`);205 if (existsSync(zipPath)) {206 runner("trash", [zipPath], {207 label: "Moving existing Chrome archive to Trash",208 });209 }210 runner(211 "zip",212 [213 "-r",214 zipPath,215 ".",216 "--exclude",217 "*.map",218 "--exclude",219 ".DS_Store",220 "--exclude",221 "__MACOSX/*",222 ],223 {224 cwd: join(ROOT, "dist-chrome"),225 label: `Packaging Chrome extension → peach-chrome-${version}.zip`,226 },227 );228 return zipPath;229}230231export function uploadChrome(232 version: string,233 runner: Runner = runCommand,234): void {235 const zipPath = packageChromeZip(version, runner);236 const extensionId = requireEnv("CWS_EXTENSION_ID");237 runner(238 resolveBin("chrome-webstore-upload"),239 [240 "upload",241 "--source",242 zipPath,243 "--extension-id",244 extensionId,245 "--auto-publish",246 ],247 {248 env: cwsUploadEnv(),249 label: "Uploading and publishing to Chrome Web Store",250 },251 );252}253254export { packageSourceArchive } from "./package-source";255256export function signFirefox(257 sourceZip: string,258 runner: Runner = runCommand,259): void {260 runner(261 resolveBin("web-ext"),262 [263 "sign",264 "--source-dir",265 join(ROOT, "dist-firefox"),266 "--artifacts-dir",267 join(ROOT, "artifacts"),268 "--channel=listed",269 "--upload-source-code",270 sourceZip,271 ],272 {273 env: amoSignEnv(),274 label: "Signing and submitting to Firefox AMO",275 },276 );277}