Files
meo/scripts/release-prep.mjs

329 lines
9.2 KiB
JavaScript

#!/usr/bin/env bun
import fs from "node:fs";
import { execFileSync } from "node:child_process";
function runGit(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
function tryGit(args) {
try {
return runGit(args);
} catch {
return "";
}
}
function fail(message) {
console.error(`ERROR: ${message}`);
process.exit(1);
}
function parseArgs(argv) {
const [first, ...rest] = argv;
if (!first) {
return { mode: "prepare", versionOrBump: "patch", write: true };
}
if (first === "finalize") {
return { mode: "finalize" };
}
if (first === "--dry-run") {
return { mode: "prepare", versionOrBump: "patch", write: false };
}
const write = !rest.includes("--dry-run");
return { mode: "prepare", versionOrBump: first, write };
}
function parseVersion(version) {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) {
fail(`Invalid version: ${version}`);
}
return match.slice(1).map(Number);
}
function bumpVersion(currentVersion, mode) {
const [major, minor, patch] = parseVersion(currentVersion);
if (mode === "patch") return `${major}.${minor}.${patch + 1}`;
if (mode === "minor") return `${major}.${minor + 1}.0`;
if (mode === "major") return `${major + 1}.0.0`;
parseVersion(mode);
return mode;
}
function ensureMainBranch() {
const branch = tryGit(["symbolic-ref", "--quiet", "--short", "HEAD"]);
if (!branch) fail("HEAD is detached. Switch to main before running release prep.");
if (branch !== "main") fail(`Current branch is '${branch}'. Switch to 'main' first.`);
}
function ensureCleanTree() {
const status = tryGit(["status", "--porcelain"]);
if (status) fail("Working tree is not clean. Commit or stash changes first.");
}
function getStatusLines() {
let status = "";
try {
status = execFileSync("git", ["status", "--porcelain"], { encoding: "utf8" });
} catch {
status = "";
}
return status
.split("\n")
.map((line) => line.trimEnd())
.filter(Boolean);
}
function readPackageJson() {
const raw = fs.readFileSync("package.json", "utf8");
return { raw, data: JSON.parse(raw) };
}
function writePackageJson(pkg) {
fs.writeFileSync("package.json", `${JSON.stringify(pkg, null, 2)}\n`);
}
function latestTag() {
return tryGit(["describe", "--tags", "--abbrev=0"]);
}
function commitSubjectsSince(tag) {
const range = tag ? `${tag}..HEAD` : "HEAD";
const out = tryGit(["log", range, "--no-merges", "--pretty=format:%s"]);
const lines = out.split("\n").map((line) => line.trim()).filter(Boolean);
return lines;
}
function parseConventionalSubject(subject) {
const match = /^(?<type>[a-z]+)(?:\((?<scope>[^)]+)\))?(?<breaking>!)?:\s*(?<message>.+)$/i.exec(subject);
if (!match?.groups) {
return null;
}
return {
type: match.groups.type.toLowerCase(),
scope: match.groups.scope || "",
breaking: Boolean(match.groups.breaking),
message: match.groups.message.trim(),
};
}
function capitalizeSentenceStart(text) {
if (!text) return text;
const trimmed = text.trim();
if (!trimmed) return trimmed;
return trimmed[0].toUpperCase() + trimmed.slice(1);
}
function removeTrailingPeriod(text) {
return text.replace(/\.$/, "");
}
function normalizeWhitespace(text) {
return text.trim();
}
function canonicalLeadVerb(word) {
const normalized = word.toLowerCase();
if (["add", "added", "implement", "implemented", "introduce", "introduced"].includes(normalized)) {
return "Added";
}
if (["improve", "improved", "refine", "refined"].includes(normalized)) {
return "Improved";
}
if (["enhance", "enhanced"].includes(normalized)) {
return "Enhanced";
}
if (["fix", "fixed", "correct", "corrected", "resolve", "resolved"].includes(normalized)) {
return "Fixed";
}
if (["remove", "removed"].includes(normalized)) {
return "Removed";
}
if (["update", "updated"].includes(normalized)) {
return "Updated";
}
if (["apply", "applied"].includes(normalized)) {
return "Applied";
}
return "";
}
function fallbackVerbForType(type) {
if (type === "feat") return "Added";
if (type === "fix") return "Fixed";
if (["perf", "refactor"].includes(type)) return "Improved";
if (["docs", "chore", "build", "ci", "style"].includes(type)) return "Updated";
return "";
}
function splitLeadWord(message) {
const match = /^([A-Za-z]+)\b(.*)$/.exec(message);
if (!match) {
return { leadWord: "", rest: message };
}
return { leadWord: match[1], rest: match[2].trimStart() };
}
// Release notes should read like user-facing changelog bullets instead of raw git subjects.
// We normalize common conventional-commit phrasing and fall back to a minimal cleanup when
// the subject doesn't match expected patterns, so release prep remains robust.
function paraphraseChangelogItem(subject) {
const parsed = parseConventionalSubject(subject);
const rawMessage = parsed ? parsed.message : subject;
const message = normalizeWhitespace(rawMessage);
if (!message) {
return "";
}
const { leadWord, rest } = splitLeadWord(message);
const semanticVerb = leadWord ? canonicalLeadVerb(leadWord) : "";
const typeVerb = parsed ? fallbackVerbForType(parsed.type) : "";
const verb = semanticVerb || typeVerb;
let result = "";
if (verb) {
result = rest ? `${verb} ${rest}` : verb;
} else {
result = capitalizeSentenceStart(message);
}
return removeTrailingPeriod(result);
}
function formatChangelogSection(version, items) {
const bullets = items.map((item) => `- ${item}`).join("\n");
return `## ${version}\n${bullets}\n\n`;
}
function prependChangelog(version, items) {
if (items.length === 0) {
fail("No commits found since the latest tag. Nothing to release.");
}
const changelogPath = "CHANGELOG.md";
const current = fs.readFileSync(changelogPath, "utf8");
const marker = "---\n";
const idx = current.indexOf(marker);
if (idx === -1) {
fail("CHANGELOG.md is missing the expected '---' separator.");
}
const insertAt = idx + marker.length;
const section = formatChangelogSection(version, items);
const next = `${current.slice(0, insertAt)}${section}${current.slice(insertAt)}`;
fs.writeFileSync(changelogPath, next);
}
function ensureTagDoesNotExist(tag) {
const exists = tryGit(["rev-parse", "-q", "--verify", `refs/tags/${tag}`]);
if (exists) fail(`Tag '${tag}' already exists.`);
}
function ensureChangelogHasVersion(version) {
const changelog = fs.readFileSync("CHANGELOG.md", "utf8");
if (!changelog.includes(`## ${version}\n`)) {
fail(`CHANGELOG.md does not contain a '## ${version}' section.`);
}
}
function ensureOnlyReleaseFilesChanged() {
const statusLines = getStatusLines();
if (statusLines.length === 0) {
fail("No local changes found. Run prepare first.");
}
const allowed = new Set(["package.json", "CHANGELOG.md"]);
const disallowed = [];
for (const line of statusLines) {
const path = line.slice(3);
if (!allowed.has(path)) {
disallowed.push(line);
}
}
if (disallowed.length > 0) {
console.error("ERROR: Finalize only allows local changes in package.json and CHANGELOG.md.");
disallowed.forEach((line) => console.error(` ${line}`));
process.exit(1);
}
}
function commitAndTag(version) {
runGit(["add", "package.json", "CHANGELOG.md"]);
runGit(["commit", "-m", `chore: update version to ${version}`]);
runGit(["tag", "-a", version, "-m", `v${version}`]);
}
function prepareRelease(versionOrBump, write) {
ensureCleanTree();
const { data: pkg } = readPackageJson();
if (!pkg.version) fail("package.json is missing a version field.");
const nextVersion = bumpVersion(pkg.version, versionOrBump);
if (nextVersion === pkg.version) {
fail(`Version is already ${pkg.version}. Provide a new version.`);
}
const tag = latestTag();
const subjects = commitSubjectsSince(tag);
const changelogItems = subjects.map(paraphraseChangelogItem).filter(Boolean);
ensureTagDoesNotExist(nextVersion);
if (!write) {
console.log(`Preparing release ${nextVersion}`);
console.log(`Current version: ${pkg.version}`);
console.log(`Latest tag: ${tag || "(none)"}`);
console.log("Changelog entries:");
changelogItems.forEach((item) => console.log(` - ${item}`));
console.log("");
console.log("Re-run without --dry-run to write package.json and CHANGELOG.md locally for review.");
process.exit(0);
}
pkg.version = nextVersion;
writePackageJson(pkg);
prependChangelog(nextVersion, changelogItems);
console.log(`Release draft prepared locally for ${nextVersion}`);
console.log("Review/edit CHANGELOG.md, then finalize with:");
console.log(" bun run release:finalize");
}
function finalizeRelease() {
ensureOnlyReleaseFilesChanged();
const { data: pkg } = readPackageJson();
if (!pkg.version) fail("package.json is missing a version field.");
ensureTagDoesNotExist(pkg.version);
ensureChangelogHasVersion(pkg.version);
commitAndTag(pkg.version);
console.log(`Release finalized locally for ${pkg.version}`);
console.log("Next steps:");
console.log(" git push origin main");
console.log(` git push origin ${pkg.version}`);
}
function main() {
const args = parseArgs(process.argv.slice(2));
ensureMainBranch();
if (args.mode === "finalize") {
finalizeRelease();
return;
}
prepareRelease(args.versionOrBump, args.write);
}
main();