release-lib.ts

7.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, 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 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}9697// ─── Command runner ────────────────────────────────────────────────────────9899export 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}153154// ─── Preflight ─────────────────────────────────────────────────────────────155156const 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}182183// ─── Shared packaging and signing ──────────────────────────────────────────184185export 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}