release-lib.ts

8.1 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]);3940// ─── Binary resolution ─────────────────────────────────────────────────────4142export 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}5253// ─── Environment management ────────────────────────────────────────────────5455function 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 cwsUploadEnv(): Record<string, string> {76  return {77    ...safeEnv(),78    CLIENT_ID: requireEnv("CWS_CLIENT_ID"),79    CLIENT_SECRET: requireEnv("CWS_CLIENT_SECRET"),80    REFRESH_TOKEN: requireEnv("CWS_REFRESH_TOKEN"),81    PUBLISHER_ID: requireEnv("PUBLISHER_ID"),82  };83}8485export function amoSignEnv(): Record<string, string> {86  return {87    ...safeEnv(),88    WEB_EXT_API_KEY: requireEnv("AMO_JWT_ISSUER"),89    WEB_EXT_API_SECRET: requireEnv("AMO_JWT_SECRET"),90  };91}9293// ─── Command runner ────────────────────────────────────────────────────────9495export interface RunOptions {96  cwd?: string;97  env?: Record<string, string>;98  label?: string;99}100101export interface CommandSpec {102  executable: string;103  args: string[];104  env: Record<string, string>;105  cwd: string;106}107108export function buildCommand(109  executable: string,110  args: string[],111  options: RunOptions = {},112): CommandSpec {113  return {114    executable,115    args: [...args],116    env: options.env ?? safeEnv(),117    cwd: options.cwd ?? ROOT,118  };119}120121export type Runner = (122  executable: string,123  args: string[],124  options?: RunOptions,125) => void;126127export function runCommand(128  executable: string,129  args: string[],130  options: RunOptions = {},131): void {132  const spec = buildCommand(executable, args, options);133  const display =134    options.label ??135    `${basename(spec.executable)} ${spec.args.join(" ")}`;136  console.log(`\n▶ ${display}`);137  const result = spawnSync(spec.executable, spec.args, {138    cwd: spec.cwd,139    env: spec.env,140    stdio: "inherit",141    shell: false,142  });143  if (result.status !== 0) {144    throw new Error(145      `Command failed (exit ${result.status}): ${basename(spec.executable)}`,146    );147  }148}149150// ─── Preflight ─────────────────────────────────────────────────────────────151152const RELEASE_CRITICAL_DEPS = [153  "chrome-webstore-upload-cli",154  "web-ext",155  "typescript",156  "@playwright/test",157];158159export function preflight(): void {160  const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));161  const devDeps: Record<string, string> = pkg.devDependencies ?? {};162  const offenders: string[] = [];163  for (const dep of RELEASE_CRITICAL_DEPS) {164    const version = devDeps[dep];165    if (!version) {166      offenders.push(`${dep} (missing from devDependencies)`);167    } else if (version.startsWith("^") || version.startsWith("~")) {168      offenders.push(`${dep} (has "${version}", must be exact)`);169    }170  }171  if (offenders.length > 0) {172    throw new Error(173      "Release preflight failed — release-critical dev dependencies must be pinned to exact versions:\n  " +174        offenders.join("\n  "),175    );176  }177}178179// ─── Shared packaging and signing ──────────────────────────────────────────180181export function readVersion(manifestPath: string): string {182  const manifest = JSON.parse(readFileSync(join(ROOT, manifestPath), "utf8"));183  if (!manifest.version) {184    throw new Error(`Could not read version from ${manifestPath}`);185  }186  return manifest.version;187}188189export function ensureArtifactsDir(): string {190  const dir = join(ROOT, "artifacts");191  if (!existsSync(dir)) mkdirSync(dir);192  return dir;193}194195export function packageChromeZip(196  version: string,197  runner: Runner = runCommand,198): string {199  const artifactsDir = ensureArtifactsDir();200  const zipPath = join(artifactsDir, `peach-chrome-${version}.zip`);201  if (existsSync(zipPath)) {202    runner("trash", [zipPath], {203      label: "Moving existing Chrome archive to Trash",204    });205  }206  runner(207    "zip",208    [209      "-r",210      zipPath,211      ".",212      "--exclude",213      "*.map",214      "--exclude",215      ".DS_Store",216      "--exclude",217      "__MACOSX/*",218    ],219    {220      cwd: join(ROOT, "dist-chrome"),221      label: `Packaging Chrome extension → peach-chrome-${version}.zip`,222    },223  );224  return zipPath;225}226227export function uploadChrome(228  version: string,229  runner: Runner = runCommand,230): void {231  const zipPath = packageChromeZip(version, runner);232  const extensionId = requireEnv("CWS_EXTENSION_ID");233  runner(234    resolveBin("chrome-webstore-upload"),235    [236      "upload",237      "--source",238      zipPath,239      "--extension-id",240      extensionId,241      "--auto-publish",242    ],243    {244      env: cwsUploadEnv(),245      label: "Uploading and publishing to Chrome Web Store",246    },247  );248}249250export function packageFirefoxSourceZip(251  version: string,252  runner: Runner = runCommand,253): string {254  const artifactsDir = ensureArtifactsDir();255  const sourceZip = join(artifactsDir, `peach-source-${version}.zip`);256  runner(257    "zip",258    [259      "-r",260      sourceZip,261      "src",262      "scripts",263      "public",264      "crypto-core",265      "manifest.json",266      "manifest.firefox.json",267      "package.json",268      "bun.lock",269      "tsconfig.json",270      "vite.config.ts",271      "--exclude",272      "node_modules/*",273      "--exclude",274      "dist-*/*",275      "--exclude",276      "crypto-core/target/*",277      "--exclude",278      "crypto-core/generated/*",279      "--exclude",280      "*.zip",281      "--exclude",282      ".DS_Store",283      "--exclude",284      "__MACOSX/*",285    ],286    {287      label: "Packaging source archive for AMO",288    },289  );290  return sourceZip;291}292293export function signFirefox(294  sourceZip: string,295  runner: Runner = runCommand,296): void {297  runner(298    resolveBin("web-ext"),299    [300      "sign",301      "--source-dir",302      join(ROOT, "dist-firefox"),303      "--artifacts-dir",304      join(ROOT, "artifacts"),305      "--channel=listed",306      "--upload-source-code",307      sourceZip,308    ],309    {310      env: amoSignEnv(),311      label: "Signing and submitting to Firefox AMO",312    },313  );314}