#!/usr/bin/env node // Local release: consume changesets, bump versions, publish to npm, push tag. // // This is a local-machine alternative to the `version.yml` CI workflow. // Trade-off: CI publishes with npm provenance via OIDC; this script does not. // If you want provenance, run the workflow manually instead of this script. // // Requirements: // - clean working tree on `main`, up to date with origin // - at least one pending changeset in .changeset/ // - `npm login` already completed (publish uses the active npm token) // // Usage: // pnpm release # interactive: review changesets, accept or override version, confirm // pnpm release --yes # accept the proposed version, skip confirmation prompt // pnpm release --dry-run # preview only — exit before any file/git/npm changes import { spawnSync } from "node:child_process"; import { readFileSync, readdirSync, writeFileSync, statSync, existsSync, unlinkSync, mkdtempSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { createInterface } from "node:readline/promises"; import { stdin, stdout } from "node:process"; const args = new Set(process.argv.slice(2)); const DRY_RUN = args.has("--dry-run"); const AUTO_YES = args.has("--yes") || args.has("-y"); const color = (c, s) => `\x1b[${c}m${s}\x1b[0m`; const info = (s) => console.log(color(36, "▶ ") + s); const ok = (s) => console.log(color(32, "✓ ") + s); const warn = (s) => console.log(color(33, "! ") + s); const fail = (s) => { console.error(color(31, "✗ ") + s); process.exit(1); }; function run(cmd, { capture = false, allowFail = false } = {}) { const r = spawnSync(cmd, { shell: true, stdio: capture ? "pipe" : "inherit", encoding: "utf8", }); if (r.status !== 0 && !allowFail) fail(`Command failed: ${cmd}`); return { status: r.status, stdout: (r.stdout || "").trim() }; } /** * Rewrite the repo-root CHANGELOG.md by aggregating every * `packages/*\/CHANGELOG.md` into a single per-version view. * * For each version that appears in any package, we emit a top-level * `## ` block, then a `### ` sub-block per package that * had an entry for that version, with the package's section body bumped * one heading level deeper (`### Patch Changes` → `#### Patch Changes`). * * Version order: take the order from the package with the most recent * release (the one whose top version is highest by semver). Any extra * versions found only in other packages are appended in semver-descending * order at the end. */ function syncRootChangelog() { const pkgsDir = "packages"; const pkgDirs = readdirSync(pkgsDir).filter((name) => { const p = join(pkgsDir, name); return statSync(p).isDirectory() && existsSync(join(p, "CHANGELOG.md")); }); // { pkgName, versions: Map, order: versionKey[] } const parsed = pkgDirs.map((dir) => { const path = join(pkgsDir, dir, "CHANGELOG.md"); const raw = readFileSync(path, "utf8"); let pkgName = dir; const titleMatch = raw.match(/^# ([^\n]+)\n/); if (titleMatch) pkgName = titleMatch[1].trim(); return { pkgName, ...parseChangelog(raw) }; }); // Pick the canonical version order from whichever package has the highest // top version (typically the public CLI). Other packages contribute any // additional versions at the tail. parsed.sort((a, b) => compareSemver(b.order[0] ?? "0", a.order[0] ?? "0")); const seen = new Set(); const versionOrder = []; for (const p of parsed) { for (const v of p.order) { if (!seen.has(v)) { seen.add(v); versionOrder.push(v); } } } const lines = [ "# Fusion changelog", "", "User-facing release notes aggregated across all packages. This file is auto-synced from each `packages/*/CHANGELOG.md` by `scripts/release.mjs` — do not edit by hand.", "", ]; for (const version of versionOrder) { lines.push(`## ${version}`, ""); // Sort packages alphabetically within a version for deterministic output. const pkgsForVersion = parsed .filter((p) => p.versions.has(version)) .sort((a, b) => a.pkgName.localeCompare(b.pkgName)); for (const p of pkgsForVersion) { const body = p.versions.get(version).trim(); if (!body) continue; lines.push(`### ${p.pkgName}`, ""); // Bump heading levels by one so package sub-sections nest cleanly. const bumped = body.replace(/^(#{1,5}) /gm, (_m, hashes) => `${hashes}# `); lines.push(bumped, ""); } } writeFileSync("CHANGELOG.md", lines.join("\n").replace(/\n{3,}/g, "\n\n")); } /** * Parse a changeset-format CHANGELOG into { versions, order }. * Splits on top-level `## ` headings; the version key is the heading text * verbatim (e.g. "0.2.5", or "0.4.0 (pre-release, unpublished)"). */ function parseChangelog(raw) { const versions = new Map(); const order = []; // Strip out the first-line title and any horizontal rules so they don't // pollute the first version section. const stripped = raw.replace(/^# [^\n]*\n?/, "").replace(/^---\s*$/gm, ""); const sections = stripped.split(/^## /m).slice(1); // drop pre-first-version preamble for (const section of sections) { const nl = section.indexOf("\n"); const key = (nl === -1 ? section : section.slice(0, nl)).trim(); const body = nl === -1 ? "" : section.slice(nl + 1).trim(); if (!versions.has(key)) { versions.set(key, body); order.push(key); } } return { versions, order }; } /** Compare two semver-ish version strings ("0.2.5", "0.4.0 (pre-release)"). */ function compareSemver(a, b) { const pa = parseVersionKey(a); const pb = parseVersionKey(b); for (let i = 0; i < 3; i++) { if (pa[i] !== pb[i]) return pa[i] - pb[i]; } return 0; } function parseVersionKey(key) { const m = key.match(/^(\d+)\.(\d+)\.(\d+)/); if (!m) return [0, 0, 0]; return [Number(m[1]), Number(m[2]), Number(m[3])]; } async function confirm(prompt) { if (AUTO_YES) return true; const rl = createInterface({ input: stdin, output: stdout }); const answer = (await rl.question(`${prompt} [y/N] `)).trim().toLowerCase(); rl.close(); return answer === "y" || answer === "yes"; } async function ask(prompt) { const rl = createInterface({ input: stdin, output: stdout }); const answer = await rl.question(prompt); rl.close(); return answer.trim(); } const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/; /** * Run `pnpm changeset status --output` to compute the proposed release plan * without applying it. Returns { proposedVersion, releases } where releases * is the bumped public packages list. * * The repo uses a single "fixed" group, so every bumped public package * shares one new version — we surface that as the canonical proposedVersion. */ function computeReleasePlan() { const dir = mkdtempSync(join(tmpdir(), "fusion-release-")); const out = join(dir, "plan.json"); const r = spawnSync("pnpm", ["changeset", "status", "--output", out], { stdio: "pipe", encoding: "utf8", }); if (r.status !== 0) { fail(`Failed to compute release plan:\n${r.stderr || r.stdout}`); } const plan = JSON.parse(readFileSync(out, "utf8")); try { unlinkSync(out); } catch { /* tmp cleanup is best-effort */ } const bumpedReleases = (plan.releases || []).filter((rel) => rel.type !== "none"); if (bumpedReleases.length === 0) { fail("Release plan contains no bumps. All changesets resolved to 'none'."); } // All public packages share a version (changeset config "fixed"); pick the // first non-none release's newVersion as canonical. Sanity-check below. const proposedVersion = bumpedReleases[0].newVersion; const mismatched = bumpedReleases.filter( (rel) => rel.newVersion !== proposedVersion && !rel.private, ); if (mismatched.length > 0) { warn("Bumped packages have differing versions; using the first as canonical:"); for (const rel of mismatched) console.log(` ${rel.name} → ${rel.newVersion}`); } return { proposedVersion, releases: bumpedReleases, plan }; } /** * Read the changeset markdown files in `.changeset/` and return [{ file, bump, summary }]. * `bump` is the highest bump declared in that file's frontmatter. */ function readChangesetSummaries() { const files = readdirSync(".changeset").filter( (f) => f.endsWith(".md") && f !== "README.md", ); return files.map((file) => { const raw = readFileSync(join(".changeset", file), "utf8"); const fm = raw.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/); let bump = "patch"; let summary = raw.trim(); if (fm) { const bumps = [...fm[1].matchAll(/:\s*(major|minor|patch)/g)].map((m) => m[1]); const order = { major: 3, minor: 2, patch: 1 }; bump = bumps.reduce((a, b) => (order[b] > order[a] ? b : a), "patch"); summary = fm[2].trim(); } // Keep the summary tight for terminal display. const firstLine = summary.split("\n").find((l) => l.trim()) ?? "(no summary)"; return { file, bump, summary: firstLine.trim() }; }); } /** * If the user picked a version different from what changesets generated, * patch every bumped package's package.json + CHANGELOG.md heading so the * commit, npm publish, and tag all use the chosen version. */ function overrideVersion(releases, proposedVersion, chosenVersion) { if (proposedVersion === chosenVersion) return; // Only rewrite packages that resolved to the canonical proposed version // (i.e. members of the "fixed" group). Other bumped packages have their // own independent versions (e.g. plugin examples) and must be left alone. const targets = releases.filter((rel) => rel.newVersion === proposedVersion); info(`Rewriting ${targets.length} package(s) to v${chosenVersion}…`); for (const rel of targets) { const dir = findPackageDir(rel.name); if (!dir) { warn(` Could not locate package directory for ${rel.name}; skipping.`); continue; } const pkgPath = join(dir, "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf8")); pkg.version = chosenVersion; writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n"); const changelogPath = join(dir, "CHANGELOG.md"); if (existsSync(changelogPath)) { const raw = readFileSync(changelogPath, "utf8"); // Replace only the most recent (top) version heading to avoid touching history. const patched = raw.replace( new RegExp(`^## ${escapeRegex(proposedVersion)}\\b`, "m"), `## ${chosenVersion}`, ); writeFileSync(changelogPath, patched); } } ok(`Version override applied: ${proposedVersion} → ${chosenVersion}`); } function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function findPackageDir(name) { // Most packages live under packages/; do an exact match on package.json name. const roots = ["packages"]; for (const root of roots) { if (!existsSync(root)) continue; for (const entry of readdirSync(root)) { const p = join(root, entry, "package.json"); if (!existsSync(p)) continue; try { const pkg = JSON.parse(readFileSync(p, "utf8")); if (pkg.name === name) return join(root, entry); } catch { /* skip unreadable/broken package.json */ } } } return null; } // --- Preflight ------------------------------------------------------------ info("Preflight checks…"); const branch = run("git rev-parse --abbrev-ref HEAD", { capture: true }).stdout; if (branch !== "main") fail(`Must be on 'main' (currently '${branch}').`); const dirty = run("git status --porcelain", { capture: true }).stdout; if (dirty) fail("Working tree is not clean. Commit or stash first."); run("git fetch origin main", { capture: true }); const ahead = run("git rev-list --count origin/main..HEAD", { capture: true }).stdout; const behind = run("git rev-list --count HEAD..origin/main", { capture: true }).stdout; if (behind !== "0") fail(`Local main is behind origin/main by ${behind} commit(s). Pull first.`); if (ahead !== "0") warn(`Local main is ahead of origin/main by ${ahead} commit(s); they will be pushed.`); const changesetSummaries = readChangesetSummaries(); if (changesetSummaries.length === 0) { fail("No pending changesets in .changeset/. Run `pnpm changeset` first."); } ok(`${changesetSummaries.length} pending changeset(s):`); for (const cs of changesetSummaries) { console.log(` ${color(33, `[${cs.bump}]`)} ${cs.summary} ${color(90, `(${cs.file})`)}`); } info("Computing proposed release plan…"); const { proposedVersion, releases } = computeReleasePlan(); const currentVersion = JSON.parse(readFileSync("packages/cli/package.json", "utf8")).version; console.log(""); console.log(` Current version : ${color(90, currentVersion)}`); console.log(` Proposed version: ${color(32, proposedVersion)}`); console.log(` Bumped packages : ${releases.map((r) => r.name).join(", ")}`); console.log(""); let chosenVersion = proposedVersion; if (!AUTO_YES) { while (true) { const answer = await ask(`Release version [${proposedVersion}]: `); if (answer === "") break; if (!SEMVER_RE.test(answer)) { warn(`Not a valid semver string: '${answer}'. Try again.`); continue; } chosenVersion = answer; break; } } if (chosenVersion !== proposedVersion) { warn(`Overriding changeset-proposed version: ${proposedVersion} → ${chosenVersion}`); } if (DRY_RUN) { warn("--dry-run: stopping before version bump. No files modified, no commit, no publish, no tag."); info(`Would release v${chosenVersion} (${releases.length} package(s) bumped).`); process.exit(0); } if (!(await confirm(`Proceed with release v${chosenVersion} (build, publish, tag)?`))) { warn("Aborted by user."); process.exit(0); } // --- Version bump --------------------------------------------------------- info("Applying changesets (version bump + CHANGELOG)…"); run("pnpm release:version"); overrideVersion(releases, proposedVersion, chosenVersion); run("node scripts/sync-workspace-version.mjs"); info("Updating lockfile…"); run("pnpm install --no-frozen-lockfile"); const cliPkg = JSON.parse(readFileSync("packages/cli/package.json", "utf8")); const version = cliPkg.version; if (version !== chosenVersion) { fail(`Post-bump version mismatch: package reports ${version}, expected ${chosenVersion}.`); } const workspacePkg = JSON.parse(readFileSync("package.json", "utf8")); if (workspacePkg.version !== chosenVersion) { fail(`Post-bump workspace version mismatch: package.json reports ${workspacePkg.version}, expected ${chosenVersion}.`); } ok(`New version: ${version}`); info("Syncing root CHANGELOG.md from packages/cli/CHANGELOG.md…"); syncRootChangelog(); ok("Root CHANGELOG.md updated."); // --- Build ---------------------------------------------------------------- info("Building all packages…"); run("pnpm build"); // --- Commit --------------------------------------------------------------- info("Committing version bump…"); run("git add -A"); run( `git commit -m "chore(release): v${version}" -m "Version bump via changesets."`, { allowFail: true } ); // --- Publish -------------------------------------------------------------- info("Publishing to npm (non-private packages only)…"); run("pnpm -r publish --access public --no-git-checks"); // --- Push + tag ----------------------------------------------------------- info("Pushing commit to origin/main…"); run("git push origin main"); info(`Creating and pushing tag v${version}…`); run(`git tag v${version}`); run(`git push origin v${version}`); ok(`Released v${version}. The 'v${version}' tag will trigger release.yml for binary builds.`);