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>
This commit is contained in:
Meta Vens 2026-09-17 22:03:22 +02:00
commit 089859deb4
8 changed files with 815 additions and 0 deletions

View file

@ -42,8 +42,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
# fetch-depth: 0 so the semver check below can see every previously
# published tag, not just the one that triggered this run.
- name: Check out CultLib
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Node
uses: actions/setup-node@v4
@ -64,6 +68,19 @@ jobs:
exit 1
fi
# docs/semver-policy.md: a breaking change under too small a bump, or a
# version that skips or reverses the previous release, fails here
# instead of shipping with a number that lies.
- name: Verify semver policy
if: startsWith(github.ref, 'refs/tags/cultcache-ts-v')
run: |
tagged="${GITHUB_REF_NAME#cultcache-ts-v}"
node scripts/check-changelog-semver.mjs \
--package "@gamecult/cultcache-ts" \
--changelog packages/cultcache-ts/CHANGELOG.md \
--version "$tagged" \
--tag-prefix cultcache-ts
- name: Build and test
working-directory: packages/cultcache-ts
run: npm test
@ -105,9 +122,13 @@ jobs:
echo "run=false" >> "$GITHUB_OUTPUT"
fi
# fetch-depth: 0 so the semver check below can see every previously
# published tag, not just the one that triggered this run.
- name: Check out CultLib
if: steps.selected.outputs.run == 'true'
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
if: steps.selected.outputs.run == 'true'
@ -115,6 +136,12 @@ jobs:
with:
python-version: "3.12"
- name: Set up Node
if: steps.selected.outputs.run == 'true'
uses: actions/setup-node@v4
with:
node-version: 24.x
- name: Verify the tag matches the manifest version
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
@ -125,6 +152,19 @@ jobs:
exit 1
fi
# docs/semver-policy.md: a breaking change under too small a bump, or a
# version that skips or reverses the previous release, fails here
# instead of shipping with a number that lies.
- name: Verify semver policy
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}"
node scripts/check-changelog-semver.mjs \
--package "${{ matrix.package }}" \
--changelog packages/${{ matrix.package }}/CHANGELOG.md \
--version "$tagged" \
--tag-prefix "${{ matrix.package }}"
# Lower layers install from source so a release of one package is tested
# against the sibling revision it was cut with, not whatever PyPI has.
- name: Install and test

View file

@ -1,5 +1,8 @@
# NuGet Packaging
See `docs/semver-policy.md` for what a version bump on these packages is
required to mean and how a release is checked against that before it ships.
CultLib publishes the managed dependency graph as separate packages. The leaf
package is `GameCult.Mesh`; its NuGet dependencies preserve the CultMesh,
CultNet, and CultCache ownership boundaries.

213
docs/semver-policy.md Normal file
View file

