From 089859deb4b063e4d737626dc9fe75cc18435554 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 17 Sep 2026 22:03:22 +0200 Subject: [PATCH] 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 --- .github/workflows/publish-packages.yml | 40 +++ docs/nuget-packaging.md | 3 + docs/semver-policy.md | 213 +++++++++++++++ .../cultmath/scripts/build-unity-package.ps1 | 19 ++ scripts/build-unity-package.ps1 | 15 + scripts/check-changelog-semver.mjs | 240 ++++++++++++++++ scripts/check-changelog-semver.test.mjs | 257 ++++++++++++++++++ scripts/verify-caching-unity-release.ps1 | 28 ++ 8 files changed, 815 insertions(+) create mode 100644 docs/semver-policy.md create mode 100644 scripts/check-changelog-semver.mjs create mode 100644 scripts/check-changelog-semver.test.mjs create mode 100644 scripts/verify-caching-unity-release.ps1 diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 6061ca7a..60cc6a01 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -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 diff --git a/docs/nuget-packaging.md b/docs/nuget-packaging.md index 986f73df..1da3ba63 100644 --- a/docs/nuget-packaging.md +++ b/docs/nuget-packaging.md @@ -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. diff --git a/docs/semver-policy.md b/docs/semver-policy.md new file mode 100644 index 00000000..e1af0365 --- /dev/null +++ b/docs/semver-policy.md @@ -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 `-v` 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 `## []` 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 + `-v*` tag older than the version being released (or treats the + release as a package's first if none exists); +2. requires a `## []` 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 +--changelog --version --tag-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. diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 index b64e9446..39e55813 100644 --- a/packages/cultmath/scripts/build-unity-package.ps1 +++ b/packages/cultmath/scripts/build-unity-package.ps1 @@ -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 ` diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index f6341d6b..bdd9fff6 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -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 /_/. diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs new file mode 100644 index 00000000..4dc3f398 --- /dev/null +++ b/scripts/check-changelog-semver.mjs @@ -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 `## []` 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 `-v` 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 --changelog --version --tag-prefix [--cwd ]", + ); + 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(); +} diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs new file mode 100644 index 00000000..9cc12ffd --- /dev/null +++ b/scripts/check-changelog-semver.test.mjs @@ -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/); + }); +}); diff --git a/scripts/verify-caching-unity-release.ps1 b/scripts/verify-caching-unity-release.ps1 new file mode 100644 index 00000000..7e28ef6c --- /dev/null +++ b/scripts/verify-caching-unity-release.ps1 @@ -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 +# 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."