CultLib/scripts/check-changelog-semver.test.mjs
Meta Vens 089859deb4 Write and enforce the semver policy, per operator ruling on 1.0.60
1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its
own changelog says "Breaking" and the version number lied. Operator ruling:
semver is policy, strictly, for every published package family.

docs/semver-policy.md states what counts as breaking per surface (managed
API, Unity inspector attributes, CultCache wire format/store compatibility,
CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag
naming, and required changelog shape. Records the 1.0.60 scar as history;
does not renumber it.

scripts/check-changelog-semver.mjs is the mechanical check: refuses a
changelog "### Breaking" section under too small a bump, and refuses a
version that skips, reverses, or repeats the previous published tag. Skips
re-litigating a version that is already tagged (rebuilding immutable
history is not a release action). Wired into publish-packages.yml for the
four npm/PyPI packages, and into build-unity-package.ps1 (cultlib),
packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new
scripts/verify-caching-unity-release.ps1 (caching.unity has no build script
of its own to hook into).

18 node:test cases cover the required table (breaking+major/minor/patch,
non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution.
Hand-mutated each rule (bump classification, pre-1.0 lane requirement,
breaking-heading detection, breaking early-return, already-published skip)
and confirmed each kill, then restored byte-exact. No mutation harness is
committed; CultLib has none today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00

257 lines
8.5 KiB
JavaScript

import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import {
BREAKING_HEADING,
classifyBump,
evaluateRelease,
hasBreakingSection,
nextVersions,
parseVersion,
} from "./check-changelog-semver.mjs";
const checkerPath = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url));
function changelogFor(version, { breaking } = {}) {
return [
"# Changelog",
"",
"All notable changes to this package are documented in this file.",
"",
`## [${version}]`,
"",
"### Changed",
"",
"- something changed",
"",
...(breaking ? [BREAKING_HEADING, "", "- something public was removed", ""] : []),
].join("\n");
}
test("parseVersion rejects non-semver strings", () => {
assert.throws(() => parseVersion("1.0"), /not a MAJOR.MINOR.PATCH version/);
assert.throws(() => parseVersion("v1.0.0"), /not a MAJOR.MINOR.PATCH version/);
});
test("nextVersions enumerates exactly the three legitimate next versions", () => {
const prev = parseVersion("1.2.3");
assert.deepEqual(nextVersions(prev), {
major: { major: 2, minor: 0, patch: 0 },
minor: { major: 1, minor: 3, patch: 0 },
patch: { major: 1, minor: 2, patch: 4 },
});
});
test("classifyBump identifies each lane and rejects everything else", () => {
const prev = parseVersion("1.2.3");
assert.equal(classifyBump(prev, parseVersion("2.0.0")), "major");
assert.equal(classifyBump(prev, parseVersion("1.3.0")), "minor");
assert.equal(classifyBump(prev, parseVersion("1.2.4")), "patch");
assert.deepEqual(classifyBump(prev, parseVersion("1.2.3")), { invalid: "no-op" });
assert.deepEqual(classifyBump(prev, parseVersion("1.2.2")), { invalid: "reverse" });
assert.deepEqual(classifyBump(prev, parseVersion("1.2.5")), { invalid: "skip" });
assert.deepEqual(classifyBump(prev, parseVersion("1.4.0")), { invalid: "skip" });
assert.deepEqual(classifyBump(prev, parseVersion("3.0.0")), { invalid: "skip" });
});
test("hasBreakingSection scopes to the named version's section only", () => {
const text = [
"# Changelog",
"",
"## [1.0.60]",
"",
"### Breaking",
"",
"- removed CultInspectorAssetPath",
"",
"## [1.0.59]",
"",
"### Added",
"",
"- something older, not breaking",
"",
].join("\n");
assert.equal(hasBreakingSection(text, "1.0.60"), true);
assert.equal(hasBreakingSection(text, "1.0.59"), false);
assert.throws(() => hasBreakingSection(text, "0.9.0"), /no "## \[0\.9\.0\]" entry/);
});
// Table-driven per the operator's required cases (spec: breaking+major=pass,
// breaking+patch=fail, breaking+minor=fail, non-breaking+minor=pass,
// 0.y.z breaking+minor=pass, skipped version=fail, downgrade=fail).
const cases = [
{
name: "breaking + major = pass",
previousVersion: "1.0.60",
version: "2.0.0",
breaking: true,
ok: true,
},
{
name: "breaking + patch = fail",
previousVersion: "1.0.60",
version: "1.0.61",
breaking: true,
ok: false,
},
{
name: "breaking + minor = fail",
previousVersion: "1.0.60",
version: "1.1.0",
breaking: true,
ok: false,
},
{
name: "non-breaking + minor = pass",
previousVersion: "1.0.60",
version: "1.1.0",
breaking: false,
ok: true,
},
{
name: "0.y.z breaking + minor = pass",
previousVersion: "0.2.3",
version: "0.3.0",
breaking: true,
ok: true,
},
{
name: "0.y.z breaking + patch = fail",
previousVersion: "0.2.3",
version: "0.2.4",
breaking: true,
ok: false,
},
{
name: "skipped version = fail",
previousVersion: "1.0.60",
version: "1.0.62",
breaking: false,
ok: false,
},
{
name: "downgrade = fail",
previousVersion: "1.0.60",
version: "1.0.59",
breaking: false,
ok: false,
},
{
name: "first release has nothing to compare against but still needs a changelog entry",
previousVersion: null,
version: "0.1.0",
breaking: false,
ok: true,
},
];
for (const testCase of cases) {
test(`evaluateRelease: ${testCase.name}`, () => {
const result = evaluateRelease({
packageName: "test-package",
changelogText: changelogFor(testCase.version, { breaking: testCase.breaking }),
version: testCase.version,
previousVersion: testCase.previousVersion,
});
assert.equal(result.ok, testCase.ok, result.reason);
});
}
test("evaluateRelease fails loudly when the version has no changelog entry at all", () => {
const result = evaluateRelease({
packageName: "test-package",
changelogText: changelogFor("1.0.0"),
version: "1.2.0",
previousVersion: "1.0.0",
});
assert.equal(result.ok, false);
assert.match(result.reason, /no "## \[1\.2\.0\]" entry/);
});
test("evaluateRelease names package, previous version, new version, and the breaking heading on failure", () => {
const result = evaluateRelease({
packageName: "org.gamecult.cultlib",
changelogText: changelogFor("1.0.61", { breaking: true }),
version: "1.0.61",
previousVersion: "1.0.60",
});
assert.equal(result.ok, false);
assert.match(result.reason, /org\.gamecult\.cultlib/);
assert.match(result.reason, /1\.0\.60/);
assert.match(result.reason, /1\.0\.61/);
assert.match(result.reason, new RegExp(BREAKING_HEADING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
});
// --- CLI-level integration tests: exercise the git-backed previous-version
// resolution and the "don't re-litigate an already-tagged release" skip,
// which the pure evaluateRelease() tests above cannot reach. ---
function withTempGitRepo(fn) {
const dir = mkdtempSync(join(tmpdir(), "cultlib-semver-check-"));
try {
execFileSync("git", ["init", "-q"], { cwd: dir });
execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir });
execFileSync("git", ["config", "user.name", "Test"], { cwd: dir });
fn(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
function runChecker(cwd, args) {
return execFileSync(process.execPath, [checkerPath, ...args, "--cwd", cwd], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
}
function runCheckerExpectFailure(cwd, args) {
try {
runChecker(cwd, args);
throw new Error("expected the checker to exit non-zero");
} catch (err) {
assert.equal(err.status, 1);
return err.stdout + err.stderr;
}
}
test("CLI: resolves the previous version from git tags and passes a valid bump", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.1.0", { breaking: false }));
const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget"]);
assert.match(output, /1\.1\.0 is a minor bump over 1\.0\.0/);
});
});
test("CLI: fails when the changelog claims breaking under too small a bump", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true }));
const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]);
assert.match(output, /needs a major bump/);
});
});
test("CLI: does not re-litigate a version already published as a tag", () => {
withTempGitRepo((dir) => {
execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir });
execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir });
// 1.0.0 itself is a scar: its own changelog claims breaking under a
// patch-looking first release. Rebuilding it locally (no new version)
// must not fail just because history already shipped that way.
execFileSync("git", ["tag", "widget-v0.9.0"], { cwd: dir });
const changelogPath = join(dir, "CHANGELOG.md");
writeFileSync(changelogPath, changelogFor("1.0.0", { breaking: true }));
const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]);
assert.match(output, /already published/);
});
});