feat(release): make release tooling + versioning ritual (WHYNOT-WP-0003 T01)
- scripts/release.mjs + `make release VERSION=x.y.z`: guarded release cut — bumps package.json, relabels CHANGELOG [Unreleased] → [x.y.z], commits, and creates the annotated git tag. Refuses on dirty tree, existing tag, duplicate section, or empty [Unreleased]. Never pushes (outward step stays manual). - DesignSystemIntroduction.md §6: document the release ritual; §9: fix the stale bootstrap host + tag note. - README: bump the install pin to the first real tag (v0.3.0). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
88
scripts/release.mjs
Normal file
88
scripts/release.mjs
Normal file
@@ -0,0 +1,88 @@
|
||||
#!/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 <x.y.z> (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)`);
|
||||
Reference in New Issue
Block a user