Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
#!/usr/bin/env node
/**
* release.mjs — prepare the files for a release: `npm run release -- 2.8.2`.
*
* All or nothing, it:
* - checks that every version string agrees with package.json (the list is in
* version-files.mjs) and that the new version is newer;
* - writes the new version into each of them;
* - sets the README citation year to the release year;
* - moves the CHANGELOG's Unreleased entries under "## [2.8.2] - <date>",
* refusing when Unreleased is empty or the version already has a section.
*
* It does not write "What's New in v2.8.2" in the README. That text is written
* by hand, and the GitHub release notes are built from it when it exists
* (release-notes.mjs), so the script reminds you when it is missing.
*
* Usage:
* npm run release -- 2.8.2 # edit the files
* npm run release -- 2.8.2 --dry-run # only list what would change
* npm run release -- 2.8.2 --date 2026-10-01 # date for the CHANGELOG heading
*
* docs/RELEASING.md has the whole process: release branch, pull request, tag.
*/
import { readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { fileURLToPath, pathToFileURL } from "url";
import {
VERSION_FILES,
compareVersions,
isVersion,
normalise,
readVersion,
restore,
writeVersion,
} from "./version-files.mjs";
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
const UNRELEASED = "## [Unreleased]\n";
/** Every file a release reads or writes, as repository paths. */
export const RELEASE_FILES = [...new Set([...VERSION_FILES.map((e) => e.file), "CHANGELOG.md"])];
/**
* The edits a release makes, without touching the disk.
*
* `files` maps each path in RELEASE_FILES to its LF-normalised text. Returns
* the new text of every file that changes, keyed the same way. Throws with a
* readable message when the release cannot be prepared.
*/
export function planRelease(files, version, date) {
if (!isVersion(version)) {
throw new Error(`"${version}" is not a version like 2.8.2 or 2.9.0-rc.1`);
}
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
throw new Error(`"${date}" is not a date like 2026-10-01`);
}
const current = readVersion(files["package.json"], VERSION_FILES[0]);
const drift = VERSION_FILES.map((entry) => [entry, readVersion(files[entry.file], entry)]).filter(
([, found]) => found !== current,
);
if (drift.length) {
const lines = drift.map(([entry, found]) => ` ${entry.file} (${entry.what}): ${found}`);
throw new Error(
`version strings disagree with package.json (${current}):\n${lines.join("\n")}`,
);
}
if (compareVersions(version, current) <= 0) {
throw new Error(`${version} is not newer than the current version ${current}`);
}
const next = { ...files };
for (const entry of VERSION_FILES) {
next[entry.file] = writeVersion(next[entry.file], entry, version);
}
next["README.md"] = next["README.md"].replace(
/^( {2}year = \{)\d{4}(\},)$/m,
`$1${date.slice(0, 4)}$2`,
);
const changelog = next["CHANGELOG.md"];
const start = changelog.indexOf(UNRELEASED);
if (start === -1) {
throw new Error("CHANGELOG.md has no '## [Unreleased]' heading");
}
const bodyStart = start + UNRELEASED.length;
const bodyEnd = changelog.indexOf("\n## [", bodyStart);
if (!changelog.slice(bodyStart, bodyEnd === -1 ? undefined : bodyEnd).trim()) {
throw new Error("nothing to release: the Unreleased section of CHANGELOG.md is empty");
}
if (changelog.includes(`\n## [${version}]`)) {
throw new Error(`CHANGELOG.md already has a section for ${version}`);
}
next["CHANGELOG.md"] =
changelog.slice(0, bodyStart) + `\n## [${version}] - ${date}\n` + changelog.slice(bodyStart);
return Object.fromEntries(Object.entries(next).filter(([path, text]) => text !== files[path]));
}
/** Things a release usually wants that the script cannot write itself. */
export function releaseReminders(files, version) {
const reminders = [];
if (!files["README.md"].includes(`\n## What's New in v${version}\n`)) {
reminders.push(
`README.md has no "## What's New in v${version}" section. Write one above the previous ` +
"release's (credit contributors there): the GitHub release notes are built from it, " +
"and without it they fall back to the CHANGELOG section.",
);
}
return reminders;
}
function today() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function main(argv) {
const args = argv.slice(2);
const dryRun = args.includes("--dry-run");
const dateAt = args.indexOf("--date");
const date = dateAt === -1 ? today() : args[dateAt + 1];
const version = args.find((arg, i) => !arg.startsWith("--") && args[i - 1] !== "--date");
if (!version) {
console.error("usage: npm run release -- <version> [--dry-run] [--date YYYY-MM-DD]");
process.exit(2);
}
const files = {};
const crlf = {};
for (const path of RELEASE_FILES) {
const { text, crlf: hadCrlf } = normalise(readFileSync(join(ROOT, path), "utf-8"));
files[path] = text;
crlf[path] = hadCrlf;
}
let changed;
try {
changed = planRelease(files, version, date);
} catch (err) {
console.error(`release: ${err.message}`);
process.exit(1);
}
const current = readVersion(files["package.json"], VERSION_FILES[0]);
console.log(`${current} -> ${version} (${date})`);
for (const [path, text] of Object.entries(changed)) {
if (!dryRun) writeFileSync(join(ROOT, path), restore(text, crlf[path]));
console.log(` ${dryRun ? "would edit" : "edited"} ${path}`);
}
for (const reminder of releaseReminders(files, version)) console.log(`\nNote: ${reminder}`);
if (!dryRun) {
console.log(
[
"",
"Next (docs/RELEASING.md):",
` 1. git switch -c release/v${version}, commit "chore: release v${version}", push, open a PR.`,
" 2. Merge it on GitHub once CI passes.",
` 3. Tag the merge commit on main and push the tag:`,
` git tag -a v${version} -m "v${version}" <merge-commit> && git push origin v${version}`,
" The Release workflow then publishes the GitHub release and moves the stable branch.",
].join("\n"),
);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
main(process.argv);
}
670d2a9c4This file is pinned to the selected release. Download the original to open it in your design tool.