Semver policy: the declaration file owns prefix, changelog and assemblies; first release and measured set are derived

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 02:36:58 +02:00
commit 38ead8c4b0

View file

@ -21,15 +21,20 @@ 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` |
| 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.
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;
@ -137,23 +142,25 @@ 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:
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. resolves the previous published version from the highest existing
`<prefix>-v*` tag strictly older than the version being released. 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. A first release is never
inferred: with no older tag, or with tags that cannot be read (a `--cwd`
that is not a git repository), the check refuses unless the caller declares
`--first-release`. The declaration is believed only when no other
`<prefix>-v*` tag exists (older or newer than the version) and
`--tag-prefix` names the directory that holds the changelog
(`packages/<prefix>/CHANGELOG.md`, the workflow's own spelling of a
package). A deleted predecessor tag leaves nothing in git to find, so under
the right prefix it cannot be told from a first release;
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. 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. Otherwise it is a first release, which no caller declares.
Tags that cannot be read (a `--cwd` that is not a git repository) refuse. A
deleted predecessor tag leaves nothing in git to find, so in a checkout that
holds other tags it cannot be told from a first release;
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
@ -177,28 +184,29 @@ above), and the check neither refuses nor demands a bump for one.
### Measured input
The Unity release scripts pass `--api-baseline-path <repo-relative directory
of tracked DLLs>` and one `--api-built <dll>` per assembly they built (plus
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, and `--api-new <dll name>` for an assembly that is new in this
release). For each built DLL the checker reads its namesake under the baseline
path at the previous tag as a git blob (no worktree, no network, unaffected by
what the working tree tracks now) and compares public API with
`Microsoft.DotNet.ApiCompat.Tool`. Each `CPnnnn` diagnostic is one measured
break. Every assembly the previous tag tracked under the baseline path that
the build no longer produces is itself a measured break, whatever its name, so
a release script passes every assembly it ships, not only its own.
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 DLL under the baseline path (a
wrong or moved path, or a tag that predates it); when git cannot read the
tracked assemblies; when a built assembly has no namesake at the tag and was
not declared with `--api-new`; when an assembly declared new is already
tracked; when no built assembly has a namesake at the tag, so that no
comparison ran (renaming the only assembly and declaring it new is this case);
and when the tool fails to measure (not installed, an unreadable assembly, a
crash: a non-zero exit with no diagnostic line). A verified first release
measures nothing.
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
@ -229,17 +237,17 @@ The release scripts run the check once the assemblies under judgement exist,
after the build and before anything is copied into the package.
Run it directly: `node scripts/check-changelog-semver.mjs --package <name>
--changelog <path> --version <x.y.z> --tag-prefix <prefix>` (CI's TS and Python
jobs and `verify-caching-unity-release.ps1` pass no measured input). Its own tests
live in `scripts/check-changelog-semver.test.mjs` (`node --test
--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
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`; a job passes `--first-release` when no
other tag of its prefix exists, and the check verifies that claim itself;
`cultnet-py`, and `cultmesh-py`, with `fetch-depth: 0` so the checkout holds
the tags;
- `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`;
- `packages/cultmath/scripts/build-unity-package.ps1`, for
`org.gamecult.cultmath`;
@ -281,14 +289,13 @@ 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.
`@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.