release-lib.ts

8.4 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, 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");1314// ─── Secret identification ─────────────────────────────────────────────────15// Parent env names + child env names for both stores. All must be stripped16// from safe (build/CI) environments and only injected into the single upload17// step that needs them.1819export 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}5758// ─── Binary resolution ──────────────────────────────────────────────────────────5960export 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}7071// ─── Environment management ────────────────────────────────────────────────7273function 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}110111// ─── Command runner ────────────────────────────────────────────────────────112113export 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}167168// ─── Preflight ─────────────────────────────────────────────────────────────169170const 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}196197// ─── Shared packaging and signing ──────────────────────────────────────────198199export 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}212213/** Matches the exclusions the previous system-`zip` call applied. */214function 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}222223export function collectDirectory(224  dir: string,225  root = dir,226): Array<{ path: string; data: Buffer }> {227  return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {228    const full = join(dir, entry.name);229    if (entry.isDirectory()) return collectDirectory(full, root);230    return [{ path: relative(root, full).split(sep).join("/"), data: readFileSync(full) }];231  });232}233234/**235 * PB-26-611: the system `zip` embedded filesystem timestamps, so two builds of236 * the same commit produced different archives and the shipped Chrome artifact237 * could not be verified by hash. Reuses the deterministic writer the AMO238 * source archive already uses — fixed DOS timestamps, deflate level 9, sorted239 * entries.240 */241export function packageChromeZip(version: string): string {242  const zipPath = join(ensureArtifactsDir(), `peach-chrome-${version}.zip`);243  const entries = collectDirectory(join(ROOT, "dist-chrome")).filter(244    (entry) => !isPackagingNoise(entry.path),245  );246  writeFileSync(zipPath, createDeterministicZip(entries));247  return zipPath;248}249250export function uploadChrome(251  version: string,252  runner: Runner = runCommand,253): void {254  const zipPath = packageChromeZip(version);255  const extensionId = requireEnv("CWS_EXTENSION_ID");256  runner(257    resolveBin("chrome-webstore-upload"),258    [259      "upload",260      "--source",261      zipPath,262      "--extension-id",263      extensionId,264      "--auto-publish",265    ],266    {267      env: cwsUploadEnv(),268      label: "Uploading and publishing to Chrome Web Store",269    },270  );271}272273export { packageSourceArchive } from "./package-source";274275export function signFirefox(276  sourceZip: string,277  runner: Runner = runCommand,278): void {279  runner(280    resolveBin("web-ext"),281    [282      "sign",283      "--source-dir",284      join(ROOT, "dist-firefox"),285      "--artifacts-dir",286      join(ROOT, "artifacts"),287      "--channel=listed",288      "--upload-source-code",289      sourceZip,290    ],291    {292      env: amoSignEnv(),293      label: "Signing and submitting to Firefox AMO",294    },295  );296}