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:
parent
45c2f4006f
commit
089859deb4
8 changed files with 815 additions and 0 deletions
40
.github/workflows/publish-packages.yml
vendored
40
.github/workflows/publish-packages.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
213
docs/semver-policy.md
Normal 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.
|
||||
|
|
@ -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 `
|
||||
|
|
|
|||
|
|
@ -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 /_/.
|
||||
|
|
|
|||
240
scripts/check-changelog-semver.mjs
Normal file
240
scripts/check-changelog-semver.mjs
Normal 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();
|
||||
}
|
||||
257
scripts/check-changelog-semver.test.mjs
Normal file
257
scripts/check-changelog-semver.test.mjs
Normal 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/);
|
||||
});
|
||||
});
|
||||
28
scripts/verify-caching-unity-release.ps1
Normal file
28
scripts/verify-caching-unity-release.ps1
Normal 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."
|
||||
Loading…
Reference in a new issue