CultLib/docs/semver-policy.md

400 lines
23 KiB
Markdown
Raw Normal View History

Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
# 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 | Published by |
| --- | --- | --- |
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` |
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
Each package's tag prefix, changelog path and, for the packages that ship
assemblies, the directory of tracked DLLs are declared in
`scripts/release-packages.mjs`, the one copy. The check reads them by package
name; no release script or workflow passes one.
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
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.
Releases are tags named `<tagPrefix>-v<version>`, with the prefix declared in
`scripts/release-packages.mjs`. The check refuses a declaration in which two
packages share a prefix or an entry lacks a string prefix or changelog.
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
## 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.5.x`) uses the
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
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 (the check also reads
`## <version>`, and either form followed by Keep a Changelog's
` - YYYY-MM-DD` date, such as `## [0.4.0] - 2026-10-03`) containing:
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
- 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.
### First release of a package
A package with no `CHANGELOG.md` yet cannot release: the check refuses with
"changelog not found". Its first release writes the file at the declared path,
`packages/<package>/CHANGELOG.md`, with one section for the version being
released. That version must still be exactly the next one after the package's
nearest ancestor tag. As of 2026-10-03 the valid next versions are:
- `cultcache-ts`, after `cultcache-ts-v0.14.0`: `0.14.1`, `0.15.0` or `1.0.0`;
- `cultcache-py`, after `cultcache-py-v0.3.0`: `0.3.1`, `0.4.0` or `1.0.0`;
- `cultnet-py` and `cultmesh-py`, which have no tags: `0.1.0`, their manifest
version, as a first release. Run it from a clone with the tags fetched, or
the check cannot tell a first release from a missing record.
On `0.y.z` a `### Breaking` section needs at least a minor bump.
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
## Enforcement
`scripts/check-changelog-semver.mjs` is the mechanical check. Given a
package name and the version being released, it reads the package's
declaration from `scripts/release-packages.mjs` in `--cwd` (an unknown package
or a missing declaration refuses), and:
1. derives the release record from `git tag -l` in `--cwd`. A same-prefix tag
is one that starts `<prefix>-v`; one that is not
`<prefix>-vMAJOR.MINOR.PATCH` refuses, naming the tag, and is never
skipped. The previous release is the newest same-prefix tag strictly older
than the version being released, by version order, that is also an ancestor
of the commit being checked: a backport tagged later on another branch is
not part of that history, so it never moves a rebuild's predecessor. A tag
equal to the version does not exempt
it and does not count as a previous release: there is no "already
published" skip, so a rebuild or a CI re-run at a tagged version is
measured against that version's predecessor, exactly as at release, and
gets the same verdict. With no older tag, a newer one refuses (the version
is older than every release), and a repository that holds no tag other than
the version's own refuses, because a first release cannot be told from a
missing record. Older tags of which none is an ancestor leave the
predecessor unknown and refuse. Otherwise it is a first release, which no
caller declares. A package that declares an assemblies directory is a first
release only when no tag in the repository, under any prefix, tracks a
managed assembly under that directory: such a tag means the package has
shipped, so the declared prefix is wrong and the check refuses, naming the
tag and the assembly. A tag snapshots the whole tree of this monorepo, so a
tag tracking a package's directory proves nothing; only a managed assembly in
the declared directory does. This is read from git, never from the changelog's
prose. The version's own tag does not count, so a first release can be
rebuilt. Only the declared directory counts: a package whose directory moved
after its releases has none of its old path's tags counted, so its release
under a misspelled prefix reads as new, and the move itself is caught only
under the right prefix, where the declared directory holds no managed assembly
at the previous tag. A package that declares no assemblies directory (the
Python and TypeScript packages and the Caching Unity package) has no such
proof: with no tag of its own prefix it is a first release. The suite test
that pins every declared prefix guards it: each prefix is declared once, each
workflow tag trigger must be a declared prefix, and each such package's prefix
must be a workflow trigger or, for the Caching Unity package the workflow does
not build, the prefix of tags that hold its own manifest at the tag's own
version, so a typo or a copied prefix fails the suite. Nothing releases
without the suite passing; see below for how each release path runs it.
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
recorded `partial-tags-first-release`.
Tags that cannot be read (a `--cwd` that is not a git repository, or a tag
whose tree cannot be listed) refuse;
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
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. when the release script supplies built assemblies (below), treats the
release as breaking if the public API measurably lost or changed a member
against the previous tag, whether or not the changelog says so; a
measured break with no `### Breaking` section is refused, naming the
count, the first diagnostics, and the heading that must declare them;
5. if the release is breaking (a `### Breaking` section, a measured break, or
both), requires the bump to be at least major (or, pre-1.0, at least
minor); a `### Breaking` section with nothing measured is valid, because
behavioural breaks are invisible to a signature comparison;
6. fails loudly, naming the package, the previous and new version, and (when
relevant) the changelog heading or the measured diagnostics that
triggered the requirement.
Additions are never a rule: adding a public member is not breaking (see
above), and the check neither refuses nor demands a bump for one.
### Measured input
A package whose declaration names an `assemblies` directory is measured, and
the release must hand over one `--api-built <dll>` per assembly it built (plus
`--api-refs <dir>` for directories that hold the built assemblies'
dependencies). A package with no such directory takes no `--api-built`, and one
that has it cannot omit it. The measured set is every managed assembly tracked
under that directory, at any depth, at the previous tag, read as git blobs (no
worktree, no network, unaffected by what the working tree tracks now) and keyed
by file name. A DLL is managed when its PE header carries a CLI header; a
native DLL has none and is not measured, and a tracked `.dll` that is not a PE
image refuses. Two managed assemblies with one name refuse. The checker
compares each built assembly with its namesake using
`Microsoft.DotNet.ApiCompat.Tool`; each `CPnnnn` diagnostic is one measured
break. Every managed assembly the previous tag tracked that the build no
longer hands over is itself a measured break, whatever its name or place, so a
release script passes every assembly it ships. A built assembly with no
namesake at the tag is an addition and is not examined.
Measuring nothing is a refusal, never a pass. The check refuses, naming what
is missing, when the previous tag tracks no managed assembly under the
declared directory (a wrong or moved directory, or a tag that predates it);
when git cannot read the tracked assemblies; and when the tool fails to
measure (not installed, an unreadable assembly, a crash: a non-zero exit with
no diagnostic line). A first release measures nothing.
The tool and the `NETStandard.Library.Ref` 2.1.0 reference assemblies are
pinned in `scripts/api-gate/` (`.config/dotnet-tools.json` and
`ApiRefs.csproj`) and restored there by the check itself, so a release does
not depend on any other tool in the repository's manifest. NuGet is needed
until both are cached under `scripts/api-gate`. Both sides of every
comparison resolve netstandard types through those reference assemblies, and the
new side also through the built DLL's own directory and every `--api-refs`
directory. No suppression file is kept: a suppression would be a second, silent
declaration of a break, so a break is declared only under `### Breaking`.
A passing measured run prints that unmeasured changes still need declaring.
#### Unmeasured changes
ApiCompat does not report these changes to a public member, and the check
therefore passes them. Each is a breaking change under the rules above and is
declared under `### Breaking`, as behavioural breaks are:
- a member gaining or losing `static`;
- a parameter gaining or losing `ref`, `out` or `in`;
- a `const` field's value changing;
- an enum member's underlying value changing;
- a field gaining or losing `readonly`;
- a default parameter value changing.
The release scripts run the check once the assemblies under judgement exist,
after the build and before anything is copied into the package.
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
Run it directly: `node scripts/check-changelog-semver.mjs --package <name>
--version <x.y.z> [--cwd <dir>] [--api-built <dll>... [--api-refs <dir>...]]`.
Any other option is a usage error (exit 2). Its own tests live in
`scripts/check-changelog-semver.test.mjs` (`node --test
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
scripts/check-changelog-semver.test.mjs`).
It is wired into:
- `.github/workflows/publish-packages.yml`, through
`scripts/release-after-suite.mjs`, for `cultcache-ts`, `cultcache-py`,
`cultnet-py`, and `cultmesh-py`, with `fetch-depth: 0` so the checkout holds
the tags;
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
- `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`.
Each of those runs the check's own tests before it releases. The Unity release
scripts run them before they call the check, and the tests pin that order. In
`publish-packages.yml` the release step runs `scripts/release-after-suite.mjs`
with the run's git ref. From one process it checks that a tag ref is the
package's declared prefix and manifest version, runs these tests, runs this
check for the tagged version, runs the package's own tests (`npm test`, or the
Python package's unit tests), and only then the release action: `npm publish`
for `cultcache-ts`, which it refuses on any ref but a release tag, and for the
Python packages the sdist and wheel build, whose output is all the PyPI upload
step uploads. The checks run with the known test-runner and token variables
removed (`NODE_OPTIONS`, `NODE_TEST_CONTEXT`, any other `NODE_TEST_*` variable,
and `NODE_AUTH_TOKEN`), so none of those can make the tests run nothing and
pass, and only the release action receives the token. That list names known
variables; it does not prove that no other inherited variable can affect a
check. The npm Publish step and the Python sdist and wheel build step start the
guard under `env -u NODE_OPTIONS`, so a `NODE_OPTIONS` preload set in the
workflow's or job's `env:` cannot run code inside the guard before its first
line.
The gate is that call, not a step condition, so no `if:` or `continue-on-error`
can release what a check refused. The tests compare each job's release steps
with their expected text exactly, less the steps' `if:` lines. Removing the call
is still possible, but only as a visible edit to those steps and to the tests.
The guard and that pin stop accidental workflow edits. They do not stop a
deliberate one: an extra step that publishes, a workflow-level `defaults:` or
`env:`, or a fake `node` on `$GITHUB_PATH` all pass them. Deliberate edits are
review's job, as ruled in `suite-gate-structural-not-parsed`, and the pin
does not try to parse the workflow for them. For the same reason it has no ban
on `shell` or `defaults`: such a ban defends only against a deliberate edit,
and it also refused harmless settings such as `defaults.run.working-directory`.
A step-level `shell:` on a release step still fails the exact comparison.
A recommendation for the operator, not something this repository can set: the
Python job already runs in the `pypi` GitHub environment, as PyPI trusted
publishing expects. Putting the npm job in an environment with required
reviewers would make every npm release wait for a person's approval. That is a
GitHub settings change.
`org.gamecult.cultlib` compiles CultMath from source and ships beside
`org.gamecult.cultmath`, so its build refuses unless it declares exactly the
CultMath package's version and every `CultMath.dll` it built equals the one
that package tracks, byte for byte. Any CultMath source change therefore means:
release `org.gamecult.cultmath`, declare its new version in
`org.gamecult.cultlib`, then release `org.gamecult.cultlib`. The bytes also
depend on the checkout and the toolchain. `.gitattributes` pins CultMath
source to CRLF on every checkout, because the tracked DLL was built from CRLF
source and line endings reach the DLL through the pdb's document checksums; a
clone checked out before that rule may still hold LF files (`git ls-files
--eol packages/cultmath/src` shows `w/lf`) until they are checked out again.
The build needs a git checkout, and refuses up front without `.git`: the repo
root maps to `/_/` through git, so an archive or source zip would write its
own path into the DLL. No `global.json` pins the .NET SDK, so the same refusal
at unchanged source and CRLF checkout means this SDK or host differs from the
one that built the tracked DLL.
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
### 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.
What stays unmeasured: behaviour behind an unchanged signature, wire formats,
native exports of the QUIC bridge, and the Caching Unity package's source
(it has no assembly). Those rest on the `### Breaking` declaration.
Write and enforce the semver policy, per operator ruling on 1.0.60 1.0.60 removed public API (CultInspectorAssetPath) under a patch bump; its own changelog says "Breaking" and the version number lied. Operator ruling: semver is policy, strictly, for every published package family. docs/semver-policy.md states what counts as breaking per surface (managed API, Unity inspector attributes, CultCache wire format/store compatibility, CultNet protocol, native bridge exports), the 0.y.z pre-1.0 convention, tag naming, and required changelog shape. Records the 1.0.60 scar as history; does not renumber it. scripts/check-changelog-semver.mjs is the mechanical check: refuses a changelog "### Breaking" section under too small a bump, and refuses a version that skips, reverses, or repeats the previous published tag. Skips re-litigating a version that is already tagged (rebuilding immutable history is not a release action). Wired into publish-packages.yml for the four npm/PyPI packages, and into build-unity-package.ps1 (cultlib), packages/cultmath/scripts/build-unity-package.ps1 (cultmath), and a new scripts/verify-caching-unity-release.ps1 (caching.unity has no build script of its own to hook into). 18 node:test cases cover the required table (breaking+major/minor/patch, non-breaking, 0.y.z, skip, downgrade) plus CLI-level git-tag resolution. Hand-mutated each rule (bump classification, pre-1.0 lane requirement, breaking-heading detection, breaking early-return, already-published skip) and confirmed each kill, then restored byte-exact. No mutation harness is committed; CultLib has none today. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 22:03:22 +02:00
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`, and `cultmesh-py`
currently have **no `CHANGELOG.md` file at all**; the check refuses their next
tagged release ("changelog not found at <declared path>") until each
package's file exists. A release with no changelog is not a smaller problem
than a mislabelled one. The next release of each creates its file:
`packages/cultcache-ts/CHANGELOG.md` arrives with the document-variants work
(`hands/variants-c2a`), and whoever cuts the next release of `cultcache-py`,
`cultnet-py` or `cultmesh-py` creates that package's file. Backfilling
changelog content for past releases would mean inventing release-note prose
for history nobody here knows first-hand, so the gap stays named.