Semver release: the suite runs first in CI and in the Unity scripts, and the Caching Unity prefix is its own tag family

publish-packages.yml runs the suite in both jobs, after .NET setup on a full-depth
checkout and before any check or publish step; the three Unity release scripts run
it before the checker. A test reads each file and fails when the step is removed,
moved, or made non-fatal. The Caching Unity pin now needs a prefix declared once
whose exact tags hold the package's own manifest at the tag's version. No NUL byte
in the checker is a test. The policy records that releases run from a full clone.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-10-03 11:43:43 +02:00
commit 7ec300a5aa
6 changed files with 160 additions and 15 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
@ -138,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: |

View file

@ -178,10 +178,14 @@ or a missing declaration refuses), and:
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 workflow tag trigger must be a
declared prefix, and each such package's prefix must be a workflow trigger or
a prefix some tag in the repository already carries, so a typo in the
declaration fails the suite before any release.
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;

View file

@ -48,6 +48,11 @@ foreach ($path in @($freshAssembly, $freshSymbols)) {
# 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 `

View file

@ -185,6 +185,11 @@ foreach ($assemblyName in $expectedAssemblies) {
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 `

View file

@ -663,10 +663,27 @@ test("CLI: the flags the declaration replaced are usage errors, as is any unknow
});
// The prefix of a package that declares no assemblies is proven by no tag's bytes, so this pin is
// its only guard against a typo in the declaration. Every workflow tag trigger must be a declared
// prefix, and every assembly-less package's prefix must be a workflow trigger or, for a package
// the workflow does not build, a prefix some tag in the repository already carries.
function prefixPinProblems(declared, workflow, tagNames) {
// its only guard against a typo in the declaration. Every prefix is declared once, every workflow
// tag trigger is a declared prefix, and every assembly-less package's prefix is a workflow trigger
// or, for a package the workflow does not build, names a family of tags that are that package's own:
// each tag '<prefix>-vX.Y.Z' carries the package's manifest, named and versioned as the tag is.
const familyProblem = (name, entry, tags, readFile) => {
const own = tags.filter((tag) => new RegExp(`^${entry.tagPrefix}-v\\d+\\.\\d+\\.\\d+$`).test(tag));
if (own.length === 0) return `${name} declares no assemblies and its prefix ${entry.tagPrefix} is neither a workflow trigger nor the prefix of any tag (are the tags fetched?)`;
const manifest = posixPath.join(posixPath.dirname(entry.changelog), "package.json");
for (const tag of own) {
let found;
try {
found = JSON.parse(readFile(tag, manifest));
} catch {
return `${name}: ${tag} holds no readable ${manifest}`;
}
if (found.name !== name || `${entry.tagPrefix}-v${found.version}` !== tag) return `${name}: ${tag} is not that package's release (${manifest} there is ${found.name} ${found.version})`;
}
return null;
};
function prefixPinProblems(declared, workflow, tagNames, readFile) {
const triggers = [...workflow.match(/^\s+tags:\r?\n((?:\s+- "[^"]+"\r?\n)+)/m)[1].matchAll(/- "([^"]+)-v\*"/g)].map((match) => match[1]);
const problems = [];
const prefixes = Object.values(declared).map((entry) => entry.tagPrefix);
@ -674,19 +691,20 @@ function prefixPinProblems(declared, workflow, tagNames) {
if (!prefixes.includes(trigger)) problems.push(`${trigger}-v* triggers the workflow but no package declares that prefix`);
}
for (const [name, entry] of Object.entries(declared)) {
if (entry.assemblies !== undefined) continue;
if (!triggers.includes(entry.tagPrefix) && !tagNames.some((tag) => tag.startsWith(`${entry.tagPrefix}-v`))) {
problems.push(`${name} declares no assemblies and its prefix ${entry.tagPrefix} is neither a workflow trigger nor a prefix any tag carries (are the tags fetched?)`);
}
if (prefixes.filter((prefix) => prefix === entry.tagPrefix).length > 1) problems.push(`${name} declares the prefix ${entry.tagPrefix}, which another package declares too`);
if (entry.assemblies !== undefined || triggers.includes(entry.tagPrefix)) continue;
const problem = familyProblem(name, entry, tagNames, readFile);
if (problem !== null) problems.push(problem);
}
return problems;
}
const workflowText = () => readFileSync(workflowFile, "utf8");
const repoTags = () => execFileSync("git", ["tag", "-l"], { cwd: repoRoot, encoding: "utf8" }).split("\n").filter(Boolean);
const readAtTag = (tag, path) => execFileSync("git", ["show", `refs/tags/${tag}:${path}`], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
test("workflow: the declared prefixes are pinned to the tag triggers, and every declared assemblies directory is tracked", () => {
assert.deepEqual(prefixPinProblems(releasePackages, workflowText(), repoTags()), []);
assert.deepEqual(prefixPinProblems(releasePackages, workflowText(), repoTags(), readAtTag), []);
for (const [name, entry] of Object.entries(releasePackages)) {
if (entry.assemblies === undefined) continue;
assert.notEqual(execFileSync("git", ["ls-files", "--", entry.assemblies], { cwd: repoRoot, encoding: "utf8" }).trim(), "", `${name} declares ${entry.assemblies}, which tracks nothing`);
@ -699,12 +717,97 @@ test("workflow: a typo in the declared prefix of any assembly-less package fails
for (const [name, entry] of assemblyLess) {
for (const tagPrefix of [entry.tagPrefix.slice(0, -1), `${entry.tagPrefix}s`, entry.tagPrefix.replace("-", "_")]) {
const typo = { ...releasePackages, [name]: { ...entry, tagPrefix } };
assert.notDeepEqual(prefixPinProblems(typo, workflowText(), repoTags()), [], `${name} with prefix ${tagPrefix} passed the pin`);
assert.notDeepEqual(prefixPinProblems(typo, workflowText(), repoTags(), readAtTag), [], `${name} with prefix ${tagPrefix} passed the pin`);
}
}
const withoutTrigger = workflowText().replace(/^\s+- "cultmesh-py-v\*"\r?\n/m, "");
assert.notEqual(withoutTrigger, workflowText());
assert.notDeepEqual(prefixPinProblems(releasePackages, withoutTrigger, repoTags().filter((tag) => !tag.startsWith("cultmesh-py-v"))), []);
assert.notDeepEqual(prefixPinProblems(releasePackages, withoutTrigger, repoTags().filter((tag) => !tag.startsWith("cultmesh-py-v")), readAtTag), []);
});
test("workflow: the Caching Unity prefix is its own tag family, not another package's prefix or a prefix some tag happens to carry", () => {
const cachingUnity = releasePackages["org.gamecult.caching.unity"];
// another package's declared prefix, and a prefix tags carry that no package declares
for (const tagPrefix of [...Object.values(releasePackages).map((entry) => entry.tagPrefix).filter((prefix) => prefix !== cachingUnity.tagPrefix), "cultmath-core-rs", "cultmath", "cultlib", "cultnet-ts"]) {
const copied = { ...releasePackages, "org.gamecult.caching.unity": { ...cachingUnity, tagPrefix } };
assert.notDeepEqual(prefixPinProblems(copied, workflowText(), repoTags(), readAtTag), [], `Caching Unity declaring ${tagPrefix} passed the pin`);
}
// a tag that merely starts with the prefix is not that package's tag
const tags = [...repoTags(), "caching-unity-v1.5.0-rc1", "caching-unity-extra-v1.0.0"];
assert.deepEqual(prefixPinProblems(releasePackages, workflowText(), tags, readAtTag), []);
// an own-looking tag whose tree is another package's release refuses
assert.notDeepEqual(prefixPinProblems(releasePackages, workflowText(), [...tags, "caching-unity-v9.9.9"], readAtTag), []);
// no tags at all (a clone without them) refuses rather than passing
assert.match(prefixPinProblems(releasePackages, workflowText(), [], readAtTag).join("\n"), /are the tags fetched\?/);
});
// The suite is what pins the declared prefixes and the checker's rules, so the places that release
// run it first: every job of publish-packages.yml, on a checkout that holds every tag, with .NET set
// up for the API gate, before any step that checks or publishes; and each Unity release script,
// before it calls the checker.
const SUITE = /node\s+--test[^\n]*check-changelog-semver\.test\.mjs/;
const CHECKER = /check-changelog-semver\.mjs/;
const code = (text) => text.split("\n").filter((line) => !line.trim().startsWith("#")).join("\n");
function workflowSuiteProblems(workflow) {
const [, jobs] = workflow.split(/^jobs:\r?\n/m);
const starts = [...jobs.matchAll(/^ ([\w-]+):\r?\n/gm)];
const problems = [];
if (starts.length === 0) problems.push("the workflow has no jobs");
starts.forEach((start, index) => {
const job = code(jobs.slice(start.index, starts[index + 1]?.index ?? jobs.length));
const at = (pattern) => job.search(pattern);
const suite = at(SUITE);
const label = `job ${start[1]}`;
if (suite < 0) return problems.push(`${label} does not run the suite`);
if (/continue-on-error/.test(job)) problems.push(`${label} lets a step fail without failing the job`);
if (at(/fetch-depth:\s*0\b/) < 0 || at(/fetch-depth:\s*0\b/) > suite) problems.push(`${label} runs the suite before checking out with fetch-depth: 0`);
if (at(/actions\/setup-dotnet@/) < 0 || at(/actions\/setup-dotnet@/) > suite) problems.push(`${label} runs the suite before setting up .NET`);
for (const [what, pattern] of [["the checker", CHECKER], ["a publish step", /^\s+- name: Publish\b/m]]) {
if (at(pattern) < 0) problems.push(`${label} has no ${what}`);
else if (at(pattern) < suite) problems.push(`${label} reaches ${what} before the suite`);
}
});
return problems;
}
function scriptSuiteProblems(script) {
const body = code(script);
if (!SUITE.test(body)) return ["does not run the suite"];
return body.search(CHECKER) < body.search(SUITE) ? ["calls the checker before the suite"] : [];
}
test("workflow: every job runs the suite first, with every tag fetched; removing or moving the step fails this test", () => {
const workflow = workflowText();
assert.deepEqual(workflowSuiteProblems(workflow), []);
const suiteLines = /^\s+run: node --test scripts\/check-changelog-semver\.test\.mjs\r?\n/gm;
assert.equal(workflow.match(suiteLines).length, 2);
// the step removed from either job, or from both
let removed = 0;
assert.deepEqual(workflowSuiteProblems(workflow.replace(suiteLines, (line) => (removed++ === 0 ? " run: true\n" : line))).length, 1);
assert.equal(workflowSuiteProblems(workflow.replace(suiteLines, " run: true\n")).length, 2);
// the step moved after the checker, a shallow checkout, no .NET, and a step allowed to fail
const moved = workflow
.replace(suiteLines, " run: true\n")
.replace(/(\s+- name: Verify semver policy\b)/, "\n - name: Late\n run: node --test scripts/check-changelog-semver.test.mjs$1");
assert.notDeepEqual(workflowSuiteProblems(moved), []);
assert.notDeepEqual(workflowSuiteProblems(workflow.replace(/fetch-depth: 0/g, "fetch-depth: 1")), []);
assert.notDeepEqual(workflowSuiteProblems(workflow.replace(/actions\/setup-dotnet@/g, "actions/setup-other@")), []);
assert.notDeepEqual(workflowSuiteProblems(workflow.replace(suiteLines, (line) => `${line} continue-on-error: true\n`)), []);
});
test("the Unity release scripts run the suite before the checker; removing it fails this test", () => {
const scripts = ["scripts/build-unity-package.ps1", "packages/cultmath/scripts/build-unity-package.ps1", "scripts/verify-caching-unity-release.ps1"];
for (const script of scripts) {
const text = readFileSync(join(repoRoot, script), "utf8");
assert.deepEqual(scriptSuiteProblems(text), [], script);
assert.deepEqual(scriptSuiteProblems(text.replace(new RegExp(SUITE.source, "g"), "node --version")), ["does not run the suite"], script);
assert.deepEqual(scriptSuiteProblems(`${text.replace(new RegExp(SUITE.source, "g"), "")}\n& node --test scripts/check-changelog-semver.test.mjs`), ["calls the checker before the suite"], script);
}
});
test("the checker's source is text: it holds no NUL byte, so grep reads it", () => {
assert.equal(readFileSync(checkerPath).includes(0), false);
});
// --- Measured public API. The fixtures are real assemblies, built here with

View file

@ -14,6 +14,12 @@ $manifestPath = Join-Path $packageRoot "package.json"
$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" `
--version $version `