From 716ef9341c97b246747d669ba8c1aa6da4354c2a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 17:03:09 +0200 Subject: [PATCH 01/31] Semver check: measure the public API against the previous tag's DLLs check-changelog-semver.mjs takes --api-baseline-path, --api-built and --api-refs, extracts the previous tag's tracked DLLs as git blobs, and runs the pinned Microsoft.DotNet.ApiCompat.Tool on each built assembly. Measured breaks make the release breaking for the lane check and must be declared under ### Breaking; a tool failure without a diagnostic refuses the release; a GameCult.* assembly the tag tracked and the build lacks is a break. Behavioural tests follow in the next commit. Co-Authored-By: Claude Opus 5.5 --- dotnet-tools.json | 7 ++ scripts/check-changelog-semver.mjs | 146 ++++++++++++++++++++++++++--- 2 files changed, 139 insertions(+), 14 deletions(-) diff --git a/dotnet-tools.json b/dotnet-tools.json index 6da5949f..b22db21d 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -8,6 +8,13 @@ "dotnet-stryker" ], "rollForward": false + }, + "microsoft.dotnet.apicompat.tool": { + "version": "10.0.202", + "commands": [ + "apicompat" + ], + "rollForward": false } } } diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 4dc3f398..f6564e33 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -8,9 +8,11 @@ // CLI wrapper that reads the changelog file and asks git for the previous // published tag, then calls `evaluateRelease`. -import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join } 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,12 +156,12 @@ 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 @@ -194,13 +211,95 @@ function tagAlreadyPublished(tagPrefix, version, cwd) { } } +// --- Measured public API: the built DLLs are compared with the DLLs the +// previous tag tracked, by Microsoft.DotNet.ApiCompat.Tool (pinned in +// dotnet-tools.json). Nothing here compares members itself. --- + +const repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +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: repoRoot, encoding: "utf8" }); + if (run.error) { + return { failure: `could not start dotnet: ${run.error.message}` }; + } + return readApiCompatRun({ status: run.status, output: `${run.stdout}${run.stderr}` }); +} + +// The DLLs the previous tag tracked under `baselinePath`, as git blobs: no +// worktree, no network, and unaffected by what the working tree holds now. +function extractBaseline({ tag, baselinePath, into, cwd }) { + const dir = baselinePath.replace(/\\/g, "/").replace(/\/+$/, ""); + const listing = execFileSync("git", ["ls-tree", "--name-only", tag, `${dir}/`], { cwd, encoding: "utf8" }); + const names = listing.split("\n").map((line) => basename(line.trim())).filter((name) => name.toLowerCase().endsWith(".dll")); + for (const name of names) { + const blob = execFileSync("git", ["show", `${tag}:${dir}/${name}`], { cwd, maxBuffer: 1 << 28 }); + writeFileSync(join(into, name), blob); + } + return names; +} + +// Compares each built assembly with its namesake at the previous tag. A built +// assembly with no namesake is new and measures nothing; a GameCult.* +// assembly the tag tracked and the build no longer produces is itself a break. +export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs = [], cwd }) { + const tag = `${tagPrefix}-v${previousVersion}`; + const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-")); + try { + const left = join(work, "left"); + mkdirSync(left); + const baselineNames = extractBaseline({ tag, baselinePath, into: left, cwd }); + const restore = spawnSync("dotnet", ["tool", "restore"], { cwd: repoRoot, encoding: "utf8" }); + if (restore.status !== 0) { + return { failure: `dotnet tool restore failed: ${`${restore.stdout}${restore.stderr}`.trim().split(/\r?\n/).slice(-3).join(" | ")}` }; + } + const builtNames = new Set(built.map((path) => basename(path))); + const breaks = []; + for (const name of baselineNames) { + if (name.startsWith("GameCult.") && !builtNames.has(name)) { + breaks.push(`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 rightRefs = [...new Set([dirname(path), ...refs])].join(","); + const run = runApiCompat(["--left-assembly", join(left, name), "--right-assembly", path, "--left-assembly-references", left, "--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 (--api-built, --api-refs) collect into arrays. +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 (REPEATABLE.has(key)) { + (args[key] ??= []).push(argv[i + 1]); + } else { + args[key] = argv[i + 1]; + } i += 1; } return args; @@ -209,9 +308,10 @@ function parseArgs(argv) { 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) { + if (!packageName || !changelog || !version || !tagPrefix || (args["api-built"] && !args["api-baseline-path"])) { console.error( - "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ]", + "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ]\n" + + " [--api-baseline-path --api-built ... [--api-refs ...]]", ); process.exitCode = 2; return; @@ -227,7 +327,25 @@ function main() { } const changelogText = readFileSync(changelog, "utf8"); const previousVersion = resolvePreviousVersion(tagPrefix, version, cwd); - const result = evaluateRelease({ packageName, changelogText, version, previousVersion }); + let measuredBreaks = []; + const built = args["api-built"] ?? []; + if (built.length > 0 && previousVersion != null) { + const measurement = measureApiBreaks({ + tagPrefix, + previousVersion, + baselinePath: args["api-baseline-path"], + built, + refs: args["api-refs"] ?? [], + cwd, + }); + if (measurement.failure) { + console.error(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`); + process.exitCode = 1; + return; + } + measuredBreaks = measurement.breaks; + } + const result = evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks }); console.log(result.reason); if (!result.ok) { process.exitCode = 1; From 8f2eab7651a522369f4e663bef06317a613e5e79 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 17:08:36 +0200 Subject: [PATCH 02/31] Semver check: behavioural tests for the measured public API Real assemblies built in the test and compared through the CLI: removal without ### Breaking, removal under too small a bump (post-1.0 and 0.y.z), additions alone, a declared break with nothing measured, a removed GameCult.* assembly, tool failure failing closed, inherited-member removals, an already-published version, the baseline coming from the tag's blob, and a first release. Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.test.mjs | 201 +++++++++++++++++++++++- 1 file changed, 200 insertions(+), 1 deletion(-) diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 9cc12ffd..79e3a79d 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -12,6 +12,7 @@ import { hasBreakingSection, nextVersions, parseVersion, + readApiCompatRun, } from "./check-changelog-semver.mjs"; const checkerPath = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url)); @@ -255,3 +256,201 @@ test("CLI: does not re-litigate a version already published as a tag", () => { assert.match(output, /already published/); }); }); + +// --- Measured public API. The fixtures are real assemblies, built here with +// the SDK and compared by the pinned ApiCompat tool through the CLI, so every +// rule is observed where the release scripts observe it. --- + +const fixtureRoot = mkdtempSync(join(tmpdir(), "cultlib-semver-fixtures-")); +process.on("exit", () => rmSync(fixtureRoot, { recursive: true, force: true })); +const builtFixtures = new Map(); + +// Builds `source` into .dll (netstandard2.1, like the shipped +// assemblies) once per distinct (name, source) and returns the dll's path. +function buildAssembly(assemblyName, source) { + const key = `${assemblyName}\n${source}`; + if (!builtFixtures.has(key)) { + const dir = join(fixtureRoot, `fx${builtFixtures.size}`); + mkdirSync(dir); + writeFileSync( + join(dir, "Fixture.csproj"), + `netstandard2.1` + + `${assemblyName}true`, + ); + writeFileSync(join(dir, "Source.cs"), source); + execFileSync("dotnet", ["build", "-c", "Release", "-o", "out", "--nologo", "-v", "q"], { cwd: dir, stdio: "pipe" }); + builtFixtures.set(key, join(dir, "out", `${assemblyName}.dll`)); + } + return builtFixtures.get(key); +} + +const PLUGINS = "unity/widget/Runtime/Plugins"; + +// A repo whose tag widget-v tracks `baseline` ({file name: dll path}) +// under PLUGINS, and whose working tree then holds `afterTag` instead. +function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0" }, fn) { + withTempGitRepo((dir) => { + mkdirSync(join(dir, PLUGINS), { recursive: true }); + for (const [name, path] of Object.entries(baseline)) copyFileSync(path, join(dir, PLUGINS, name)); + execFileSync("git", ["add", "."], { cwd: dir }); + execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir }); + execFileSync("git", ["tag", `widget-v${previous}`], { cwd: dir }); + if (afterTag !== baseline) { + for (const name of Object.keys(baseline)) rmSync(join(dir, PLUGINS, name)); + for (const [name, path] of Object.entries(afterTag)) copyFileSync(path, join(dir, PLUGINS, name)); + execFileSync("git", ["add", "-A", "."], { cwd: dir }); + execFileSync("git", ["commit", "-q", "-m", "after the release"], { cwd: dir }); + } + fn(dir); + }); +} + +function measuredArgs(dir, version, builtPaths, { breaking = false } = {}) { + const changelogPath = join(dir, "CHANGELOG.md"); + writeFileSync(changelogPath, changelogFor(version, { breaking })); + return [ + "--package", "widget", "--changelog", changelogPath, "--version", version, "--tag-prefix", "widget", + "--api-baseline-path", PLUGINS, ...builtPaths.flatMap((path) => ["--api-built", path]), + ]; +} + +const WITH_GONE = "namespace N { public class C { public void Keep() {} public void Gone() {} } }"; +const WITHOUT_GONE = "namespace N { public class C { public void Keep() {} } }"; +const WITH_ADDED = "namespace N { public class C { public void Keep() {} public void Gone() {} public void Added() {} } }"; + +const oldWidget = () => buildAssembly("GameCult.Widget", WITH_GONE); +const trimmedWidget = () => buildAssembly("GameCult.Widget", WITHOUT_GONE); + +test("measured: a removed public member without ### Breaking is refused and named", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()])); + assert.match(output, /Gone/); + assert.match(output, /declare them under "### Breaking"/); + assert.match(output, /breaks 1 public API member/); + }); +}); + +test("measured: a removed member with the heading needs a major bump after 1.0 and a minor bump before it", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const tooSmall = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()], { breaking: true })); + assert.match(tooSmall, /needs a major bump/); + const ok = runChecker(dir, measuredArgs(dir, "2.0.0", [trimmedWidget()], { breaking: true })); + assert.match(ok, /2\.0\.0 is a major bump/); + }); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() }, previous: "0.4.0" }, (dir) => { + const patch = runCheckerExpectFailure(dir, measuredArgs(dir, "0.4.1", [trimmedWidget()], { breaking: true })); + assert.match(patch, /at least a minor bump/); + const ok = runChecker(dir, measuredArgs(dir, "0.5.0", [trimmedWidget()], { breaking: true })); + assert.match(ok, /0\.5\.0 is a minor bump/); + }); +}); + +test("measured: additions alone pass under a patch bump with no heading", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const output = runChecker(dir, measuredArgs(dir, "1.0.1", [buildAssembly("GameCult.Widget", WITH_ADDED)])); + assert.match(output, /1\.0\.1 is a patch bump over 1\.0\.0, no breaking section/); + }); +}); + +test("measured: a declared break with nothing measured still passes", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const output = runChecker(dir, measuredArgs(dir, "2.0.0", [oldWidget()], { breaking: true })); + assert.match(output, /2\.0\.0 is a major bump over 1\.0\.0 and has a "### Breaking" section, which agree/); + }); +}); + +test("measured: a removed GameCult.* assembly is a break, a new assembly and a removed third-party one are not", () => { + const other = buildAssembly("GameCult.Other", "namespace O { public class D {} }"); + const thirdParty = buildAssembly("Vendor.Lib", "namespace V { public class E {} }"); + const baseline = { "GameCult.Widget.dll": oldWidget(), "GameCult.Other.dll": other, "Vendor.Lib.dll": thirdParty }; + withReleasedBaseline({ baseline }, (dir) => { + const refused = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [oldWidget()])); + assert.match(refused, /GameCult\.Other\.dll exists at widget-v1\.0\.0 but is no longer built/); + assert.doesNotMatch(refused, /Vendor\.Lib/); + assert.match(refused, /breaks 1 public API member/); + const brandNew = buildAssembly("GameCult.Brand", "namespace B { public class F {} }"); + const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, brandNew])); + assert.match(ok, /1\.0\.1 is a patch bump/); + }); +}); + +test("measured: a tool failure with no diagnostic fails closed", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const notAnAssembly = join(dir, "GameCult.Widget.dll"); + writeFileSync(notAnAssembly, "not an assembly"); + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [notAnAssembly])); + assert.match(output, /could not be measured/); + assert.match(output, /without measuring/); + }); +}); + +test("readApiCompatRun: diagnostics are breaks, an empty failure is a failure, an empty success is clean", () => { + const breaks = readApiCompatRun({ status: 1, output: "API compatibility errors between a (left) and b (right):\nCP0002: Member 'void C.M()' exists on a but not on b\n" }); + assert.deepEqual(breaks, { breaks: ["CP0002: Member 'void C.M()' exists on a but not on b"] }); + assert.match(readApiCompatRun({ status: 1, output: "Unhandled exception: boom" }).failure, /exited 1 without measuring: Unhandled exception: boom/); + assert.deepEqual(readApiCompatRun({ status: 0, output: "" }), { breaks: [] }); + assert.deepEqual(readApiCompatRun({ status: 0, output: "Could not resolve reference 'netstandard.dll'\n" }), { breaks: [] }); +}); + +test("measured: removed inherited members are caught although netstandard cannot be resolved", () => { + // The shipped assemblies are netstandard2.1 and ApiCompat cannot resolve netstandard.dll + // for them; both ways an inherited member can disappear are still reported. + const viaNetstandardBase = buildAssembly("GameCult.Widget", "namespace N { public class C : System.Collections.Generic.List { } }"); + const baseDropped = buildAssembly("GameCult.Widget", "namespace N { public class C { } }"); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": viaNetstandardBase } }, (dir) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [baseDropped])); + assert.match(output, /does not inherit from base type 'System.Collections.Generic.List'/); + }); + const inheritsInh = buildAssembly("GameCult.Widget", "namespace N { public class B { public void Inh() {} } public class C : B { } }"); + const baseLostInh = buildAssembly("GameCult.Widget", "namespace N { public class B { } public class C : B { } }"); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": inheritsInh } }, (dir) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [baseLostInh])); + assert.match(output, /N.B.Inh()/); + }); +}); + +test("measured: an already-published version is not measured", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const junk = join(dir, "junk.dll"); + writeFileSync(junk, "junk"); + const output = runChecker(dir, measuredArgs(dir, "1.0.0", [junk], { breaking: true })); + assert.match(output, /already published/); + }); +}); + +test("measured: the baseline is the tag's blob, not the working tree's DLL", () => { + withReleasedBaseline( + { baseline: { "GameCult.Widget.dll": oldWidget() }, afterTag: { "GameCult.Widget.dll": trimmedWidget() } }, + (dir) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()])); + assert.match(output, /Gone/); + }, + ); +}); + +test("measured: a first release measures nothing", () => { + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + const junk = join(dir, "junk.dll"); + writeFileSync(junk, "junk"); + const output = runChecker(dir, measuredArgs(dir, "1.0.0", [junk])); + assert.match(output, /first release/); + }); +}); + +test("evaluateRelease: measured breaks count as breaking for the lane, declared or not", () => { + const evaluate = (version, previousVersion, breaking, measuredBreaks) => + evaluateRelease({ packageName: "p", changelogText: changelogFor(version, { breaking }), version, previousVersion, measuredBreaks }); + assert.equal(evaluate("1.1.0", "1.0.0", false, ["CP0002: x"]).ok, false); + assert.equal(evaluate("1.1.0", "1.0.0", true, ["CP0002: x"]).ok, false); + assert.equal(evaluate("2.0.0", "1.0.0", true, ["CP0002: x"]).ok, true); + assert.equal(evaluate("0.2.0", "0.1.0", true, ["CP0002: x"]).ok, true); + assert.equal(evaluate("0.1.1", "0.1.0", true, ["CP0002: x"]).ok, false); + assert.equal(evaluate("1.0.1", "1.0.0", false, []).ok, true); + const many = Array.from({ length: 7 }, (_, i) => `CP0002: m${i}`); + const reason = evaluate("1.1.0", "1.0.0", false, many).reason; + assert.match(reason, /breaks 7 public API/); + assert.match(reason, /m4/); + assert.doesNotMatch(reason, /m5/); + assert.match(reason, /; \.\.\./); +}); From b8e1f7e0f6e525a1bf9180725a1645a3bff71fd0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 17:08:36 +0200 Subject: [PATCH 03/31] Unity release scripts: run the semver check after the build, with the built DLLs The check moves from before the build to once the assemblies under judgement exist, and receives every shipped GameCult.* DLL (cultlib) or the fresh CultMath.dll with the baseline path of the previous tag's tracked plugins. The early calls are deleted; each script keeps exactly one. Co-Authored-By: Claude Opus 5.5 --- .../cultmath/scripts/build-unity-package.ps1 | 38 +++++++++--------- scripts/build-unity-package.ps1 | 39 ++++++++++++------- 2 files changed, 43 insertions(+), 34 deletions(-) diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 index 228d735c..390f523f 100644 --- a/packages/cultmath/scripts/build-unity-package.ps1 +++ b/packages/cultmath/scripts/build-unity-package.ps1 @@ -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,25 @@ 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 +& 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 ` + --api-baseline-path "packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins" ` + --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", diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 9de7c718..0539863a 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -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,30 @@ 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 GameCult.* assembly against the DLLs the previous cultlib-unity tag +# tracked (read from git), so it runs here, once the assemblies under judgement exist. +$measuredArguments = @() +foreach ($assemblyName in $expectedAssemblies) { + if ($assemblyName.StartsWith("GameCult.")) { + $measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName) + } +} +foreach ($refRoot in @($publishRoot, $quicPublishRoot, $webSocketPublishRoot)) { + $measuredArguments += @("--api-refs", $refRoot) +} +& 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 ` + --api-baseline-path "unity/org.gamecult.cultlib/Runtime/Plugins" ` + @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) { From 65bc144da0d0ecfb1804c0e2ed3be9b84e40337d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 17:08:36 +0200 Subject: [PATCH 04/31] Semver policy: describe the measured input and its failure rule Co-Authored-By: Claude Opus 5.5 --- docs/semver-policy.md | 53 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 5 deletions(-) diff --git a/docs/semver-policy.md b/docs/semver-policy.md index e1af0365..92c66d6d 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -148,13 +148,52 @@ tag prefix, it: 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 + +The Unity release scripts pass `--api-baseline-path ` and one `--api-built ` per assembly they built (plus +`--api-refs ` for directories that hold the built assemblies' +dependencies). For each built DLL that has a namesake under the baseline path +at the previous tag, the checker reads that namesake as a git blob at the tag +(no worktree, no network, unaffected by what the working tree tracks now) and +compares public API with `Microsoft.DotNet.ApiCompat.Tool`, pinned in +`dotnet-tools.json` and run through `dotnet tool run`; the first +`dotnet tool restore` needs NuGet, and later runs use the local cache. Each +`CPnnnn` diagnostic is one measured break. A built DLL with no baseline is new +and measures nothing. A `GameCult.*` assembly the previous tag tracked and the +build no longer produces is itself a measured break. A package's first release +(no previous tag) measures nothing. + +The tool failing to measure (not installed, an unreadable assembly, a crash: +a non-zero exit with no diagnostic line) refuses the release; it is never a +pass. ApiCompat cannot resolve `netstandard.dll` for the shipped +netstandard2.1 assemblies and prints a resolution warning; removed inherited +members and base types are still reported, and a test pins that. No +suppression file is kept: a suppression would be a second, silent declaration +of a break, so a break is declared only under `### Breaking`. + +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 ---changelog --version --tag-prefix `. Its own tests +--changelog --version --tag-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 scripts/check-changelog-semver.test.mjs`). @@ -193,6 +232,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 — From ed31a9768947c960246eee1e2bb6c9a5b096bbe5 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 18:01:37 +0200 Subject: [PATCH 05/31] Semver check tests: cache built fixture assemblies by content Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.test.mjs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 79e3a79d..40d0f48c 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -261,17 +262,18 @@ test("CLI: does not re-litigate a version already published as a tag", () => { // the SDK and compared by the pinned ApiCompat tool through the CLI, so every // rule is observed where the release scripts observe it. --- -const fixtureRoot = mkdtempSync(join(tmpdir(), "cultlib-semver-fixtures-")); -process.on("exit", () => rmSync(fixtureRoot, { recursive: true, force: true })); -const builtFixtures = new Map(); +// Built fixtures are cached by content under the temp directory, so reruns (and a +// mutation tool's many reruns) do not rebuild the same tiny assemblies. +const fixtureRoot = join(tmpdir(), "cultlib-semver-fixtures"); // Builds `source` into .dll (netstandard2.1, like the shipped // assemblies) once per distinct (name, source) and returns the dll's path. function buildAssembly(assemblyName, source) { - const key = `${assemblyName}\n${source}`; - if (!builtFixtures.has(key)) { - const dir = join(fixtureRoot, `fx${builtFixtures.size}`); - mkdirSync(dir); + const dir = join(fixtureRoot, createHash("sha256").update(`${assemblyName} +${source}`).digest("hex").slice(0, 16)); + const dll = join(dir, "out", `${assemblyName}.dll`); + if (!existsSync(dll)) { + mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "Fixture.csproj"), `netstandard2.1` + @@ -279,9 +281,8 @@ function buildAssembly(assemblyName, source) { ); writeFileSync(join(dir, "Source.cs"), source); execFileSync("dotnet", ["build", "-c", "Release", "-o", "out", "--nologo", "-v", "q"], { cwd: dir, stdio: "pipe" }); - builtFixtures.set(key, join(dir, "out", `${assemblyName}.dll`)); } - return builtFixtures.get(key); + return dll; } const PLUGINS = "unity/widget/Runtime/Plugins"; From 6a3dae89548cc4451fd2b386b3328a57d09072fd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 18:32:38 +0200 Subject: [PATCH 06/31] Semver check: run tests from outside the repo, pin cleanup, usage and restore failure The checker now runs with the target repo as its working directory in tests, so the tool manifest is found from the script's own location as it is in the release scripts. Adds tests for usage errors, a missing changelog, backslash and trailing-slash baseline paths, temp-file cleanup, a restore failure and a missing dotnet failing closed, the five-line failure excerpt and the five-diagnostic cap. Drops two unreachable defaults and an unreachable spawn-error branch from the checker. Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.mjs | 8 +- scripts/check-changelog-semver.test.mjs | 122 ++++++++++++++++++++++-- 2 files changed, 119 insertions(+), 11 deletions(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index f6564e33..17c918c6 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -232,9 +232,6 @@ export function readApiCompatRun({ status, output }) { function runApiCompat(args) { const run = spawnSync("dotnet", ["tool", "run", "apicompat", ...args], { cwd: repoRoot, encoding: "utf8" }); - if (run.error) { - return { failure: `could not start dotnet: ${run.error.message}` }; - } return readApiCompatRun({ status: run.status, output: `${run.stdout}${run.stderr}` }); } @@ -254,7 +251,7 @@ function extractBaseline({ tag, baselinePath, into, cwd }) { // Compares each built assembly with its namesake at the previous tag. A built // assembly with no namesake is new and measures nothing; a GameCult.* // assembly the tag tracked and the build no longer produces is itself a break. -export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs = [], cwd }) { +export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, cwd }) { const tag = `${tagPrefix}-v${previousVersion}`; const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-")); try { @@ -263,7 +260,8 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui const baselineNames = extractBaseline({ tag, baselinePath, into: left, cwd }); const restore = spawnSync("dotnet", ["tool", "restore"], { cwd: repoRoot, encoding: "utf8" }); if (restore.status !== 0) { - return { failure: `dotnet tool restore failed: ${`${restore.stdout}${restore.stderr}`.trim().split(/\r?\n/).slice(-3).join(" | ")}` }; + const detail = restore.error ? restore.error.message : `${restore.stdout}${restore.stderr}`.trim().split(/\r?\n/).slice(-3).join(" | "); + return { failure: `dotnet tool restore failed: ${detail}` }; } const builtNames = new Set(built.map((path) => basename(path))); const breaks = []; diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 40d0f48c..5d74083e 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -204,19 +204,23 @@ function withTempGitRepo(fn) { } } -function runChecker(cwd, args) { - return execFileSync(process.execPath, [checkerPath, ...args, "--cwd", cwd], { +// Runs the checker with `cwd` as its working directory, which is never the +// repository that holds the checker: the release scripts do not run it from there either. +function runChecker(cwd, args, { env, script = checkerPath } = {}) { + return execFileSync(process.execPath, [script, ...args, "--cwd", cwd], { + cwd, + env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], }); } -function runCheckerExpectFailure(cwd, args) { +function runCheckerExpectFailure(cwd, args, { status = 1, ...options } = {}) { try { - runChecker(cwd, args); + runChecker(cwd, args, options); throw new Error("expected the checker to exit non-zero"); } catch (err) { - assert.equal(err.status, 1); + assert.equal(err.status, status); return err.stdout + err.stderr; } } @@ -455,3 +459,109 @@ test("evaluateRelease: measured breaks count as breaking for the lane, declared assert.doesNotMatch(reason, /m5/); assert.match(reason, /; \.\.\./); }); + +test("measured: every missing required option is a usage error", () => { + withTempGitRepo((dir) => { + const complete = ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.1.0", "--tag-prefix", "widget"]; + for (let i = 0; i < complete.length; i += 2) { + const withoutOne = complete.filter((_, index) => index !== i && index !== i + 1); + const output = runCheckerExpectFailure(dir, withoutOne, { status: 2 }); + assert.match(output, /usage: check-changelog-semver\.mjs/); + assert.match(output, /--api-baseline-path/); + } + const builtWithoutBaseline = runCheckerExpectFailure(dir, [...complete, "--api-built", "x.dll"], { status: 2 }); + assert.match(builtWithoutBaseline, /usage:/); + }); +}); + +test("CLI: a missing changelog fails and names the path; stray positional words are ignored", () => { + withTempGitRepo((dir) => { + const missing = join(dir, "NOPE.md"); + const output = runCheckerExpectFailure(dir, ["stray", "--package", "widget", "--changelog", missing, "--version", "1.1.0", "--tag-prefix", "widget"]); + assert.match(output, /changelog not found at .*NOPE.md/); + }); +}); + +test("measured: the baseline path may use backslashes or a trailing slash", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + for (const path of [`${PLUGINS}/`, PLUGINS.replace(/\//g, "\\")]) { + const args = measuredArgs(dir, "1.1.0", [trimmedWidget()]); + args[args.indexOf("--api-baseline-path") + 1] = path; + assert.match(runCheckerExpectFailure(dir, args), /Gone/); + } + }); +}); + +test("measured: leaves no temporary files behind, passing or failing", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-scratch-")); + try { + const env = { ...process.env, TMPDIR: scratch, TEMP: scratch, TMP: scratch }; + runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env }); + assert.deepEqual(readdirSync(scratch), []); + runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]), { env }); + assert.deepEqual(readdirSync(scratch), []); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +test("measured: a tool that cannot be restored fails closed and says why", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + // The checker finds its tool manifest next to its own scripts directory; a copy without one cannot restore. + const bare = mkdtempSync(join(tmpdir(), "cultlib-semver-bare-")); + try { + mkdirSync(join(bare, "scripts")); + const script = join(bare, "scripts", "check-changelog-semver.mjs"); + copyFileSync(checkerPath, script); + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); + assert.match(output, /could not be measured/); + assert.match(output, /dotnet tool restore failed: .*manifest/i); + } finally { + rmSync(bare, { recursive: true, force: true }); + } + }); +}); + +test("measured: dotnet missing from the PATH fails closed", { skip: process.platform === "win32" }, () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const gitOnly = mkdtempSync(join(tmpdir(), "cultlib-semver-path-")); + try { + const git = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim(); + symlinkSync(git, join(gitOnly, "git")); + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env: { ...process.env, PATH: gitOnly } }); + assert.match(output, /dotnet tool restore failed: .*ENOENT/); + } finally { + rmSync(gitOnly, { recursive: true, force: true }); + } + }); +}); + +test("readApiCompatRun: only line-initial diagnostics count, and a failure shows the first five trimmed lines", () => { + assert.deepEqual(readApiCompatRun({ status: 1, output: "warning CP1002: not at the start\nCP0002: Member 'x'\n" }), { breaks: ["CP0002: Member 'x'"] }); + const failure = readApiCompatRun({ status: 3, output: "\n one\ntwo\nthree\nfour\nfive\nsix\n" }).failure; + assert.equal(failure, "apicompat exited 3 without measuring: one | two | three | four | five"); +}); + +test("evaluateRelease: lists at most five measured breaks, and says nothing more for five", () => { + const evaluate = (count) => + evaluateRelease({ + packageName: "p", + changelogText: changelogFor("1.1.0"), + version: "1.1.0", + previousVersion: "1.0.0", + measuredBreaks: Array.from({ length: count }, (_, i) => `CP0002: m${i}`), + }).reason; + assert.match(evaluate(5), /\(CP0002: m0; CP0002: m1; CP0002: m2; CP0002: m3; CP0002: m4\);/); + assert.doesNotMatch(evaluate(5), /\.\.\./); + assert.match(evaluate(6), /m4; \.\.\.\);/); + const agreed = evaluateRelease({ + packageName: "p", + changelogText: changelogFor("2.0.0", { breaking: true }), + version: "2.0.0", + previousVersion: "1.0.0", + measuredBreaks: ["CP0002: m0"], + }).reason; + assert.match(agreed, /a "### Breaking" section and 1 measured public API break\(s\)/); +}); From f40069dd88e96ddd6c4d5435a50d7b8a9db7cf3e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 18:34:05 +0200 Subject: [PATCH 07/31] Semver check tests: scope the cleanup assertion to the checker's own temp dirs, cover a broken manifest Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.test.mjs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 5d74083e..0d37a300 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -497,27 +497,32 @@ test("measured: leaves no temporary files behind, passing or failing", () => { const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-scratch-")); try { const env = { ...process.env, TMPDIR: scratch, TEMP: scratch, TMP: scratch }; + const leftovers = () => readdirSync(scratch).filter((name) => name.startsWith("cultlib-apicompat-")); runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env }); - assert.deepEqual(readdirSync(scratch), []); + assert.deepEqual(leftovers(), []); runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]), { env }); - assert.deepEqual(readdirSync(scratch), []); + assert.deepEqual(leftovers(), []); } finally { rmSync(scratch, { recursive: true, force: true }); } }); }); -test("measured: a tool that cannot be restored fails closed and says why", () => { +test("measured: a missing or unrestorable tool fails closed and says why", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - // The checker finds its tool manifest next to its own scripts directory; a copy without one cannot restore. + // The checker finds its tool manifest next to its own scripts directory. const bare = mkdtempSync(join(tmpdir(), "cultlib-semver-bare-")); try { mkdirSync(join(bare, "scripts")); const script = join(bare, "scripts", "check-changelog-semver.mjs"); copyFileSync(checkerPath, script); - const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); - assert.match(output, /could not be measured/); - assert.match(output, /dotnet tool restore failed: .*manifest/i); + const noManifest = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); + assert.match(noManifest, /could not be measured/); + assert.match(noManifest, /without measuring: Cannot find a tool in the manifest/); + writeFileSync(join(bare, "dotnet-tools.json"), "{ this is not a manifest"); + const brokenManifest = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); + assert.match(brokenManifest, /could not be measured/); + assert.match(brokenManifest, /dotnet tool restore failed: S/); } finally { rmSync(bare, { recursive: true, force: true }); } From 84a34b8735c1d5f5f0e6af3e0a3d1998efd3d12f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 18:35:27 +0200 Subject: [PATCH 08/31] Semver check tests: pin the broken-manifest failure text Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.test.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 0d37a300..039895fd 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -522,7 +522,7 @@ test("measured: a missing or unrestorable tool fails closed and says why", () => writeFileSync(join(bare, "dotnet-tools.json"), "{ this is not a manifest"); const brokenManifest = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); assert.match(brokenManifest, /could not be measured/); - assert.match(brokenManifest, /dotnet tool restore failed: S/); + assert.match(brokenManifest, /dotnet tool restore failed: Json parsing error/); } finally { rmSync(bare, { recursive: true, force: true }); } From 32f5700b2ae25a87e2ed62c6bef3663520bef44c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 19:03:00 +0200 Subject: [PATCH 09/31] Semver check tests: double trailing slash, and a process started outside the target repo Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.test.mjs | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 039895fd..68e36f57 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -204,11 +204,12 @@ function withTempGitRepo(fn) { } } -// Runs the checker with `cwd` as its working directory, which is never the -// repository that holds the checker: the release scripts do not run it from there either. -function runChecker(cwd, args, { env, script = checkerPath } = {}) { +// Runs the checker on the repository at `cwd` (--cwd). The process itself starts in +// `processCwd`, by default that same repository, never the one that holds the checker: +// the release scripts do not run it from there either. +function runChecker(cwd, args, { env, script = checkerPath, processCwd = cwd } = {}) { return execFileSync(process.execPath, [script, ...args, "--cwd", cwd], { - cwd, + cwd: processCwd, env, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], @@ -484,7 +485,7 @@ test("CLI: a missing changelog fails and names the path; stray positional words test("measured: the baseline path may use backslashes or a trailing slash", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - for (const path of [`${PLUGINS}/`, PLUGINS.replace(/\//g, "\\")]) { + for (const path of [`${PLUGINS}/`, `${PLUGINS}//`, PLUGINS.replace(/\//g, "\\")]) { const args = measuredArgs(dir, "1.1.0", [trimmedWidget()]); args[args.indexOf("--api-baseline-path") + 1] = path; assert.match(runCheckerExpectFailure(dir, args), /Gone/); @@ -492,6 +493,18 @@ test("measured: the baseline path may use backslashes or a trailing slash", () = }); }); +test("measured: reads the repository named by --cwd, not the directory the process started in", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const elsewhere = mkdtempSync(join(tmpdir(), "cultlib-semver-elsewhere-")); + try { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]), { processCwd: elsewhere }); + assert.match(output, /Gone/); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } + }); +}); + test("measured: leaves no temporary files behind, passing or failing", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-scratch-")); From 83e3f81263528b8ad559a6ff310eb2ea5629fb41 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 19:52:14 +0200 Subject: [PATCH 10/31] Semver check: measuring nothing refuses; reference assemblies; tool restored alone - A missing or moved baseline path, a previous tag that does not track the DLLs, and a built DLL with no namesake at the tag (unless declared with --api-new) refuse, naming what is missing. - First release is declared with --first-release, never inferred: no previous tag, or tags that cannot be read, refuse without it. - The "already published" skip is removed; a local tag of the released version exempts nothing. - ApiCompat and the pinned NETStandard.Library.Ref 2.1.0 live in scripts/api-gate and are restored there, so the root manifest (dotnet-stryker) is no longer needed by a release. Both sides resolve netstandard through the reference assemblies. - Tests: refusals, netstandard false breaks, cross-assembly references, restore diagnostics. Co-Authored-By: Claude Opus 5.5 --- dotnet-tools.json | 7 - scripts/api-gate/.config/dotnet-tools.json | 13 + scripts/api-gate/.gitignore | 1 + scripts/api-gate/ApiRefs.csproj | 11 + scripts/check-changelog-semver.mjs | 163 ++++++++----- scripts/check-changelog-semver.test.mjs | 265 +++++++++++++++++---- 6 files changed, 350 insertions(+), 110 deletions(-) create mode 100644 scripts/api-gate/.config/dotnet-tools.json create mode 100644 scripts/api-gate/.gitignore create mode 100644 scripts/api-gate/ApiRefs.csproj diff --git a/dotnet-tools.json b/dotnet-tools.json index b22db21d..6da5949f 100644 --- a/dotnet-tools.json +++ b/dotnet-tools.json @@ -8,13 +8,6 @@ "dotnet-stryker" ], "rollForward": false - }, - "microsoft.dotnet.apicompat.tool": { - "version": "10.0.202", - "commands": [ - "apicompat" - ], - "rollForward": false } } } diff --git a/scripts/api-gate/.config/dotnet-tools.json b/scripts/api-gate/.config/dotnet-tools.json new file mode 100644 index 00000000..fc58fff7 --- /dev/null +++ b/scripts/api-gate/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "microsoft.dotnet.apicompat.tool": { + "version": "10.0.202", + "commands": [ + "apicompat" + ], + "rollForward": false + } + } +} diff --git a/scripts/api-gate/.gitignore b/scripts/api-gate/.gitignore new file mode 100644 index 00000000..23053de0 --- /dev/null +++ b/scripts/api-gate/.gitignore @@ -0,0 +1 @@ +packages/ diff --git a/scripts/api-gate/ApiRefs.csproj b/scripts/api-gate/ApiRefs.csproj new file mode 100644 index 00000000..68c63f07 --- /dev/null +++ b/scripts/api-gate/ApiRefs.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.1 + $(MSBuildThisFileDirectory)packages + + + + + diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 17c918c6..5cca0c4d 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -9,7 +9,7 @@ // published tag, then calls `evaluateRelease`. import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; @@ -168,15 +168,12 @@ export function evaluateRelease({ packageName, changelogText, version, previousV // changelog off disk, then delegates to the pure functions above. --- function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { - let tags; - try { - tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8" }) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - } catch { - return null; - } + // A directory that cannot list tags (not a git repository, git missing) throws: that + // is not evidence that there was no previous release. + const tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); const prefix = `${tagPrefix}-v`; const current = parseVersion(currentVersion); let best = null; @@ -198,24 +195,13 @@ function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { return best ? formatVersion(best) : null; } -// True when `-v` already exists. The check gates a NEW -// release; rebuilding an already-tagged, already-immutable version (routine -// local iteration, CI re-runs) is not a release action and should not keep -// re-litigating history on every build. -function tagAlreadyPublished(tagPrefix, version, cwd) { - try { - const output = execFileSync("git", ["tag", "-l", `${tagPrefix}-v${version}`], { cwd, encoding: "utf8" }); - return output.trim().length > 0; - } catch { - return false; - } -} - // --- Measured public API: the built DLLs are compared with the DLLs the -// previous tag tracked, by Microsoft.DotNet.ApiCompat.Tool (pinned in -// dotnet-tools.json). Nothing here compares members itself. --- +// 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 repoRoot = dirname(dirname(fileURLToPath(import.meta.url))); +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 @@ -231,10 +217,36 @@ export function readApiCompatRun({ status, output }) { } function runApiCompat(args) { - const run = spawnSync("dotnet", ["tool", "run", "apicompat", ...args], { cwd: repoRoot, encoding: "utf8" }); + 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 }; +} + // The DLLs the previous tag tracked under `baselinePath`, as git blobs: no // worktree, no network, and unaffected by what the working tree holds now. function extractBaseline({ tag, baselinePath, into, cwd }) { @@ -245,25 +257,35 @@ function extractBaseline({ tag, baselinePath, into, cwd }) { const blob = execFileSync("git", ["show", `${tag}:${dir}/${name}`], { cwd, maxBuffer: 1 << 28 }); writeFileSync(join(into, name), blob); } - return names; + return { dir, names }; } -// Compares each built assembly with its namesake at the previous tag. A built -// assembly with no namesake is new and measures nothing; a GameCult.* -// assembly the tag tracked and the build no longer produces is itself a break. -export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, cwd }) { +// Compares each built assembly with its namesake at the previous tag. Measuring +// nothing is a failure: the baseline path must hold DLLs at the tag, and every +// built assembly must have a namesake there unless the caller declares it new +// (`declaredNew`). A GameCult.* assembly the tag tracked and the build no longer +// produces is itself a break. +export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, declaredNew = [], cwd }) { const tag = `${tagPrefix}-v${previousVersion}`; const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-")); try { const left = join(work, "left"); mkdirSync(left); - const baselineNames = extractBaseline({ tag, baselinePath, into: left, cwd }); - const restore = spawnSync("dotnet", ["tool", "restore"], { cwd: repoRoot, encoding: "utf8" }); - if (restore.status !== 0) { - const detail = restore.error ? restore.error.message : `${restore.stdout}${restore.stderr}`.trim().split(/\r?\n/).slice(-3).join(" | "); - return { failure: `dotnet tool restore failed: ${detail}` }; + const { dir, names: baselineNames } = extractBaseline({ tag, baselinePath, into: left, cwd }); + if (baselineNames.length === 0) { + return { failure: `${tag} tracks no assembly under ${dir}: the baseline path is missing or moved, or that tag does not track the DLLs` }; } const builtNames = new Set(built.map((path) => basename(path))); + for (const name of declaredNew) { + if (baselineNames.includes(name)) return { failure: `${name} is declared new but ${tag} already tracks it under ${dir}` }; + } + for (const name of builtNames) { + if (!baselineNames.includes(name) && !declaredNew.includes(name)) { + return { failure: `${name} is built but ${tag} does not track it under ${dir}; if it is a new assembly, declare it with --api-new ${name}` }; + } + } + const gate = prepareGate(); + if (gate.failure) return gate; const breaks = []; for (const name of baselineNames) { if (name.startsWith("GameCult.") && !builtNames.has(name)) { @@ -273,8 +295,9 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui for (const path of built) { const name = basename(path); if (!baselineNames.includes(name)) continue; - const rightRefs = [...new Set([dirname(path), ...refs])].join(","); - const run = runApiCompat(["--left-assembly", join(left, name), "--right-assembly", path, "--left-assembly-references", left, "--right-assembly-references", rightRefs]); + 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); } @@ -284,8 +307,9 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui } } -// Repeatable options (--api-built, --api-refs) collect into arrays. -const REPEATABLE = new Set(["api-built", "api-refs"]); +// Repeatable options collect into arrays; flags take no value. +const REPEATABLE = new Set(["api-built", "api-refs", "api-new"]); +const FLAGS = new Set(["first-release"]); function parseArgs(argv) { const args = {}; @@ -293,6 +317,10 @@ function parseArgs(argv) { const arg = argv[i]; if (!arg.startsWith("--")) continue; const key = arg.slice(2); + if (FLAGS.has(key)) { + args[key] = true; + continue; + } if (REPEATABLE.has(key)) { (args[key] ??= []).push(argv[i + 1]); } else { @@ -303,30 +331,53 @@ function parseArgs(argv) { return args; } +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"'; + 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 || (args["api-built"] && !args["api-baseline-path"])) { + const built = args["api-built"] ?? []; + if (!packageName || !changelog || !version || !tagPrefix || (built.length > 0) !== Boolean(args["api-baseline-path"])) { console.error( - "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ]\n" + - " [--api-baseline-path --api-built ... [--api-refs ...]]", + "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ] [--first-release]\n" + + " [--api-baseline-path --api-built ... [--api-refs ...] [--api-new ...]]", ); process.exitCode = 2; return; } - if (tagAlreadyPublished(tagPrefix, version, cwd)) { - console.log(`${packageName}: ${version} is already published as ${tagPrefix}-v${version}; not re-checking a rebuild of immutable history`); - return; - } if (!existsSync(changelog)) { - console.error(`${packageName}: changelog not found at ${changelog}`); - process.exitCode = 1; + refuse(`${packageName}: changelog not found at ${changelog}`); return; } const changelogText = readFileSync(changelog, "utf8"); - const previousVersion = resolvePreviousVersion(tagPrefix, version, cwd); + const firstRelease = args["first-release"] === true; + let previousVersion; + try { + previousVersion = resolvePreviousVersion(tagPrefix, version, cwd); + } catch { + if (!firstRelease) { + refuse(`${packageName}: the tags of --cwd could not be read (not a git repository?), so the previous release is unknown; ` + + "if this is the package's first release, declare it with --first-release"); + return; + } + previousVersion = null; + } + if (previousVersion == null && !firstRelease) { + refuse(`${packageName}: no ${tagPrefix}-v tag older than ${version} exists, so there is nothing to compare against; ` + + "if this is the package's first release, declare it with --first-release"); + return; + } + if (previousVersion != null && firstRelease) { + refuse(`${packageName}: --first-release was declared but ${tagPrefix}-v${previousVersion} already exists`); + return; + } let measuredBreaks = []; - const built = args["api-built"] ?? []; if (built.length > 0 && previousVersion != null) { const measurement = measureApiBreaks({ tagPrefix, @@ -334,11 +385,11 @@ function main() { baselinePath: args["api-baseline-path"], built, refs: args["api-refs"] ?? [], + declaredNew: args["api-new"] ?? [], cwd, }); if (measurement.failure) { - console.error(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`); - process.exitCode = 1; + refuse(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`); return; } measuredBreaks = measurement.breaks; @@ -347,6 +398,10 @@ function main() { console.log(result.reason); if (!result.ok) { process.exitCode = 1; + return; + } + if (previousVersion != null && built.length > 0) { + console.log(UNMEASURED_NOTICE); } } diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 68e36f57..141e29c3 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { @@ -248,18 +248,50 @@ test("CLI: fails when the changelog claims breaking under too small a bump", () }); }); -test("CLI: does not re-litigate a version already published as a tag", () => { +test("CLI: a tag for the version being released does not skip the check", () => { withTempGitRepo((dir) => { execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - // 1.0.0 itself is a scar: its own changelog claims breaking under a - // patch-looking first release. Rebuilding it locally (no new version) - // must not fail just because history already shipped that way. - execFileSync("git", ["tag", "widget-v0.9.0"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.1"], { cwd: dir }); const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.0", { breaking: true })); - const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]); - assert.match(output, /already published/); + writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true })); + const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); + assert.match(output, /needs a major bump/); + }); +}); + +test("CLI: no previous tag, or tags that cannot be read, refuse unless the first release is declared", () => { + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + execFileSync("git", ["tag", "other-v1.0.0"], { cwd: dir }); + const changelogPath = join(dir, "CHANGELOG.md"); + writeFileSync(changelogPath, changelogFor("1.0.0")); + const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; + assert.match(runCheckerExpectFailure(dir, args), /nothing to compare against; if this is the package's first release, declare it with --first-release/); + assert.match(runChecker(dir, [...args, "--first-release"]), /first release \(1\.0\.0\)/); + // A flag takes no value: the option after it is still read. + assert.match(runChecker(dir, ["--first-release", ...args]), /first release/); + }); + const notARepo = mkdtempSync(join(tmpdir(), "cultlib-semver-norepo-")); + try { + const changelogPath = join(notARepo, "CHANGELOG.md"); + writeFileSync(changelogPath, changelogFor("1.0.0")); + const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; + assert.match(runCheckerExpectFailure(notARepo, args), /tags of --cwd could not be read/); + assert.match(runChecker(notARepo, [...args, "--first-release"]), /first release/); + } finally { + rmSync(notARepo, { recursive: true, force: true }); + } +}); + +test("CLI: declaring a first release while an earlier tag exists is refused", () => { + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + const changelogPath = join(dir, "CHANGELOG.md"); + writeFileSync(changelogPath, changelogFor("1.1.0")); + const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget", "--first-release"]); + assert.match(output, /--first-release was declared but widget-v1\.0\.0 already exists/); }); }); @@ -273,16 +305,18 @@ const fixtureRoot = join(tmpdir(), "cultlib-semver-fixtures"); // Builds `source` into .dll (netstandard2.1, like the shipped // assemblies) once per distinct (name, source) and returns the dll's path. -function buildAssembly(assemblyName, source) { +function buildAssembly(assemblyName, source, references = []) { const dir = join(fixtureRoot, createHash("sha256").update(`${assemblyName} -${source}`).digest("hex").slice(0, 16)); +${source} +${references.join("|")}`).digest("hex").slice(0, 16)); const dll = join(dir, "out", `${assemblyName}.dll`); if (!existsSync(dll)) { mkdirSync(dir, { recursive: true }); writeFileSync( join(dir, "Fixture.csproj"), `netstandard2.1` + - `${assemblyName}true`, + `${assemblyName}true` + + `${references.map((path) => `${path}`).join("")}`, ); writeFileSync(join(dir, "Source.cs"), source); execFileSync("dotnet", ["build", "-c", "Release", "-o", "out", "--nologo", "-v", "q"], { cwd: dir, stdio: "pipe" }); @@ -311,12 +345,13 @@ function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0 }); } -function measuredArgs(dir, version, builtPaths, { breaking = false } = {}) { +function measuredArgs(dir, version, builtPaths, { breaking = false, news = [], refs = [] } = {}) { const changelogPath = join(dir, "CHANGELOG.md"); writeFileSync(changelogPath, changelogFor(version, { breaking })); return [ "--package", "widget", "--changelog", changelogPath, "--version", version, "--tag-prefix", "widget", "--api-baseline-path", PLUGINS, ...builtPaths.flatMap((path) => ["--api-built", path]), + ...news.flatMap((name) => ["--api-new", name]), ...refs.flatMap((refDir) => ["--api-refs", refDir]), ]; } @@ -365,7 +400,7 @@ test("measured: a declared break with nothing measured still passes", () => { }); }); -test("measured: a removed GameCult.* assembly is a break, a new assembly and a removed third-party one are not", () => { +test("measured: a removed GameCult.* assembly is a break, a declared new assembly and a removed third-party one are not", () => { const other = buildAssembly("GameCult.Other", "namespace O { public class D {} }"); const thirdParty = buildAssembly("Vendor.Lib", "namespace V { public class E {} }"); const baseline = { "GameCult.Widget.dll": oldWidget(), "GameCult.Other.dll": other, "Vendor.Lib.dll": thirdParty }; @@ -375,7 +410,7 @@ test("measured: a removed GameCult.* assembly is a break, a new assembly and a r assert.doesNotMatch(refused, /Vendor\.Lib/); assert.match(refused, /breaks 1 public API member/); const brandNew = buildAssembly("GameCult.Brand", "namespace B { public class F {} }"); - const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, brandNew])); + const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, brandNew], { news: ["GameCult.Brand.dll"] })); assert.match(ok, /1\.0\.1 is a patch bump/); }); }); @@ -398,7 +433,7 @@ test("readApiCompatRun: diagnostics are breaks, an empty failure is a failure, a assert.deepEqual(readApiCompatRun({ status: 0, output: "Could not resolve reference 'netstandard.dll'\n" }), { breaks: [] }); }); -test("measured: removed inherited members are caught although netstandard cannot be resolved", () => { +test("measured: removed inherited members are caught", () => { // The shipped assemblies are netstandard2.1 and ApiCompat cannot resolve netstandard.dll // for them; both ways an inherited member can disappear are still reported. const viaNetstandardBase = buildAssembly("GameCult.Widget", "namespace N { public class C : System.Collections.Generic.List { } }"); @@ -415,12 +450,50 @@ test("measured: removed inherited members are caught although netstandard cannot }); }); -test("measured: an already-published version is not measured", () => { +test("measured: netstandard reference assemblies keep non-breaking changes to netstandard-derived types clean", () => { + const pass = (baseline, built) => + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": baseline } }, (dir) => { + assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", [built])), /1\.0\.1 is a patch bump/); + }); + // Inserting a class between the type and its netstandard base keeps every old base in the chain. + pass( + buildAssembly("GameCult.Widget", "namespace N { public class C : System.Exception { } }"), + buildAssembly("GameCult.Widget", "namespace N { public class C : System.ArgumentException { } }"), + ); + // Dropping a member that hid a netstandard member leaves the inherited one visible. + pass( + buildAssembly("GameCult.Widget", 'namespace N { public class C : System.Exception { public new string Message => "m"; } }'), + buildAssembly("GameCult.Widget", "namespace N { public class C : System.Exception { } }"), + ); +}); + +test("measured: a base type moved to a subclass in another assembly resolves through --api-refs and the baseline directory", () => { + const oldA = buildAssembly("Vendor.A", "namespace A { public class Base { public void Inh() {} } }"); + const newA = buildAssembly("Vendor.A", "namespace A { public class Base { public void Inh() {} } public class Mid : Base { } }"); + const oldB = buildAssembly("GameCult.Widget", "namespace B { public class D : A.Base { } }", [oldA]); + const newB = buildAssembly("GameCult.Widget", "namespace B { public class D : A.Mid { } }", [newA]); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldB, "Vendor.A.dll": oldA } }, (dir) => { + const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-refs-")); + try { + mkdirSync(join(scratch, "built")); + mkdirSync(join(scratch, "refs")); + copyFileSync(newB, join(scratch, "built", "GameCult.Widget.dll")); + copyFileSync(newA, join(scratch, "refs", "Vendor.A.dll")); + const built = join(scratch, "built", "GameCult.Widget.dll"); + // The new base assembly is reachable only through --api-refs: the checker must pass it on. + assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", [built], { refs: [join(scratch, "refs")] })), /1\.0\.1 is a patch bump/); + assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [built])), /CP0007/); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +test("measured: a local tag of the released version does not skip the measurement", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - const junk = join(dir, "junk.dll"); - writeFileSync(junk, "junk"); - const output = runChecker(dir, measuredArgs(dir, "1.0.0", [junk], { breaking: true })); - assert.match(output, /already published/); + execFileSync("git", ["tag", "widget-v1.1.0"], { cwd: dir }); + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()])); + assert.match(output, /Gone/); }); }); @@ -434,13 +507,55 @@ test("measured: the baseline is the tag's blob, not the working tree's DLL", () ); }); -test("measured: a first release measures nothing", () => { +test("measured: a declared first release measures nothing", () => { withTempGitRepo((dir) => { execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); const junk = join(dir, "junk.dll"); writeFileSync(junk, "junk"); - const output = runChecker(dir, measuredArgs(dir, "1.0.0", [junk])); + const output = runChecker(dir, [...measuredArgs(dir, "1.0.0", [junk]), "--first-release"]); assert.match(output, /first release/); + assert.doesNotMatch(output, /public API measured/); + }); +}); + +test("measured: measuring nothing refuses, naming what is missing", () => { + const fail = (dir, args) => runCheckerExpectFailure(dir, args); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + // a baseline path that is wrong or moved + const moved = measuredArgs(dir, "1.1.0", [trimmedWidget()]); + moved[moved.indexOf("--api-baseline-path") + 1] = "unity/moved/Runtime/Plugins"; + assert.match(fail(dir, moved), /widget-v1\.0\.0 tracks no assembly under unity\/moved\/Runtime\/Plugins: the baseline path is missing or moved/); + // a shipped DLL the tag does not track + const fresh = buildAssembly("GameCult.Fresh", "namespace F { public class G {} }"); + const output = fail(dir, measuredArgs(dir, "1.1.0", [oldWidget(), fresh])); + assert.match(output, /GameCult\.Fresh\.dll is built but widget-v1\.0\.0 does not track it under .*; if it is a new assembly, declare it with --api-new GameCult\.Fresh\.dll/); + // declaring an assembly new that the tag already tracks is a wrong declaration + const wrong = fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()], { news: ["GameCult.Widget.dll"] })); + assert.match(wrong, /GameCult\.Widget\.dll is declared new but widget-v1\.0\.0 already tracks it/); + }); + // a previous tag that does not track the DLLs at all + withTempGitRepo((dir) => { + mkdirSync(join(dir, PLUGINS), { recursive: true }); + writeFileSync(join(dir, PLUGINS, "README.txt"), "no dlls yet"); + execFileSync("git", ["add", "."], { cwd: dir }); + execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + assert.match(fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()])), /does not track the DLLs/); + }); +}); + +test("measured: a passing release says that unmeasured changes still need declaring", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const output = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()])); + assert.match(output, /public API measured; changes ApiCompat cannot see \(see "Unmeasured changes" in docs\/semver-policy\.md\) must still be declared under "### Breaking"/); + }); + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + const changelogPath = join(dir, "CHANGELOG.md"); + writeFileSync(changelogPath, changelogFor("1.0.1")); + const unmeasured = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); + assert.doesNotMatch(unmeasured, /public API measured/); }); }); @@ -472,6 +587,8 @@ test("measured: every missing required option is a usage error", () => { } const builtWithoutBaseline = runCheckerExpectFailure(dir, [...complete, "--api-built", "x.dll"], { status: 2 }); assert.match(builtWithoutBaseline, /usage:/); + const baselineWithoutBuilt = runCheckerExpectFailure(dir, [...complete, "--api-baseline-path", PLUGINS], { status: 2 }); + assert.match(baselineWithoutBuilt, /usage:/); }); }); @@ -521,38 +638,88 @@ test("measured: leaves no temporary files behind, passing or failing", () => { }); }); -test("measured: a missing or unrestorable tool fails closed and says why", () => { - withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - // The checker finds its tool manifest next to its own scripts directory. - const bare = mkdtempSync(join(tmpdir(), "cultlib-semver-bare-")); - try { - mkdirSync(join(bare, "scripts")); - const script = join(bare, "scripts", "check-changelog-semver.mjs"); - copyFileSync(checkerPath, script); - const noManifest = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); - assert.match(noManifest, /could not be measured/); - assert.match(noManifest, /without measuring: Cannot find a tool in the manifest/); - writeFileSync(join(bare, "dotnet-tools.json"), "{ this is not a manifest"); - const brokenManifest = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); - assert.match(brokenManifest, /could not be measured/); - assert.match(brokenManifest, /dotnet tool restore failed: Json parsing error/); - } finally { - rmSync(bare, { recursive: true, force: true }); +// A copy of the checker with its own scripts/api-gate directory, so a test can break the +// gate without touching the repository's. +function withCheckerCopy(gateFiles, fn) { + const bare = mkdtempSync(join(tmpdir(), "cultlib-semver-bare-")); + try { + mkdirSync(join(bare, "scripts")); + const script = join(bare, "scripts", "check-changelog-semver.mjs"); + copyFileSync(checkerPath, script); + for (const [path, text] of Object.entries(gateFiles)) { + mkdirSync(dirname(join(bare, "scripts", "api-gate", path)), { recursive: true }); + writeFileSync(join(bare, "scripts", "api-gate", path), text); } + fn(script); + } finally { + rmSync(bare, { recursive: true, force: true }); + } +} + +test("measured: a missing or broken tool manifest fails closed and says why", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + withCheckerCopy({}, (script) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); + assert.match(output, /could not be measured/); + assert.match(output, /the api-gate tool manifest \(scripts\/api-gate\/\.config\/dotnet-tools\.json\) is missing/); + }); + withCheckerCopy({ ".config/dotnet-tools.json": "{ this is not a manifest" }, (script) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); + assert.match(output, /could not be measured/); + assert.match(output, /dotnet tool restore failed: .*Json parsing error/); + }); }); }); -test("measured: dotnet missing from the PATH fails closed", { skip: process.platform === "win32" }, () => { +// A directory holding git and a stand-in `dotnet` shell script, as the whole PATH. +function withFakeDotnet(dotnetScript, fn) { + const bin = mkdtempSync(join(tmpdir(), "cultlib-semver-path-")); + try { + const git = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim(); + symlinkSync(git, join(bin, "git")); + if (dotnetScript != null) writeFileSync(join(bin, "dotnet"), `#!/bin/sh\n${dotnetScript}\n`, { mode: 0o755 }); + fn({ ...process.env, PATH: bin }); + } finally { + rmSync(bin, { recursive: true, force: true }); + } +} + +const posix = { skip: process.platform === "win32" }; + +test("measured: dotnet missing from the PATH fails closed", posix, () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - const gitOnly = mkdtempSync(join(tmpdir(), "cultlib-semver-path-")); - try { - const git = execFileSync("sh", ["-c", "command -v git"], { encoding: "utf8" }).trim(); - symlinkSync(git, join(gitOnly, "git")); - const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env: { ...process.env, PATH: gitOnly } }); + withFakeDotnet(null, (env) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env }); assert.match(output, /dotnet tool restore failed: .*ENOENT/); - } finally { - rmSync(gitOnly, { recursive: true, force: true }); - } + }); + }); +}); + +test("measured: a restore failure shows the last three lines of the tool's own output, in order", posix, () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const args = measuredArgs(dir, "1.0.1", [oldWidget()]); + // stdout then stderr, CRLF and LF line ends, trailing blank lines: only the last three real lines remain. + const toolRestore = "if [ \"$1\" = tool ] && [ \"$2\" = restore ]; then printf 'out1\\nout2\\n'; printf 'err1\\r\\nerr2\\n\\n' >&2; exit 1; fi\nexit 0"; + withFakeDotnet(toolRestore, (env) => { + const output = runCheckerExpectFailure(dir, args, { env }); + assert.match(output, /could not be measured against widget-v1\.0\.0: dotnet tool restore failed: out2 \| err1 \| err2\s*$/); + }); + const packageRestore = "if [ \"$1\" = restore ]; then printf 'a\\nb\\nc\\nd\\n' >&2; exit 1; fi\nexit 0"; + withFakeDotnet(packageRestore, (env) => { + const output = runCheckerExpectFailure(dir, args, { env }); + assert.match(output, /: dotnet restore failed: b \| c \| d\s*$/); + }); + }); +}); + +test("measured: restores that succeed without delivering the reference assemblies fail closed", posix, () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + withCheckerCopy({ ".config/dotnet-tools.json": "{}" }, (script) => { + withFakeDotnet("exit 0", (env) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env }); + assert.match(output, /the pinned netstandard reference assemblies were not restored under scripts\/api-gate\/packages/); + }); + }); }); }); From c5d9728b05c810bae7e78acf0cff292fd8238175 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 19:52:17 +0200 Subject: [PATCH 11/31] Semver policy: refusals, first-release declaration, unmeasured changes list Co-Authored-By: Claude Opus 5.5 --- docs/semver-policy.md | 65 ++++++++++++++++++++++++++++++------------- 1 file changed, 46 insertions(+), 19 deletions(-) diff --git a/docs/semver-policy.md b/docs/semver-policy.md index 92c66d6d..9ff1807f 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -141,8 +141,13 @@ package name, its `CHANGELOG.md` path, the version being released, and a git tag prefix, it: 1. resolves the previous published version from the highest existing - `-v*` tag older than the version being released (or treats the - release as a package's first if none exists); + `-v*` tag older than the version being released. A first release + is never inferred: with no such 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`; declaring it while an earlier tag exists is + refused too. A tag for the version being released does not exempt it: there + is no "already published" skip, so callers do not run the check for a + version they are only rebuilding; 2. requires a `## []` changelog entry to exist at all; 3. classifies the version bump (major/minor/patch) against the previous version, refusing anything that is not exactly the next version in some @@ -169,24 +174,46 @@ above), and the check neither refuses nor demands a bump for one. The Unity release scripts pass `--api-baseline-path ` and one `--api-built ` per assembly they built (plus `--api-refs ` for directories that hold the built assemblies' -dependencies). For each built DLL that has a namesake under the baseline path -at the previous tag, the checker reads that namesake as a git blob at the tag -(no worktree, no network, unaffected by what the working tree tracks now) and -compares public API with `Microsoft.DotNet.ApiCompat.Tool`, pinned in -`dotnet-tools.json` and run through `dotnet tool run`; the first -`dotnet tool restore` needs NuGet, and later runs use the local cache. Each -`CPnnnn` diagnostic is one measured break. A built DLL with no baseline is new -and measures nothing. A `GameCult.*` assembly the previous tag tracked and the -build no longer produces is itself a measured break. A package's first release -(no previous tag) measures nothing. +dependencies, and `--api-new ` 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. A `GameCult.*` assembly the previous tag tracked and the build no +longer produces is itself a measured break. -The tool failing to measure (not installed, an unreadable assembly, a crash: -a non-zero exit with no diagnostic line) refuses the release; it is never a -pass. ApiCompat cannot resolve `netstandard.dll` for the shipped -netstandard2.1 assemblies and prints a resolution warning; removed inherited -members and base types are still reported, and a test pins that. No -suppression file is kept: a suppression would be a second, silent declaration -of a break, so a break is declared only under `### Breaking`. +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 a built assembly has no +namesake at the tag and was not declared with `--api-new`; when an assembly +declared new is already tracked; 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 declared with `--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. From e7786ffac2e47c6dc832c921aab43e24ed590df6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 21:27:03 +0200 Subject: [PATCH 12/31] Semver check: a malformed version is refused by name, and refusals are pinned never to repeat input contents A malformed --version fell into the tag-reading catch and was reported as unreadable tags. It is now validated first and refused without repeating the value. A canary test runs seven refusals (measured break, unreadable built DLL, unreadable baseline blob, missing entry, skipped version, breaking under too small a bump) with a canary in the changelog and the assemblies and asserts it never appears in the output. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-changelog-semver.mjs | 6 +++ scripts/check-changelog-semver.test.mjs | 60 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 5cca0c4d..a6c1e6c0 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -351,6 +351,12 @@ function main() { process.exitCode = 2; return; } + // 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)) { refuse(`${packageName}: changelog not found at ${changelog}`); return; diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 141e29c3..0f8edc3a 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -750,3 +750,63 @@ test("evaluateRelease: lists at most five measured breaks, and says nothing more }).reason; assert.match(agreed, /a "### Breaking" section and 1 measured public API break\(s\)/); }); + +test("CLI: a malformed version is refused by name, without repeating it, with or without --first-release", () => { + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + const changelogPath = join(dir, "CHANGELOG.md"); + writeFileSync(changelogPath, changelogFor("1.1.0")); + for (const extra of [[], ["--first-release"]]) { + const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.x-canary", "--tag-prefix", "widget", ...extra]); + assert.match(output, /--version is not a MAJOR\.MINOR\.PATCH version/); + assert.doesNotMatch(output, /canary/); + assert.doesNotMatch(output, /could not be read/); + } + }); +}); + +test("refusals never repeat what the inputs contain", () => { + const canary = "CANARY-5f3a9c-never-echoed"; + const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-canary-")); + try { + const junk = join(scratch, "GameCult.Widget.dll"); + writeFileSync(junk, `MZ not an assembly ${canary}`); + const canaryChangelog = (dir, version, { breaking = false } = {}) => { + const path = join(dir, "CHANGELOG.md"); + writeFileSync(path, changelogFor(version, { breaking }).replace("- something changed", `- something changed ${canary}`) + `\n## [0.0.1]\n\n- ${canary}\n`); + return path; + }; + const refuse = (dir, args) => { + const output = runCheckerExpectFailure(dir, args); + assert.doesNotMatch(output, new RegExp(canary), `the refusal repeated an input: ${output}`); + return output; + }; + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const measured = (version, built, options) => { + const args = measuredArgs(dir, version, built, options); + args[args.indexOf("--changelog") + 1] = canaryChangelog(dir, version, options); + return args; + }; + // a measured break, with the canary in the changelog it must be declared in + assert.match(refuse(dir, measured("1.1.0", [trimmedWidget()])), /Gone/); + // the tool cannot read the built assembly + assert.match(refuse(dir, measured("1.0.1", [junk])), /could not be measured/); + // no entry for the version, a skipped version, a breaking entry under too small a bump + const noEntry = measured("1.0.1", [oldWidget()]); + noEntry[noEntry.indexOf("--version") + 1] = "1.0.2"; + assert.match(refuse(dir, noEntry), /no "## \[1\.0\.2\]" entry/); + const skip = measured("1.0.9", [oldWidget()]); + assert.match(refuse(dir, skip), /skips ahead/); + assert.match(refuse(dir, measured("1.0.1", [oldWidget()], { breaking: true })), /needs a major bump/); + }); + // the tag's own blob is unreadable + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": junk } }, (dir) => { + const args = measuredArgs(dir, "1.0.1", [oldWidget()]); + args[args.indexOf("--changelog") + 1] = canaryChangelog(dir, "1.0.1"); + assert.match(refuse(dir, args), /could not be measured/); + }); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +}); From 0ac870f132dfd2febafedceacb3be9260335e02c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 21:28:26 +0200 Subject: [PATCH 13/31] Semver check tests: pin git's silence, the gate manifest file, netstandard.dll, the first-release hint Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.test.mjs | 26 +++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 0f8edc3a..68cf1290 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -277,7 +277,9 @@ test("CLI: no previous tag, or tags that cannot be read, refuse unless the first const changelogPath = join(notARepo, "CHANGELOG.md"); writeFileSync(changelogPath, changelogFor("1.0.0")); const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; - assert.match(runCheckerExpectFailure(notARepo, args), /tags of --cwd could not be read/); + const refused = runCheckerExpectFailure(notARepo, args); + assert.match(refused, /tags of --cwd could not be read .*; if this is the package's first release, declare it with --first-release/); + assert.doesNotMatch(refused, /fatal/); // git's own complaint is not forwarded assert.match(runChecker(notARepo, [...args, "--first-release"]), /first release/); } finally { rmSync(notARepo, { recursive: true, force: true }); @@ -658,11 +660,14 @@ function withCheckerCopy(gateFiles, fn) { test("measured: a missing or broken tool manifest fails closed and says why", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - withCheckerCopy({}, (script) => { - const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); - assert.match(output, /could not be measured/); - assert.match(output, /the api-gate tool manifest \(scripts\/api-gate\/\.config\/dotnet-tools\.json\) is missing/); - }); + // A gate directory without the manifest, and one with a .config directory but no manifest in it. + for (const gateFiles of [{}, { ".config/other.json": "{}" }]) { + withCheckerCopy(gateFiles, (script) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); + assert.match(output, /could not be measured/); + assert.match(output, /the api-gate tool manifest \(scripts\/api-gate\/\.config\/dotnet-tools\.json\) is missing/); + }); + } withCheckerCopy({ ".config/dotnet-tools.json": "{ this is not a manifest" }, (script) => { const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script }); assert.match(output, /could not be measured/); @@ -715,9 +720,14 @@ test("measured: a restore failure shows the last three lines of the tool's own o test("measured: restores that succeed without delivering the reference assemblies fail closed", posix, () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { withCheckerCopy({ ".config/dotnet-tools.json": "{}" }, (script) => { + const notRestored = /the pinned netstandard reference assemblies were not restored under scripts\/api-gate\/packages/; withFakeDotnet("exit 0", (env) => { - const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env }); - assert.match(output, /the pinned netstandard reference assemblies were not restored under scripts\/api-gate\/packages/); + assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env }), notRestored); + }); + // The package directory arrives, but without netstandard.dll in it. + const emptyPackage = 'if [ "$1" = restore ]; then mkdir -p packages/netstandard.library.ref/2.1.0/ref/netstandard2.1; fi\nexit 0'; + withFakeDotnet(emptyPackage, (env) => { + assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env }), notRestored); }); }); }); From df13b502b9625d156a193728d2f0c10168173b8f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 21:30:37 +0200 Subject: [PATCH 14/31] Semver check: built and reference paths are resolved against the process, not the tool's directory The ApiCompat tool runs from scripts/api-gate, so a relative --api-built or --api-refs failed as an unreadable assembly. The release scripts pass absolute paths; any other caller does not have to. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-changelog-semver.mjs | 7 ++++--- scripts/check-changelog-semver.test.mjs | 15 +++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index a6c1e6c0..93a9d53e 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -11,7 +11,7 @@ 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 } from "node:path"; +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 @@ -342,7 +342,8 @@ const UNMEASURED_NOTICE = function main() { const args = parseArgs(process.argv.slice(2)); const { package: packageName, changelog, version, "tag-prefix": tagPrefix, cwd = process.cwd() } = args; - const built = args["api-built"] ?? []; + // 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 (!packageName || !changelog || !version || !tagPrefix || (built.length > 0) !== Boolean(args["api-baseline-path"])) { console.error( "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ] [--first-release]\n" + @@ -390,7 +391,7 @@ function main() { previousVersion, baselinePath: args["api-baseline-path"], built, - refs: args["api-refs"] ?? [], + refs: (args["api-refs"] ?? []).map((path) => resolve(path)), declaredNew: args["api-new"] ?? [], cwd, }); diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 68cf1290..2c6da50e 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -820,3 +820,18 @@ test("refusals never repeat what the inputs contain", () => { rmSync(scratch, { recursive: true, force: true }); } }); + +test("measured: built and reference paths given relative to the process are read from there", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-relative-")); + try { + mkdirSync(join(scratch, "refs")); + copyFileSync(trimmedWidget(), join(scratch, "GameCult.Widget.dll")); + const args = measuredArgs(dir, "1.1.0", ["GameCult.Widget.dll"], { refs: ["refs"] }); + // The tool is started elsewhere, so an unresolved relative path would not be found. + assert.match(runCheckerExpectFailure(dir, args, { processCwd: scratch }), /Gone/); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); From c358ef9739f00feda2fef5d89a9ca005cb6892e0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 21:38:55 +0200 Subject: [PATCH 15/31] Semver check: drop the unobservable stdio option; the empty-package test really creates the directory Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.mjs | 2 +- scripts/check-changelog-semver.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 93a9d53e..30dfa94a 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -170,7 +170,7 @@ export function evaluateRelease({ packageName, changelogText, version, previousV function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { // A directory that cannot list tags (not a git repository, git missing) throws: that // is not evidence that there was no previous release. - const tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + const tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8" }) .split("\n") .map((line) => line.trim()) .filter(Boolean); diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 2c6da50e..3d11a3db 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -725,7 +725,7 @@ test("measured: restores that succeed without delivering the reference assemblie assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env }), notRestored); }); // The package directory arrives, but without netstandard.dll in it. - const emptyPackage = 'if [ "$1" = restore ]; then mkdir -p packages/netstandard.library.ref/2.1.0/ref/netstandard2.1; fi\nexit 0'; + const emptyPackage = 'if [ "$1" = restore ]; then /bin/mkdir -p packages/netstandard.library.ref/2.1.0/ref/netstandard2.1; fi\nexit 0'; withFakeDotnet(emptyPackage, (env) => { assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { script, env }), notRestored); }); From 68c1296b7af1bd0c8d17aa1db3311ac59efb2412 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 2 Oct 2026 21:44:03 +0200 Subject: [PATCH 16/31] Semver check: keep git's stderr out of the refusal (the fatal-message test fails without it) Co-Authored-By: Claude Opus 5.5 --- scripts/check-changelog-semver.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 30dfa94a..93a9d53e 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -170,7 +170,7 @@ export function evaluateRelease({ packageName, changelogText, version, previousV function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { // A directory that cannot list tags (not a git repository, git missing) throws: that // is not evidence that there was no previous release. - const tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8" }) + const tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) .split("\n") .map((line) => line.trim()) .filter(Boolean); From 652b0837d236e57a526cd763d03988c402ca5ddd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 00:58:33 +0200 Subject: [PATCH 17/31] Semver check: one rule for an unbuilt tracked assembly, comparisons must run, git failures are refusals, a first release is cross-checked Every assembly the previous tag tracks that the build does not hand over is a break, whatever its name; a run that compared nothing is refused (the --api-new rename of a lone assembly). The cultlib build now hands over every shipped assembly. --first-release is believed only with no other same-prefix tag and the prefix its changelog directory names. git ls-tree/show failures and an unreadable changelog become refusals. Tests pin the CultMath-shaped rename, the left-reference scenario (a dependency's netstandard base chain changes), the first-release probes and the workflow's own first-release line. Co-Authored-By: Claude Sonnet 5.5 --- scripts/build-unity-package.ps1 | 9 +- scripts/check-changelog-semver.mjs | 67 ++++++---- scripts/check-changelog-semver.test.mjs | 170 ++++++++++++++++++++++-- 3 files changed, 204 insertions(+), 42 deletions(-) diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 0539863a..c508bc8d 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -173,13 +173,12 @@ 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 GameCult.* assembly against the DLLs the previous cultlib-unity tag -# tracked (read from git), so it runs here, once the assemblies under judgement exist. +# 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. $measuredArguments = @() foreach ($assemblyName in $expectedAssemblies) { - if ($assemblyName.StartsWith("GameCult.")) { - $measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName) - } + $measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName) } foreach ($refRoot in @($publishRoot, $quicPublishRoot, $webSocketPublishRoot)) { $measuredArguments += @("--api-refs", $refRoot) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 93a9d53e..e1be5ab9 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -167,6 +167,9 @@ export function evaluateRelease({ packageName, changelogText, version, previousV // --- CLI wrapper: resolves the previous published tag via git and reads the // changelog off disk, then delegates to the pure functions above. --- +// The previous release is the newest same-prefix tag strictly older than the version. +// `other` is any same-prefix tag that is not the version itself, older or newer: a +// package with one has been released before, whatever the version arithmetic says. function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { // A directory that cannot list tags (not a git repository, git missing) throws: that // is not evidence that there was no previous release. @@ -177,6 +180,7 @@ function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { const prefix = `${tagPrefix}-v`; const current = parseVersion(currentVersion); let best = null; + let other = null; for (const tag of tags) { if (!tag.startsWith(prefix)) continue; const versionText = tag.slice(prefix.length); @@ -187,12 +191,13 @@ function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { continue; } if (sameVersion(candidate, current)) continue; // the tag being released itself + other ??= tag; if (compareVersions(candidate, current) >= 0) continue; // ignore anything not older if (best === null || compareVersions(candidate, best) > 0) { best = candidate; } } - return best ? formatVersion(best) : null; + return { previous: best ? formatVersion(best) : null, other }; } // --- Measured public API: the built DLLs are compared with the DLLs the @@ -248,30 +253,37 @@ function prepareGate() { } // The DLLs the previous tag tracked under `baselinePath`, as git blobs: no -// worktree, no network, and unaffected by what the working tree holds now. +// worktree, no network, and unaffected by what the working tree holds now. A git +// failure is a refusal that names the step, never git's own output. function extractBaseline({ tag, baselinePath, into, cwd }) { const dir = baselinePath.replace(/\\/g, "/").replace(/\/+$/, ""); - const listing = execFileSync("git", ["ls-tree", "--name-only", tag, `${dir}/`], { cwd, encoding: "utf8" }); - const names = listing.split("\n").map((line) => basename(line.trim())).filter((name) => name.toLowerCase().endsWith(".dll")); - for (const name of names) { - const blob = execFileSync("git", ["show", `${tag}:${dir}/${name}`], { cwd, maxBuffer: 1 << 28 }); - writeFileSync(join(into, name), blob); + const git = (args, options) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], ...options }); + try { + const listing = git(["ls-tree", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" }); + const names = listing.split("\n").map((line) => basename(line.trim())).filter((name) => name.toLowerCase().endsWith(".dll")); + for (const name of names) { + writeFileSync(join(into, name), git(["show", `${tag}:${dir}/${name}`], { maxBuffer: 1 << 28 })); + } + return { dir, names }; + } catch { + return { failure: `git could not read the assemblies tracked under ${dir} at ${tag}` }; } - return { dir, names }; } // Compares each built assembly with its namesake at the previous tag. Measuring -// nothing is a failure: the baseline path must hold DLLs at the tag, and every +// nothing is a failure: the baseline path must hold DLLs at the tag, every // built assembly must have a namesake there unless the caller declares it new -// (`declaredNew`). A GameCult.* assembly the tag tracked and the build no longer -// produces is itself a break. +// (`declaredNew`), and at least one comparison must run. An assembly the tag +// tracked and the build no longer produces is itself a break, whatever its name. export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, declaredNew = [], cwd }) { const tag = `${tagPrefix}-v${previousVersion}`; const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-")); try { const left = join(work, "left"); mkdirSync(left); - const { dir, names: baselineNames } = extractBaseline({ tag, baselinePath, into: left, cwd }); + const baseline = extractBaseline({ tag, baselinePath, into: left, cwd }); + if (baseline.failure) return baseline; + const { dir, names: baselineNames } = baseline; if (baselineNames.length === 0) { return { failure: `${tag} tracks no assembly under ${dir}: the baseline path is missing or moved, or that tag does not track the DLLs` }; } @@ -286,12 +298,11 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui } const gate = prepareGate(); if (gate.failure) return gate; - const breaks = []; - for (const name of baselineNames) { - if (name.startsWith("GameCult.") && !builtNames.has(name)) { - breaks.push(`assembly ${name} exists at ${tag} but is no longer built`); - } + const unbuilt = baselineNames.filter((name) => !builtNames.has(name)); + if (unbuilt.length === baselineNames.length) { + return { failure: `no built assembly has a namesake tracked at ${tag}, so nothing was compared (${tag} tracks ${baselineNames.join(", ")})` }; } + const breaks = unbuilt.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; @@ -358,15 +369,18 @@ function main() { refuse(`${packageName}: --version is not a MAJOR.MINOR.PATCH version`); return; } - if (!existsSync(changelog)) { - refuse(`${packageName}: changelog not found at ${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; } - const changelogText = readFileSync(changelog, "utf8"); const firstRelease = args["first-release"] === true; let previousVersion; + let otherTag = null; try { - previousVersion = resolvePreviousVersion(tagPrefix, version, cwd); + ({ previous: previousVersion, other: otherTag } = resolvePreviousVersion(tagPrefix, version, cwd)); } catch { if (!firstRelease) { refuse(`${packageName}: the tags of --cwd could not be read (not a git repository?), so the previous release is unknown; ` + @@ -380,8 +394,15 @@ function main() { "if this is the package's first release, declare it with --first-release"); return; } - if (previousVersion != null && firstRelease) { - refuse(`${packageName}: --first-release was declared but ${tagPrefix}-v${previousVersion} already exists`); + if (firstRelease && otherTag != null) { + refuse(`${packageName}: --first-release was declared but ${otherTag} already exists`); + return; + } + // A package's tag prefix is its directory under packages/ (the workflow's tag triggers and + // job matrix spell it the same way), so a first release is believed only for the prefix + // its changelog's directory names. + if (firstRelease && basename(dirname(resolve(changelog))) !== tagPrefix) { + refuse(`${packageName}: --first-release is accepted only when --tag-prefix names the directory that holds the changelog`); return; } let measuredBreaks = []; diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 3d11a3db..ad7e58a3 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -260,12 +260,19 @@ test("CLI: a tag for the version being released does not skip the check", () => }); }); +// A package's changelog lives in a directory named like its tag prefix (packages//). +function packageChangelog(dir, version, prefix = "widget") { + mkdirSync(join(dir, prefix), { recursive: true }); + const path = join(dir, prefix, "CHANGELOG.md"); + writeFileSync(path, changelogFor(version)); + return path; +} + test("CLI: no previous tag, or tags that cannot be read, refuse unless the first release is declared", () => { withTempGitRepo((dir) => { execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); execFileSync("git", ["tag", "other-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.0")); + const changelogPath = packageChangelog(dir, "1.0.0"); const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; assert.match(runCheckerExpectFailure(dir, args), /nothing to compare against; if this is the package's first release, declare it with --first-release/); assert.match(runChecker(dir, [...args, "--first-release"]), /first release \(1\.0\.0\)/); @@ -274,8 +281,7 @@ test("CLI: no previous tag, or tags that cannot be read, refuse unless the first }); const notARepo = mkdtempSync(join(tmpdir(), "cultlib-semver-norepo-")); try { - const changelogPath = join(notARepo, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.0")); + const changelogPath = packageChangelog(notARepo, "1.0.0"); const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; const refused = runCheckerExpectFailure(notARepo, args); assert.match(refused, /tags of --cwd could not be read .*; if this is the package's first release, declare it with --first-release/); @@ -297,6 +303,69 @@ test("CLI: declaring a first release while an earlier tag exists is refused", () }); }); +test("CLI: --first-release is believed only with no other same-prefix tag and the package's own prefix", () => { + const declared = (dir, version, prefix = "widget") => [ + "--package", "widget", "--changelog", packageChangelog(dir, version), "--version", version, "--tag-prefix", prefix, "--first-release", + ]; + // a newer tag of the prefix: not a first release (the unflagged run refuses it too) + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v2.0.0"], { cwd: dir }); + assert.match(runCheckerExpectFailure(dir, declared(dir, "0.9.0")), /--first-release was declared but widget-v(1|2)\.0\.0 already exists/); + }); + // a mistyped prefix, with the real package's tags in the repository + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + const args = declared(dir, "1.0.1", "widgit"); + assert.match(runCheckerExpectFailure(dir, args), /--first-release is accepted only when --tag-prefix names the directory that holds the changelog/); + // the right prefix names the tag that exists, so it is refused for that + assert.match(runCheckerExpectFailure(dir, declared(dir, "1.0.1")), /already exists/); + }); + // with the predecessor tag deleted, a mistyped prefix is still refused by the prefix check + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + execFileSync("git", ["tag", "-d", "widget-v1.0.0"], { cwd: dir }); + assert.match(runCheckerExpectFailure(dir, declared(dir, "1.0.1", "widgit")), /names the directory that holds the changelog/); + }); + // the version's own tag is not another tag: a re-run of a package's only release + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + assert.match(runChecker(dir, declared(dir, "1.0.0")), /first release \(1\.0\.0\)/); + }); +}); + +// The publish workflow's own first-release decision, run as the workflow runs it, then handed +// to the checker: a package's first release passes, and the release after it does too. +const workflowFile = fileURLToPath(new URL("../.github/workflows/publish-packages.yml", import.meta.url)); + +test("workflow: the --first-release line passes a package's first release and its second", { skip: process.platform === "win32" }, () => { + const workflow = readFileSync(workflowFile, "utf8"); + const lines = [...workflow.matchAll(/^ *if \[ -z "\$\(git tag -l '([^']*)-v\*' [^\n]*first="--first-release"; fi\r?$/gm)]; + assert.equal(lines.length, 2, "the cultcache-ts job and the python matrix each decide it once"); + for (const [line, prefix] of lines) { + const decision = line.trim().replace("${{ matrix.package }}", "widget"); + const first = (dir, tag) => execFileSync("bash", ["-c", `${decision}; printf '%s' "$first"`], { cwd: dir, encoding: "utf8", env: { ...process.env, GITHUB_REF_NAME: tag } }); + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + const name = prefix.includes("matrix") ? "widget" : prefix; + execFileSync("git", ["tag", `${name}-v1.0.0`], { cwd: dir }); + assert.equal(first(dir, `${name}-v1.0.0`), "--first-release"); + execFileSync("git", ["tag", `${name}-v1.1.0`], { cwd: dir }); + assert.equal(first(dir, `${name}-v1.1.0`), ""); + // another package's tags are not this package's releases + execFileSync("git", ["tag", "unrelated-v9.0.0"], { cwd: dir }); + execFileSync("git", ["tag", "-d", `${name}-v1.0.0`], { cwd: dir }); + execFileSync("git", ["tag", "-d", `${name}-v1.1.0`], { cwd: dir }); + execFileSync("git", ["tag", `${name}-v0.1.0`], { cwd: dir }); + assert.equal(first(dir, `${name}-v0.1.0`), "--first-release"); + }); + } +}); + // --- Measured public API. The fixtures are real assemblies, built here with // the SDK and compared by the pinned ApiCompat tool through the CLI, so every // rule is observed where the release scripts observe it. --- @@ -402,21 +471,39 @@ test("measured: a declared break with nothing measured still passes", () => { }); }); -test("measured: a removed GameCult.* assembly is a break, a declared new assembly and a removed third-party one are not", () => { +test("measured: a tracked assembly that is no longer built is a break, whatever its name; a declared new assembly is not", () => { const other = buildAssembly("GameCult.Other", "namespace O { public class D {} }"); const thirdParty = buildAssembly("Vendor.Lib", "namespace V { public class E {} }"); const baseline = { "GameCult.Widget.dll": oldWidget(), "GameCult.Other.dll": other, "Vendor.Lib.dll": thirdParty }; withReleasedBaseline({ baseline }, (dir) => { const refused = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [oldWidget()])); assert.match(refused, /GameCult\.Other\.dll exists at widget-v1\.0\.0 but is no longer built/); - assert.doesNotMatch(refused, /Vendor\.Lib/); - assert.match(refused, /breaks 1 public API member/); + assert.match(refused, /Vendor\.Lib\.dll exists at widget-v1\.0\.0 but is no longer built/); + assert.match(refused, /breaks 2 public API member/); const brandNew = buildAssembly("GameCult.Brand", "namespace B { public class F {} }"); - const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, brandNew], { news: ["GameCult.Brand.dll"] })); + const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, thirdParty, brandNew], { news: ["GameCult.Brand.dll"] })); assert.match(ok, /1\.0\.1 is a patch bump/); }); }); +test("measured: renaming the only assembly and declaring it new compares nothing and is refused", () => { + // CultMath's shape: one assembly, and its name is not GameCult.*. + const cultMath = buildAssembly("CultMath", WITH_GONE); + const renamedTrimmed = buildAssembly("CultMath.Next", WITHOUT_GONE); + withReleasedBaseline({ baseline: { "CultMath.dll": cultMath } }, (dir) => { + const own = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [buildAssembly("CultMath", WITHOUT_GONE)])); + assert.match(own, /Gone/); + const renamed = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [renamedTrimmed], { news: ["CultMath.Next.dll"] })); + assert.match(renamed, /no built assembly has a namesake tracked at widget-v1\.0\.0, so nothing was compared/); + assert.match(renamed, /CultMath\.dll/); + // with a comparison that ran, the unbuilt tracked assembly is itself the break + withReleasedBaseline({ baseline: { "CultMath.dll": cultMath, "Other.dll": buildAssembly("Other", "namespace O { public class D {} }") } }, (both) => { + const partial = runCheckerExpectFailure(both, measuredArgs(both, "1.0.1", [cultMath, renamedTrimmed], { news: ["CultMath.Next.dll"] })); + assert.match(partial, /Other\.dll exists at widget-v1\.0\.0 but is no longer built/); + }); + }); +}); + test("measured: a tool failure with no diagnostic fails closed", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { const notAnAssembly = join(dir, "GameCult.Widget.dll"); @@ -478,13 +565,35 @@ test("measured: a base type moved to a subclass in another assembly resolves thr const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-refs-")); try { mkdirSync(join(scratch, "built")); + mkdirSync(join(scratch, "builtA")); mkdirSync(join(scratch, "refs")); copyFileSync(newB, join(scratch, "built", "GameCult.Widget.dll")); + copyFileSync(newA, join(scratch, "builtA", "Vendor.A.dll")); copyFileSync(newA, join(scratch, "refs", "Vendor.A.dll")); - const built = join(scratch, "built", "GameCult.Widget.dll"); - // The new base assembly is reachable only through --api-refs: the checker must pass it on. - assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", [built], { refs: [join(scratch, "refs")] })), /1\.0\.1 is a patch bump/); - assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [built])), /CP0007/); + const built = [join(scratch, "built", "GameCult.Widget.dll"), join(scratch, "builtA", "Vendor.A.dll")]; + // The new base assembly is reachable from the widget's directory only through --api-refs: the checker must pass it on. + assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", built, { refs: [join(scratch, "refs")] })), /1\.0\.1 is a patch bump/); + assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", built)), /CP0007/); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }); +}); + +test("measured: a base chain changed in a dependency is seen on the GameCult type through the left references", () => { + // Old Vendor.A: Mid derives from Exception. New Vendor.A: Mid has no base. GameCult's D derives from Mid in both. + // ApiCompat reports the lost ISerializable on D only when the left side can resolve netstandard and Vendor.A. + const oldA = buildAssembly("Vendor.A", "namespace A { public class Mid : System.Exception { } }"); + const newA = buildAssembly("Vendor.A", "namespace A { public class Mid { } }"); + const oldB = buildAssembly("GameCult.Widget", "namespace B { public class D : A.Mid { } }", [oldA]); + const newB = buildAssembly("GameCult.Widget", "namespace B { public class D : A.Mid { } }", [newA]); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldB, "Vendor.A.dll": oldA } }, (dir) => { + const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-left-")); + try { + copyFileSync(newB, join(scratch, "GameCult.Widget.dll")); + copyFileSync(newA, join(scratch, "Vendor.A.dll")); + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [join(scratch, "GameCult.Widget.dll"), join(scratch, "Vendor.A.dll")])); + assert.match(output, /Type 'B\.D' does not implement interface 'System\.Runtime\.Serialization\.ISerializable'/); } finally { rmSync(scratch, { recursive: true, force: true }); } @@ -514,7 +623,9 @@ test("measured: a declared first release measures nothing", () => { execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); const junk = join(dir, "junk.dll"); writeFileSync(junk, "junk"); - const output = runChecker(dir, [...measuredArgs(dir, "1.0.0", [junk]), "--first-release"]); + const args = measuredArgs(dir, "1.0.0", [junk]); + args[args.indexOf("--changelog") + 1] = packageChangelog(dir, "1.0.0"); + const output = runChecker(dir, [...args, "--first-release"]); assert.match(output, /first release/); assert.doesNotMatch(output, /public API measured/); }); @@ -546,6 +657,37 @@ test("measured: measuring nothing refuses, naming what is missing", () => { }); }); +test("measured: a git failure while reading the baseline, and a changelog that is not a file, are refusals without a stack or git's output", () => { + const clean = (output) => { + assert.doesNotMatch(output, /fatal|Command failed|\n\s+at |usage: git|unknown option/); + return output; + }; + // the tag tracks a gitlink named like a DLL: ls-tree lists it, git show cannot read it + withTempGitRepo((dir) => { + mkdirSync(join(dir, PLUGINS), { recursive: true }); + copyFileSync(oldWidget(), join(dir, PLUGINS, "GameCult.Widget.dll")); + execFileSync("git", ["add", "."], { cwd: dir }); + execFileSync("git", ["update-index", "--add", "--cacheinfo", "160000,1111111111111111111111111111111111111111,unity/widget/Runtime/Plugins/Gitlink.dll"], { cwd: dir }); + execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + const output = clean(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]))); + assert.match(output, /git could not read the assemblies tracked under unity\/widget\/Runtime\/Plugins at widget-v1\.0\.0/); + }); + // a baseline path that git would take for an option + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const args = measuredArgs(dir, "1.0.1", [oldWidget()]); + args[args.indexOf("--api-baseline-path") + 1] = "--bogus"; + assert.match(clean(runCheckerExpectFailure(dir, args)), /tracks no assembly under --bogus|git could not read/); + }); + // a changelog path that is a directory + withTempGitRepo((dir) => { + execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + mkdirSync(join(dir, "CHANGELOG.md")); + const output = clean(runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.0.0", "--tag-prefix", "widget"])); + assert.match(output, /changelog cannot be read at /); + }); +}); + test("measured: a passing release says that unmeasured changes still need declaring", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { const output = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()])); From 4c7ae3adc60da4eb46fb61011880f3141667ff4f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 00:58:33 +0200 Subject: [PATCH 18/31] Publish workflow: a package's first release passes --first-release, which the checker verifies Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/publish-packages.yml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 60cc6a01..5e4129ed 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -75,11 +75,17 @@ jobs: if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') run: | tagged="${GITHUB_REF_NAME#cultcache-ts-v}" + # The package's first release carries --first-release. The checker believes it only + # when no other cultcache-ts tag exists, so this test only avoids refusing the + # releases that follow; it does not decide anything. + first="" + if [ -z "$(git tag -l 'cultcache-ts-v*' | grep -vxF "$GITHUB_REF_NAME" || true)" ]; then first="--first-release"; fi node scripts/check-changelog-semver.mjs \ --package "@gamecult/cultcache-ts" \ --changelog packages/cultcache-ts/CHANGELOG.md \ --version "$tagged" \ - --tag-prefix cultcache-ts + --tag-prefix cultcache-ts \ + $first - name: Build and test working-directory: packages/cultcache-ts @@ -159,11 +165,15 @@ jobs: if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') run: | tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}" + # See the cultcache-ts job: the checker, not this test, decides whether a first release is real. + first="" + if [ -z "$(git tag -l '${{ matrix.package }}-v*' | grep -vxF "$GITHUB_REF_NAME" || true)" ]; then first="--first-release"; fi node scripts/check-changelog-semver.mjs \ --package "${{ matrix.package }}" \ --changelog packages/${{ matrix.package }}/CHANGELOG.md \ --version "$tagged" \ - --tag-prefix "${{ matrix.package }}" + --tag-prefix "${{ matrix.package }}" \ + $first # 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. From 133dd9f760c40eba87514cbd35378506233d02ff Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 00:58:34 +0200 Subject: [PATCH 19/31] Semver policy: a rebuild is measured against its predecessor, a first release is cross-checked, every tracked assembly counts Co-Authored-By: Claude Sonnet 5.5 --- docs/semver-policy.md | 41 ++++++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/docs/semver-policy.md b/docs/semver-policy.md index 9ff1807f..15a95913 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -141,13 +141,19 @@ package name, its `CHANGELOG.md` path, the version being released, and a git tag prefix, it: 1. resolves the previous published version from the highest existing - `-v*` tag older than the version being released. A first release - is never inferred: with no such 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`; declaring it while an earlier tag exists is - refused too. A tag for the version being released does not exempt it: there - is no "already published" skip, so callers do not run the check for a - version they are only rebuilding; + `-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 + `-v*` tag exists (older or newer than the version) and + `--tag-prefix` names the directory that holds the changelog + (`packages//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; 2. requires a `## []` changelog entry to exist at all; 3. classifies the version bump (major/minor/patch) against the previous version, refusing anything that is not exactly the next version in some @@ -179,16 +185,20 @@ 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. A `GameCult.*` assembly the previous tag tracked and the build no -longer produces is itself a measured break. +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. 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 a built assembly has no -namesake at the tag and was not declared with `--api-new`; when an assembly -declared new is already tracked; 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 declared with `--first-release` measures nothing. +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. The tool and the `NETStandard.Library.Ref` 2.1.0 reference assemblies are pinned in `scripts/api-gate/` (`.config/dotnet-tools.json` and @@ -228,7 +238,8 @@ 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`; a job passes `--first-release` when no + other tag of its prefix exists, and the check verifies that claim itself; - `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`; - `packages/cultmath/scripts/build-unity-package.ps1`, for `org.gamecult.cultmath`; From 23d7beac206a3d5dee6b94b0aa533f998fc8a650 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 02:36:54 +0200 Subject: [PATCH 20/31] Semver check derives what callers used to declare: first release, tag prefix, changelog and measured set The checker reads each package's tag prefix, changelog and assemblies directory from scripts/release-packages.mjs and derives the release record from git tags. A first release is the absence of any older tag in a checkout that holds other tags; a malformed same-prefix tag, a checkout without tags and an unreadable repository refuse. The measured set is every managed assembly (CLI header in the PE bytes) tracked at the previous tag at any depth; an unbuilt one is a break, a built assembly with no namesake is an addition. Deleted with their branches, refusals and tests: --first-release, --api-new, --tag-prefix, --changelog, --api-baseline-path and the workflow first-release decision line. Any unknown option is now a usage error. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/publish-packages.yml | 22 +- .../cultmath/scripts/build-unity-package.ps1 | 3 - scripts/build-unity-package.ps1 | 5 +- scripts/check-changelog-semver.mjs | 234 ++++---- scripts/check-changelog-semver.test.mjs | 557 +++++++++++------- scripts/release-packages.mjs | 38 ++ scripts/verify-caching-unity-release.ps1 | 3 - 7 files changed, 527 insertions(+), 335 deletions(-) create mode 100644 scripts/release-packages.mjs diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 5e4129ed..5c9b361a 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -75,17 +75,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') run: | tagged="${GITHUB_REF_NAME#cultcache-ts-v}" - # The package's first release carries --first-release. The checker believes it only - # when no other cultcache-ts tag exists, so this test only avoids refusing the - # releases that follow; it does not decide anything. - first="" - if [ -z "$(git tag -l 'cultcache-ts-v*' | grep -vxF "$GITHUB_REF_NAME" || true)" ]; then first="--first-release"; fi - node scripts/check-changelog-semver.mjs \ - --package "@gamecult/cultcache-ts" \ - --changelog packages/cultcache-ts/CHANGELOG.md \ - --version "$tagged" \ - --tag-prefix cultcache-ts \ - $first + node scripts/check-changelog-semver.mjs --package "@gamecult/cultcache-ts" --version "$tagged" - name: Build and test working-directory: packages/cultcache-ts @@ -165,15 +155,7 @@ jobs: if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') run: | tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}" - # See the cultcache-ts job: the checker, not this test, decides whether a first release is real. - first="" - if [ -z "$(git tag -l '${{ matrix.package }}-v*' | grep -vxF "$GITHUB_REF_NAME" || true)" ]; then first="--first-release"; fi - node scripts/check-changelog-semver.mjs \ - --package "${{ matrix.package }}" \ - --changelog packages/${{ matrix.package }}/CHANGELOG.md \ - --version "$tagged" \ - --tag-prefix "${{ matrix.package }}" \ - $first + 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. diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 index 390f523f..0e14cad6 100644 --- a/packages/cultmath/scripts/build-unity-package.ps1 +++ b/packages/cultmath/scripts/build-unity-package.ps1 @@ -50,11 +50,8 @@ $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 ` - --api-baseline-path "packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins" ` --api-built $freshAssembly if ($LASTEXITCODE -ne 0) { throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)." diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index c508bc8d..6605ed93 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -176,6 +176,8 @@ foreach ($assemblyName in $expectedAssemblies) { # 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) @@ -185,11 +187,8 @@ foreach ($refRoot in @($publishRoot, $quicPublishRoot, $webSocketPublishRoot)) { } & 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 ` - --api-baseline-path "unity/org.gamecult.cultlib/Runtime/Plugins" ` @measuredArguments if ($LASTEXITCODE -ne 0) { throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)." diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index e1be5ab9..fce29b9f 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -5,8 +5,8 @@ // 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, spawnSync } from "node:child_process"; import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -164,40 +164,53 @@ export function evaluateRelease({ packageName, changelogText, version, previousV 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. --- -// The previous release is the newest same-prefix tag strictly older than the version. -// `other` is any same-prefix tag that is not the version itself, older or newer: a -// package with one has been released before, whatever the version arithmetic says. -function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { - // A directory that cannot list tags (not a git repository, git missing) throws: that - // is not evidence that there was no previous release. - const tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); +// The release record, derived from `git tag -l` in --cwd. The previous release is the +// newest '-vMAJOR.MINOR.PATCH' tag strictly older than the version; 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, because a checkout with none cannot tell a first release +// from a missing record. +function resolveRelease(tagPrefix, versionText, cwd) { + let tags; + try { + tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + } catch { + 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); + const current = parseVersion(versionText); + const own = `${prefix}${formatVersion(current)}`; let best = null; - let other = null; + 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 - other ??= tag; - if (compareVersions(candidate, current) >= 0) continue; // ignore anything not older - if (best === null || compareVersions(candidate, best) > 0) { + const candidate = parseVersion(text); + const order = compareVersions(candidate, current); + if (order === 0) continue; // the tag being released itself + if (order > 0) { + newer ??= tag; + } else if (best === null || compareVersions(candidate, best) > 0) { best = candidate; } } - return { previous: best ? formatVersion(best) : null, other }; + if (best !== null) return { previous: formatVersion(best) }; + 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)" }; + } + return { previous: null }; } // --- Measured public API: the built DLLs are compared with the DLLs the @@ -252,57 +265,83 @@ function prepareGate() { return { refs }; } -// The DLLs the previous tag tracked under `baselinePath`, as git blobs: no -// worktree, no network, and unaffected by what the working tree holds now. A git -// failure is a refusal that names the step, never git's own output. -function extractBaseline({ tag, baselinePath, into, cwd }) { - const dir = baselinePath.replace(/\\/g, "/").replace(/\/+$/, ""); +// 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 listing = git(["ls-tree", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" }); - const names = listing.split("\n").map((line) => basename(line.trim())).filter((name) => name.toLowerCase().endsWith(".dll")); - for (const name of names) { - writeFileSync(join(into, name), git(["show", `${tag}:${dir}/${name}`], { maxBuffer: 1 << 28 })); - } - return { dir, names }; + 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 { 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 baseline path must hold DLLs at the tag, every -// built assembly must have a namesake there unless the caller declares it new -// (`declaredNew`), and at least one comparison must run. An assembly the tag -// tracked and the build no longer produces is itself a break, whatever its name. -export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, built, refs, declaredNew = [], cwd }) { +// 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, baselinePath, into: left, cwd }); + const baseline = extractBaseline({ tag, dir: assemblies, into: left, cwd }); if (baseline.failure) return baseline; - const { dir, names: baselineNames } = baseline; + const baselineNames = baseline.names; if (baselineNames.length === 0) { - return { failure: `${tag} tracks no assembly under ${dir}: the baseline path is missing or moved, or that tag does not track the DLLs` }; - } - const builtNames = new Set(built.map((path) => basename(path))); - for (const name of declaredNew) { - if (baselineNames.includes(name)) return { failure: `${name} is declared new but ${tag} already tracks it under ${dir}` }; - } - for (const name of builtNames) { - if (!baselineNames.includes(name) && !declaredNew.includes(name)) { - return { failure: `${name} is built but ${tag} does not track it under ${dir}; if it is a new assembly, declare it with --api-new ${name}` }; - } + 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 unbuilt = baselineNames.filter((name) => !builtNames.has(name)); - if (unbuilt.length === baselineNames.length) { - return { failure: `no built assembly has a namesake tracked at ${tag}, so nothing was compared (${tag} tracks ${baselineNames.join(", ")})` }; - } - const breaks = unbuilt.map((name) => `assembly ${name} exists at ${tag} but is no longer built`); + 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; @@ -318,9 +357,9 @@ export function measureApiBreaks({ tagPrefix, previousVersion, baselinePath, bui } } -// Repeatable options collect into arrays; flags take no value. -const REPEATABLE = new Set(["api-built", "api-refs", "api-new"]); -const FLAGS = new Set(["first-release"]); +// 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 = {}; @@ -328,10 +367,7 @@ function parseArgs(argv) { const arg = argv[i]; if (!arg.startsWith("--")) continue; const key = arg.slice(2); - if (FLAGS.has(key)) { - args[key] = true; - continue; - } + if (!KNOWN.has(key)) return { unknown: arg }; if (REPEATABLE.has(key)) { (args[key] ??= []).push(argv[i + 1]); } else { @@ -350,15 +386,14 @@ function refuse(message) { const UNMEASURED_NOTICE = 'public API measured; changes ApiCompat cannot see (see "Unmeasured changes" in docs/semver-policy.md) must still be declared under "### Breaking"'; -function main() { +async function main() { const args = parseArgs(process.argv.slice(2)); - const { package: packageName, changelog, version, "tag-prefix": tagPrefix, cwd = process.cwd() } = args; + 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 (!packageName || !changelog || !version || !tagPrefix || (built.length > 0) !== Boolean(args["api-baseline-path"])) { + if (args.unknown || !packageName || !version) { console.error( - "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ] [--first-release]\n" + - " [--api-baseline-path --api-built ... [--api-refs ...] [--api-new ...]]", + "usage: check-changelog-semver.mjs --package --version [--cwd ] [--api-built ... [--api-refs ...]]", ); process.exitCode = 2; return; @@ -369,6 +404,21 @@ function main() { refuse(`${packageName}: --version is not a MAJOR.MINOR.PATCH version`); return; } + // 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 entry = Object.hasOwn(declared ?? {}, packageName) ? declared[packageName] : null; + if (entry == null || typeof entry.tagPrefix !== "string" || typeof entry.changelog !== "string") { + 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"); @@ -376,44 +426,28 @@ function main() { refuse(`${packageName}: ${err.code === "ENOENT" ? "changelog not found" : "changelog cannot be read"} at ${changelog}`); return; } - const firstRelease = args["first-release"] === true; - let previousVersion; - let otherTag = null; - try { - ({ previous: previousVersion, other: otherTag } = resolvePreviousVersion(tagPrefix, version, cwd)); - } catch { - if (!firstRelease) { - refuse(`${packageName}: the tags of --cwd could not be read (not a git repository?), so the previous release is unknown; ` + - "if this is the package's first release, declare it with --first-release"); - return; - } - previousVersion = null; - } - if (previousVersion == null && !firstRelease) { - refuse(`${packageName}: no ${tagPrefix}-v tag older than ${version} exists, so there is nothing to compare against; ` + - "if this is the package's first release, declare it with --first-release"); + 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 (firstRelease && otherTag != null) { - refuse(`${packageName}: --first-release was declared but ${otherTag} already exists`); + if (typeof assemblies !== "string" && built.length > 0) { + refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`); return; } - // A package's tag prefix is its directory under packages/ (the workflow's tag triggers and - // job matrix spell it the same way), so a first release is believed only for the prefix - // its changelog's directory names. - if (firstRelease && basename(dirname(resolve(changelog))) !== tagPrefix) { - refuse(`${packageName}: --first-release is accepted only when --tag-prefix names the directory that holds the changelog`); + const release = resolveRelease(tagPrefix, version, cwd); + if (release.failure) { + refuse(`${packageName}: ${release.failure}`); return; } + const previousVersion = release.previous; let measuredBreaks = []; - if (built.length > 0 && previousVersion != null) { + if (previousVersion != null && built.length > 0) { const measurement = measureApiBreaks({ tagPrefix, previousVersion, - baselinePath: args["api-baseline-path"], + assemblies, built, refs: (args["api-refs"] ?? []).map((path) => resolve(path)), - declaredNew: args["api-new"] ?? [], cwd, }); if (measurement.failure) { @@ -435,5 +469,5 @@ function main() { const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - main(); + await main(); } diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index ad7e58a3..1d2516dc 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -11,6 +11,7 @@ import { classifyBump, evaluateRelease, hasBreakingSection, + isManagedAssembly, nextVersions, parseVersion, readApiCompatRun, @@ -188,9 +189,22 @@ test("evaluateRelease names package, previous version, new version, and the brea assert.match(result.reason, new RegExp(BREAKING_HEADING.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); }); -// --- CLI-level integration tests: exercise the git-backed previous-version -// resolution and the "don't re-litigate an already-tagged release" skip, -// which the pure evaluateRelease() tests above cannot reach. --- +// --- CLI-level integration tests: exercise the declaration, the git-backed release +// resolution and the derived first release, which the pure evaluateRelease() tests +// above cannot reach. --- + +const PLUGINS = "unity/widget/Runtime/Plugins"; +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const workflowFile = join(repoRoot, ".github", "workflows", "publish-packages.yml"); +const releasePackages = (await import("./release-packages.mjs")).default; + +// The package every fixture repository declares in its own scripts/release-packages.mjs. +const WIDGET = { tagPrefix: "widget", changelog: "CHANGELOG.md" }; + +function declare(dir, entries) { + mkdirSync(join(dir, "scripts"), { recursive: true }); + writeFileSync(join(dir, "scripts", "release-packages.mjs"), `export default ${JSON.stringify(entries)};\n`); +} function withTempGitRepo(fn) { const dir = mkdtempSync(join(tmpdir(), "cultlib-semver-check-")); @@ -198,6 +212,7 @@ function withTempGitRepo(fn) { execFileSync("git", ["init", "-q"], { cwd: dir }); execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: dir }); execFileSync("git", ["config", "user.name", "Test"], { cwd: dir }); + declare(dir, { widget: WIDGET }); fn(dir); } finally { rmSync(dir, { recursive: true, force: true }); @@ -226,143 +241,180 @@ function runCheckerExpectFailure(cwd, args, { status = 1, ...options } = {}) { } } +const git = (dir, ...args) => execFileSync("git", args, { cwd: dir }); +const commitAndTag = (dir, ...tags) => { + git(dir, "commit", "--allow-empty", "-q", "-m", "init"); + for (const tag of tags) git(dir, "tag", tag); +}; +const writeChangelog = (dir, version, options) => writeFileSync(join(dir, "CHANGELOG.md"), changelogFor(version, options)); +const widgetArgs = (version) => ["--package", "widget", "--version", version]; + test("CLI: resolves the previous version from git tags and passes a valid bump", () => { withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.1.0", { breaking: false })); - const output = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget"]); + commitAndTag(dir, "widget-v1.0.0"); + writeChangelog(dir, "1.1.0"); + const output = runChecker(dir, widgetArgs("1.1.0")); assert.match(output, /1\.1\.0 is a minor bump over 1\.0\.0/); }); }); test("CLI: fails when the changelog claims breaking under too small a bump", () => { withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true })); - const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); + commitAndTag(dir, "widget-v1.0.0"); + writeChangelog(dir, "1.0.1", { breaking: true }); + const output = runCheckerExpectFailure(dir, widgetArgs("1.0.1")); assert.match(output, /needs a major bump/); }); }); test("CLI: a tag for the version being released does not skip the check", () => { withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.1"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.1", { breaking: true })); - const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); + commitAndTag(dir, "widget-v1.0.0", "widget-v1.0.1"); + writeChangelog(dir, "1.0.1", { breaking: true }); + const output = runCheckerExpectFailure(dir, widgetArgs("1.0.1")); assert.match(output, /needs a major bump/); }); }); -// A package's changelog lives in a directory named like its tag prefix (packages//). -function packageChangelog(dir, version, prefix = "widget") { - mkdirSync(join(dir, prefix), { recursive: true }); - const path = join(dir, prefix, "CHANGELOG.md"); - writeFileSync(path, changelogFor(version)); - return path; -} - -test("CLI: no previous tag, or tags that cannot be read, refuse unless the first release is declared", () => { +test("CLI: a first release is derived from the tags, and nothing is measured for it", () => { withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "other-v1.0.0"], { cwd: dir }); - const changelogPath = packageChangelog(dir, "1.0.0"); - const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; - assert.match(runCheckerExpectFailure(dir, args), /nothing to compare against; if this is the package's first release, declare it with --first-release/); - assert.match(runChecker(dir, [...args, "--first-release"]), /first release \(1\.0\.0\)/); - // A flag takes no value: the option after it is still read. - assert.match(runChecker(dir, ["--first-release", ...args]), /first release/); + // another package's tag exists; this package has none + commitAndTag(dir, "other-v1.0.0"); + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); + const junk = join(dir, "junk.dll"); + writeFileSync(junk, "junk"); + const measured = runChecker(dir, measuredArgs(dir, "1.0.0", [junk])); + assert.match(measured, /first release \(1\.0\.0\)/); + assert.doesNotMatch(measured, /public API measured/); + declare(dir, { widget: WIDGET }); + assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/); + // the version's own tag, beside another package's, is still a first release + git(dir, "tag", "widget-v1.0.0"); + assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/); }); +}); + +test("CLI: a checkout that holds no other tag refuses, and no option rescues it", () => { + const holdsNone = /this checkout holds no tags; a first release cannot be told from a missing record/; + withTempGitRepo((dir) => { + git(dir, "commit", "--allow-empty", "-q", "-m", "init"); + writeChangelog(dir, "1.0.0"); + assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), holdsNone); + git(dir, "tag", "widget-v1.0.0"); + assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), holdsNone); + // an option that used to declare the release is now an unknown option, not a rescue + runCheckerExpectFailure(dir, [...widgetArgs("1.0.0"), "--first-release"], { status: 2 }); + }); +}); + +test("CLI: tags that cannot be read refuse without git's output", () => { const notARepo = mkdtempSync(join(tmpdir(), "cultlib-semver-norepo-")); try { - const changelogPath = packageChangelog(notARepo, "1.0.0"); - const args = ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.0", "--tag-prefix", "widget"]; - const refused = runCheckerExpectFailure(notARepo, args); - assert.match(refused, /tags of --cwd could not be read .*; if this is the package's first release, declare it with --first-release/); + declare(notARepo, { widget: WIDGET }); + writeChangelog(notARepo, "1.0.0"); + const refused = runCheckerExpectFailure(notARepo, widgetArgs("1.0.0")); + assert.match(refused, /tags of --cwd could not be read \(not a git repository\?\), so the previous release is unknown/); assert.doesNotMatch(refused, /fatal/); // git's own complaint is not forwarded - assert.match(runChecker(notARepo, [...args, "--first-release"]), /first release/); } finally { rmSync(notARepo, { recursive: true, force: true }); } }); -test("CLI: declaring a first release while an earlier tag exists is refused", () => { - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.1.0")); - const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.0", "--tag-prefix", "widget", "--first-release"]); - assert.match(output, /--first-release was declared but widget-v1\.0\.0 already exists/); - }); -}); - -test("CLI: --first-release is believed only with no other same-prefix tag and the package's own prefix", () => { - const declared = (dir, version, prefix = "widget") => [ - "--package", "widget", "--changelog", packageChangelog(dir, version), "--version", version, "--tag-prefix", prefix, "--first-release", - ]; - // a newer tag of the prefix: not a first release (the unflagged run refuses it too) - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v2.0.0"], { cwd: dir }); - assert.match(runCheckerExpectFailure(dir, declared(dir, "0.9.0")), /--first-release was declared but widget-v(1|2)\.0\.0 already exists/); - }); - // a mistyped prefix, with the real package's tags in the repository - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const args = declared(dir, "1.0.1", "widgit"); - assert.match(runCheckerExpectFailure(dir, args), /--first-release is accepted only when --tag-prefix names the directory that holds the changelog/); - // the right prefix names the tag that exists, so it is refused for that - assert.match(runCheckerExpectFailure(dir, declared(dir, "1.0.1")), /already exists/); - }); - // with the predecessor tag deleted, a mistyped prefix is still refused by the prefix check - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - execFileSync("git", ["tag", "-d", "widget-v1.0.0"], { cwd: dir }); - assert.match(runCheckerExpectFailure(dir, declared(dir, "1.0.1", "widgit")), /names the directory that holds the changelog/); - }); - // the version's own tag is not another tag: a re-run of a package's only release - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - assert.match(runChecker(dir, declared(dir, "1.0.0")), /first release \(1\.0\.0\)/); - }); -}); - -// The publish workflow's own first-release decision, run as the workflow runs it, then handed -// to the checker: a package's first release passes, and the release after it does too. -const workflowFile = fileURLToPath(new URL("../.github/workflows/publish-packages.yml", import.meta.url)); - -test("workflow: the --first-release line passes a package's first release and its second", { skip: process.platform === "win32" }, () => { - const workflow = readFileSync(workflowFile, "utf8"); - const lines = [...workflow.matchAll(/^ *if \[ -z "\$\(git tag -l '([^']*)-v\*' [^\n]*first="--first-release"; fi\r?$/gm)]; - assert.equal(lines.length, 2, "the cultcache-ts job and the python matrix each decide it once"); - for (const [line, prefix] of lines) { - const decision = line.trim().replace("${{ matrix.package }}", "widget"); - const first = (dir, tag) => execFileSync("bash", ["-c", `${decision}; printf '%s' "$first"`], { cwd: dir, encoding: "utf8", env: { ...process.env, GITHUB_REF_NAME: tag } }); +test("CLI: a same-prefix tag that is not MAJOR.MINOR.PATCH refuses, never skipped", () => { + for (const malformed of ["widget-v0.9.0-rc1", "widget-v1.0", "widget-vnext"]) { + // alone withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - const name = prefix.includes("matrix") ? "widget" : prefix; - execFileSync("git", ["tag", `${name}-v1.0.0`], { cwd: dir }); - assert.equal(first(dir, `${name}-v1.0.0`), "--first-release"); - execFileSync("git", ["tag", `${name}-v1.1.0`], { cwd: dir }); - assert.equal(first(dir, `${name}-v1.1.0`), ""); - // another package's tags are not this package's releases - execFileSync("git", ["tag", "unrelated-v9.0.0"], { cwd: dir }); - execFileSync("git", ["tag", "-d", `${name}-v1.0.0`], { cwd: dir }); - execFileSync("git", ["tag", "-d", `${name}-v1.1.0`], { cwd: dir }); - execFileSync("git", ["tag", `${name}-v0.1.0`], { cwd: dir }); - assert.equal(first(dir, `${name}-v0.1.0`), "--first-release"); + commitAndTag(dir, malformed); + writeChangelog(dir, "0.9.0"); + const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0")); + assert.match(output, new RegExp(`${malformed.replace(/\./g, "\\.")} starts with widget-v but is not widget-vMAJOR\\.MINOR\\.PATCH`)); }); + // beside an older valid release the version would otherwise be measured against + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v0.8.0", malformed); + writeChangelog(dir, "0.9.0"); + const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0")); + assert.match(output, new RegExp(`${malformed.replace(/\./g, "\\.")} starts with`)); + }); + } +}); + +test("CLI: a version older than every release refuses", () => { + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v1.0.0", "widget-v2.0.0"); + writeChangelog(dir, "0.9.0"); + const output = runCheckerExpectFailure(dir, widgetArgs("0.9.0")); + assert.match(output, /widget-v(1|2)\.0\.0 exists but no widget-v tag is older than 0\.9\.0: the version is older than every release/); + }); +}); + +test("CLI: the tag prefix is the declaration's, not a spelling the caller or the directory supplies", () => { + withTempGitRepo((dir) => { + declare(dir, { widget: { tagPrefix: "widget-unity", changelog: "widget/CHANGELOG.md" } }); + mkdirSync(join(dir, "widget")); + const changelog = (version) => writeFileSync(join(dir, "widget", "CHANGELOG.md"), changelogFor(version)); + // widget-v1.2.0 is another package's tag: were it this package's, 1.1.0 would be a reversal + commitAndTag(dir, "widget-v1.2.0", "widget-unity-v1.0.0"); + changelog("1.1.0"); + assert.match(runChecker(dir, widgetArgs("1.1.0")), /1\.1\.0 is a minor bump over 1\.0\.0/); + // with the declared prefix's only release deleted, 1.0.1 is a first release, not a bump over widget-v1.2.0 + git(dir, "tag", "-d", "widget-unity-v1.0.0"); + changelog("1.0.1"); + assert.match(runChecker(dir, widgetArgs("1.0.1")), /first release \(1\.0\.1\)/); + }); +}); + +test("CLI: an undeclared package refuses, and so does a name that only the prototype holds", () => { + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v1.0.0"); + writeChangelog(dir, "1.1.0"); + for (const name of ["gadget", "constructor", "__proto__"]) { + const output = runCheckerExpectFailure(dir, ["--package", name, "--version", "1.1.0"]); + assert.match(output, /not a package declared in scripts\/release-packages\.mjs/); + } + }); +}); + +test("CLI: a repository with no declaration refuses", () => { + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v1.0.0"); + writeChangelog(dir, "1.1.0"); + rmSync(join(dir, "scripts"), { recursive: true }); + assert.match(runCheckerExpectFailure(dir, widgetArgs("1.1.0")), /scripts\/release-packages\.mjs could not be loaded from --cwd/); + }); +}); + +test("CLI: the flags the declaration replaced are usage errors, as is any unknown option", () => { + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v1.0.0"); + writeChangelog(dir, "1.1.0"); + // the same call without the extra option passes, so a silently ignored flag would exit 0 + assert.match(runChecker(dir, widgetArgs("1.1.0")), /minor bump/); + for (const extra of [ + ["--first-release"], + ["--first-release", "true"], + ["--first-release=true"], + ["--api-new", "GameCult.Widget.dll"], + ["--tag-prefix", "widget"], + ["--changelog", join(dir, "CHANGELOG.md")], + ["--api-baseline-path", PLUGINS], + ["--bogus", "1"], + ]) { + assert.match(runCheckerExpectFailure(dir, [...widgetArgs("1.1.0"), ...extra], { status: 2 }), /usage: check-changelog-semver\.mjs/); + assert.match(runCheckerExpectFailure(dir, [...extra, ...widgetArgs("1.1.0")], { status: 2 }), /usage:/); + } + }); +}); + +test("workflow: every tag trigger is a declared prefix, and every declared assemblies directory is tracked", () => { + const workflow = readFileSync(workflowFile, "utf8"); + const triggers = [...workflow.match(/^\s+tags:\r?\n((?:\s+- "[^"]+"\r?\n)+)/m)[1].matchAll(/- "([^"]+)-v\*"/g)].map((match) => match[1]); + assert.ok(triggers.length >= 4, "the workflow triggers on the npm and PyPI packages' tags"); + const prefixes = Object.values(releasePackages).map((entry) => entry.tagPrefix); + for (const trigger of triggers) assert.ok(prefixes.includes(trigger), `${trigger}-v* triggers the workflow but no package declares that prefix`); + 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`); } }); @@ -395,20 +447,25 @@ ${references.join("|")}`).digest("hex").slice(0, 16)); return dll; } -const PLUGINS = "unity/widget/Runtime/Plugins"; - -// A repo whose tag widget-v tracks `baseline` ({file name: dll path}) -// under PLUGINS, and whose working tree then holds `afterTag` instead. +// A repo whose tag widget-v tracks `baseline` ({path under PLUGINS: dll path}) under +// PLUGINS, and whose working tree then holds `afterTag` instead. The package declares PLUGINS +// as its assemblies directory. function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0" }, fn) { withTempGitRepo((dir) => { - mkdirSync(join(dir, PLUGINS), { recursive: true }); - for (const [name, path] of Object.entries(baseline)) copyFileSync(path, join(dir, PLUGINS, name)); + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); + const place = (files) => { + for (const [name, path] of Object.entries(files)) { + mkdirSync(dirname(join(dir, PLUGINS, name)), { recursive: true }); + copyFileSync(path, join(dir, PLUGINS, name)); + } + }; + place(baseline); execFileSync("git", ["add", "."], { cwd: dir }); execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir }); execFileSync("git", ["tag", `widget-v${previous}`], { cwd: dir }); if (afterTag !== baseline) { for (const name of Object.keys(baseline)) rmSync(join(dir, PLUGINS, name)); - for (const [name, path] of Object.entries(afterTag)) copyFileSync(path, join(dir, PLUGINS, name)); + place(afterTag); execFileSync("git", ["add", "-A", "."], { cwd: dir }); execFileSync("git", ["commit", "-q", "-m", "after the release"], { cwd: dir }); } @@ -416,13 +473,12 @@ function withReleasedBaseline({ baseline, afterTag = baseline, previous = "1.0.0 }); } -function measuredArgs(dir, version, builtPaths, { breaking = false, news = [], refs = [] } = {}) { - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor(version, { breaking })); +function measuredArgs(dir, version, builtPaths, { breaking = false, refs = [] } = {}) { + writeChangelog(dir, version, { breaking }); return [ - "--package", "widget", "--changelog", changelogPath, "--version", version, "--tag-prefix", "widget", - "--api-baseline-path", PLUGINS, ...builtPaths.flatMap((path) => ["--api-built", path]), - ...news.flatMap((name) => ["--api-new", name]), ...refs.flatMap((refDir) => ["--api-refs", refDir]), + ...widgetArgs(version), + ...builtPaths.flatMap((path) => ["--api-built", path]), + ...refs.flatMap((refDir) => ["--api-refs", refDir]), ]; } @@ -471,7 +527,7 @@ test("measured: a declared break with nothing measured still passes", () => { }); }); -test("measured: a tracked assembly that is no longer built is a break, whatever its name; a declared new assembly is not", () => { +test("measured: a tracked assembly that is no longer built is a break, whatever its name; a built assembly with no namesake is an addition", () => { const other = buildAssembly("GameCult.Other", "namespace O { public class D {} }"); const thirdParty = buildAssembly("Vendor.Lib", "namespace V { public class E {} }"); const baseline = { "GameCult.Widget.dll": oldWidget(), "GameCult.Other.dll": other, "Vendor.Lib.dll": thirdParty }; @@ -481,26 +537,147 @@ test("measured: a tracked assembly that is no longer built is a break, whatever assert.match(refused, /Vendor\.Lib\.dll exists at widget-v1\.0\.0 but is no longer built/); assert.match(refused, /breaks 2 public API member/); const brandNew = buildAssembly("GameCult.Brand", "namespace B { public class F {} }"); - const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, thirdParty, brandNew], { news: ["GameCult.Brand.dll"] })); + const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget(), other, thirdParty, brandNew])); assert.match(ok, /1\.0\.1 is a patch bump/); }); }); -test("measured: renaming the only assembly and declaring it new compares nothing and is refused", () => { +test("measured: renaming the only assembly is a break with nothing declared, and a declared break can ship", () => { // CultMath's shape: one assembly, and its name is not GameCult.*. const cultMath = buildAssembly("CultMath", WITH_GONE); const renamedTrimmed = buildAssembly("CultMath.Next", WITHOUT_GONE); withReleasedBaseline({ baseline: { "CultMath.dll": cultMath } }, (dir) => { const own = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [buildAssembly("CultMath", WITHOUT_GONE)])); assert.match(own, /Gone/); - const renamed = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [renamedTrimmed], { news: ["CultMath.Next.dll"] })); - assert.match(renamed, /no built assembly has a namesake tracked at widget-v1\.0\.0, so nothing was compared/); - assert.match(renamed, /CultMath\.dll/); - // with a comparison that ran, the unbuilt tracked assembly is itself the break - withReleasedBaseline({ baseline: { "CultMath.dll": cultMath, "Other.dll": buildAssembly("Other", "namespace O { public class D {} }") } }, (both) => { - const partial = runCheckerExpectFailure(both, measuredArgs(both, "1.0.1", [cultMath, renamedTrimmed], { news: ["CultMath.Next.dll"] })); - assert.match(partial, /Other\.dll exists at widget-v1\.0\.0 but is no longer built/); + const renamed = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [renamedTrimmed])); + assert.match(renamed, /assembly CultMath\.dll exists at widget-v1\.0\.0 but is no longer built/); + const shipped = runChecker(dir, measuredArgs(dir, "2.0.0", [renamedTrimmed], { breaking: true })); + assert.match(shipped, /2\.0\.0 is a major bump over 1\.0\.0 and has a "### Breaking" section and 1 measured public API break/); + }); + // with another assembly built, the unbuilt one is still the break + const other = buildAssembly("Other", "namespace O { public class D {} }"); + withReleasedBaseline({ baseline: { "CultMath.dll": cultMath, "Other.dll": other } }, (both) => { + const partial = runCheckerExpectFailure(both, measuredArgs(both, "1.0.1", [cultMath, renamedTrimmed])); + assert.match(partial, /Other\.dll exists at widget-v1\.0\.0 but is no longer built/); + }); +}); + +test("measured: an assembly tracked in a subdirectory is measured, and is a break when it is no longer built", () => { + const widget = oldWidget(); + const sub = buildAssembly("GameCult.B", "namespace B { public class K { public void Keep() {} public void Gone() {} } }"); + const subTrimmed = buildAssembly("GameCult.B", "namespace B { public class K { public void Keep() {} } }"); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": widget, "sub/GameCult.B.dll": sub } }, (dir) => { + const unbuilt = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [widget])); + assert.match(unbuilt, /assembly GameCult\.B\.dll exists at widget-v1\.0\.0 but is no longer built/); + const compared = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [widget, subTrimmed])); + assert.match(compared, /B\.K\.Gone/); + assert.doesNotMatch(compared, /no longer built/); + assert.match(runChecker(dir, measuredArgs(dir, "1.0.1", [widget, sub])), /1\.0\.1 is a patch bump/); + }); +}); + +// A PE image written byte by byte. A managed assembly's CLI header is data directory 14; a +// native DLL has none. +function peImage({ plus = true, directories = 16, cli = null } = {}) { + const pe = 0x40; + const optionalSize = (plus ? 112 : 96) + directories * 8; + const bytes = Buffer.alloc(pe + 24 + optionalSize); + bytes.writeUInt16LE(0x5a4d, 0); + bytes.writeUInt32LE(pe, 0x3c); + bytes.writeUInt32LE(0x00004550, pe); + bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, pe + 4); + bytes.writeUInt16LE(optionalSize, pe + 20); + const optional = pe + 24; + bytes.writeUInt16LE(plus ? 0x20b : 0x10b, optional); + const dirs = optional + (plus ? 112 : 96); + bytes.writeUInt32LE(directories, dirs - 4); + if (cli) { + bytes.writeUInt32LE(cli.rva, dirs + 14 * 8); + bytes.writeUInt32LE(cli.size, dirs + 14 * 8 + 4); + } + return bytes; +} + +const CLI = { rva: 0x2000, size: 0x48 }; + +test("isManagedAssembly: a non-empty CLI header makes an assembly managed, nothing else does", () => { + assert.equal(isManagedAssembly(peImage({ cli: CLI })), true); + assert.equal(isManagedAssembly(peImage({ plus: false, cli: CLI })), true); + assert.equal(isManagedAssembly(peImage()), false); + assert.equal(isManagedAssembly(peImage({ plus: false })), false); + assert.equal(isManagedAssembly(peImage({ cli: { rva: CLI.rva, size: 0 } })), false); + assert.equal(isManagedAssembly(peImage({ cli: { rva: 0, size: CLI.size } })), false); + // too few data directories to have a CLI header, whatever the bytes after them say + assert.equal(isManagedAssembly(peImage({ directories: 14 })), false); + assert.equal(isManagedAssembly(readFileSync(oldWidget())), true); +}); + +test("isManagedAssembly: bytes that are not a PE image throw", () => { + const withByte = (offset, value) => { + const bytes = peImage({ cli: CLI }); + bytes[offset] = value; + return bytes; + }; + for (const bytes of [ + Buffer.alloc(0), + Buffer.from("not a PE image, just text that is long enough to be read as a header ........."), + peImage({ cli: CLI }).subarray(0, 0x50), + peImage({ cli: CLI }).subarray(0, 0x40 + 24 + 100), + withByte(0, 0x4e), + withByte(0x40, 0x51), + withByte(0x40 + 24 + 1, 0x00), + ]) { + assert.throws(() => isManagedAssembly(bytes), /not a PE image/); + } +}); + +test("isManagedAssembly: the shipped QUIC bridge is native and the shipped managed assemblies are managed", () => { + const plugins = join(repoRoot, "unity", "org.gamecult.cultlib", "Runtime", "Plugins"); + assert.equal(isManagedAssembly(readFileSync(join(plugins, "x86_64", "gamecult_mesh_quic_native.dll"))), false); + assert.equal(isManagedAssembly(readFileSync(join(plugins, "GameCult.Caching.dll"))), true); +}); + +test("measured: natives are not measured, decided by their bytes; a .dll that is not a PE image refuses", () => { + const scratch = mkdtempSync(join(tmpdir(), "cultlib-semver-pe-")); + try { + const file = (name, bytes) => { + const path = join(scratch, name); + writeFileSync(path, bytes); + return path; + }; + // a native in x86_64/ and one beside the managed assemblies: neither is built, neither is a break + const baseline = { "GameCult.Widget.dll": oldWidget(), "x86_64/native.dll": file("a.dll", peImage()), "Native32.dll": file("b.dll", peImage({ plus: false })) }; + withReleasedBaseline({ baseline }, (dir) => { + const ok = runChecker(dir, measuredArgs(dir, "1.0.1", [oldWidget()])); + assert.match(ok, /1\.0\.1 is a patch bump/); + assert.doesNotMatch(ok, /native/i); }); + for (const [name, bytes] of [["text.dll", Buffer.from("not a PE image")], ["cut.dll", peImage({ cli: CLI }).subarray(0, 0x50)]]) { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget(), "x86_64/bogus.dll": file(name, bytes) } }, (dir) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()])); + assert.match(output, /unity\/widget\/Runtime\/Plugins\/x86_64\/bogus\.dll at widget-v1\.0\.0 is a \.dll but not a PE image/); + }); + } + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +}); + +test("measured: two managed assemblies with one name refuse", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget(), "a/X.dll": oldWidget(), "b/X.dll": oldWidget() } }, (dir) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()])); + assert.match(output, /widget-v1\.0\.0 tracks two managed assemblies named X\.dll/); + }); +}); + +test("measured: a declared assemblies directory makes measurement mandatory, and a package without one takes no --api-built", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [])); + assert.match(output, /ships the assemblies under unity\/widget\/Runtime\/Plugins, so the release must hand them over with --api-built/); + }); + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v1.0.0"); + assert.match(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()])), /declares no assemblies directory/); }); }); @@ -618,36 +795,16 @@ test("measured: the baseline is the tag's blob, not the working tree's DLL", () ); }); -test("measured: a declared first release measures nothing", () => { - withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - const junk = join(dir, "junk.dll"); - writeFileSync(junk, "junk"); - const args = measuredArgs(dir, "1.0.0", [junk]); - args[args.indexOf("--changelog") + 1] = packageChangelog(dir, "1.0.0"); - const output = runChecker(dir, [...args, "--first-release"]); - assert.match(output, /first release/); - assert.doesNotMatch(output, /public API measured/); - }); -}); - test("measured: measuring nothing refuses, naming what is missing", () => { const fail = (dir, args) => runCheckerExpectFailure(dir, args); withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - // a baseline path that is wrong or moved - const moved = measuredArgs(dir, "1.1.0", [trimmedWidget()]); - moved[moved.indexOf("--api-baseline-path") + 1] = "unity/moved/Runtime/Plugins"; - assert.match(fail(dir, moved), /widget-v1\.0\.0 tracks no assembly under unity\/moved\/Runtime\/Plugins: the baseline path is missing or moved/); - // a shipped DLL the tag does not track - const fresh = buildAssembly("GameCult.Fresh", "namespace F { public class G {} }"); - const output = fail(dir, measuredArgs(dir, "1.1.0", [oldWidget(), fresh])); - assert.match(output, /GameCult\.Fresh\.dll is built but widget-v1\.0\.0 does not track it under .*; if it is a new assembly, declare it with --api-new GameCult\.Fresh\.dll/); - // declaring an assembly new that the tag already tracks is a wrong declaration - const wrong = fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()], { news: ["GameCult.Widget.dll"] })); - assert.match(wrong, /GameCult\.Widget\.dll is declared new but widget-v1\.0\.0 already tracks it/); + // a declared directory that is wrong or moved + declare(dir, { widget: { ...WIDGET, assemblies: "unity/moved/Runtime/Plugins" } }); + assert.match(fail(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()])), /widget-v1\.0\.0 tracks no managed assembly under unity\/moved\/Runtime\/Plugins: the declared directory is missing or moved/); }); // a previous tag that does not track the DLLs at all withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); mkdirSync(join(dir, PLUGINS), { recursive: true }); writeFileSync(join(dir, PLUGINS, "README.txt"), "no dlls yet"); execFileSync("git", ["add", "."], { cwd: dir }); @@ -655,6 +812,16 @@ test("measured: measuring nothing refuses, naming what is missing", () => { execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); assert.match(fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()])), /does not track the DLLs/); }); + // a previous tag whose only DLLs are native + withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); + mkdirSync(join(dir, PLUGINS), { recursive: true }); + writeFileSync(join(dir, PLUGINS, "native.dll"), peImage()); + execFileSync("git", ["add", "."], { cwd: dir }); + execFileSync("git", ["commit", "-q", "-m", "release"], { cwd: dir }); + execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); + assert.match(fail(dir, measuredArgs(dir, "1.1.0", [oldWidget()])), /tracks no managed assembly/); + }); }); test("measured: a git failure while reading the baseline, and a changelog that is not a file, are refusals without a stack or git's output", () => { @@ -664,6 +831,7 @@ test("measured: a git failure while reading the baseline, and a changelog that i }; // the tag tracks a gitlink named like a DLL: ls-tree lists it, git show cannot read it withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); mkdirSync(join(dir, PLUGINS), { recursive: true }); copyFileSync(oldWidget(), join(dir, PLUGINS, "GameCult.Widget.dll")); execFileSync("git", ["add", "."], { cwd: dir }); @@ -673,17 +841,16 @@ test("measured: a git failure while reading the baseline, and a changelog that i const output = clean(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]))); assert.match(output, /git could not read the assemblies tracked under unity\/widget\/Runtime\/Plugins at widget-v1\.0\.0/); }); - // a baseline path that git would take for an option + // a declared directory that git would take for an option withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - const args = measuredArgs(dir, "1.0.1", [oldWidget()]); - args[args.indexOf("--api-baseline-path") + 1] = "--bogus"; - assert.match(clean(runCheckerExpectFailure(dir, args)), /tracks no assembly under --bogus|git could not read/); + declare(dir, { widget: { ...WIDGET, assemblies: "--bogus" } }); + assert.match(clean(runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]))), /tracks no managed assembly under --bogus|git could not read/); }); // a changelog path that is a directory withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); + commitAndTag(dir, "widget-v1.0.0"); mkdirSync(join(dir, "CHANGELOG.md")); - const output = clean(runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.0.0", "--tag-prefix", "widget"])); + const output = clean(runCheckerExpectFailure(dir, widgetArgs("1.0.1"))); assert.match(output, /changelog cannot be read at /); }); }); @@ -694,11 +861,9 @@ test("measured: a passing release says that unmeasured changes still need declar assert.match(output, /public API measured; changes ApiCompat cannot see \(see "Unmeasured changes" in docs\/semver-policy\.md\) must still be declared under "### Breaking"/); }); withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.0.1")); - const unmeasured = runChecker(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.0.1", "--tag-prefix", "widget"]); + commitAndTag(dir, "widget-v1.0.0"); + writeChangelog(dir, "1.0.1"); + const unmeasured = runChecker(dir, widgetArgs("1.0.1")); assert.doesNotMatch(unmeasured, /public API measured/); }); }); @@ -720,37 +885,23 @@ test("evaluateRelease: measured breaks count as breaking for the lane, declared assert.match(reason, /; \.\.\./); }); -test("measured: every missing required option is a usage error", () => { +test("every missing required option is a usage error", () => { withTempGitRepo((dir) => { - const complete = ["--package", "widget", "--changelog", join(dir, "CHANGELOG.md"), "--version", "1.1.0", "--tag-prefix", "widget"]; + const complete = widgetArgs("1.1.0"); for (let i = 0; i < complete.length; i += 2) { const withoutOne = complete.filter((_, index) => index !== i && index !== i + 1); const output = runCheckerExpectFailure(dir, withoutOne, { status: 2 }); assert.match(output, /usage: check-changelog-semver\.mjs/); - assert.match(output, /--api-baseline-path/); + assert.match(output, /--api-built/); } - const builtWithoutBaseline = runCheckerExpectFailure(dir, [...complete, "--api-built", "x.dll"], { status: 2 }); - assert.match(builtWithoutBaseline, /usage:/); - const baselineWithoutBuilt = runCheckerExpectFailure(dir, [...complete, "--api-baseline-path", PLUGINS], { status: 2 }); - assert.match(baselineWithoutBuilt, /usage:/); }); }); -test("CLI: a missing changelog fails and names the path; stray positional words are ignored", () => { +test("CLI: a missing changelog fails and names the declared path; stray positional words are ignored", () => { withTempGitRepo((dir) => { - const missing = join(dir, "NOPE.md"); - const output = runCheckerExpectFailure(dir, ["stray", "--package", "widget", "--changelog", missing, "--version", "1.1.0", "--tag-prefix", "widget"]); - assert.match(output, /changelog not found at .*NOPE.md/); - }); -}); - -test("measured: the baseline path may use backslashes or a trailing slash", () => { - withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { - for (const path of [`${PLUGINS}/`, `${PLUGINS}//`, PLUGINS.replace(/\//g, "\\")]) { - const args = measuredArgs(dir, "1.1.0", [trimmedWidget()]); - args[args.indexOf("--api-baseline-path") + 1] = path; - assert.match(runCheckerExpectFailure(dir, args), /Gone/); - } + declare(dir, { widget: { ...WIDGET, changelog: "docs/NOPE.md" } }); + const output = runCheckerExpectFailure(dir, ["stray", ...widgetArgs("1.1.0")]); + assert.match(output, /changelog not found at .*docs.NOPE\.md/); }); }); @@ -903,18 +1054,14 @@ test("evaluateRelease: lists at most five measured breaks, and says nothing more assert.match(agreed, /a "### Breaking" section and 1 measured public API break\(s\)/); }); -test("CLI: a malformed version is refused by name, without repeating it, with or without --first-release", () => { +test("CLI: a malformed version is refused by name, without repeating it", () => { withTempGitRepo((dir) => { - execFileSync("git", ["commit", "--allow-empty", "-q", "-m", "init"], { cwd: dir }); - execFileSync("git", ["tag", "widget-v1.0.0"], { cwd: dir }); - const changelogPath = join(dir, "CHANGELOG.md"); - writeFileSync(changelogPath, changelogFor("1.1.0")); - for (const extra of [[], ["--first-release"]]) { - const output = runCheckerExpectFailure(dir, ["--package", "widget", "--changelog", changelogPath, "--version", "1.1.x-canary", "--tag-prefix", "widget", ...extra]); - assert.match(output, /--version is not a MAJOR\.MINOR\.PATCH version/); - assert.doesNotMatch(output, /canary/); - assert.doesNotMatch(output, /could not be read/); - } + commitAndTag(dir, "widget-v1.0.0"); + writeChangelog(dir, "1.1.0"); + const output = runCheckerExpectFailure(dir, widgetArgs("1.1.x-canary")); + assert.match(output, /--version is not a MAJOR\.MINOR\.PATCH version/); + assert.doesNotMatch(output, /canary/); + assert.doesNotMatch(output, /could not be read/); }); }); @@ -925,9 +1072,7 @@ test("refusals never repeat what the inputs contain", () => { const junk = join(scratch, "GameCult.Widget.dll"); writeFileSync(junk, `MZ not an assembly ${canary}`); const canaryChangelog = (dir, version, { breaking = false } = {}) => { - const path = join(dir, "CHANGELOG.md"); - writeFileSync(path, changelogFor(version, { breaking }).replace("- something changed", `- something changed ${canary}`) + `\n## [0.0.1]\n\n- ${canary}\n`); - return path; + writeFileSync(join(dir, "CHANGELOG.md"), changelogFor(version, { breaking }).replace("- something changed", `- something changed ${canary}`) + `\n## [0.0.1]\n\n- ${canary}\n`); }; const refuse = (dir, args) => { const output = runCheckerExpectFailure(dir, args); @@ -937,7 +1082,7 @@ test("refusals never repeat what the inputs contain", () => { withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { const measured = (version, built, options) => { const args = measuredArgs(dir, version, built, options); - args[args.indexOf("--changelog") + 1] = canaryChangelog(dir, version, options); + canaryChangelog(dir, version, options); return args; }; // a measured break, with the canary in the changelog it must be declared in @@ -952,10 +1097,10 @@ test("refusals never repeat what the inputs contain", () => { assert.match(refuse(dir, skip), /skips ahead/); assert.match(refuse(dir, measured("1.0.1", [oldWidget()], { breaking: true })), /needs a major bump/); }); - // the tag's own blob is unreadable + // the tag's own blob is not a PE image withReleasedBaseline({ baseline: { "GameCult.Widget.dll": junk } }, (dir) => { const args = measuredArgs(dir, "1.0.1", [oldWidget()]); - args[args.indexOf("--changelog") + 1] = canaryChangelog(dir, "1.0.1"); + canaryChangelog(dir, "1.0.1"); assert.match(refuse(dir, args), /could not be measured/); }); } finally { diff --git a/scripts/release-packages.mjs b/scripts/release-packages.mjs new file mode 100644 index 00000000..d70e0026 --- /dev/null +++ b/scripts/release-packages.mjs @@ -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 '-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", + }, +}; diff --git a/scripts/verify-caching-unity-release.ps1 b/scripts/verify-caching-unity-release.ps1 index 7e28ef6c..7bf55b68 100644 --- a/scripts/verify-caching-unity-release.ps1 +++ b/scripts/verify-caching-unity-release.ps1 @@ -11,15 +11,12 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot $packageRoot = Join-Path $repoRoot "src\GameCult.Unity\Assets\Caching" $manifestPath = Join-Path $packageRoot "package.json" -$changelogPath = Join-Path $packageRoot "CHANGELOG.md" $version = (Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json).version & node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") ` --package "org.gamecult.caching.unity" ` - --changelog $changelogPath ` --version $version ` - --tag-prefix "caching-unity" ` --cwd $repoRoot if ($LASTEXITCODE -ne 0) { throw "org.gamecult.caching.unity failed the semver policy check (see docs/semver-policy.md)." From 38ead8c4b0f9e608402af0cfc0eb009d48a7cf6f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 02:36:58 +0200 Subject: [PATCH 21/31] Semver policy: the declaration file owns prefix, changelog and assemblies; first release and measured set are derived Co-Authored-By: Claude Sonnet 5.5 --- docs/semver-policy.md | 127 ++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 60 deletions(-) diff --git a/docs/semver-policy.md b/docs/semver-policy.md index 15a95913..6083c209 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -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 - `-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 - `-v*` tag exists (older or newer than the version) and - `--tag-prefix` names the directory that holds the changelog - (`packages//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 `-v`; one that is not + `-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 `## []` 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 ` and one `--api-built ` per assembly they built (plus +A package whose declaration names an `assemblies` directory is measured, and +the release must hand over one `--api-built ` per assembly it built (plus `--api-refs ` for directories that hold the built assemblies' -dependencies, and `--api-new ` 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 ---changelog --version --tag-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 [--cwd ] [--api-built ... [--api-refs ...]]`. +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 ") 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. From 3a25a9d3aaf767fd5c4c025c9195f851fe2015a4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 05:55:12 +0200 Subject: [PATCH 22/31] Semver check: baseline is the newest older tag that is an ancestor of the commit; first release refused when the changelog lists an earlier version; declaration checked whole The previous release is chosen by version order among same-prefix tags strictly older than the version, and must be an ancestor of HEAD, so a later backport on another branch never moves a rebuild's predecessor. A release with no previous tag is refused when the changelog already lists an earlier version, so a mistyped prefix cannot make an unmeasured first release. The declaration is validated as a whole: two packages sharing a prefix, or an entry without a string tagPrefix and changelog, refuse. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-changelog-semver.mjs | 73 +++++++++++++--- scripts/check-changelog-semver.test.mjs | 105 +++++++++++++++++++++++- 2 files changed, 167 insertions(+), 11 deletions(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index fce29b9f..0a9ec96e 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -168,11 +168,18 @@ export function evaluateRelease({ packageName, changelogText, version, previousV // changelog, then delegates to the pure functions above. --- // The release record, derived from `git tag -l` in --cwd. The previous release is the -// newest '-vMAJOR.MINOR.PATCH' tag strictly older than the version; 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, because a checkout with none cannot tell a first release -// from a missing record. +// newest '-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, +// because a checkout with none cannot tell a first release from a missing record. +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) { let tags; try { @@ -186,7 +193,7 @@ function resolveRelease(tagPrefix, versionText, cwd) { const prefix = `${tagPrefix}-v`; const current = parseVersion(versionText); const own = `${prefix}${formatVersion(current)}`; - let best = null; + const older = []; let newer = null; for (const tag of tags) { if (!tag.startsWith(prefix)) continue; @@ -199,11 +206,19 @@ function resolveRelease(tagPrefix, versionText, cwd) { if (order === 0) continue; // the tag being released itself if (order > 0) { newer ??= tag; - } else if (best === null || compareVersions(candidate, best) > 0) { - best = candidate; + } else { + older.push({ tag, version: candidate }); } } - if (best !== null) return { previous: formatVersion(best) }; + 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` }; } @@ -213,6 +228,34 @@ function resolveRelease(tagPrefix, versionText, cwd) { return { previous: null }; } +// The newest `## [x.y.z]` entry of the changelog older than the version, or null. A release with +// no previous tag whose changelog already lists an earlier version is not a first release: the +// declared tag prefix names none of the package's releases. +function earlierChangelogVersion(changelogText, versionText) { + const current = parseVersion(versionText); + let newest = null; + for (const match of changelogText.matchAll(/^##\s*\[?(\d+\.\d+\.\d+)\]?\s*$/gm)) { + const entry = parseVersion(match[1]); + if (compareVersions(entry, current) < 0 && (newest === null || compareVersions(entry, newest) > 0)) newest = entry; + } + return newest === null ? null : formatVersion(newest); +} + +// 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 @@ -412,8 +455,13 @@ async function main() { refuse(`${packageName}: scripts/release-packages.mjs could not be loaded from --cwd`); return; } + 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 || typeof entry.tagPrefix !== "string" || typeof entry.changelog !== "string") { + if (entry == null) { refuse(`${packageName}: not a package declared in scripts/release-packages.mjs`); return; } @@ -440,6 +488,11 @@ async function main() { return; } const previousVersion = release.previous; + const earlier = previousVersion == null ? earlierChangelogVersion(changelogText, version) : null; + if (earlier !== null) { + refuse(`${packageName}: ${version} would be a first release, but the changelog already lists ${earlier} and no ${tagPrefix}-v tag is an earlier release: the declared tag prefix is wrong`); + return; + } let measuredBreaks = []; if (previousVersion != null && built.length > 0) { const measurement = measureApiBreaks({ diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 1d2516dc..52ea590b 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -8,6 +8,7 @@ import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { BREAKING_HEADING, + checkDeclaration, classifyBump, evaluateRelease, hasBreakingSection, @@ -243,7 +244,7 @@ function runCheckerExpectFailure(cwd, args, { status = 1, ...options } = {}) { const git = (dir, ...args) => execFileSync("git", args, { cwd: dir }); const commitAndTag = (dir, ...tags) => { - git(dir, "commit", "--allow-empty", "-q", "-m", "init"); + git(dir, "commit", "--allow-empty", "-q", "-m", `init ${tags.join(" ")}`); for (const tag of tags) git(dir, "tag", tag); }; const writeChangelog = (dir, version, options) => writeFileSync(join(dir, "CHANGELOG.md"), changelogFor(version, options)); @@ -348,6 +349,108 @@ test("CLI: a version older than every release refuses", () => { }); }); +test("CLI: the previous release is the newest older tag by version order, not git's string order", () => { + withTempGitRepo((dir) => { + // git tag -l lists these as 1.0.0, 1.0.5, 1.10.0, 1.2.0, 1.9.0, 2.0.0: 1.10.0 sorts before 1.2.0 + commitAndTag(dir, "widget-v1.0.0", "widget-v1.2.0", "widget-v1.10.0", "widget-v1.9.0", "widget-v2.0.0", "other-v9.9.9"); + git(dir, "tag", "-a", "-m", "annotated", "widget-v1.0.5"); + const previousOf = (version) => { + writeChangelog(dir, version); + return runChecker(dir, widgetArgs(version)); + }; + assert.match(previousOf("1.10.1"), /1\.10\.1 is a patch bump over 1\.10\.0/); + assert.match(previousOf("1.11.0"), /1\.11\.0 is a minor bump over 1\.10\.0/); + assert.match(previousOf("1.3.0"), /1\.3\.0 is a minor bump over 1\.2\.0/); + assert.match(previousOf("1.0.6"), /1\.0\.6 is a patch bump over 1\.0\.5/); + assert.match(previousOf("2.0.1"), /2\.0\.1 is a patch bump over 2\.0\.0/); + assert.match(previousOf("3.0.0"), /3\.0\.0 is a major bump over 2\.0\.0/); + }); +}); + +test("CLI: a later backport tag on another branch does not change a rebuild's predecessor", () => { + const head = (dir) => git(dir, "rev-parse", "HEAD").toString().trim(); + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v0.2.0"); + const older = head(dir); + commitAndTag(dir, "widget-v0.3.0"); + const rebuilt = head(dir); + writeChangelog(dir, "0.3.0"); + const atRelease = runChecker(dir, widgetArgs("0.3.0")); + assert.match(atRelease, /0\.3\.0 is a minor bump over 0\.2\.0/); + // the backport is cut from the older release, later, and is never merged + git(dir, "checkout", "-q", older); + commitAndTag(dir, "widget-v0.2.5"); + const backport = head(dir); + git(dir, "checkout", "-q", rebuilt); + assert.equal(runChecker(dir, widgetArgs("0.3.0")), atRelease); + // history that does contain the backport still measures against it + git(dir, "checkout", "-q", backport); + writeChangelog(dir, "0.2.6"); + assert.match(runChecker(dir, widgetArgs("0.2.6")), /0\.2\.6 is a patch bump over 0\.2\.5/); + }); + // older tags that are all outside the history leave the predecessor unknown + withTempGitRepo((dir) => { + commitAndTag(dir, "other-v1.0.0"); + const root = head(dir); + git(dir, "checkout", "-q", "--detach"); + commitAndTag(dir, "widget-v0.1.0"); + git(dir, "checkout", "-q", root); + writeChangelog(dir, "0.2.0"); + assert.match(runCheckerExpectFailure(dir, widgetArgs("0.2.0")), /none is an ancestor of the commit being checked/); + }); +}); + +test("CLI: a declared prefix that names no release is not a first release when the changelog lists earlier versions", () => { + // CultMath's shape: 0.3.0 is released and tracks the DLL; 0.3.1 removes a member. + const cultMath = buildAssembly("CultMath", WITH_GONE); + const trimmed = buildAssembly("CultMath", WITHOUT_GONE); + withReleasedBaseline({ baseline: { "CultMath.dll": cultMath }, previous: "0.3.0" }, (dir) => { + const entry = { ...WIDGET, assemblies: PLUGINS }; + const args = measuredArgs(dir, "0.3.1", [trimmed]); + writeFileSync(join(dir, "CHANGELOG.md"), `${changelogFor("0.3.1")}\n## [0.3.0]\n\n- the release\n`); + assert.match(runCheckerExpectFailure(dir, args), /Gone/); + // one mistyped character in the declaration + declare(dir, { widget: { ...entry, tagPrefix: "widgt" } }); + const typo = runCheckerExpectFailure(dir, args); + assert.match(typo, /0\.3\.1 would be a first release, but the changelog already lists 0\.3\.0/); + assert.doesNotMatch(typo, /first release \(/); + // a release the changelog lists nothing before is still a first release + writeChangelog(dir, "0.3.1"); + assert.match(runChecker(dir, args), /first release \(0\.3\.1\)/); + }); +}); + +test("CLI: two packages that share a tag prefix are refused, and so is an entry that is not whole", () => { + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v1.0.0"); + writeChangelog(dir, "1.0.1"); + declare(dir, { widget: WIDGET, gadget: { ...WIDGET } }); + for (const name of ["widget", "gadget"]) { + const output = runCheckerExpectFailure(dir, ["--package", name, "--version", "1.0.1"]); + assert.match(output, /scripts\/release-packages\.mjs is malformed: widget and gadget share one tag prefix/); + } + // distinct prefixes pass + declare(dir, { widget: WIDGET, gadget: { ...WIDGET, tagPrefix: "gadget" } }); + assert.match(runChecker(dir, widgetArgs("1.0.1")), /patch bump over 1\.0\.0/); + for (const broken of [ + { changelog: "CHANGELOG.md" }, + { tagPrefix: 7, changelog: "CHANGELOG.md" }, + { tagPrefix: "widget" }, + { tagPrefix: "widget", changelog: ["CHANGELOG.md"] }, + null, + ]) { + declare(dir, { widget: WIDGET, gadget: broken }); + const output = runCheckerExpectFailure(dir, widgetArgs("1.0.1")); + assert.match(output, /scripts\/release-packages\.mjs is malformed: gadget must declare a string tagPrefix and a string changelog/); + } + }); +}); + +test("the real declaration is whole and shares no tag prefix", () => { + assert.equal(checkDeclaration(releasePackages), null); + assert.equal(new Set(Object.values(releasePackages).map((entry) => entry.tagPrefix)).size, Object.keys(releasePackages).length); +}); + test("CLI: the tag prefix is the declaration's, not a spelling the caller or the directory supplies", () => { withTempGitRepo((dir) => { declare(dir, { widget: { tagPrefix: "widget-unity", changelog: "widget/CHANGELOG.md" } }); From a9225e878699d9b2d841b43f2d4178e385a7be40 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 05:55:13 +0200 Subject: [PATCH 23/31] Semver policy: ancestor baseline, first-release changelog guard, declaration validation Co-Authored-By: Claude Sonnet 5.5 --- docs/semver-policy.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/semver-policy.md b/docs/semver-policy.md index 6083c209..87387f30 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -41,8 +41,9 @@ own header comment) and are out of scope for tagged-release semver policing; a `Cargo.toml` version still exists and should follow this policy by convention, but nothing in this repo checks it mechanically today. -Tag naming stays `-v` for every package above, matching the -prefixes already in use. +Releases are tags named `-v`, 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 @@ -150,14 +151,21 @@ or a missing declaration refuses), and: is one that starts `-v`; one that is not `-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 + 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. Otherwise it is a first release, which no caller declares. + 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; it is refused when the package's changelog already lists an + earlier version, because then the declared prefix names none of its + releases. 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; From 2610434917c48e609f41b28203e2eecf027a95df Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 08:01:30 +0200 Subject: [PATCH 24/31] Semver check: a first release is one whose package folder no tag has tracked The changelog-heading guard missed 12 of 17 heading shapes and a first changelog entry. It and its parsing are deleted. A release with no same-prefix baseline is a first release only when no tag in the repository, under any prefix, tracks a file under the package's folder (declared assemblies dir, else the changelog's directory); otherwise it refuses naming the tag. A tag whose tree cannot be read refuses. Tests pin the mistyped prefix for CultMath 0.3.1, cultlib 1.0.61 and a Python package, a genuine first release, the moved-folder choice, and an unreadable ancestry and tree. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-changelog-semver.mjs | 52 ++++++---- scripts/check-changelog-semver.test.mjs | 128 ++++++++++++++++++++---- 2 files changed, 142 insertions(+), 38 deletions(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 0a9ec96e..ea3cecd3 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -11,7 +11,7 @@ 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 { basename, dirname, join, posix, resolve } from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; // The exact heading spelling the policy mandates for a breaking-change @@ -173,14 +173,15 @@ export function evaluateRelease({ packageName, changelogText, version, previousV // --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, -// because a checkout with none cannot tell a first release from a missing record. +// 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 file under the package's folder. 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) { +function resolveRelease(tagPrefix, versionText, cwd, folder) { let tags; try { tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) @@ -225,20 +226,33 @@ function resolveRelease(tagPrefix, versionText, cwd) { 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)" }; } + // Under every other prefix too: a package 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 = firstTagTracking(tags.filter((tag) => tag !== own), folder, cwd); + if (tracked.failure) return tracked; + if (tracked.tag) { + return { failure: `no ${prefix} tag is an earlier release, but ${tracked.tag} tracks files under ${folder}: the package has shipped before, so the declared tag prefix is wrong` }; + } return { previous: null }; } -// The newest `## [x.y.z]` entry of the changelog older than the version, or null. A release with -// no previous tag whose changelog already lists an earlier version is not a first release: the -// declared tag prefix names none of the package's releases. -function earlierChangelogVersion(changelogText, versionText) { - const current = parseVersion(versionText); - let newest = null; - for (const match of changelogText.matchAll(/^##\s*\[?(\d+\.\d+\.\d+)\]?\s*$/gm)) { - const entry = parseVersion(match[1]); - if (compareVersions(entry, current) < 0 && (newest === null || compareVersions(entry, newest) > 0)) newest = entry; +// The first of the tags that tracks any file under `folder`, or none. A tag that cannot be read +// is a refusal, never a tag that tracks nothing. +function firstTagTracking(tags, folder, cwd) { + const pathspec = folder === "." ? [] : ["--", `${folder}/`]; + for (const tag of tags) { + try { + const listing = execFileSync("git", ["ls-tree", "-r", "--name-only", `refs/tags/${tag}`, ...pathspec], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (listing.trim() !== "") return { tag }; + } catch { + return { failure: `the files ${tag} tracks under ${folder} could not be read, so a first release cannot be told from a mistyped prefix` }; + } } - return newest === null ? null : formatVersion(newest); + return {}; } // The declaration as a whole: every entry names a string tagPrefix and changelog, and no two @@ -482,17 +496,15 @@ async function main() { refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`); return; } - const release = resolveRelease(tagPrefix, version, cwd); + // The folder whose tracking by a tag tells a shipped package from a new one: the shipped + // assemblies, else the package's own directory (where its changelog lives). + const folder = typeof assemblies === "string" ? assemblies : posix.dirname(entry.changelog); + const release = resolveRelease(tagPrefix, version, cwd, folder); if (release.failure) { refuse(`${packageName}: ${release.failure}`); return; } const previousVersion = release.previous; - const earlier = previousVersion == null ? earlierChangelogVersion(changelogText, version) : null; - if (earlier !== null) { - refuse(`${packageName}: ${version} would be a first release, but the changelog already lists ${earlier} and no ${tagPrefix}-v tag is an earlier release: the declared tag prefix is wrong`); - return; - } let measuredBreaks = []; if (previousVersion != null && built.length > 0) { const measurement = measureApiBreaks({ diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 52ea590b..6542efe5 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -3,7 +3,7 @@ import { execFileSync } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { basename, dirname, join } from "node:path"; +import { basename, dirname, join, posix as posixPath } from "node:path"; import { fileURLToPath } from "node:url"; import { test } from "node:test"; import { @@ -400,23 +400,115 @@ test("CLI: a later backport tag on another branch does not change a rebuild's pr }); }); -test("CLI: a declared prefix that names no release is not a first release when the changelog lists earlier versions", () => { - // CultMath's shape: 0.3.0 is released and tracks the DLL; 0.3.1 removes a member. - const cultMath = buildAssembly("CultMath", WITH_GONE); - const trimmed = buildAssembly("CultMath", WITHOUT_GONE); - withReleasedBaseline({ baseline: { "CultMath.dll": cultMath }, previous: "0.3.0" }, (dir) => { - const entry = { ...WIDGET, assemblies: PLUGINS }; - const args = measuredArgs(dir, "0.3.1", [trimmed]); - writeFileSync(join(dir, "CHANGELOG.md"), `${changelogFor("0.3.1")}\n## [0.3.0]\n\n- the release\n`); - assert.match(runCheckerExpectFailure(dir, args), /Gone/); - // one mistyped character in the declaration - declare(dir, { widget: { ...entry, tagPrefix: "widgt" } }); - const typo = runCheckerExpectFailure(dir, args); - assert.match(typo, /0\.3\.1 would be a first release, but the changelog already lists 0\.3\.0/); - assert.doesNotMatch(typo, /first release \(/); - // a release the changelog lists nothing before is still a first release - writeChangelog(dir, "0.3.1"); - assert.match(runChecker(dir, args), /first release \(0\.3\.1\)/); +// A repository where each tag tracks the files it is given: tag name -> { path: contents }. +// Every file is committed under the tag that first names it, so a later tag tracks the earlier ones'. +function tagWithFiles(dir, tag, files) { + for (const [path, contents] of Object.entries(files)) { + mkdirSync(dirname(join(dir, path)), { recursive: true }); + writeFileSync(join(dir, path), contents); + } + git(dir, "add", "--", ...Object.keys(files)); + commitAndTag(dir, tag); +} + +test("CLI: a mistyped declared prefix is refused, because a tag under another prefix tracks the package's files", () => { + // The real entries of CultMath, cultlib and a Python package, each released once under its + // real prefix, then the declaration misspelled. Nothing is measured: the refusal comes from git. + const cases = [ + { name: "org.gamecult.cultmath", released: "0.3.0", next: "0.3.1", file: "CultMath.dll" }, + { name: "org.gamecult.cultlib", released: "1.0.60", next: "1.0.61", file: "GameCult.Core.dll" }, + { name: "cultcache-py", released: "0.1.0", next: "0.1.1", file: "cultcache/__init__.py" }, + ]; + for (const { name, released, next, file } of cases) { + const real = releasePackages[name]; + const folder = real.assemblies ?? posixPath.dirname(real.changelog); + withTempGitRepo((dir) => { + declare(dir, { [name]: real }); + tagWithFiles(dir, `${real.tagPrefix}-v${released}`, { [`${folder}/${file}`]: "released" }); + mkdirSync(dirname(join(dir, real.changelog)), { recursive: true }); + writeFileSync(join(dir, real.changelog), changelogFor(next)); + const junk = join(dir, "junk.dll"); + writeFileSync(junk, "junk"); + const args = ["--package", name, "--version", next, ...(real.assemblies ? ["--api-built", junk] : [])]; + declare(dir, { [name]: { ...real, tagPrefix: real.tagPrefix.slice(0, -1) } }); + const refused = runCheckerExpectFailure(dir, args); + assert.match(refused, new RegExp(`${real.tagPrefix}-v${released} tracks files under `)); + assert.doesNotMatch(refused, /first release \(/); + // the same release under the real prefix is a bump over its predecessor, not a first release + if (!real.assemblies) { + declare(dir, { [name]: real }); + assert.match(runChecker(dir, args), /patch bump over/); + } + }); + } +}); + +test("CLI: no tag tracks the package's folder, so a release is a first release however many other tags exist", () => { + withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, changelog: "widget/CHANGELOG.md", assemblies: PLUGINS } }); + tagWithFiles(dir, "other-v1.0.0", { "other/file.txt": "other" }); + tagWithFiles(dir, "other-v1.1.0", { "other/more.txt": "more" }); + // the package's folder arrives after every tag, and its files are not tracked by any of them + mkdirSync(join(dir, "widget"), { recursive: true }); + writeFileSync(join(dir, "widget", "CHANGELOG.md"), changelogFor("1.0.0")); + const junk = join(dir, "junk.dll"); + writeFileSync(junk, "junk"); + const args = [...widgetArgs("1.0.0"), "--api-built", junk]; + assert.match(runChecker(dir, args), /first release \(1\.0\.0\)/); + // its own tag does not stop a rebuild of that first release + tagWithFiles(dir, "widget-v1.0.0", { [`${PLUGINS}/GameCult.Widget.dll`]: "shipped" }); + assert.match(runChecker(dir, args), /first release \(1\.0\.0\)/); + // but the same files under any other tag make a later release with no same-prefix baseline a refusal + declare(dir, { widget: { ...WIDGET, changelog: "widget/CHANGELOG.md", assemblies: PLUGINS, tagPrefix: "widgt" } }); + assert.match(runCheckerExpectFailure(dir, args), /widget-v1\.0\.0 tracks files under unity\/widget\/Runtime\/Plugins/); + }); +}); + +test("CLI: a package without assemblies is told from a new one by its own directory, and a changelog at the root by the whole tree", () => { + withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, changelog: "pkg/CHANGELOG.md" } }); + tagWithFiles(dir, "other-v1.0.0", { "elsewhere.txt": "x" }); + mkdirSync(join(dir, "pkg")); + writeFileSync(join(dir, "pkg", "CHANGELOG.md"), changelogFor("1.0.0")); + assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/); + tagWithFiles(dir, "other-v1.1.0", { "pkg/code.py": "x" }); + assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /other-v1\.1\.0 tracks files under pkg/); + }); + withTempGitRepo((dir) => { + tagWithFiles(dir, "other-v1.0.0", { "elsewhere.txt": "x" }); + writeChangelog(dir, "1.0.0"); + assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /other-v1.0.0 tracks files under ./); + }); +}); + +test("CLI: a folder that moved is the declared folder alone: the old path does not count", () => { + const baseline = buildAssembly("GameCult.Widget", WITH_GONE); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": baseline } }, (dir) => { + const moved = "unity/widget2/Runtime/Plugins"; + const built = buildAssembly("GameCult.Widget", WITH_GONE); + // under the real prefix the moved folder holds nothing at the tag: refused as a moved directory + declare(dir, { widget: { ...WIDGET, assemblies: moved } }); + writeChangelog(dir, "1.0.1"); + assert.match(runCheckerExpectFailure(dir, [...widgetArgs("1.0.1"), "--api-built", built]), /tracks no managed assembly under unity\/widget2\/Runtime\/Plugins/); + // under a mistyped prefix, no tag tracks the new folder, and the old one is not looked at + declare(dir, { widget: { ...WIDGET, assemblies: moved, tagPrefix: "widgt" } }); + assert.match(runChecker(dir, [...widgetArgs("1.0.1"), "--api-built", built]), /first release \(1\.0\.1\)/); + }); +}); + +test("CLI: a tag that cannot be read at its ancestry or its files refuses", () => { + withTempGitRepo((dir) => { + commitAndTag(dir, "widget-v0.8.0"); + // a tag whose object is missing from the repository (a damaged checkout) + writeFileSync(join(dir, ".git", "refs", "tags", "widget-v0.9.0"), "1".repeat(40) + "\n"); + writeChangelog(dir, "1.0.0"); + assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /whether widget-v0\.9\.0 is an ancestor of the commit being checked could not be read/); + }); + withTempGitRepo((dir) => { + commitAndTag(dir, "other-v1.0.0"); + writeFileSync(join(dir, ".git", "refs", "tags", "other-v2.0.0"), "1".repeat(40) + "\n"); + writeChangelog(dir, "1.0.0"); + assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /could not be read, so a first release cannot be told from a mistyped prefix/); }); }); From a8a6d1bf856b7b15c77a01567b3db9e27151c2c7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 08:01:30 +0200 Subject: [PATCH 25/31] Semver policy: first release is derived from the folder's tracking by tags Co-Authored-By: Claude Sonnet 5.5 --- docs/semver-policy.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/semver-policy.md b/docs/semver-policy.md index 87387f30..3992f502 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -163,12 +163,21 @@ or a missing declaration refuses), and: 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; it is refused when the package's changelog already lists an - earlier version, because then the declared prefix names none of its - releases. - 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; + caller declares, only when no tag in the repository, under any prefix, tracks + a file under the package's folder (its declared assemblies directory, else + the directory of its changelog): a tag that tracks one means the package has + shipped, so the declared prefix is wrong and the check refuses, naming the + tag. 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 + folder counts: a package whose folder 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 folder holds no managed assembly at the previous tag. A + package whose files other packages' tags track (a monorepo tag tracks every + package in the tree) cannot be released first through this check once such a + tag exists. + 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 `## []` 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 From e940a373161e6a92199e2128810fbbb066ba3851 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 08:50:30 +0200 Subject: [PATCH 26/31] Semver check: the folder pathspec has no root special case and the tree listing is compared as git prints it Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-changelog-semver.mjs | 5 ++--- scripts/check-changelog-semver.test.mjs | 4 +++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index ea3cecd3..c11fcd2e 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -239,15 +239,14 @@ function resolveRelease(tagPrefix, versionText, cwd, folder) { // The first of the tags that tracks any file under `folder`, or none. A tag that cannot be read // is a refusal, never a tag that tracks nothing. function firstTagTracking(tags, folder, cwd) { - const pathspec = folder === "." ? [] : ["--", `${folder}/`]; for (const tag of tags) { try { - const listing = execFileSync("git", ["ls-tree", "-r", "--name-only", `refs/tags/${tag}`, ...pathspec], { + const listing = execFileSync("git", ["ls-tree", "-r", "--name-only", `refs/tags/${tag}`, "--", `${folder}/`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }); - if (listing.trim() !== "") return { tag }; + if (listing !== "") return { tag }; } catch { return { failure: `the files ${tag} tracks under ${folder} could not be read, so a first release cannot be told from a mistyped prefix` }; } diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 6542efe5..f7669d0d 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -508,7 +508,9 @@ test("CLI: a tag that cannot be read at its ancestry or its files refuses", () = commitAndTag(dir, "other-v1.0.0"); writeFileSync(join(dir, ".git", "refs", "tags", "other-v2.0.0"), "1".repeat(40) + "\n"); writeChangelog(dir, "1.0.0"); - assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /could not be read, so a first release cannot be told from a mistyped prefix/); + const refused = runCheckerExpectFailure(dir, widgetArgs("1.0.0")); + assert.match(refused, /could not be read, so a first release cannot be told from a mistyped prefix/); + assert.doesNotMatch(refused, /fatal|bad object|1111111/); // git's own complaint is not forwarded }); }); From 8c3e834967d3e690a959c3ca9b9a472cc6a1d918 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 08:57:06 +0200 Subject: [PATCH 27/31] Semver check: a first release is proved only by a managed assembly in the declared folder Monorepo snapshot tags track every package's directory, so tracking a file proved nothing about a package's own releases. A package with an assemblies directory is a first release only when no tag under any prefix tracks a managed assembly there (decided by PE bytes, once per blob). Assembly-less packages are a first release when no tag of their own prefix exists; their prefix is pinned by the suite test tying each declared prefix to a workflow trigger or an existing tag, which now fails on a typo for every assembly-less package. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-changelog-semver.mjs | 63 ++++++++----- scripts/check-changelog-semver.test.mjs | 119 ++++++++++++++++-------- 2 files changed, 122 insertions(+), 60 deletions(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index c11fcd2e..a24ba839 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -11,7 +11,7 @@ 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, posix, resolve } from "node:path"; +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 @@ -173,15 +173,17 @@ export function evaluateRelease({ packageName, changelogText, version, previousV // --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 file under the package's folder. +// 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, folder) { +function resolveRelease(tagPrefix, versionText, cwd, assemblies) { let tags; try { tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) @@ -226,29 +228,49 @@ function resolveRelease(tagPrefix, versionText, cwd, folder) { 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)" }; } - // Under every other prefix too: a package 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 = firstTagTracking(tags.filter((tag) => tag !== own), folder, cwd); - if (tracked.failure) return tracked; - if (tracked.tag) { - return { failure: `no ${prefix} tag is an earlier release, but ${tracked.tag} tracks files under ${folder}: the package has shipped before, so the declared tag prefix is wrong` }; + 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 }; } -// The first of the tags that tracks any file under `folder`, or none. A tag that cannot be read -// is a refusal, never a tag that tracks nothing. -function firstTagTracking(tags, folder, 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 listing = execFileSync("git", ["ls-tree", "-r", "--name-only", `refs/tags/${tag}`, "--", `${folder}/`], { + const entries = execFileSync("git", ["ls-tree", "-r", "-z", `refs/tags/${tag}`, "--", `${dir}/`], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], - }); - if (listing !== "") return { tag }; + }) + .split("") + .filter((entry) => entry.toLowerCase().endsWith(".dll")); + for (const entry of entries) { + const tab = entry.indexOf(" "); + 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 files ${tag} tracks under ${folder} could not be read, so a first release cannot be told from a mistyped prefix` }; + return { failure: `the assemblies ${tag} tracks under ${dir} could not be read, so a first release cannot be told from a mistyped prefix` }; } } return {}; @@ -495,10 +517,7 @@ async function main() { refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`); return; } - // The folder whose tracking by a tag tells a shipped package from a new one: the shipped - // assemblies, else the package's own directory (where its changelog lives). - const folder = typeof assemblies === "string" ? assemblies : posix.dirname(entry.changelog); - const release = resolveRelease(tagPrefix, version, cwd, folder); + const release = resolveRelease(tagPrefix, version, cwd, assemblies); if (release.failure) { refuse(`${packageName}: ${release.failure}`); return; diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index f7669d0d..b770e4ed 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -411,73 +411,81 @@ function tagWithFiles(dir, tag, files) { commitAndTag(dir, tag); } -test("CLI: a mistyped declared prefix is refused, because a tag under another prefix tracks the package's files", () => { - // The real entries of CultMath, cultlib and a Python package, each released once under its - // real prefix, then the declaration misspelled. Nothing is measured: the refusal comes from git. +test("CLI: a mistyped declared prefix of a Unity package is refused, because a tag under another prefix tracks its managed assembly", () => { + // The real entries of CultMath and cultlib, each released once under its real prefix, then + // the declaration misspelled. Nothing is measured: the refusal comes from git. const cases = [ { name: "org.gamecult.cultmath", released: "0.3.0", next: "0.3.1", file: "CultMath.dll" }, { name: "org.gamecult.cultlib", released: "1.0.60", next: "1.0.61", file: "GameCult.Core.dll" }, - { name: "cultcache-py", released: "0.1.0", next: "0.1.1", file: "cultcache/__init__.py" }, ]; for (const { name, released, next, file } of cases) { const real = releasePackages[name]; - const folder = real.assemblies ?? posixPath.dirname(real.changelog); withTempGitRepo((dir) => { declare(dir, { [name]: real }); - tagWithFiles(dir, `${real.tagPrefix}-v${released}`, { [`${folder}/${file}`]: "released" }); + tagWithFiles(dir, `${real.tagPrefix}-v${released}`, { [`${real.assemblies}/${file}`]: peImage({ cli: CLI }) }); mkdirSync(dirname(join(dir, real.changelog)), { recursive: true }); writeFileSync(join(dir, real.changelog), changelogFor(next)); const junk = join(dir, "junk.dll"); writeFileSync(junk, "junk"); - const args = ["--package", name, "--version", next, ...(real.assemblies ? ["--api-built", junk] : [])]; + const args = ["--package", name, "--version", next, "--api-built", junk]; declare(dir, { [name]: { ...real, tagPrefix: real.tagPrefix.slice(0, -1) } }); const refused = runCheckerExpectFailure(dir, args); - assert.match(refused, new RegExp(`${real.tagPrefix}-v${released} tracks files under `)); + assert.ok(refused.includes(`${real.tagPrefix}-v${released} tracks the managed assembly ${real.assemblies}/${file}`), refused); assert.doesNotMatch(refused, /first release \(/); - // the same release under the real prefix is a bump over its predecessor, not a first release - if (!real.assemblies) { - declare(dir, { [name]: real }); - assert.match(runChecker(dir, args), /patch bump over/); - } }); } }); -test("CLI: no tag tracks the package's folder, so a release is a first release however many other tags exist", () => { +test("CLI: only a managed assembly under the declared assemblies directory proves an earlier release", () => { withTempGitRepo((dir) => { - declare(dir, { widget: { ...WIDGET, changelog: "widget/CHANGELOG.md", assemblies: PLUGINS } }); + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); tagWithFiles(dir, "other-v1.0.0", { "other/file.txt": "other" }); - tagWithFiles(dir, "other-v1.1.0", { "other/more.txt": "more" }); - // the package's folder arrives after every tag, and its files are not tracked by any of them - mkdirSync(join(dir, "widget"), { recursive: true }); - writeFileSync(join(dir, "widget", "CHANGELOG.md"), changelogFor("1.0.0")); + // files under the directory that are not managed assemblies prove nothing: text, and a native DLL + tagWithFiles(dir, "other-v1.1.0", { [`${PLUGINS}/notes.txt`]: "text", [`${PLUGINS}/x86_64/native.dll`]: peImage() }); + writeChangelog(dir, "1.0.0"); const junk = join(dir, "junk.dll"); writeFileSync(junk, "junk"); const args = [...widgetArgs("1.0.0"), "--api-built", junk]; assert.match(runChecker(dir, args), /first release \(1\.0\.0\)/); // its own tag does not stop a rebuild of that first release - tagWithFiles(dir, "widget-v1.0.0", { [`${PLUGINS}/GameCult.Widget.dll`]: "shipped" }); + tagWithFiles(dir, "widget-v1.0.0", { [`${PLUGINS}/GameCult.Widget.dll`]: peImage({ cli: CLI }) }); assert.match(runChecker(dir, args), /first release \(1\.0\.0\)/); - // but the same files under any other tag make a later release with no same-prefix baseline a refusal - declare(dir, { widget: { ...WIDGET, changelog: "widget/CHANGELOG.md", assemblies: PLUGINS, tagPrefix: "widgt" } }); - assert.match(runCheckerExpectFailure(dir, args), /widget-v1\.0\.0 tracks files under unity\/widget\/Runtime\/Plugins/); + // but a managed assembly under any other tag makes a release with no same-prefix baseline a refusal + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS, tagPrefix: "widgt" } }); + const refused = runCheckerExpectFailure(dir, args); + assert.ok(refused.includes(`widget-v1.0.0 tracks the managed assembly ${PLUGINS}/GameCult.Widget.dll`), refused); + }); + withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); + tagWithFiles(dir, "other-v1.0.0", { [`${PLUGINS}/bogus.dll`]: "not a PE image" }); + writeChangelog(dir, "1.0.0"); + const junk = join(dir, "junk.dll"); + writeFileSync(junk, "junk"); + assert.match(runCheckerExpectFailure(dir, [...widgetArgs("1.0.0"), "--api-built", junk]), /bogus\.dll at other-v1\.0\.0 is a \.dll but not a PE image/); }); }); -test("CLI: a package without assemblies is told from a new one by its own directory, and a changelog at the root by the whole tree", () => { +test("CLI: a package with no assemblies is a first release when no tag of its own prefix exists, whatever other tags track", () => { withTempGitRepo((dir) => { declare(dir, { widget: { ...WIDGET, changelog: "pkg/CHANGELOG.md" } }); tagWithFiles(dir, "other-v1.0.0", { "elsewhere.txt": "x" }); - mkdirSync(join(dir, "pkg")); + tagWithFiles(dir, "other-v1.1.0", { "pkg/code.py": "x" }); + mkdirSync(join(dir, "pkg"), { recursive: true }); writeFileSync(join(dir, "pkg", "CHANGELOG.md"), changelogFor("1.0.0")); assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/); - tagWithFiles(dir, "other-v1.1.0", { "pkg/code.py": "x" }); - assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /other-v1\.1\.0 tracks files under pkg/); + // once it has a tag of its own prefix, that tag is the baseline + git(dir, "tag", "widget-v1.0.0"); + writeFileSync(join(dir, "pkg", "CHANGELOG.md"), changelogFor("1.0.1")); + assert.match(runChecker(dir, widgetArgs("1.0.1")), /patch bump over 1\.0\.0/); }); + // the real entry of cultnet-py, whose directory every other package's tag tracks withTempGitRepo((dir) => { - tagWithFiles(dir, "other-v1.0.0", { "elsewhere.txt": "x" }); - writeChangelog(dir, "1.0.0"); - assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /other-v1.0.0 tracks files under ./); + const real = releasePackages["cultnet-py"]; + declare(dir, { "cultnet-py": real }); + tagWithFiles(dir, "caching-unity-v1.2.0", { "packages/cultnet-py/cultnet/__init__.py": "x" }); + mkdirSync(dirname(join(dir, real.changelog)), { recursive: true }); + writeFileSync(join(dir, real.changelog), changelogFor("0.1.0")); + assert.match(runChecker(dir, ["--package", "cultnet-py", "--version", "0.1.0"]), /first release \(0\.1\.0\)/); }); }); @@ -490,7 +498,7 @@ test("CLI: a folder that moved is the declared folder alone: the old path does n declare(dir, { widget: { ...WIDGET, assemblies: moved } }); writeChangelog(dir, "1.0.1"); assert.match(runCheckerExpectFailure(dir, [...widgetArgs("1.0.1"), "--api-built", built]), /tracks no managed assembly under unity\/widget2\/Runtime\/Plugins/); - // under a mistyped prefix, no tag tracks the new folder, and the old one is not looked at + // under a mistyped prefix, no tag tracks a managed assembly in the new folder, and the old one is not looked at declare(dir, { widget: { ...WIDGET, assemblies: moved, tagPrefix: "widgt" } }); assert.match(runChecker(dir, [...widgetArgs("1.0.1"), "--api-built", built]), /first release \(1\.0\.1\)/); }); @@ -505,10 +513,12 @@ test("CLI: a tag that cannot be read at its ancestry or its files refuses", () = assert.match(runCheckerExpectFailure(dir, widgetArgs("1.0.0")), /whether widget-v0\.9\.0 is an ancestor of the commit being checked could not be read/); }); withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); commitAndTag(dir, "other-v1.0.0"); writeFileSync(join(dir, ".git", "refs", "tags", "other-v2.0.0"), "1".repeat(40) + "\n"); - writeChangelog(dir, "1.0.0"); - const refused = runCheckerExpectFailure(dir, widgetArgs("1.0.0")); + const junk = join(dir, "junk.dll"); + writeFileSync(junk, "junk"); + const refused = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.0", [junk])); assert.match(refused, /could not be read, so a first release cannot be told from a mistyped prefix/); assert.doesNotMatch(refused, /fatal|bad object|1111111/); // git's own complaint is not forwarded }); @@ -603,18 +613,51 @@ test("CLI: the flags the declaration replaced are usage errors, as is any unknow }); }); -test("workflow: every tag trigger is a declared prefix, and every declared assemblies directory is tracked", () => { - const workflow = readFileSync(workflowFile, "utf8"); +// 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) { const triggers = [...workflow.match(/^\s+tags:\r?\n((?:\s+- "[^"]+"\r?\n)+)/m)[1].matchAll(/- "([^"]+)-v\*"/g)].map((match) => match[1]); - assert.ok(triggers.length >= 4, "the workflow triggers on the npm and PyPI packages' tags"); - const prefixes = Object.values(releasePackages).map((entry) => entry.tagPrefix); - for (const trigger of triggers) assert.ok(prefixes.includes(trigger), `${trigger}-v* triggers the workflow but no package declares that prefix`); + const problems = []; + const prefixes = Object.values(declared).map((entry) => entry.tagPrefix); + for (const trigger of triggers) { + 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?)`); + } + } + return problems; +} + +const workflowText = () => readFileSync(workflowFile, "utf8"); +const repoTags = () => execFileSync("git", ["tag", "-l"], { cwd: repoRoot, encoding: "utf8" }).split("\n").filter(Boolean); + +test("workflow: the declared prefixes are pinned to the tag triggers, and every declared assemblies directory is tracked", () => { + assert.deepEqual(prefixPinProblems(releasePackages, workflowText(), repoTags()), []); 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`); } }); +test("workflow: a typo in the declared prefix of any assembly-less package fails the pin, and so does a deleted trigger", () => { + const assemblyLess = Object.entries(releasePackages).filter(([, entry]) => entry.assemblies === undefined); + assert.deepEqual(assemblyLess.map(([name]) => name).sort(), ["@gamecult/cultcache-ts", "cultcache-py", "cultmesh-py", "cultnet-py", "org.gamecult.caching.unity"]); + 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`); + } + } + 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"))), []); +}); + // --- Measured public API. The fixtures are real assemblies, built here with // the SDK and compared by the pinned ApiCompat tool through the CLI, so every // rule is observed where the release scripts observe it. --- From 98eb42af609d520c33c12ffaeac1f13b03c7fe9d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 08:57:06 +0200 Subject: [PATCH 28/31] Semver policy: first release is proved by a managed assembly; assembly-less prefixes are pinned by the suite Co-Authored-By: Claude Sonnet 5.5 --- docs/semver-policy.md | 30 ++++++++++++++++++------------ 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/semver-policy.md b/docs/semver-policy.md index 3992f502..aafd7f3d 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -163,19 +163,25 @@ or a missing declaration refuses), and: 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, only when no tag in the repository, under any prefix, tracks - a file under the package's folder (its declared assemblies directory, else - the directory of its changelog): a tag that tracks one means the package has + 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. 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 - folder counts: a package whose folder 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 folder holds no managed assembly at the previous tag. A - package whose files other packages' tags track (a monorepo tag tracks every - package in the tree) cannot be released first through this check once such a - tag exists. + 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 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. 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 `## []` changelog entry to exist at all; From 8c9faf06c66c39656c7f2910ff11705169567979 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 09:53:17 +0200 Subject: [PATCH 29/31] Semver check tests: the first-release probe's cwd and blob size are pinned, and assembly-less packages read no tags' trees Also defines the PE fixture before the tests that use it, so a filtered run no longer reaches it before initialization. Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-changelog-semver.test.mjs | 73 +++++++++++++++++-------- 1 file changed, 49 insertions(+), 24 deletions(-) diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index b770e4ed..1f962b6d 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -411,6 +411,30 @@ function tagWithFiles(dir, tag, files) { commitAndTag(dir, tag); } +// A PE image written byte by byte. A managed assembly's CLI header is data directory 14; a +// native DLL has none. +function peImage({ plus = true, directories = 16, cli = null } = {}) { + const pe = 0x40; + const optionalSize = (plus ? 112 : 96) + directories * 8; + const bytes = Buffer.alloc(pe + 24 + optionalSize); + bytes.writeUInt16LE(0x5a4d, 0); + bytes.writeUInt32LE(pe, 0x3c); + bytes.writeUInt32LE(0x00004550, pe); + bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, pe + 4); + bytes.writeUInt16LE(optionalSize, pe + 20); + const optional = pe + 24; + bytes.writeUInt16LE(plus ? 0x20b : 0x10b, optional); + const dirs = optional + (plus ? 112 : 96); + bytes.writeUInt32LE(directories, dirs - 4); + if (cli) { + bytes.writeUInt32LE(cli.rva, dirs + 14 * 8); + bytes.writeUInt32LE(cli.size, dirs + 14 * 8 + 4); + } + return bytes; +} + +const CLI = { rva: 0x2000, size: 0x48 }; + test("CLI: a mistyped declared prefix of a Unity package is refused, because a tag under another prefix tracks its managed assembly", () => { // The real entries of CultMath and cultlib, each released once under its real prefix, then // the declaration misspelled. Nothing is measured: the refusal comes from git. @@ -465,6 +489,31 @@ test("CLI: only a managed assembly under the declared assemblies directory prove }); }); +test("CLI: the first-release probe reads the repository of --cwd and a blob of any size, and reads no tree for a package without assemblies", () => { + const elsewhere = mkdtempSync(join(tmpdir(), "cultlib-semver-elsewhere-")); + try { + withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); + // a managed assembly larger than the default 1 MiB buffer of a child process + tagWithFiles(dir, "other-v1.0.0", { [`${PLUGINS}/Big.dll`]: Buffer.concat([peImage({ cli: CLI }), Buffer.alloc(2 << 20)]) }); + writeChangelog(dir, "1.0.0"); + const junk = join(dir, "junk.dll"); + writeFileSync(junk, "junk"); + const refused = runCheckerExpectFailure(dir, [...widgetArgs("1.0.0"), "--api-built", junk], { processCwd: elsewhere }); + assert.ok(refused.includes(`other-v1.0.0 tracks the managed assembly ${PLUGINS}/Big.dll`), refused); + }); + withTempGitRepo((dir) => { + commitAndTag(dir, "other-v1.0.0"); + writeFileSync(join(dir, ".git", "refs", "tags", "other-v2.0.0"), "1".repeat(40) + "\n"); + writeChangelog(dir, "1.0.0"); + // an unreadable tag matters only to a package whose assemblies it could have shipped + assert.match(runChecker(dir, widgetArgs("1.0.0")), /first release \(1\.0\.0\)/); + }); + } finally { + rmSync(elsewhere, { recursive: true, force: true }); + } +}); + test("CLI: a package with no assemblies is a first release when no tag of its own prefix exists, whatever other tags track", () => { withTempGitRepo((dir) => { declare(dir, { widget: { ...WIDGET, changelog: "pkg/CHANGELOG.md" } }); @@ -816,30 +865,6 @@ test("measured: an assembly tracked in a subdirectory is measured, and is a brea }); }); -// A PE image written byte by byte. A managed assembly's CLI header is data directory 14; a -// native DLL has none. -function peImage({ plus = true, directories = 16, cli = null } = {}) { - const pe = 0x40; - const optionalSize = (plus ? 112 : 96) + directories * 8; - const bytes = Buffer.alloc(pe + 24 + optionalSize); - bytes.writeUInt16LE(0x5a4d, 0); - bytes.writeUInt32LE(pe, 0x3c); - bytes.writeUInt32LE(0x00004550, pe); - bytes.writeUInt16LE(plus ? 0x8664 : 0x14c, pe + 4); - bytes.writeUInt16LE(optionalSize, pe + 20); - const optional = pe + 24; - bytes.writeUInt16LE(plus ? 0x20b : 0x10b, optional); - const dirs = optional + (plus ? 112 : 96); - bytes.writeUInt32LE(directories, dirs - 4); - if (cli) { - bytes.writeUInt32LE(cli.rva, dirs + 14 * 8); - bytes.writeUInt32LE(cli.size, dirs + 14 * 8 + 4); - } - return bytes; -} - -const CLI = { rva: 0x2000, size: 0x48 }; - test("isManagedAssembly: a non-empty CLI header makes an assembly managed, nothing else does", () => { assert.equal(isManagedAssembly(peImage({ cli: CLI })), true); assert.equal(isManagedAssembly(peImage({ plus: false, cli: CLI })), true); From c84a1fcc06f7d9983326309074b78221c8e00eca Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 11:43:42 +0200 Subject: [PATCH 30/31] Semver check: the NUL and TAB in the ls-tree reader are escapes, so the file is text Co-Authored-By: Claude Sonnet 5.5 --- scripts/check-changelog-semver.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index a24ba839..80af1226 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -253,10 +253,10 @@ function firstTagTrackingManaged(tags, dir, cwd) { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], }) - .split("") + .split("\0") .filter((entry) => entry.toLowerCase().endsWith(".dll")); for (const entry of entries) { - const tab = entry.indexOf(" "); + const tab = entry.indexOf("\t"); const id = entry.slice(0, tab).split(" ")[2]; const path = entry.slice(tab + 1); if (!managedBlobs.has(id)) { From 7ec300a5aafefe60893b93e7dd50d7d1c8e4fc6b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 3 Oct 2026 11:43:43 +0200 Subject: [PATCH 31/31] 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 --- .github/workflows/publish-packages.yml | 22 +++ docs/semver-policy.md | 12 +- .../cultmath/scripts/build-unity-package.ps1 | 5 + scripts/build-unity-package.ps1 | 5 + scripts/check-changelog-semver.test.mjs | 125 ++++++++++++++++-- scripts/verify-caching-unity-release.ps1 | 6 + 6 files changed, 160 insertions(+), 15 deletions(-) diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 5c9b361a..1ed1f176 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -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: | diff --git a/docs/semver-policy.md b/docs/semver-policy.md index aafd7f3d..b40fb3e3 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -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 `## []` changelog entry to exist at all; diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 index 0e14cad6..4af5862e 100644 --- a/packages/cultmath/scripts/build-unity-package.ps1 +++ b/packages/cultmath/scripts/build-unity-package.ps1 @@ -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 ` diff --git a/scripts/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 6605ed93..2f277c30 100644 --- a/scripts/build-unity-package.ps1 +++ b/scripts/build-unity-package.ps1 @@ -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 ` diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 1f962b6d..62a78068 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -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 '-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 diff --git a/scripts/verify-caching-unity-release.ps1 b/scripts/verify-caching-unity-release.ps1 index 7bf55b68..17bd63ee 100644 --- a/scripts/verify-caching-unity-release.ps1 +++ b/scripts/verify-caching-unity-release.ps1 @@ -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 `