Merge hands/semver-measured: measured semver for every package, verified by Soul s7

The release check measures each package against its predecessor tag instead of
trusting caller-declared flags. First releases are proved by a managed assembly,
the suite runs in CI and the Unity scripts before publish, and the Caching Unity
prefix is its own tag family. Verdict asura:verdict:cut-semver-measured.s7 holds;
one Low finding (suite-step-condition-unpinned) is deferred.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 12:09:35 +02:00
commit 162bd1fcce
11 changed files with 1922 additions and 162 deletions

View file

@ -55,6 +55,17 @@ jobs:
node-version: 24.x
registry-url: https://registry.npmjs.org
# The suite pins the release checker's rules and the declared tag prefixes
# (docs/semver-policy.md). It runs before any check or publish step, on a
# checkout that holds every tag.
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
- name: Test the release checks
run: node --test scripts/check-changelog-semver.test.mjs
- name: Install workspace dependencies
run: npm ci
@ -75,11 +86,7 @@ jobs:
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
node scripts/check-changelog-semver.mjs --package "@gamecult/cultcache-ts" --version "$tagged"
- name: Build and test
working-directory: packages/cultcache-ts
@ -142,6 +149,17 @@ jobs:
with:
node-version: 24.x
- name: Set up .NET
if: steps.selected.outputs.run == 'true'
uses: actions/setup-dotnet@v4
with:
dotnet-version: 10.0.x
# See the same step in the npm job: the suite comes before any check or publish step.
- name: Test the release checks
if: steps.selected.outputs.run == 'true'
run: node --test scripts/check-changelog-semver.test.mjs
- name: Verify the tag matches the manifest version
if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/')
run: |
@ -159,11 +177,7 @@ jobs:
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 }}"
node scripts/check-changelog-semver.mjs --package "${{ matrix.package }}" --version "$tagged"
# 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.

View file

