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>
257 lines
8.5 KiB
JavaScript
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/);
|
|
});
|
|
});
|