mirror of
https://github.com/vadimmelnicuk/meo.git
synced 2026-05-03 12:40:38 +00:00
329 lines
9.2 KiB
JavaScript
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();
|