release-lib.ts

7.6 KB · raw

1// scripts/release-lib.ts2// Shared argv-based runner for release tooling (PB-26-211).3// No shell, no online resolution, no inherited store credentials.45import { 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");1112// ─── Secret identification ─────────────────────────────────────────────────13// Parent env names + child env names for both stores. All must be stripped14// from safe (build/CI) environments and only injected into the single upload15// step that needs them.1617export 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}5556// ─── Binary resolution ──────────────────────────────────────────────────────────5758export 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}6869// ─── Environment management ────────────────────────────────────────────────7071function 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}108109// ─── Command runner ────────────────────────────────────────────────────────110111export 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}165166// ─── Preflight ─────────────────────────────────────────────────────────────167168const 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}194195// ─── Shared packaging and signing ──────────────────────────────────────────196197export 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}