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