Files
goclaw/scripts/ci/semantic-beta-version.mjs
T

198 lines
6.3 KiB
JavaScript

#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { writeFileSync, appendFileSync } from "node:fs";
const prerelease = process.env.PRERELEASE_ID || "beta";
const initialVersion = process.env.INITIAL_VERSION || "0.1.0";
function git(args) {
return execFileSync("git", args, { encoding: "utf8" }).trim();
}
function setOutput(name, value) {
const output = process.env.GITHUB_OUTPUT;
if (output) {
appendFileSync(output, `${name}=${value}\n`);
return;
}
console.log(`${name}=${value}`);
}
function parseVersion(value) {
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+)\.(\d+))?$/.exec(value);
if (!match) return null;
return {
raw: value,
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
preid: match[4] || "",
prenumber: match[5] ? Number(match[5]) : 0,
};
}
function tagCommit(tag) {
return git(["rev-list", "-n", "1", tag]);
}
function compareBase(a, b) {
return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
}
function compareVersion(a, b) {
const base = compareBase(a, b);
if (base !== 0) return base;
if (!a.preid && b.preid) return 1;
if (a.preid && !b.preid) return -1;
if (a.preid !== b.preid) return a.preid.localeCompare(b.preid);
return a.prenumber - b.prenumber;
}
function bump(base, level) {
if (level === "major") return { major: base.major + 1, minor: 0, patch: 0 };
if (level === "minor") return { major: base.major, minor: base.minor + 1, patch: 0 };
return { major: base.major, minor: base.minor, patch: base.patch + 1 };
}
function versionText(version) {
return `${version.major}.${version.minor}.${version.patch}`;
}
function commitLevel(message) {
const header = message.split(/\r?\n/, 1)[0] || "";
if (/^[a-zA-Z]+(?:\([^)]+\))?!:/.test(header) || /\nBREAKING[ -]CHANGE:/.test(message)) {
return "major";
}
if (/^feat(?:\([^)]+\))?:/.test(header)) return "minor";
if (/^(fix|perf)(?:\([^)]+\))?:/.test(header) || /^revert:/.test(header)) return "patch";
return "";
}
function maxBase(a, b) {
return compareBase(a, b) >= 0 ? a : b;
}
function writeNoRelease(reason) {
setOutput("released", "false");
setOutput("version", "");
setOutput("tag", "");
console.log(reason);
}
function releaseTags(args = []) {
return git(["tag", ...args, "--list", "v[0-9]*"])
.split(/\r?\n/)
.filter(Boolean)
.map(parseVersion)
.filter(Boolean)
.map((tag) => ({ ...tag, commit: tagCommit(tag.raw) }))
.sort(compareVersion)
.reverse();
}
const versionTags = releaseTags();
const mergedTags = releaseTags(["--merged", "HEAD"]);
const latest = versionTags[0];
const latestStable = versionTags.find((tag) => !tag.preid);
function isUsablePrerelease(tag) {
return tag.preid !== prerelease || !latestStable || compareBase(tag, latestStable) > 0;
}
const usableVersionTags = versionTags.filter(isUsablePrerelease);
const usableMergedTags = mergedTags.filter(isUsablePrerelease);
const latestPrerelease = usableVersionTags.find((tag) => tag.preid === prerelease);
const latestMerged = usableMergedTags[0];
const latestMergedStable = usableMergedTags.find((tag) => !tag.preid);
const latestMergedPrerelease = usableMergedTags.find((tag) => tag.preid === prerelease);
const head = git(["rev-parse", "HEAD"]);
const zero = { major: 0, minor: 0, patch: 0 };
const initial = parseVersion(initialVersion) || { major: 0, minor: 1, patch: 0 };
const previousMergedTag = usableMergedTags.find((tag) => tag.raw !== latestMerged?.raw);
const repairTag = latestMergedPrerelease?.commit === head
&& (!latestStable || compareBase(latestMergedPrerelease, latestStable) > 0)
? latestMergedPrerelease
: null;
const range = latestMerged ? [`${latestMerged.raw}..HEAD`] : [];
const logRange = repairTag && previousMergedTag ? [`${previousMergedTag.raw}..HEAD`] : range;
const log = git(["log", "--format=%B%x1e", ...logRange]);
const messages = log.split("\x1e").map((message) => message.trim()).filter(Boolean);
if (latest?.preid && latest.preid !== prerelease) {
writeNoRelease(`Latest prerelease tag ${latest.raw} is not a ${prerelease} release; skipping ${prerelease} automation.`);
process.exit(0);
}
if (repairTag) {
const version = repairTag.raw.replace(/^v/, "");
const releaseNotes = [
`## ${repairTag.raw}`,
"",
`Automated ${prerelease} release from ${head}.`,
"",
"### Changes",
"",
...(messages.length ? messages.map((message) => `- ${message.split(/\r?\n/, 1)[0]}`) : ["- Repair release assets for this tag."]),
"",
].join("\n");
writeFileSync("release-notes.md", releaseNotes);
setOutput("released", "true");
setOutput("version", version);
setOutput("tag", repairTag.raw);
setOutput("notes_path", "release-notes.md");
console.log(`Repairing ${prerelease} release: ${repairTag.raw}`);
process.exit(0);
}
const releaseBase = maxBase(latestStable || initial || zero, latestMergedStable || initial || zero);
let level = "";
for (const message of messages) {
const next = commitLevel(message);
if (next === "major") {
level = "major";
break;
}
if (next === "minor" && level !== "major") level = "minor";
if (next === "patch" && !level) level = "patch";
}
if (!level) {
writeNoRelease("No release-worthy conventional commits found since the last merged release tag.");
process.exit(0);
}
const bumpedBase = bump(releaseBase, level);
const prereleaseBase = latestPrerelease || zero;
let targetBase = maxBase(bumpedBase, prereleaseBase);
if (!latest && compareBase(targetBase, initial) < 0) targetBase = initial;
const nextNumber = compareBase(targetBase, prereleaseBase) === 0
? latestPrerelease.prenumber + 1
: 1;
const version = `${versionText(targetBase)}-${prerelease}.${nextNumber}`;
const tag = `v${version}`;
if (versionTags.some((existing) => existing.raw === tag)) {
writeNoRelease(`Computed tag ${tag} already exists.`);
process.exit(0);
}
const releaseNotes = [
`## ${tag}`,
"",
`Automated ${prerelease} release from ${process.env.GITHUB_SHA || git(["rev-parse", "HEAD"])}.`,
"",
"### Changes",
"",
...messages.map((message) => `- ${message.split(/\r?\n/, 1)[0]}`),
"",
].join("\n");
writeFileSync("release-notes.md", releaseNotes);
setOutput("released", "true");
setOutput("version", version);
setOutput("tag", tag);
setOutput("notes_path", "release-notes.md");
console.log(`Next ${prerelease} release: ${tag}`);