@ -0,0 +1,213 @@
# Semantic Versioning Policy
Operator ruling, 2026-09-17: **semantic versioning is policy** for every
package CultLib publishes, strictly. A release that removes or changes public
surface incompatibly is a major bump, full stop — not a judgment call left to
the person cutting the release.
This document says what "breaking" means for each kind of surface this repo
actually ships, and how a release is checked against it. See
`docs/nuget-packaging.md` for how the managed/Unity packaging mechanics work;
this document is the versioning contract those mechanics must satisfy.
## The scar
`org.gamecult.cultlib` 1.0.60 removed `CultInspectorAssetPath` — public API —
and shipped as a patch bump. Its own changelog entry is titled "Breaking"; the
version number said otherwise. A reviewer relying on the number alone would
have missed it. This is recorded as history, not fixed by renumbering: `1.0.60`
stays `1.0.60`. This policy, and the check below, exist so the next one is
caught before it ships instead of written up afterward.
## Packages in scope
| Package | Kind | Tag prefix | Published by |
| --- | --- | --- | --- |
| `org.gamecult.cultlib` | Unity UPM | `cultlib-unity` | `scripts/build-unity-package.ps1`, consumed by git tag |
| `org.gamecult.caching.unity` | Unity UPM | `caching-unity` | tagged directly (no build script; see "Coverage gaps") |
| `org.gamecult.cultmath` | Unity UPM | `cultmath-unity` | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
| `@gamecult/cultcache-ts` | npm | `cultcache-ts` | `.github/workflows/publish-packages.yml` |
| `cultcache-py` | PyPI | `cultcache-py` | `.github/workflows/publish-packages.yml` |
| `cultnet-py` | PyPI | `cultnet-py` | `.github/workflows/publish-packages.yml` |
| `cultmesh-py` | PyPI | `cultmesh-py` | `.github/workflows/publish-packages.yml` |
Rust crates are consumed as pinned git revisions (see `publish-packages.yml`'s
own header comment) and are out of scope for tagged-release semver policing;
a `Cargo.toml` version still exists and should follow this policy by
convention, but nothing in this repo checks it mechanically today.
Tag naming stays `<package>-v<version>` for every package above, matching the
prefixes already in use.
## What counts as breaking
Semver's ordinary rule — additive is minor or patch, anything a consumer's
existing code or data can no longer rely on is major — applies per surface as
follows.
### Managed public API (`org.gamecult.cultlib`, NuGet packages, `cultmath`)
Breaking: removing or renaming a public type/member, changing a public
member's signature or observable behavior in an incompatible way, tightening
a public contract (new required parameter, narrowed accepted input),
or moving a type across assemblies/namespaces without a compatible forwarder.
Not breaking: adding a new public member, type, or overload; widening an
accepted type; deprecating (marking obsolete) without removing.
### Unity package inspector attributes (`org.gamecult.cultlib`,
`org.gamecult.caching.unity`)
These ship as ordinary public API by the rule above, but get their own
callout because they are easy to mistake for "just editor tooling": an
attribute class, its constructor shape, and the serialized value shape it
reads/writes are all public surface a consumer's `[CultInspectorX(...)]`
usage and serialized scene/prefab data depend on. Removing or renaming an
attribute, or changing what it expects a annotated member's persisted value to
look like (as `CultInspectorAssetPath` to `CultInspectorAssetGuid` did in
1.0.60) is breaking.
### Persisted wire format / store compatibility (CultCache)
Governed by `src/GameCult.Caching/Contracts/cultcache-schema-compatibility.md`
and `cultcache-persistence-format.md` — read those for the authoritative
rules. In semver terms: anything those documents classify as **hard
rejection** (type change, reference/value change, one-vs-many change, target
schema change, name/index lookup semantics change, a persisted schema with no
compatible local candidate) is breaking for any package that reads or writes
the format. Anything classified as **soft-migratable drift** (compatible
schema id drift, an ignorable missing slot, a defaultable missing slot) is not
breaking on its own, but changing what counts as soft-migratable — loosening
or tightening the drift rules themselves — is breaking for whichever package
owns that classification logic.
### CultNet protocol surface
Breaking: a wire-incompatible change to a CultNet message, frame, or
handshake shape; removing or repurposing a message type or field that another
runtime's implementation depends on; changing routing, authority, or trust
semantics such that a peer built against the previous protocol version
misbehaves rather than cleanly failing. Additive, backward-compatible framing
changes (a new optional field a receiver may ignore) are not breaking.
### Native bridge exported functions (`GameCult.Mesh.Quic.Native`)
Breaking: removing or changing the signature/ABI of a `CULTMESH_API`
(`extern "C"`) exported function, or changing an exported function's
behavior such that the existing managed P/Invoke caller (`GameCult.Mesh.Quic.Native`'s
C# side) or Rust caller (`cultmath-core`'s equivalent `#[no_mangle] extern "C"`
surface) no longer gets what it asked for. Swapping the underlying TLS/QUIC
implementation (as 1.0.60 did, Schannel to OpenSSL MsQuic) is not breaking
*by itself* as long as the exported v1 functions keep their signatures and
behavior — 1.0.60's actual breaking change was the inspector attribute
removal, not the bridge swap, and its changelog correctly filed the bridge
swap under "Changed" rather than "Breaking".
## Pre-1.0 packages
A package at `0.y.z` (currently `org.gamecult.cultmath`, at `0.2.x`) uses the
standard pre-1.0 convention: `MAJOR` stays `0` during initial development, so
`MINOR` carries what `MAJOR` means once the package reaches `1.0.0`. Concretely:
- a breaking change bumps `MINOR` (and resets `PATCH` to `0`);
- a non-breaking change bumps `PATCH`.
Leaving `0.y.z` for `1.0.0` is a deliberate, separate decision (the API is now
considered stable), not something the checker infers on its own.
## What a release changelog entry must contain
Every published package keeps a `CHANGELOG.md` beside its manifest
(`unity/org.gamecult.cultlib/CHANGELOG.md` and
`src/GameCult.Unity/Assets/Caching/CHANGELOG.md` are the existing precedent).
Each release adds a `## [<version>]` section containing:
- a category subsection per change (`### Added`, `### Changed`, `### Fixed`,
etc., following Keep a Changelog style);
- **`### Breaking`** — this exact heading, this exact spelling — for any
change that meets the "what counts as breaking" rules above. This heading
is not decorative: the CI check below greps for it by name.
- enough detail that a consumer can tell what to change, in the style of the
1.0.60 entry (name the removed/changed symbol and its replacement).
A version with no changelog entry at all fails the check below, the same as
a mislabelled one — an undocumented release is not a smaller sin than a
mislabelled one.
## Enforcement
`scripts/check-changelog-semver.mjs` is the mechanical check. Given a
package name, its `CHANGELOG.md` path, the version being released, and a git
tag prefix, it:
1. resolves the previous published version from the highest existing
`<prefix>-v*` tag older than the version being released (or treats the
release as a package's first if none exists);
2. requires a `## [<version>]` changelog entry to exist at all;
3. classifies the version bump (major/minor/patch) against the previous
version, refusing anything that is not exactly the next version in some
lane — a skip, a reversal, or a repeat are all refused, not just wrong
bump sizes;
4. if the changelog entry has a `### Breaking` section, requires the bump to
be at least major (or, pre-1.0, at least minor);
5. fails loudly, naming the package, the previous and new version, and (when
relevant) the changelog heading that triggered the requirement.
Run it directly: `node scripts/check-changelog-semver.mjs --package <name>
--changelog <path> --version <x.y.z> --tag-prefix <prefix>`. Its own tests
live in `scripts/check-changelog-semver.test.mjs` (`node --test
scripts/check-changelog-semver.test.mjs`).
It is wired into:
- `.github/workflows/publish-packages.yml`, right after each job's existing
"tag matches manifest version" check, for `cultcache-ts`, `cultcache-py`,
`cultnet-py`, and `cultmesh-py`;
- `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`;
- `packages/cultmath/scripts/build-unity-package.ps1`, for
`org.gamecult.cultmath`;
- `scripts/verify-caching-unity-release.ps1`, for `org.gamecult.caching.unity`.
### Coverage gaps
`publish-packages.yml` only triggers on `cultcache-ts-v*`, `cultcache-py-v*`,
`cultnet-py-v*`, and `cultmesh-py-v*` tag pushes — it does not build or gate
the Unity UPM packages at all; those are consumed directly by git tag
(`unity/org.gamecult.cultlib`, `packages/cultmath/unity/org.gamecult.cultmath`,
`src/GameCult.Unity/Assets/Caching`), so a workflow trigger on their tags
cannot be added to that file without also giving it a reason to check out and
build Unity content it currently has no job for. The check is instead wired
into the scripts that already run at Unity-package release time:
`build-unity-package.ps1` and `packages/cultmath/scripts/build-unity-package.ps1`
already verify version consistency between the built assembly and
`package.json`, so the semver check runs alongside that as one more release
gate. `org.gamecult.caching.unity` has no build script at all today — it is
pure C# source tagged directly with no compile step — so
`scripts/verify-caching-unity-release.ps1` exists solely to run this check
before that package is tagged; it is not a build script and does not become
one.
None of this is enforced by a git hook or branch protection rule: it runs
when someone remembers to run the Unity release scripts, the same way the
existing assembly-version check does. A tag pushed by hand without running
the release script bypasses both checks equally; closing that gap is a CI
trigger on tag push for the Unity packages, not something this change adds.
Rust crates (consumed as pinned git revisions, not tagged releases in the
usual sense) and the CultNet protocol surface itself (no automated wire
compatibility test currently gates a release) are policy by convention only —
described above, not mechanically checked. Extending the checker to a Rust
crate's `Cargo.toml`/`CHANGELOG.md` or to an automated CultNet interop
compatibility gate is future work, not part of this change.
`@gamecult/cultcache-ts`, `cultcache-py`, `cultnet-py`, `cultmesh-py`, and
`org.gamecult.cultmath` currently have **no `CHANGELOG.md` file at all**. This
is a real, pre-existing gap, not something introduced by this policy: their
next tagged release will fail the new check for exactly that reason ("no
changelog entry for version X") until each package's `CHANGELOG.md` exists.
That failure is intended — a release with no changelog is not a smaller
problem than a mislabelled one — but it does mean the very next release of
any of these five packages needs a `CHANGELOG.md` created first. Backfilling
changelog content for their past releases is not part of this change: it
would mean inventing release-note prose for history this pass has no
first-hand knowledge of, which is worse than leaving the gap named.

View file

@ -27,6 +27,25 @@ if (Test-Path -LiteralPath $outputRoot) {
Remove-Item -LiteralPath $outputRoot -Recurse -Force
}
# docs/semver-policy.md (CultLib root, two levels up from this script):
# refuse to build a release whose CHANGELOG.md entry claims a breaking change
# under too small a bump, or whose version skips or reverses the previous
# published tag. Runs before the build below so a bad version number fails
# fast. cultmath is pre-1.0 (0.y.z): a breaking change must be at least a
# minor bump. $repoRoot above is this package's own root (packages\cultmath),
# not CultLib's; the shared checker script lives one level above that.
$cultLibRoot = Split-Path -Parent (Split-Path -Parent $repoRoot)
$cultmathVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
& node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultmath" `
--changelog (Join-Path $templateRoot "CHANGELOG.md") `
--version $cultmathVersion `
--tag-prefix "cultmath-unity" `
--cwd $cultLibRoot
if ($LASTEXITCODE -ne 0) {
throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)."
}
# The tracked DLL and pdb are committed beside their source, so neither may name a commit: Source Link
# writes the commit SHA into the pdb, and the DLL carries that pdb's content id.
dotnet build $projectPath -c $Configuration -f netstandard2.1 -o $buildRoot --no-incremental `

View file

@ -38,6 +38,21 @@ if (Test-Path -LiteralPath $outputRoot) {
}
$unityPackageVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version
# docs/semver-policy.md: refuse to build a release whose CHANGELOG.md entry
# claims a breaking change under too small a bump, or whose version skips or
# reverses the previous published tag. Runs before the (slow) build below so
# a bad version number fails fast.
& node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultlib" `
--changelog (Join-Path $templateRoot "CHANGELOG.md") `
--version $unityPackageVersion `
--tag-prefix "cultlib-unity" `
--cwd $repoRoot
if ($LASTEXITCODE -ne 0) {
throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)."
}
# The tracked DLLs and pdbs are committed beside their source, so none may name a commit or a worktree:
# Source Link writes the commit SHA into the pdb, the informational version carries it too, and each DLL
# carries its pdb's content id. ContinuousIntegrationBuild maps the local source path to /_/.

View file

@ -0,0 +1,240 @@
#!/usr/bin/env node
// Refuses a release whose CHANGELOG.md entry claims a breaking change under a
// bump too small to say so, or whose version skips or reverses relative to
// the previous published tag. See docs/semver-policy.md for the policy this
// enforces; this script is only the mechanical check.
//
// Pure, testable logic lives in the exported functions below. `main()` is the
// CLI wrapper that reads the changelog file and asks git for the previous
// published tag, then calls `evaluateRelease`.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
// The exact heading spelling the policy mandates for a breaking-change
// section. `unity/org.gamecult.cultlib/CHANGELOG.md`'s 1.0.60 entry set this
// precedent; this check is what makes that heading load-bearing instead of
// decorative.
export const BREAKING_HEADING = "### Breaking";
const VERSION_PATTERN = /^(\d+)\.(\d+)\.(\d+)$/;
export function parseVersion(version) {
const match = VERSION_PATTERN.exec(version.trim());
if (!match) {
throw new Error(`not a MAJOR.MINOR.PATCH version: "${version}"`);
}
const [, major, minor, patch] = match;
return { major: Number(major), minor: Number(minor), patch: Number(patch) };
}
function formatVersion(v) {
return `${v.major}.${v.minor}.${v.patch}`;
}
// The three (and only three) versions that legitimately follow `prev`: one
// component incremented, everything to its right reset to zero. Anything
// else is a skip, a reversal, a no-op, or a multi-step jump — all refused by
// the same rule, since none of them is "the next version".
export function nextVersions(prev) {
return {
major: { major: prev.major + 1, minor: 0, patch: 0 },
minor: { major: prev.major, minor: prev.minor + 1, patch: 0 },
patch: { major: prev.major, minor: prev.minor, patch: prev.patch + 1 },
};
}
function sameVersion(a, b) {
return a.major === b.major && a.minor === b.minor && a.patch === b.patch;
}
function compareVersions(a, b) {
if (a.major !== b.major) return a.major - b.major;
if (a.minor !== b.minor) return a.minor - b.minor;
return a.patch - b.patch;
}
// Classifies `next` relative to `prev`. Returns one of "major" | "minor" |
// "patch" when `next` is exactly the next version in that lane, or an
// { invalid: "skip" | "reverse" | "no-op" } descriptor otherwise.
export function classifyBump(prev, next) {
if (sameVersion(prev, next)) {
return { invalid: "no-op" };
}
const candidates = nextVersions(prev);
for (const [lane, candidate] of Object.entries(candidates)) {
if (sameVersion(candidate, next)) {
return lane;
}
}
return { invalid: compareVersions(next, prev) < 0 ? "reverse" : "skip" };
}
// Extracts the `## [<version>]` section of a changelog and reports whether it
// carries a BREAKING_HEADING subsection. Throws if the version has no entry
// at all — an undocumented release is refused the same as a mislabelled one.
export function hasBreakingSection(changelogText, version) {
const sectionHeading = new RegExp(
`^##\\s*\\[?${version.replace(/\./g, "\\.")}\\]?\\s*$`,
"m",
);
const startMatch = sectionHeading.exec(changelogText);
if (!startMatch) {
throw new Error(`no "## [${version}]" entry found in changelog`);
}
const start = startMatch.index + startMatch[0].length;
const rest = changelogText.slice(start);
const nextSectionMatch = /^##\s/m.exec(rest);
const section = nextSectionMatch ? rest.slice(0, nextSectionMatch.index) : rest;
const breakingHeading = new RegExp(`^${BREAKING_HEADING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "m");
return breakingHeading.test(section);
}
// The pre-1.0 lane a package is releasing FROM. `0.y.z` treats a minor bump
// as the breaking lane (semver's own 0.y.z convention: MAJOR is pinned at 0,
// so MINOR carries what MAJOR would mean once the package reaches 1.0.0).
function isPreOneZero(prev) {
return prev.major === 0;
}
// Evaluates one release. `previousVersion` may be null for a package's first
// ever release (nothing to compare against, so the bump rule does not apply,
// but a changelog entry is still required).
export function evaluateRelease({ packageName, changelogText, version, previousVersion }) {
const next = parseVersion(version);
let breaking;
try {
breaking = hasBreakingSection(changelogText, version);
} catch (err) {
return { ok: false, reason: `${packageName}: ${err.message}` };
}
if (previousVersion == null) {
return { ok: true, reason: `${packageName}: first release (${version}); no prior version to compare against` };
}
const prev = parseVersion(previousVersion);
const bump = classifyBump(prev, next);
if (typeof bump !== "string") {
const verb = bump.invalid === "no-op" ? "repeats" : bump.invalid === "reverse" ? "reverses" : "skips ahead of";
return {
ok: false,
reason: `${packageName}: version ${version} ${verb} the previous published version ${previousVersion}; ` +
`the next version must be exactly one of ${formatVersion(nextVersions(prev).major)}, ` +
`${formatVersion(nextVersions(prev).minor)}, or ${formatVersion(nextVersions(prev).patch)}`,
};
}
if (!breaking) {
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion}, no breaking section` };
}
const pre1 = isPreOneZero(prev);
const requiredLanes = pre1 ? ["major", "minor"] : ["major"];
if (!requiredLanes.includes(bump)) {
const requirement = pre1
? `at least a minor bump (the 0.y.z convention's breaking lane)`
: `a major bump`;
return {
ok: false,
reason: `${packageName}: ${version}'s changelog has a "${BREAKING_HEADING}" section but is only a ${bump} bump ` +
`over ${previousVersion}; a breaking release needs ${requirement}`,
};
}
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and its "${BREAKING_HEADING}" section agrees` };
}
// --- CLI wrapper: resolves the previous published tag via git and reads the
// changelog off disk, then delegates to the pure functions above. ---
function resolvePreviousVersion(tagPrefix, currentVersion, cwd) {
let tags;
try {
tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8" })
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
} catch {
return null;
}
const prefix = `${tagPrefix}-v`;
const current = parseVersion(currentVersion);
let best = null;
for (const tag of tags) {
if (!tag.startsWith(prefix)) continue;
const versionText = tag.slice(prefix.length);
let candidate;
try {
candidate = parseVersion(versionText);
} catch {
continue;
}
if (sameVersion(candidate, current)) continue; // the tag being released itself
if (compareVersions(candidate, current) >= 0) continue; // ignore anything not older
if (best === null || compareVersions(candidate, best) > 0) {
best = candidate;
}
}
return best ? formatVersion(best) : null;
}
// True when `<tagPrefix>-v<version>` already exists. The check gates a NEW
// release; rebuilding an already-tagged, already-immutable version (routine
// local iteration, CI re-runs) is not a release action and should not keep
// re-litigating history on every build.
function tagAlreadyPublished(tagPrefix, version, cwd) {
try {
const output = execFileSync("git", ["tag", "-l", `${tagPrefix}-v${version}`], { cwd, encoding: "utf8" });
return output.trim().length > 0;
} catch {
return false;
}
}
function parseArgs(argv) {
const args = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg.startsWith("--")) continue;
const key = arg.slice(2);
args[key] = argv[i + 1];
i += 1;
}
return args;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const { package: packageName, changelog, version, "tag-prefix": tagPrefix, cwd = process.cwd() } = args;
if (!packageName || !changelog || !version || !tagPrefix) {
console.error(
"usage: check-changelog-semver.mjs --package <name> --changelog <path> --version <x.y.z> --tag-prefix <prefix> [--cwd <dir>]",
);
process.exitCode = 2;
return;
}
if (tagAlreadyPublished(tagPrefix, version, cwd)) {
console.log(`${packageName}: ${version} is already published as ${tagPrefix}-v${version}; not re-checking a rebuild of immutable history`);
return;
}
if (!existsSync(changelog)) {
console.error(`${packageName}: changelog not found at ${changelog}`);
process.exitCode = 1;
return;
}
const changelogText = readFileSync(changelog, "utf8");
const previousVersion = resolvePreviousVersion(tagPrefix, version, cwd);
const result = evaluateRelease({ packageName, changelogText, version, previousVersion });
console.log(result.reason);
if (!result.ok) {
process.exitCode = 1;
}
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
main();
}

View file

@ -0,0 +1,257 @@
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/);
});
});

View file

@ -0,0 +1,28 @@
# org.gamecult.caching.unity has no build step: it is pure C# editor source
# tagged directly from src/GameCult.Unity/Assets/Caching, unlike
# org.gamecult.cultlib and org.gamecult.cultmath which compile a DLL first.
# That means it has no existing script this repo's semver check can ride
# along with (docs/semver-policy.md, "Coverage gaps"). This script exists
# solely to be that gate: run it before tagging a caching-unity-v<version>
# release.
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent $PSScriptRoot
$packageRoot = Join-Path $repoRoot "src\GameCult.Unity\Assets\Caching"
$manifestPath = Join-Path $packageRoot "package.json"
$changelogPath = Join-Path $packageRoot "CHANGELOG.md"
$version = (Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json).version
& node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.caching.unity" `
--changelog $changelogPath `
--version $version `
--tag-prefix "caching-unity" `
--cwd $repoRoot
if ($LASTEXITCODE -ne 0) {
throw "org.gamecult.caching.unity failed the semver policy check (see docs/semver-policy.md)."
}
Write-Host "org.gamecult.caching.unity $version cleared the semver policy check."