@ -21,23 +21,29 @@ 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;
a `Cargo.toml` version still exists and should follow this policy by
convention, but nothing in this repo checks it mechanically today.
Tag naming stays `<package>-v<version>` for every package above, matching the
prefixes already in use.
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.
## What counts as breaking
@ -137,32 +143,138 @@ 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 older than the version being released (or treats the
release as a package's first if none exists);
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. The workflow and the
three Unity release scripts run the suite before any check or publish step.
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;
2. requires a `## [<version>]` changelog entry to exist at all;
3. classifies the version bump (major/minor/patch) against the previous
version, refusing anything that is not exactly the next version in some
lane — a skip, a reversal, or a repeat are all refused, not just wrong
bump sizes;
4. if the changelog entry has a `### Breaking` section, requires the bump to
be at least major (or, pre-1.0, at least minor);
5. fails loudly, naming the package, the previous and new version, and (when
relevant) the changelog heading that triggered the requirement.
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.
Run it directly: `node scripts/check-changelog-semver.mjs --package <name>
--changelog <path> --version <x.y.z> --tag-prefix <prefix>`. Its own tests
live in `scripts/check-changelog-semver.test.mjs` (`node --test
--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`;
`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`;
@ -193,6 +305,10 @@ 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.
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 —
@ -200,14 +316,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.

View file

@ -27,25 +27,6 @@ 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 `
@ -60,6 +41,27 @@ foreach ($path in @($freshAssembly, $freshSymbols)) {
}
}
# docs/semver-policy.md (CultLib root, two levels up from this script): refuse a release whose version
# does not tell the truth. The check measures the freshly built CultMath.dll's public API against the
# DLL the previous cultmath-unity tag tracked (read from git), so it runs once the build exists.
# cultmath is pre-1.0 (0.y.z): a break 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
# The suite pins the checker's rules and the declared tag prefixes, so it runs first.
& node --test (Join-Path $cultLibRoot "scripts\check-changelog-semver.test.mjs")
if ($LASTEXITCODE -ne 0) {
throw "The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing."
}
& node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultmath" `
--version $cultmathVersion `
--cwd $cultLibRoot `
--api-built $freshAssembly
if ($LASTEXITCODE -ne 0) {
throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)."
}
$requiredTemplateFiles = @(
"LICENSE.md",
"LICENSE.md.meta",

View file

@ -0,0 +1,13 @@
{
"version": 1,
"isRoot": true,
"tools": {
"microsoft.dotnet.apicompat.tool": {
"version": "10.0.202",
"commands": [
"apicompat"
],
"rollForward": false
}
}
}

1
scripts/api-gate/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
packages/

View file

@ -0,0 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Restores the netstandard2.1 reference assemblies for scripts/check-changelog-semver.mjs.
Not built: PackageDownload only fetches the package into ./packages. -->
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<RestorePackagesPath>$(MSBuildThisFileDirectory)packages</RestorePackagesPath>
</PropertyGroup>
<ItemGroup>
<PackageDownload Include="NETStandard.Library.Ref" Version="[2.1.0]" />
</ItemGroup>
</Project>

View file

@ -39,21 +39,6 @@ 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)."
}
# Release gate: cultlib's Unity package resolves CultMath through the org.gamecult.cultmath
# dependency, so a cultlib release must not run ahead of the CultMath package it names.
# (1) The declared dependency version must not exceed the CultMath package's own version.
@ -187,6 +172,33 @@ foreach ($assemblyName in $expectedAssemblies) {
}
}
# docs/semver-policy.md: refuse a release whose version does not tell the truth. The check measures
# the public API of every shipped assembly against the DLLs the previous cultlib-unity tag
# tracked (read from git), so it runs here, once the assemblies under judgement exist. Every
# tracked assembly the build does not hand over counts as removed, so all of them are handed over.
# The package's tag prefix, changelog and assemblies directory are declared in
# scripts/release-packages.mjs; this call passes only the version and the built assemblies.
$measuredArguments = @()
foreach ($assemblyName in $expectedAssemblies) {
$measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName)
}
foreach ($refRoot in @($publishRoot, $quicPublishRoot, $webSocketPublishRoot)) {
$measuredArguments += @("--api-refs", $refRoot)
}
# The suite pins the checker's rules and the declared tag prefixes, so it runs first.
& node --test (Join-Path $repoRoot "scripts\check-changelog-semver.test.mjs")
if ($LASTEXITCODE -ne 0) {
throw "The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing."
}
& node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") `
--package "org.gamecult.cultlib" `
--version $unityPackageVersion `
--cwd $repoRoot `
@measuredArguments
if ($LASTEXITCODE -ne 0) {
throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)."
}
Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") `
-Destination (Join-Path $outputRoot "Runtime")
foreach ($assemblyName in $expectedAssemblies) {

View file

@ -5,12 +5,14 @@
// 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`.
// CLI wrapper that reads the package's declaration (scripts/release-packages.mjs),
// its changelog and the repository's tags, then calls `evaluateRelease`.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { execFileSync, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath, 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
@ -100,12 +102,15 @@ function isPreOneZero(prev) {
// 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 }) {
// but a changelog entry is still required). `measuredBreaks` lists the public
// API diagnostics measured against the previous tag's assemblies; a non-empty
// list makes the release breaking whether or not the changelog says so, and
// the changelog must then declare it.
export function evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks = [] }) {
const next = parseVersion(version);
let breaking;
let declared;
try {
breaking = hasBreakingSection(changelogText, version);
declared = hasBreakingSection(changelogText, version);
} catch (err) {
return { ok: false, reason: `${packageName}: ${err.message}` };
}
@ -127,9 +132,21 @@ export function evaluateRelease({ packageName, changelogText, version, previousV
};
}
if (!breaking) {
const measured = measuredBreaks.length > 0;
if (measured && !declared) {
const shown = measuredBreaks.slice(0, 5).join("; ") + (measuredBreaks.length > 5 ? "; ..." : "");
return {
ok: false,
reason: `${packageName}: ${version} breaks ${measuredBreaks.length} public API member(s) measured against ` +
`${previousVersion} (${shown}); declare them under "${BREAKING_HEADING}" in the changelog and bump accordingly`,
};
}
if (!declared) {
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion}, no breaking section` };
}
const evidence = measured
? `a "${BREAKING_HEADING}" section and ${measuredBreaks.length} measured public API break(s)`
: `a "${BREAKING_HEADING}" section`;
const pre1 = isPreOneZero(prev);
const requiredLanes = pre1 ? ["major", "minor"] : ["major"];
@ -139,102 +156,401 @@ export function evaluateRelease({ packageName, changelogText, version, previousV
: `a major bump`;
return {
ok: false,
reason: `${packageName}: ${version}'s changelog has a "${BREAKING_HEADING}" section but is only a ${bump} bump ` +
reason: `${packageName}: ${version} has ${evidence} 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` };
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and has ${evidence}, which agree` };
}
// --- CLI wrapper: resolves the previous published tag via git and reads the
// changelog off disk, then delegates to the pure functions above. ---
// --- CLI wrapper: reads the package's declaration, the release record (git tags) and the
// changelog, then delegates to the pure functions above. ---
function resolvePreviousVersion(tagPrefix, currentVersion, cwd) {
// The release record, derived from `git tag -l` in --cwd. The previous release is the
// newest '<prefix>-vMAJOR.MINOR.PATCH' tag strictly older than the version, by version order
// (never git's string order), that is also an ancestor of the commit being checked (HEAD of
// --cwd): a backport tagged later on another branch is not in this commit's history, so it
// never changes a rebuild's predecessor. The version's own tag is ignored, so a rebuild at a
// tagged version gets its release-time verdict. A same-prefix tag that does not parse is never
// skipped. A first release is the absence of any older tag in a checkout that holds other tags (a
// checkout with none cannot tell a first release from a missing record), none of which, under
// any prefix, tracks a managed assembly under the package's declared assemblies directory. A
// package that declares none has no such proof: the prefix of an assembly-less package is
// pinned by the suite test that ties every declared prefix to its tag trigger.
function isAncestorOfHead(tag, cwd) {
const run = spawnSync("git", ["merge-base", "--is-ancestor", `refs/tags/${tag}`, "HEAD"], { cwd, stdio: "ignore" });
return run.status === 0 ? true : run.status === 1 ? false : null;
}
function resolveRelease(tagPrefix, versionText, cwd, assemblies) {
let tags;
try {
tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8" })
tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
.split("\n")
.map((line) => line.trim())
.filter(Boolean);
} catch {
return null;
return { failure: "the tags of --cwd could not be read (not a git repository?), so the previous release is unknown" };
}
const prefix = `${tagPrefix}-v`;
const current = parseVersion(currentVersion);
let best = null;
const current = parseVersion(versionText);
const own = `${prefix}${formatVersion(current)}`;
const older = [];
let newer = 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;
const text = tag.slice(prefix.length);
if (!VERSION_PATTERN.test(text)) {
return { failure: `${tag} starts with ${prefix} but is not ${prefix}MAJOR.MINOR.PATCH, so it cannot be ordered among the releases` };
}
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;
const candidate = parseVersion(text);
const order = compareVersions(candidate, current);
if (order === 0) continue; // the tag being released itself
if (order > 0) {
newer ??= tag;
} else {
older.push({ tag, version: candidate });
}
}
return best ? formatVersion(best) : null;
older.sort((a, b) => compareVersions(b.version, a.version));
for (const { tag, version } of older) {
const ancestor = isAncestorOfHead(tag, cwd);
if (ancestor === null) return { failure: `whether ${tag} is an ancestor of the commit being checked could not be read` };
if (ancestor) return { previous: formatVersion(version) };
}
if (older.length > 0) {
return { failure: `${older.length} older ${prefix} tag(s) exist but none is an ancestor of the commit being checked, so the previous release is unknown` };
}
if (newer !== null) {
return { failure: `${newer} exists but no ${prefix} tag is older than ${formatVersion(current)}: the version is older than every release` };
}
if (!tags.some((tag) => tag !== own)) {
return { failure: "this checkout holds no tags; a first release cannot be told from a missing record (fetch the tags)" };
}
if (typeof assemblies === "string") {
// Under every other prefix too: a package whose assemblies some tag has shipped is not new,
// whatever the declared prefix says. This is read from git, never from the changelog's prose.
const tracked = firstTagTrackingManaged(tags.filter((tag) => tag !== own), assemblies, cwd);
if (tracked.failure) return tracked;
if (tracked.tag) {
return { failure: `no ${prefix} tag is an earlier release, but ${tracked.tag} tracks the managed assembly ${tracked.path}: the package has shipped before, so the declared tag prefix is wrong` };
}
}
return { previous: null };
}
// True when `<tagPrefix>-v<version>` already exists. The check gates a NEW
// release; rebuilding an already-tagged, already-immutable version (routine
// local iteration, CI re-runs) is not a release action and should not keep
// re-litigating history on every build.
function tagAlreadyPublished(tagPrefix, version, cwd) {
// The first of the tags that tracks a managed assembly anywhere under `dir`, or none. Whether a
// blob is managed is decided by its bytes once per blob id, however many tags track it. A tag
// that cannot be read, or a .dll that is not a PE image, is a refusal, never a tag that tracks
// nothing.
function firstTagTrackingManaged(tags, dir, cwd) {
const managedBlobs = new Map();
for (const tag of tags) {
try {
const entries = execFileSync("git", ["ls-tree", "-r", "-z", `refs/tags/${tag}`, "--", `${dir}/`], {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
})
.split("\0")
.filter((entry) => entry.toLowerCase().endsWith(".dll"));
for (const entry of entries) {
const tab = entry.indexOf("\t");
const id = entry.slice(0, tab).split(" ")[2];
const path = entry.slice(tab + 1);
if (!managedBlobs.has(id)) {
const bytes = execFileSync("git", ["cat-file", "blob", id], { cwd, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 28 });
try {
managedBlobs.set(id, isManagedAssembly(bytes));
} catch {
return { failure: `${path} at ${tag} is a .dll but not a PE image, so a first release cannot be told from a mistyped prefix` };
}
}
if (managedBlobs.get(id)) return { tag, path };
}
} catch {
return { failure: `the assemblies ${tag} tracks under ${dir} could not be read, so a first release cannot be told from a mistyped prefix` };
}
}
return {};
}
// The declaration as a whole: every entry names a string tagPrefix and changelog, and no two
// packages share a prefix (each would be measured against the other's releases). Returns a
// refusal text, or null.
export function checkDeclaration(declared) {
const seen = new Map();
for (const [name, entry] of Object.entries(declared ?? {})) {
if (entry == null || typeof entry.tagPrefix !== "string" || typeof entry.changelog !== "string") {
return `${name} must declare a string tagPrefix and a string changelog`;
}
if (seen.has(entry.tagPrefix)) return `${seen.get(entry.tagPrefix)} and ${name} share one tag prefix`;
seen.set(entry.tagPrefix, name);
}
return null;
}
// --- Measured public API: the built DLLs are compared with the DLLs the
// previous tag tracked, by Microsoft.DotNet.ApiCompat.Tool. The tool, and the
// netstandard reference assemblies it needs to resolve inherited members, are
// pinned in scripts/api-gate/ and restored there; nothing here compares
// members itself. ---
const gateRoot = join(dirname(fileURLToPath(import.meta.url)), "api-gate");
const DIAGNOSTIC_LINE = /^CP\d{4}: /;
// One ApiCompat run's verdict. A diagnostic line is a measured break. A
// non-zero exit with no diagnostic line means the tool did not measure
// anything (missing tool, unreadable input, crash): that is a failure, never a
// pass.
export function readApiCompatRun({ status, output }) {
const breaks = output.split(/\r?\n/).filter((line) => DIAGNOSTIC_LINE.test(line));
if (breaks.length === 0 && status !== 0) {
return { failure: `apicompat exited ${status} without measuring: ${output.trim().split(/\r?\n/).slice(0, 5).join(" | ")}` };
}
return { breaks };
}
function runApiCompat(args) {
const run = spawnSync("dotnet", ["tool", "run", "apicompat", ...args], { cwd: gateRoot, encoding: "utf8" });
return readApiCompatRun({ status: run.status, output: `${run.stdout}${run.stderr}` });
}
function lastLines(text) {
return text.trim().split(/\r?\n/).slice(-3).join(" | ");
}
// Restores the pinned tool and the pinned netstandard reference package, and
// returns the reference assemblies' directory. Both come from scripts/api-gate/
// and need NuGet only until they are cached there.
function prepareGate() {
if (!existsSync(join(gateRoot, ".config", "dotnet-tools.json"))) {
return { failure: "the api-gate tool manifest (scripts/api-gate/.config/dotnet-tools.json) is missing" };
}
for (const [what, args] of [["dotnet tool restore", ["tool", "restore"]], ["dotnet restore", ["restore", "ApiRefs.csproj", "--nologo", "-v", "q"]]]) {
const run = spawnSync("dotnet", args, { cwd: gateRoot, encoding: "utf8" });
if (run.status !== 0) {
return { failure: `${what} failed: ${run.error ? run.error.message : lastLines(`${run.stdout}${run.stderr}`)}` };
}
}
const packageDir = join(gateRoot, "packages", "netstandard.library.ref");
const versions = existsSync(packageDir) ? readdirSync(packageDir).sort() : [];
const refs = versions.length > 0 ? join(packageDir, versions[0], "ref", "netstandard2.1") : null;
if (refs == null || !existsSync(join(refs, "netstandard.dll"))) {
return { failure: "the pinned netstandard reference assemblies were not restored under scripts/api-gate/packages" };
}
return { refs };
}
// Whether a PE image is a managed assembly: its optional header's data directory 14 (the
// CLI header) is non-empty. A PE without one is native and ApiCompat cannot read it. A buffer
// that is not a PE image, or is cut short, throws: decided from the bytes, never from the
// file's name or place.
export function isManagedAssembly(bytes) {
const need = (end) => {
if (end > bytes.length) throw new RangeError("not a PE image");
};
need(0x40);
if (bytes.readUInt16LE(0) !== 0x5a4d) throw new RangeError("not a PE image");
const pe = bytes.readUInt32LE(0x3c);
need(pe + 24);
if (bytes.readUInt32LE(pe) !== 0x00004550) throw new RangeError("not a PE image");
const optionalSize = bytes.readUInt16LE(pe + 20);
const optional = pe + 24;
const magic = bytes.readUInt16LE(optional);
if (magic !== 0x10b && magic !== 0x20b) throw new RangeError("not a PE image");
const directories = optional + (magic === 0x10b ? 96 : 112);
need(directories);
if (bytes.readUInt32LE(directories - 4) <= 14 || optionalSize < directories + 15 * 8 - optional) return false;
need(directories + 15 * 8);
return bytes.readUInt32LE(directories + 14 * 8) !== 0 && bytes.readUInt32LE(directories + 14 * 8 + 4) !== 0;
}
// The managed assemblies the previous tag tracked anywhere under `dir`, as git blobs: no
// worktree, no network, and unaffected by what the working tree holds now. Each is written to
// `into` by file name. A git failure is a refusal that names the step, never git's own output.
function extractBaseline({ tag, dir, into, cwd }) {
const git = (args, options) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], ...options });
const blobs = [];
try {
const output = execFileSync("git", ["tag", "-l", `${tagPrefix}-v${version}`], { cwd, encoding: "utf8" });
return output.trim().length > 0;
const paths = git(["ls-tree", "-r", "-z", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" })
.split("\0")
.filter((path) => path.toLowerCase().endsWith(".dll"));
for (const path of paths) blobs.push({ path, bytes: git(["show", `${tag}:${path}`], { maxBuffer: 1 << 28 }) });
} catch {
return false;
return { failure: `git could not read the assemblies tracked under ${dir} at ${tag}` };
}
const names = [];
for (const { path, bytes } of blobs) {
let managed;
try {
managed = isManagedAssembly(bytes);
} catch {
return { failure: `${path} at ${tag} is a .dll but not a PE image` };
}
if (!managed) continue;
const name = basename(path);
if (names.includes(name)) return { failure: `${tag} tracks two managed assemblies named ${name} under ${dir}` };
writeFileSync(join(into, name), bytes);
names.push(name);
}
return { names };
}
// Compares each built assembly with its namesake at the previous tag. Measuring nothing is a
// failure: the declared directory must hold a managed assembly at the tag. A managed assembly
// the tag tracked and the build no longer hands over is itself a break, whatever its name or
// place; a built assembly with no namesake is an addition and is not examined.
export function measureApiBreaks({ tagPrefix, previousVersion, assemblies, built, refs, cwd }) {
const tag = `${tagPrefix}-v${previousVersion}`;
const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-"));
try {
const left = join(work, "left");
mkdirSync(left);
const baseline = extractBaseline({ tag, dir: assemblies, into: left, cwd });
if (baseline.failure) return baseline;
const baselineNames = baseline.names;
if (baselineNames.length === 0) {
return { failure: `${tag} tracks no managed assembly under ${assemblies}: the declared directory is missing or moved, or that tag does not track the DLLs` };
}
const gate = prepareGate();
if (gate.failure) return gate;
const builtNames = new Set(built.map((path) => basename(path)));
const breaks = baselineNames
.filter((name) => !builtNames.has(name))
.map((name) => `assembly ${name} exists at ${tag} but is no longer built`);
for (const path of built) {
const name = basename(path);
if (!baselineNames.includes(name)) continue;
const leftRefs = [left, gate.refs].join(",");
const rightRefs = [...new Set([dirname(path), ...refs, gate.refs])].join(",");
const run = runApiCompat(["--left-assembly", join(left, name), "--right-assembly", path, "--left-assembly-references", leftRefs, "--right-assembly-references", rightRefs]);
if (run.failure) return { failure: `${name}: ${run.failure}` };
breaks.push(...run.breaks);
}
return { breaks };
} finally {
rmSync(work, { recursive: true, force: true });
}
}
// Repeatable options collect into arrays. Any other option is a usage error.
const KNOWN = new Set(["package", "version", "cwd", "api-built", "api-refs"]);
const REPEATABLE = new Set(["api-built", "api-refs"]);
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];
if (!KNOWN.has(key)) return { unknown: arg };
if (REPEATABLE.has(key)) {
(args[key] ??= []).push(argv[i + 1]);
} else {
args[key] = argv[i + 1];
}
i += 1;
}
return args;
}
function main() {
function refuse(message) {
console.error(message);
process.exitCode = 1;
}
const UNMEASURED_NOTICE =
'public API measured; changes ApiCompat cannot see (see "Unmeasured changes" in docs/semver-policy.md) must still be declared under "### Breaking"';
async 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) {
const { package: packageName, version, cwd = process.cwd() } = args;
// The tool runs from scripts/api-gate, so a path given relative to this process is made absolute first.
const built = (args["api-built"] ?? []).map((path) => resolve(path));
if (args.unknown || !packageName || !version) {
console.error(
"usage: check-changelog-semver.mjs --package <name> --changelog <path> --version <x.y.z> --tag-prefix <prefix> [--cwd <dir>]",
"usage: check-changelog-semver.mjs --package <name> --version <x.y.z> [--cwd <dir>] [--api-built <dll>... [--api-refs <dir>...]]",
);
process.exitCode = 2;
return;
}
if (tagAlreadyPublished(tagPrefix, version, cwd)) {
console.log(`${packageName}: ${version} is already published as ${tagPrefix}-v${version}; not re-checking a rebuild of immutable history`);
// Checked before anything reads tags: a malformed version is its own refusal, and the
// value is not repeated back.
if (!VERSION_PATTERN.test(version.trim())) {
refuse(`${packageName}: --version is not a MAJOR.MINOR.PATCH version`);
return;
}
if (!existsSync(changelog)) {
console.error(`${packageName}: changelog not found at ${changelog}`);
process.exitCode = 1;
// The package's release identity is declared once, in the repository being released.
let declared;
try {
declared = (await import(pathToFileURL(resolve(cwd, "scripts", "release-packages.mjs")).href)).default;
} catch {
refuse(`${packageName}: scripts/release-packages.mjs could not be loaded from --cwd`);
return;
}
const changelogText = readFileSync(changelog, "utf8");
const previousVersion = resolvePreviousVersion(tagPrefix, version, cwd);
const result = evaluateRelease({ packageName, changelogText, version, previousVersion });
const malformed = checkDeclaration(declared);
if (malformed) {
refuse(`${packageName}: scripts/release-packages.mjs is malformed: ${malformed}`);
return;
}
const entry = Object.hasOwn(declared ?? {}, packageName) ? declared[packageName] : null;
if (entry == null) {
refuse(`${packageName}: not a package declared in scripts/release-packages.mjs`);
return;
}
const { tagPrefix, assemblies } = entry;
const changelog = resolve(cwd, entry.changelog);
let changelogText;
try {
changelogText = readFileSync(changelog, "utf8");
} catch (err) {
refuse(`${packageName}: ${err.code === "ENOENT" ? "changelog not found" : "changelog cannot be read"} at ${changelog}`);
return;
}
if (typeof assemblies === "string" && built.length === 0) {
refuse(`${packageName}: ships the assemblies under ${assemblies}, so the release must hand them over with --api-built`);
return;
}
if (typeof assemblies !== "string" && built.length > 0) {
refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`);
return;
}
const release = resolveRelease(tagPrefix, version, cwd, assemblies);
if (release.failure) {
refuse(`${packageName}: ${release.failure}`);
return;
}
const previousVersion = release.previous;
let measuredBreaks = [];
if (previousVersion != null && built.length > 0) {
const measurement = measureApiBreaks({
tagPrefix,
previousVersion,
assemblies,
built,
refs: (args["api-refs"] ?? []).map((path) => resolve(path)),
cwd,
});
if (measurement.failure) {
refuse(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`);
return;
}
measuredBreaks = measurement.breaks;
}
const result = evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks });
console.log(result.reason);
if (!result.ok) {
process.exitCode = 1;
return;
}
if (previousVersion != null && built.length > 0) {
console.log(UNMEASURED_NOTICE);
}
}
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) {
main();
await main();
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,38 @@
// The one declaration of what each released package is, read by
// scripts/check-changelog-semver.mjs (docs/semver-policy.md) and by nothing a caller types.
// Keyed by the --package name. Paths are repo-relative.
// tagPrefix releases are tags named '<tagPrefix>-vMAJOR.MINOR.PATCH'
// changelog the package's CHANGELOG.md
// assemblies the directory whose tracked DLLs the package ships; absent when it ships none
export default {
"org.gamecult.cultlib": {
tagPrefix: "cultlib-unity",
changelog: "unity/org.gamecult.cultlib/CHANGELOG.md",
assemblies: "unity/org.gamecult.cultlib/Runtime/Plugins",
},
"org.gamecult.cultmath": {
tagPrefix: "cultmath-unity",
changelog: "packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md",
assemblies: "packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins",
},
"org.gamecult.caching.unity": {
tagPrefix: "caching-unity",
changelog: "src/GameCult.Unity/Assets/Caching/CHANGELOG.md",
},
"@gamecult/cultcache-ts": {
tagPrefix: "cultcache-ts",
changelog: "packages/cultcache-ts/CHANGELOG.md",
},
"cultcache-py": {
tagPrefix: "cultcache-py",
changelog: "packages/cultcache-py/CHANGELOG.md",
},
"cultnet-py": {
tagPrefix: "cultnet-py",
changelog: "packages/cultnet-py/CHANGELOG.md",
},
"cultmesh-py": {
tagPrefix: "cultmesh-py",
changelog: "packages/cultmesh-py/CHANGELOG.md",
},
};

View file

@ -11,15 +11,18 @@ $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
# The suite pins the checker's rules and the declared tag prefixes, so it runs first.
& node --test (Join-Path $repoRoot "scripts\check-changelog-semver.test.mjs")
if ($LASTEXITCODE -ne 0) {
throw "The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing."
}
& 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)."