#!/usr/bin/env node // Cut a release: bump package.json, close the CHANGELOG [Unreleased] section, // commit, and create an annotated git tag. The tag is the immutable version // anchor consumers pin (WHYNOT-WP-0003). Publishing is a separate step (T02). // // make release VERSION=0.3.0 # or: node scripts/release.mjs 0.3.0 // // Guards (refuses, never half-applies): // - VERSION must be semver `x.y.z` // - working tree must be clean (untracked files are allowed) // - tag `vX.Y.Z` must not already exist // - CHANGELOG `## [Unreleased]` must exist and be non-empty // - CHANGELOG must not already have a `## [X.Y.Z]` section // // Exit codes (match adapters/ADAPTER_CONTRACT.md): 0 ok · 2 usage/config · 3 guard fail. import { execSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; const CHANGELOG = "CHANGELOG.md"; const PKG = "package.json"; function die(code, msg) { console.error(`release: ${msg}`); process.exit(code); } function git(args) { return execSync(`git ${args}`, { encoding: "utf8" }).trim(); } const raw = (process.argv[2] || "").replace(/^v/, ""); if (!/^\d+\.\d+\.\d+$/.test(raw)) { die(2, `usage: node scripts/release.mjs (got "${process.argv[2] ?? ""}")`); } const version = raw; const tag = `v${version}`; // Working tree must be clean (ignore untracked `??` lines, e.g. pnpm-lock.yaml). const dirty = git("status --porcelain") .split("\n") .filter((l) => l && !l.startsWith("??")); if (dirty.length) { die(2, `working tree not clean — commit or stash first:\n${dirty.join("\n")}`); } // Tag must not exist. try { git(`rev-parse --verify --quiet refs/tags/${tag}`); die(3, `tag ${tag} already exists`); } catch { /* not found — good */ } const changelog = readFileSync(CHANGELOG, "utf8"); if (changelog.includes(`## [${version}]`)) { die(3, `CHANGELOG already has a [${version}] section`); } // Isolate the [Unreleased] body (between its header and the next `## [`). const unreleased = changelog.match(/## \[Unreleased\]\s*\n([\s\S]*?)(?=\n## \[|$)/); if (!unreleased) die(3, `CHANGELOG has no "## [Unreleased]" section`); if (!unreleased[1].trim()) { die(3, `CHANGELOG [Unreleased] is empty — nothing to release`); } const today = new Date().toISOString().slice(0, 10); // Cut: keep a fresh empty [Unreleased] above, relabel the old one to [version] — date. const newChangelog = changelog.replace( "## [Unreleased]\n", `## [Unreleased]\n\n## [${version}] — ${today}\n`, ); // Bump package.json with a targeted replace so the hand-aligned formatting survives. const pkg = readFileSync(PKG, "utf8"); const bumped = pkg.replace(/("version":\s*)"[^"]+"/, `$1"${version}"`); if (bumped === pkg) die(2, `could not find "version" in ${PKG}`); writeFileSync(CHANGELOG, newChangelog); writeFileSync(PKG, bumped); git(`add ${PKG} ${CHANGELOG}`); execSync(`git commit -m "release: ${tag}"`, { stdio: "inherit" }); execSync(`git tag -a ${tag} -m "${tag}"`, { stdio: "inherit" }); console.log(`\n✓ released ${tag}`); console.log(` next: git push --follow-tags origin main`); console.log(` then: publish (WHYNOT-WP-0003 T02)`);