#!/usr/bin/env node /** * Release orchestrator for the cacheable monorepo. * * Versions are set **manually** (each package's `version` in package.json is * bumped by a human / release PR). This script never changes versions. Its job * is to decide, for every publishable workspace package, whether the locally * declared version still needs to be staged, and — unless running a dry run * — to stage the ones that do. A maintainer promotes staged versions later. * * How "needs publishing" is decided: * 1. Enumerate workspace packages with `pnpm -r ls --depth -1 --json`. * 2. Drop packages that are `private` or explicitly ignored (see * IGNORED_PACKAGES) — these are never published to npm. * 3. For each remaining package, fetch its document from the npm registry: * - 404 → the package has never been published → publish * - version listed → this exact version is already on npm → skip * - version absent → a newer (manually-set) version is ready → publish * 4. The full plan is computed before anything is staged. If the registry * state of any package cannot be determined (after retries), the run aborts * *before* staging anything — a release is all-or-nothing on a known * plan, never a partial guess. * * Staging happens in **dependency order** (topological sort over the * workspace's runtime deps): a package is always staged after the workspace * dependencies it relies on, because pnpm rewrites each `workspace:^` reference * to the dependency's concrete version at pack time, and a dependent must * never be released ahead of a dependency it points at. If a run fails partway, * dependencies are already staged before their dependents. * * Staging uses pnpm only (never npm) with provenance, so packages are * cryptographically linked to this repo + workflow when run from CI with an * OIDC `id-token: write` permission: * * pnpm --filter pack --pack-destination packed * pnpm stage publish ./packed/.tgz --access public --provenance --no-git-checks * * Usage: * node scripts/release.mjs # stage every package whose version is new * node scripts/release.mjs --dry-run # print the plan + validate packaging, stage nothing * node scripts/release.mjs --json # emit the plan as JSON (implies no publishing noise) * * Environment: * NPM_CONFIG_REGISTRY override the registry queried + published to (default: npmjs.org) * GITHUB_STEP_SUMMARY when set, a markdown summary table is appended to it * GITHUB_OUTPUT when set, `published-count` / `published-packages` outputs are written * * Exit codes: 0 = success (including "nothing to publish"); non-zero = a registry * lookup or a publish failed. */ import { spawnSync } from "node:child_process"; import { appendFileSync, existsSync, mkdirSync, readFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; const REGISTRY = (process.env.NPM_CONFIG_REGISTRY || "https://registry.npmjs.org").replace(/\/$/, ""); /** * Packages that live in the workspace and are *not* marked `private`, yet should * never be published to npm. Keep this list small and documented — removing a * name here is all it takes to start publishing that package. */ const IGNORED_PACKAGES = new Set([ // Internal benchmarking harness — run from source, never distributed on npm. "@cacheable/benchmark", ]); function parseArgs(argv) { const args = { dryRun: false, json: false, help: false }; for (const arg of argv) { switch (arg) { case "--dry-run": case "-d": args.dryRun = true; break; case "--json": args.json = true; break; case "--help": case "-h": args.help = true; break; default: console.error(`Unknown argument: ${arg}`); process.exit(2); } } return args; } const HELP = `Release orchestrator for the cacheable monorepo. Usage: node scripts/release.mjs Stage every package whose version is not yet on npm node scripts/release.mjs --dry-run Print the plan and validate packaging without staging node scripts/release.mjs --json Emit the publish plan as JSON Versions are set manually; this script never bumps them.`; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); /** Enumerate publishable workspace packages via pnpm (respects pnpm-workspace.yaml). */ function listWorkspacePackages() { const result = spawnSync("pnpm", ["-r", "ls", "--depth", "-1", "--json"], { encoding: "utf8", maxBuffer: 32 * 1024 * 1024, }); if (result.status !== 0) { throw new Error(`\`pnpm -r ls\` failed: ${result.stderr || result.stdout || result.error?.message}`); } /** @type {Array<{name:string,version:string,path:string,private?:boolean}>} */ const entries = JSON.parse(result.stdout); return entries .filter((pkg) => pkg.name && pkg.version) .filter((pkg) => pkg.private !== true) .filter((pkg) => !IGNORED_PACKAGES.has(pkg.name)) .map((pkg) => ({ name: pkg.name, version: pkg.version, path: pkg.path })); } /** Read the workspace packages a given package depends on at runtime. */ function readInternalDeps(pkg, workspaceNames) { const manifest = JSON.parse(readFileSync(path.join(pkg.path, "package.json"), "utf8")); // Only runtime-facing deps are rewritten into the published manifest and // thus constrain publish order — devDependencies are not installed by // consumers, so a dev-only `workspace:` link never affects ordering. const deps = { ...manifest.dependencies, ...manifest.optionalDependencies, ...manifest.peerDependencies, }; return Object.keys(deps).filter((dep) => workspaceNames.has(dep)); } /** * Order packages so every package comes after the workspace dependencies it * relies on (Kahn's algorithm; alphabetical within a tier for determinism). * Throws on a dependency cycle. */ function orderByDependencies(packages) { const workspaceNames = new Set(packages.map((pkg) => pkg.name)); const byName = new Map(packages.map((pkg) => [pkg.name, pkg])); const deps = new Map(packages.map((pkg) => [pkg.name, new Set(readInternalDeps(pkg, workspaceNames))])); const ordered = []; const emitted = new Set(); let remaining = packages.map((pkg) => pkg.name).sort(); while (remaining.length > 0) { const ready = remaining.filter((name) => [...deps.get(name)].every((dep) => emitted.has(dep))); if (ready.length === 0) { throw new Error(`dependency cycle among workspace packages: ${remaining.join(", ")}`); } for (const name of ready) { ordered.push(byName.get(name)); emitted.add(name); } remaining = remaining.filter((name) => !emitted.has(name)); } return ordered; } /** Fetch a package's document from the registry, with retry + backoff. 404 → null. */ async function fetchRegistryDoc(name, { retries = 4 } = {}) { const url = `${REGISTRY}/${name.replaceAll("/", "%2F")}`; let lastError; for (let attempt = 0; attempt <= retries; attempt++) { try { const res = await fetch(url, { headers: { accept: "application/json" }, // Fail fast instead of hanging on a stalled registry; a timeout is // caught below and retried with backoff like any other error. signal: AbortSignal.timeout(10_000), }); if (res.status === 404) { return null; } if (!res.ok) { throw new Error(`registry responded ${res.status}`); } return await res.json(); } catch (error) { lastError = error; if (attempt < retries) { await sleep(2 ** attempt * 1000); // 1s, 2s, 4s, 8s } } } throw new Error(`failed to query the registry for ${name}: ${lastError?.message ?? lastError}`); } /** Compare two semver versions by precedence. Returns -1, 0, or 1; build metadata is ignored. */ function compareSemver(a, b) { const parse = (version) => { const [main] = String(version).split("+"); // drop build metadata const dash = main.indexOf("-"); const core = dash === -1 ? main : main.slice(0, dash); const prerelease = dash === -1 ? "" : main.slice(dash + 1); return { nums: core.split(".").map((n) => Number.parseInt(n, 10) || 0), prerelease }; }; const pa = parse(a); const pb = parse(b); for (let i = 0; i < 3; i++) { const diff = (pa.nums[i] ?? 0) - (pb.nums[i] ?? 0); if (diff !== 0) return diff < 0 ? -1 : 1; } // Equal cores: a prerelease has LOWER precedence than the matching release. if (pa.prerelease === "" || pb.prerelease === "") { if (pa.prerelease === pb.prerelease) return 0; return pa.prerelease === "" ? 1 : -1; } const ida = pa.prerelease.split("."); const idb = pb.prerelease.split("."); for (let i = 0; i < Math.max(ida.length, idb.length); i++) { const x = ida[i]; const y = idb[i]; if (x === undefined) return -1; // fewer identifiers → lower precedence if (y === undefined) return 1; const xNum = /^\d+$/.test(x); const yNum = /^\d+$/.test(y); if (xNum && yNum) { const diff = Number.parseInt(x, 10) - Number.parseInt(y, 10); if (diff !== 0) return diff < 0 ? -1 : 1; } else if (xNum !== yNum) { return xNum ? -1 : 1; // numeric identifiers rank below alphanumeric } else if (x !== y) { return x < y ? -1 : 1; } } return 0; } /** Resolve whether a single package needs publishing. */ async function resolvePlanEntry(pkg) { const doc = await fetchRegistryDoc(pkg.name); if (doc === null) { return { ...pkg, registryVersion: null, action: "publish", reason: "not yet on npm" }; } const publishedVersions = new Set(Object.keys(doc.versions ?? {})); const latest = doc["dist-tags"]?.latest ?? null; if (publishedVersions.has(pkg.version)) { return { ...pkg, registryVersion: latest, action: "skip", reason: "already published" }; } // Guard against an accidental rollback: publishing a version below the // current `latest` would move the default dist-tag — and every consumer on // a `^`/`~` range — backward. Refuse rather than silently downgrade. if (latest && compareSemver(pkg.version, latest) < 0) { return { ...pkg, registryVersion: latest, action: "error", reason: `version ${pkg.version} is behind registry latest ${latest}`, }; } return { ...pkg, registryVersion: latest, action: "publish", reason: `new version (registry latest ${latest ?? "none"})` }; } /** Build the full plan up front; throws if any package's state can't be determined. */ async function buildPlan(packages) { return Promise.all(packages.map((pkg) => resolvePlanEntry(pkg))); } function renderTable(plan) { const rows = plan.map((entry) => ({ package: entry.name, local: entry.version, registry: entry.registryVersion ?? "—", action: entry.action.toUpperCase(), })); const headers = ["package", "local", "registry", "action"]; const widths = headers.map((header) => Math.max(header.length, ...rows.map((row) => String(row[header]).length)), ); const line = (cols) => cols.map((col, i) => String(col).padEnd(widths[i])).join(" "); console.log(line(headers)); console.log(widths.map((width) => "-".repeat(width)).join(" ")); for (const row of rows) { console.log(line(headers.map((header) => row[header]))); } } function writeStepSummary(plan, { dryRun }) { if (!process.env.GITHUB_STEP_SUMMARY) { return; } const title = dryRun ? "Release plan (dry run)" : "Release"; const lines = [ `## ${title}`, "", "| Package | Local | Registry | Action |", "| --- | --- | --- | --- |", ...plan.map( (entry) => `| \`${entry.name}\` | ${entry.version} | ${entry.registryVersion ?? "—"} | ${entry.action} |`, ), "", ]; appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${lines.join("\n")}\n`); } function writeOutputs(published) { if (!process.env.GITHUB_OUTPUT) { return; } const names = published.map((entry) => entry.name); appendFileSync( process.env.GITHUB_OUTPUT, `published-count=${names.length}\npublished-packages=${names.join(",")}\n`, ); } /** Pack a package; on a real run, stage the tarball with pnpm + provenance. */ function publishPackage(entry, { dryRun }) { const packDir = path.resolve("packed"); mkdirSync(packDir, { recursive: true }); const packArgs = ["--filter", entry.name, "pack", "--pack-destination", packDir]; console.log(`\n$ pnpm ${packArgs.join(" ")}`); const packResult = spawnSync("pnpm", packArgs, { stdio: "inherit" }); if (packResult.status !== 0) { return false; } const tarballName = `${entry.name.replace(/^@/, "").replace("/", "-")}-${entry.version}.tgz`; const tarballAbs = path.join(packDir, tarballName); if (!existsSync(tarballAbs)) { console.error(`Expected tarball missing: ${tarballAbs}`); return false; } const localTarball = `./${path.relative(process.cwd(), tarballAbs)}`; if (dryRun) { console.log(`[dry run] packed ${localTarball}`); return true; } // --no-git-checks: git-checks run even for a tarball and fail on a detached // release-tag checkout and on the untracked pack output. const stageArgs = [ "stage", "publish", localTarball, "--access", "public", "--provenance", "--no-git-checks", ]; console.log(`\n$ pnpm ${stageArgs.join(" ")}`); const result = spawnSync("pnpm", stageArgs, { stdio: "inherit" }); return result.status === 0; } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(HELP); return; } const packages = orderByDependencies(listWorkspacePackages()); const plan = await buildPlan(packages); if (args.json) { console.log(JSON.stringify(plan, null, 2)); return; } console.log(`Registry: ${REGISTRY}\n`); renderTable(plan); writeStepSummary(plan, { dryRun: args.dryRun }); // Fail closed on a known-bad plan before publishing anything (e.g. a version // that would move `latest` backward). Reported even in a dry run. const errors = plan.filter((entry) => entry.action === "error"); if (errors.length > 0) { console.error("\nRefusing to release — resolve these first:"); for (const entry of errors) { console.error(` - ${entry.name}: ${entry.reason}`); } console.error("\nBump each to a version newer than the published latest, or drop it from this release."); process.exit(1); } const toPublish = plan.filter((entry) => entry.action === "publish"); if (toPublish.length === 0) { console.log("\nNothing to publish — every package is already at its registry version."); writeOutputs([]); return; } console.log( `\n${args.dryRun ? "[dry run] would stage" : "Staging"} ${toPublish.length} package(s): ${toPublish .map((entry) => `${entry.name}@${entry.version}`) .join(", ")}`, ); const published = []; const failed = []; for (const entry of toPublish) { const ok = publishPackage(entry, { dryRun: args.dryRun }); if (ok) { published.push(entry); continue; } failed.push(entry); // Stop a real publish at the first failure: the list is in dependency // order, so releasing a dependent after its dependency failed would // reference a version that never made it to the registry. A dry run // keeps going to surface every packaging problem at once. if (!args.dryRun) { break; } } if (!args.dryRun) { writeOutputs(published); } console.log(""); if (failed.length > 0) { console.error(`Failed to stage ${failed.length} package(s):`); for (const entry of failed) { console.error(` - ${entry.name}@${entry.version}`); } const attempted = new Set([...published, ...failed].map((entry) => entry.name)); const notAttempted = toPublish.filter((entry) => !attempted.has(entry.name)); if (notAttempted.length > 0) { console.error("\nNot attempted (aborted after the failure above, in dependency order):"); for (const entry of notAttempted) { console.error(` - ${entry.name}@${entry.version}`); } } process.exit(1); } console.log( args.dryRun ? `Dry run complete — ${toPublish.length} package(s) would be staged.` : `Staged ${published.length} package(s) successfully.`, ); } main().catch((error) => { console.error(error.message ?? error); process.exit(1); });