release-lib.ts

7.8 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 releaseGateEnv(): Record<string, string> {92  return { ...safeEnv(), PEACH_RELEASE_BROWSER_GATE: "1" };93}9495export function cwsUploadEnv(): Record<string, string> {96  return {97    ...safeEnv(),98    CLIENT_ID: requireEnv("CWS_CLIENT_ID"),99    CLIENT_SECRET: requireEnv("CWS_CLIENT_SECRET"),100    REFRESH_TOKEN: requireEnv("CWS_REFRESH_TOKEN"),101    PUBLISHER_ID: requireEnv("PUBLISHER_ID"),102  };103}104105export function amoSignEnv(): Record<string, string> {106  return {107    ...safeEnv(),108    WEB_EXT_API_KEY: requireEnv("AMO_JWT_ISSUER"),109    WEB_EXT_API_SECRET: requireEnv("AMO_JWT_SECRET"),110  };111}112113// ─── Command runner ────────────────────────────────────────────────────────114115export interface RunOptions {116  cwd?: string;117  env?: Record<string, string>;118  label?: string;119}120121export interface CommandSpec {122  executable: string;123  args: string[];124  env: Record<string, string>;125  cwd: string;126}127128export function buildCommand(129  executable: string,130  args: string[],131  options: RunOptions = {},132): CommandSpec {133  return {134    executable,135    args: [...args],136    env: options.env ?? safeEnv(),137    cwd: options.cwd ?? ROOT,138  };139}140141export type Runner = (142  executable: string,143  args: string[],144  options?: RunOptions,145) => void;146147export function runCommand(148  executable: string,149  args: string[],150  options: RunOptions = {},151): void {152  const spec = buildCommand(executable, args, options);153  const display =154    options.label ??155    `${basename(spec.executable)} ${spec.args.join(" ")}`;156  console.log(`\n▶ ${display}`);157  const result = spawnSync(spec.executable, spec.args, {158    cwd: spec.cwd,159    env: spec.env,160    stdio: "inherit",161    shell: false,162  });163  if (result.status !== 0) {164    throw new Error(165      `Command failed (exit ${result.status}): ${basename(spec.executable)}`,166    );167  }168}169170// ─── Preflight ─────────────────────────────────────────────────────────────171172const RELEASE_CRITICAL_DEPS = [173  "chrome-webstore-upload-cli",174  "web-ext",175  "typescript",176  "@playwright/test",177];178179export function preflight(): void {180  const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));181  const devDeps: Record<string, string> = pkg.devDependencies ?? {};182  const offenders: string[] = [];183  for (const dep of RELEASE_CRITICAL_DEPS) {184    const version = devDeps[dep];185    if (!version) {186      offenders.push(`${dep} (missing from devDependencies)`);187    } else if (version.startsWith("^") || version.startsWith("~")) {188      offenders.push(`${dep} (has "${version}", must be exact)`);189    }190  }191  if (offenders.length > 0) {192    throw new Error(193      "Release preflight failed — release-critical dev dependencies must be pinned to exact versions:\n  " +194        offenders.join("\n  "),195    );196  }197}198199// ─── Shared packaging and signing ──────────────────────────────────────────200201export function readVersion(manifestPath: string): string {202  const manifest = JSON.parse(readFileSync(join(ROOT, manifestPath), "utf8"));203  if (!manifest.version) {204    throw new Error(`Could not read version from ${manifestPath}`);205  }206  return manifest.version;207}208209export function ensureArtifactsDir(): string {210  const dir = join(ROOT, "artifacts");211  if (!existsSync(dir)) mkdirSync(dir);212  return dir;213}214215export function packageChromeZip(216  version: string,217  runner: Runner = runCommand,218): string {219  const artifactsDir = ensureArtifactsDir();220  const zipPath = join(artifactsDir, `peach-chrome-${version}.zip`);221  if (existsSync(zipPath)) {222    runner("trash", [zipPath], {223      label: "Moving existing Chrome archive to Trash",224    });225  }226  runner(227    "zip",228    [229      "-r",230      zipPath,231      ".",232      "--exclude",233      "*.map",234      "--exclude",235      ".DS_Store",236      "--exclude",237      "__MACOSX/*",238    ],239    {240      cwd: join(ROOT, "dist-chrome"),241      label: `Packaging Chrome extension → peach-chrome-${version}.zip`,242    },243  );244  return zipPath;245}246247export function uploadChrome(248  version: string,249  runner: Runner = runCommand,250): void {251  const zipPath = packageChromeZip(version, runner);252  const extensionId = requireEnv("CWS_EXTENSION_ID");253  runner(254    resolveBin("chrome-webstore-upload"),255    [256      "upload",257      "--source",258      zipPath,259      "--extension-id",260      extensionId,261      "--auto-publish",262    ],263    {264      env: cwsUploadEnv(),265      label: "Uploading and publishing to Chrome Web Store",266    },267  );268}269270export { packageSourceArchive } from "./package-source";271272export function signFirefox(273  sourceZip: string,274  runner: Runner = runCommand,275): void {276  runner(277    resolveBin("web-ext"),278    [279      "sign",280      "--source-dir",281      join(ROOT, "dist-firefox"),282      "--artifacts-dir",283      join(ROOT, "artifacts"),284      "--channel=listed",285      "--upload-source-code",286      sourceZip,287    ],288    {289      env: amoSignEnv(),290      label: "Signing and submitting to Firefox AMO",291    },292  );293}