diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 60cc6a01..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 @@ -75,11 +86,7 @@ jobs: if: startsWith(github.ref, 'refs/tags/cultcache-ts-v') run: | tagged="${GITHUB_REF_NAME#cultcache-ts-v}" - node scripts/check-changelog-semver.mjs \ - --package "@gamecult/cultcache-ts" \ - --changelog packages/cultcache-ts/CHANGELOG.md \ - --version "$tagged" \ - --tag-prefix cultcache-ts + node scripts/check-changelog-semver.mjs --package "@gamecult/cultcache-ts" --version "$tagged" - name: Build and test working-directory: packages/cultcache-ts @@ -142,6 +149,17 @@ jobs: with: node-version: 24.x + - name: Set up .NET + if: steps.selected.outputs.run == 'true' + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 10.0.x + + # See the same step in the npm job: the suite comes before any check or publish step. + - name: Test the release checks + if: steps.selected.outputs.run == 'true' + run: node --test scripts/check-changelog-semver.test.mjs + - name: Verify the tag matches the manifest version if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') run: | @@ -159,11 +177,7 @@ jobs: if: steps.selected.outputs.run == 'true' && startsWith(github.ref, 'refs/tags/') run: | tagged="${GITHUB_REF_NAME#${{ matrix.package }}-v}" - node scripts/check-changelog-semver.mjs \ - --package "${{ matrix.package }}" \ - --changelog packages/${{ matrix.package }}/CHANGELOG.md \ - --version "$tagged" \ - --tag-prefix "${{ matrix.package }}" + node scripts/check-changelog-semver.mjs --package "${{ matrix.package }}" --version "$tagged" # Lower layers install from source so a release of one package is tested # against the sibling revision it was cut with, not whatever PyPI has. diff --git a/docs/semver-policy.md b/docs/semver-policy.md index e1af0365..b40fb3e3 100644 --- a/docs/semver-policy.md +++ b/docs/semver-policy.md @@ -21,23 +21,29 @@ caught before it ships instead of written up afterward. ## Packages in scope -| Package | Kind | Tag prefix | Published by | -| --- | --- | --- | --- | -| `org.gamecult.cultlib` | Unity UPM | `cultlib-unity` | `scripts/build-unity-package.ps1`, consumed by git tag | -| `org.gamecult.caching.unity` | Unity UPM | `caching-unity` | tagged directly (no build script; see "Coverage gaps") | -| `org.gamecult.cultmath` | Unity UPM | `cultmath-unity` | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag | -| `@gamecult/cultcache-ts` | npm | `cultcache-ts` | `.github/workflows/publish-packages.yml` | -| `cultcache-py` | PyPI | `cultcache-py` | `.github/workflows/publish-packages.yml` | -| `cultnet-py` | PyPI | `cultnet-py` | `.github/workflows/publish-packages.yml` | -| `cultmesh-py` | PyPI | `cultmesh-py` | `.github/workflows/publish-packages.yml` | +| Package | Kind | Published by | +| --- | --- | --- | +| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag | +| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") | +| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag | +| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` | +| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` | +| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` | +| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` | + +Each package's tag prefix, changelog path and, for the packages that ship +assemblies, the directory of tracked DLLs are declared in +`scripts/release-packages.mjs`, the one copy. The check reads them by package +name; no release script or workflow passes one. Rust crates are consumed as pinned git revisions (see `publish-packages.yml`'s own header comment) and are out of scope for tagged-release semver policing; a `Cargo.toml` version still exists and should follow this policy by convention, but nothing in this repo checks it mechanically today. -Tag naming stays `-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 @@ -137,32 +143,138 @@ mislabelled one. ## Enforcement `scripts/check-changelog-semver.mjs` is the mechanical check. Given a -package name, its `CHANGELOG.md` path, the version being released, and a git -tag prefix, it: +package name and the version being released, it reads the package's +declaration from `scripts/release-packages.mjs` in `--cwd` (an unknown package +or a missing declaration refuses), and: -1. resolves the previous published version from the highest existing - `-v*` tag older than the version being released (or treats the - release as a package's first if none exists); +1. derives the release record from `git tag -l` in `--cwd`. A same-prefix tag + is one that starts `-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, by version order, that is also an ancestor + of the commit being checked: a backport tagged later on another branch is + not part of that history, so it never moves a rebuild's predecessor. A tag + equal to the version does not exempt + it and does not count as a previous release: there is no "already + published" skip, so a rebuild or a CI re-run at a tagged version is + measured against that version's predecessor, exactly as at release, and + gets the same verdict. With no older tag, a newer one refuses (the version + is older than every release), and a repository that holds no tag other than + the version's own refuses, because a first release cannot be told from a + missing record. Older tags of which none is an ancestor leave the + predecessor unknown and refuse. Otherwise it is a first release, which no + caller declares. A package that declares an assemblies directory is a first + release only when no tag in the repository, under any prefix, tracks a + managed assembly under that directory: such a tag means the package has + shipped, so the declared prefix is wrong and the check refuses, naming the + tag and the assembly. A tag snapshots the whole tree of this monorepo, so a + tag tracking a package's directory proves nothing; only a managed assembly in + the declared directory does. This is read from git, never from the changelog's + prose. The version's own tag does not count, so a first release can be + rebuilt. Only the declared directory counts: a package whose directory moved + after its releases has none of its old path's tags counted, so its release + under a misspelled prefix reads as new, and the move itself is caught only + under the right prefix, where the declared directory holds no managed assembly + at the previous tag. A package that declares no assemblies directory (the + Python and TypeScript packages and the Caching Unity package) has no such + proof: with no tag of its own prefix it is a first release. The suite test + that pins every declared prefix guards it: each prefix is declared once, each + workflow tag trigger must be a declared prefix, and each such package's prefix + must be a workflow trigger or, for the Caching Unity package the workflow does + not build, the prefix of tags that hold its own manifest at the tag's own + version, so a typo or a copied prefix fails the suite. The workflow and the + three Unity release scripts run the suite before any check or publish step. + Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the + recorded `partial-tags-first-release`. + Tags that cannot be read (a `--cwd` that is not a git repository, or a tag + whose tree cannot be listed) refuse; 2. requires a `## []` changelog entry to exist at all; 3. classifies the version bump (major/minor/patch) against the previous version, refusing anything that is not exactly the next version in some lane — a skip, a reversal, or a repeat are all refused, not just wrong bump sizes; -4. if the changelog entry has a `### Breaking` section, requires the bump to - be at least major (or, pre-1.0, at least minor); -5. fails loudly, naming the package, the previous and new version, and (when - relevant) the changelog heading that triggered the requirement. +4. when the release script supplies built assemblies (below), treats the + release as breaking if the public API measurably lost or changed a member + against the previous tag, whether or not the changelog says so; a + measured break with no `### Breaking` section is refused, naming the + count, the first diagnostics, and the heading that must declare them; +5. if the release is breaking (a `### Breaking` section, a measured break, or + both), requires the bump to be at least major (or, pre-1.0, at least + minor); a `### Breaking` section with nothing measured is valid, because + behavioural breaks are invisible to a signature comparison; +6. fails loudly, naming the package, the previous and new version, and (when + relevant) the changelog heading or the measured diagnostics that + triggered the requirement. + +Additions are never a rule: adding a public member is not breaking (see +above), and the check neither refuses nor demands a bump for one. + +### Measured input + +A package whose declaration names an `assemblies` directory is measured, and +the release must hand over one `--api-built ` per assembly it built (plus +`--api-refs ` for directories that hold the built assemblies' +dependencies). A package with no such directory takes no `--api-built`, and one +that has it cannot omit it. The measured set is every managed assembly tracked +under that directory, at any depth, at the previous tag, read as git blobs (no +worktree, no network, unaffected by what the working tree tracks now) and keyed +by file name. A DLL is managed when its PE header carries a CLI header; a +native DLL has none and is not measured, and a tracked `.dll` that is not a PE +image refuses. Two managed assemblies with one name refuse. The checker +compares each built assembly with its namesake using +`Microsoft.DotNet.ApiCompat.Tool`; each `CPnnnn` diagnostic is one measured +break. Every managed assembly the previous tag tracked that the build no +longer hands over is itself a measured break, whatever its name or place, so a +release script passes every assembly it ships. A built assembly with no +namesake at the tag is an addition and is not examined. + +Measuring nothing is a refusal, never a pass. The check refuses, naming what +is missing, when the previous tag tracks no managed assembly under the +declared directory (a wrong or moved directory, or a tag that predates it); +when git cannot read the tracked assemblies; and when the tool fails to +measure (not installed, an unreadable assembly, a crash: a non-zero exit with +no diagnostic line). A first release measures nothing. + +The tool and the `NETStandard.Library.Ref` 2.1.0 reference assemblies are +pinned in `scripts/api-gate/` (`.config/dotnet-tools.json` and +`ApiRefs.csproj`) and restored there by the check itself, so a release does +not depend on any other tool in the repository's manifest. NuGet is needed +until both are cached under `scripts/api-gate`. Both sides of every +comparison resolve netstandard types through those reference assemblies, and the +new side also through the built DLL's own directory and every `--api-refs` +directory. No suppression file is kept: a suppression would be a second, silent +declaration of a break, so a break is declared only under `### Breaking`. + +A passing measured run prints that unmeasured changes still need declaring. + +#### Unmeasured changes + +ApiCompat does not report these changes to a public member, and the check +therefore passes them. Each is a breaking change under the rules above and is +declared under `### Breaking`, as behavioural breaks are: + +- a member gaining or losing `static`; +- a parameter gaining or losing `ref`, `out` or `in`; +- a `const` field's value changing; +- an enum member's underlying value changing; +- a field gaining or losing `readonly`; +- a default parameter value changing. + +The release scripts run the check once the assemblies under judgement exist, +after the build and before anything is copied into the package. Run it directly: `node scripts/check-changelog-semver.mjs --package ---changelog --version --tag-prefix `. 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`; + `cultnet-py`, and `cultmesh-py`, with `fetch-depth: 0` so the checkout holds + the tags; - `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`; - `packages/cultmath/scripts/build-unity-package.ps1`, for `org.gamecult.cultmath`; @@ -193,6 +305,10 @@ existing assembly-version check does. A tag pushed by hand without running the release script bypasses both checks equally; closing that gap is a CI trigger on tag push for the Unity packages, not something this change adds. +What stays unmeasured: behaviour behind an unchanged signature, wire formats, +native exports of the QUIC bridge, and the Caching Unity package's source +(it has no assembly). Those rest on the `### Breaking` declaration. + Rust crates (consumed as pinned git revisions, not tagged releases in the usual sense) and the CultNet protocol surface itself (no automated wire compatibility test currently gates a release) are policy by convention only — @@ -200,14 +316,13 @@ described above, not mechanically checked. Extending the checker to a Rust crate's `Cargo.toml`/`CHANGELOG.md` or to an automated CultNet interop compatibility gate is future work, not part of this change. -`@gamecult/cultcache-ts`, `cultcache-py`, `cultnet-py`, `cultmesh-py`, and -`org.gamecult.cultmath` currently have **no `CHANGELOG.md` file at all**. This -is a real, pre-existing gap, not something introduced by this policy: their -next tagged release will fail the new check for exactly that reason ("no -changelog entry for version X") until each package's `CHANGELOG.md` exists. -That failure is intended — a release with no changelog is not a smaller -problem than a mislabelled one — but it does mean the very next release of -any of these five packages needs a `CHANGELOG.md` created first. Backfilling -changelog content for their past releases is not part of this change: it -would mean inventing release-note prose for history this pass has no -first-hand knowledge of, which is worse than leaving the gap named. +`@gamecult/cultcache-ts`, `cultcache-py`, `cultnet-py`, and `cultmesh-py` +currently have **no `CHANGELOG.md` file at all**; the check refuses their next +tagged release ("changelog not found at ") 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. diff --git a/packages/cultmath/scripts/build-unity-package.ps1 b/packages/cultmath/scripts/build-unity-package.ps1 index 228d735c..4af5862e 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,27 @@ foreach ($path in @($freshAssembly, $freshSymbols)) { } } +# docs/semver-policy.md (CultLib root, two levels up from this script): refuse a release whose version +# does not tell the truth. The check measures the freshly built CultMath.dll's public API against the +# DLL the previous cultmath-unity tag tracked (read from git), so it runs once the build exists. +# cultmath is pre-1.0 (0.y.z): a break must be at least a minor bump. $repoRoot above is this package's +# own root (packages\cultmath), not CultLib's; the shared checker script lives one level above that. +$cultLibRoot = Split-Path -Parent (Split-Path -Parent $repoRoot) +$cultmathVersion = (Get-Content -LiteralPath (Join-Path $templateRoot "package.json") -Raw | ConvertFrom-Json).version +# The suite pins the checker's rules and the declared tag prefixes, so it runs first. +& node --test (Join-Path $cultLibRoot "scripts\check-changelog-semver.test.mjs") +if ($LASTEXITCODE -ne 0) { + throw "The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing." +} +& node (Join-Path $cultLibRoot "scripts\check-changelog-semver.mjs") ` + --package "org.gamecult.cultmath" ` + --version $cultmathVersion ` + --cwd $cultLibRoot ` + --api-built $freshAssembly +if ($LASTEXITCODE -ne 0) { + throw "CultMath Unity package failed the semver policy check (see docs/semver-policy.md)." +} + $requiredTemplateFiles = @( "LICENSE.md", "LICENSE.md.meta", 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/build-unity-package.ps1 b/scripts/build-unity-package.ps1 index 9de7c718..2f277c30 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,33 @@ foreach ($assemblyName in $expectedAssemblies) { } } +# docs/semver-policy.md: refuse a release whose version does not tell the truth. The check measures +# the public API of every shipped assembly against the DLLs the previous cultlib-unity tag +# tracked (read from git), so it runs here, once the assemblies under judgement exist. Every +# tracked assembly the build does not hand over counts as removed, so all of them are handed over. +# The package's tag prefix, changelog and assemblies directory are declared in +# scripts/release-packages.mjs; this call passes only the version and the built assemblies. +$measuredArguments = @() +foreach ($assemblyName in $expectedAssemblies) { + $measuredArguments += @("--api-built", $publishedByName[$assemblyName].FullName) +} +foreach ($refRoot in @($publishRoot, $quicPublishRoot, $webSocketPublishRoot)) { + $measuredArguments += @("--api-refs", $refRoot) +} +# The suite pins the checker's rules and the declared tag prefixes, so it runs first. +& node --test (Join-Path $repoRoot "scripts\check-changelog-semver.test.mjs") +if ($LASTEXITCODE -ne 0) { + throw "The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing." +} +& node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") ` + --package "org.gamecult.cultlib" ` + --version $unityPackageVersion ` + --cwd $repoRoot ` + @measuredArguments +if ($LASTEXITCODE -ne 0) { + throw "CultLib Unity package failed the semver policy check (see docs/semver-policy.md)." +} + Copy-Item -LiteralPath (Join-Path $templateRoot "Runtime\GameCult.CultLib.asmdef") ` -Destination (Join-Path $outputRoot "Runtime") foreach ($assemblyName in $expectedAssemblies) { diff --git a/scripts/check-changelog-semver.mjs b/scripts/check-changelog-semver.mjs index 4dc3f398..80af1226 100644 --- a/scripts/check-changelog-semver.mjs +++ b/scripts/check-changelog-semver.mjs @@ -5,12 +5,14 @@ // enforces; this script is only the mechanical check. // // Pure, testable logic lives in the exported functions below. `main()` is the -// CLI wrapper that reads the changelog file and asks git for the previous -// published tag, then calls `evaluateRelease`. +// CLI wrapper that reads the package's declaration (scripts/release-packages.mjs), +// its changelog and the repository's tags, then calls `evaluateRelease`. -import { execFileSync } from "node:child_process"; -import { existsSync, readFileSync } from "node:fs"; -import { pathToFileURL } from "node:url"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; // The exact heading spelling the policy mandates for a breaking-change // section. `unity/org.gamecult.cultlib/CHANGELOG.md`'s 1.0.60 entry set this @@ -100,12 +102,15 @@ function isPreOneZero(prev) { // Evaluates one release. `previousVersion` may be null for a package's first // ever release (nothing to compare against, so the bump rule does not apply, -// but a changelog entry is still required). -export function evaluateRelease({ packageName, changelogText, version, previousVersion }) { +// but a changelog entry is still required). `measuredBreaks` lists the public +// API diagnostics measured against the previous tag's assemblies; a non-empty +// list makes the release breaking whether or not the changelog says so, and +// the changelog must then declare it. +export function evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks = [] }) { const next = parseVersion(version); - let breaking; + let declared; try { - breaking = hasBreakingSection(changelogText, version); + declared = hasBreakingSection(changelogText, version); } catch (err) { return { ok: false, reason: `${packageName}: ${err.message}` }; } @@ -127,9 +132,21 @@ export function evaluateRelease({ packageName, changelogText, version, previousV }; } - if (!breaking) { + const measured = measuredBreaks.length > 0; + if (measured && !declared) { + const shown = measuredBreaks.slice(0, 5).join("; ") + (measuredBreaks.length > 5 ? "; ..." : ""); + return { + ok: false, + reason: `${packageName}: ${version} breaks ${measuredBreaks.length} public API member(s) measured against ` + + `${previousVersion} (${shown}); declare them under "${BREAKING_HEADING}" in the changelog and bump accordingly`, + }; + } + if (!declared) { return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion}, no breaking section` }; } + const evidence = measured + ? `a "${BREAKING_HEADING}" section and ${measuredBreaks.length} measured public API break(s)` + : `a "${BREAKING_HEADING}" section`; const pre1 = isPreOneZero(prev); const requiredLanes = pre1 ? ["major", "minor"] : ["major"]; @@ -139,102 +156,401 @@ export function evaluateRelease({ packageName, changelogText, version, previousV : `a major bump`; return { ok: false, - reason: `${packageName}: ${version}'s changelog has a "${BREAKING_HEADING}" section but is only a ${bump} bump ` + + reason: `${packageName}: ${version} has ${evidence} but is only a ${bump} bump ` + `over ${previousVersion}; a breaking release needs ${requirement}`, }; } - return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and its "${BREAKING_HEADING}" section agrees` }; + return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and has ${evidence}, which agree` }; } -// --- CLI wrapper: resolves the previous published tag via git and reads the -// changelog off disk, then delegates to the pure functions above. --- +// --- CLI wrapper: reads the package's declaration, the release record (git tags) and the +// changelog, then delegates to the pure functions above. --- -function resolvePreviousVersion(tagPrefix, currentVersion, cwd) { +// The release record, derived from `git tag -l` in --cwd. The previous release is the +// newest '-vMAJOR.MINOR.PATCH' tag strictly older than the version, by version order +// (never git's string order), that is also an ancestor of the commit being checked (HEAD of +// --cwd): a backport tagged later on another branch is not in this commit's history, so it +// never changes a rebuild's predecessor. The version's own tag is ignored, so a rebuild at a +// tagged version gets its release-time verdict. A same-prefix tag that does not parse is never +// skipped. A first release is the absence of any older tag in a checkout that holds other tags (a +// checkout with none cannot tell a first release from a missing record), none of which, under +// any prefix, tracks a managed assembly under the package's declared assemblies directory. A +// package that declares none has no such proof: the prefix of an assembly-less package is +// pinned by the suite test that ties every declared prefix to its tag trigger. +function isAncestorOfHead(tag, cwd) { + const run = spawnSync("git", ["merge-base", "--is-ancestor", `refs/tags/${tag}`, "HEAD"], { cwd, stdio: "ignore" }); + return run.status === 0 ? true : run.status === 1 ? false : null; +} + +function resolveRelease(tagPrefix, versionText, cwd, assemblies) { let tags; try { - tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8" }) + tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }) .split("\n") .map((line) => line.trim()) .filter(Boolean); } catch { - return null; + return { failure: "the tags of --cwd could not be read (not a git repository?), so the previous release is unknown" }; } const prefix = `${tagPrefix}-v`; - const current = parseVersion(currentVersion); - let best = null; + const current = parseVersion(versionText); + const own = `${prefix}${formatVersion(current)}`; + const older = []; + let newer = null; for (const tag of tags) { if (!tag.startsWith(prefix)) continue; - const versionText = tag.slice(prefix.length); - let candidate; - try { - candidate = parseVersion(versionText); - } catch { - continue; + const text = tag.slice(prefix.length); + if (!VERSION_PATTERN.test(text)) { + return { failure: `${tag} starts with ${prefix} but is not ${prefix}MAJOR.MINOR.PATCH, so it cannot be ordered among the releases` }; } - if (sameVersion(candidate, current)) continue; // the tag being released itself - if (compareVersions(candidate, current) >= 0) continue; // ignore anything not older - if (best === null || compareVersions(candidate, best) > 0) { - best = candidate; + const candidate = parseVersion(text); + const order = compareVersions(candidate, current); + if (order === 0) continue; // the tag being released itself + if (order > 0) { + newer ??= tag; + } else { + older.push({ tag, version: candidate }); } } - return best ? formatVersion(best) : null; + older.sort((a, b) => compareVersions(b.version, a.version)); + for (const { tag, version } of older) { + const ancestor = isAncestorOfHead(tag, cwd); + if (ancestor === null) return { failure: `whether ${tag} is an ancestor of the commit being checked could not be read` }; + if (ancestor) return { previous: formatVersion(version) }; + } + if (older.length > 0) { + return { failure: `${older.length} older ${prefix} tag(s) exist but none is an ancestor of the commit being checked, so the previous release is unknown` }; + } + if (newer !== null) { + return { failure: `${newer} exists but no ${prefix} tag is older than ${formatVersion(current)}: the version is older than every release` }; + } + if (!tags.some((tag) => tag !== own)) { + return { failure: "this checkout holds no tags; a first release cannot be told from a missing record (fetch the tags)" }; + } + if (typeof assemblies === "string") { + // Under every other prefix too: a package whose assemblies some tag has shipped is not new, + // whatever the declared prefix says. This is read from git, never from the changelog's prose. + const tracked = firstTagTrackingManaged(tags.filter((tag) => tag !== own), assemblies, cwd); + if (tracked.failure) return tracked; + if (tracked.tag) { + return { failure: `no ${prefix} tag is an earlier release, but ${tracked.tag} tracks the managed assembly ${tracked.path}: the package has shipped before, so the declared tag prefix is wrong` }; + } + } + return { previous: null }; } -// True when `-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) { +// The first of the tags that tracks a managed assembly anywhere under `dir`, or none. Whether a +// blob is managed is decided by its bytes once per blob id, however many tags track it. A tag +// that cannot be read, or a .dll that is not a PE image, is a refusal, never a tag that tracks +// nothing. +function firstTagTrackingManaged(tags, dir, cwd) { + const managedBlobs = new Map(); + for (const tag of tags) { + try { + const entries = execFileSync("git", ["ls-tree", "-r", "-z", `refs/tags/${tag}`, "--", `${dir}/`], { + cwd, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }) + .split("\0") + .filter((entry) => entry.toLowerCase().endsWith(".dll")); + for (const entry of entries) { + const tab = entry.indexOf("\t"); + const id = entry.slice(0, tab).split(" ")[2]; + const path = entry.slice(tab + 1); + if (!managedBlobs.has(id)) { + const bytes = execFileSync("git", ["cat-file", "blob", id], { cwd, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 28 }); + try { + managedBlobs.set(id, isManagedAssembly(bytes)); + } catch { + return { failure: `${path} at ${tag} is a .dll but not a PE image, so a first release cannot be told from a mistyped prefix` }; + } + } + if (managedBlobs.get(id)) return { tag, path }; + } + } catch { + return { failure: `the assemblies ${tag} tracks under ${dir} could not be read, so a first release cannot be told from a mistyped prefix` }; + } + } + return {}; +} + +// The declaration as a whole: every entry names a string tagPrefix and changelog, and no two +// packages share a prefix (each would be measured against the other's releases). Returns a +// refusal text, or null. +export function checkDeclaration(declared) { + const seen = new Map(); + for (const [name, entry] of Object.entries(declared ?? {})) { + if (entry == null || typeof entry.tagPrefix !== "string" || typeof entry.changelog !== "string") { + return `${name} must declare a string tagPrefix and a string changelog`; + } + if (seen.has(entry.tagPrefix)) return `${seen.get(entry.tagPrefix)} and ${name} share one tag prefix`; + seen.set(entry.tagPrefix, name); + } + return null; +} + +// --- Measured public API: the built DLLs are compared with the DLLs the +// previous tag tracked, by Microsoft.DotNet.ApiCompat.Tool. The tool, and the +// netstandard reference assemblies it needs to resolve inherited members, are +// pinned in scripts/api-gate/ and restored there; nothing here compares +// members itself. --- + +const gateRoot = join(dirname(fileURLToPath(import.meta.url)), "api-gate"); +const DIAGNOSTIC_LINE = /^CP\d{4}: /; + +// One ApiCompat run's verdict. A diagnostic line is a measured break. A +// non-zero exit with no diagnostic line means the tool did not measure +// anything (missing tool, unreadable input, crash): that is a failure, never a +// pass. +export function readApiCompatRun({ status, output }) { + const breaks = output.split(/\r?\n/).filter((line) => DIAGNOSTIC_LINE.test(line)); + if (breaks.length === 0 && status !== 0) { + return { failure: `apicompat exited ${status} without measuring: ${output.trim().split(/\r?\n/).slice(0, 5).join(" | ")}` }; + } + return { breaks }; +} + +function runApiCompat(args) { + const run = spawnSync("dotnet", ["tool", "run", "apicompat", ...args], { cwd: gateRoot, encoding: "utf8" }); + return readApiCompatRun({ status: run.status, output: `${run.stdout}${run.stderr}` }); +} + +function lastLines(text) { + return text.trim().split(/\r?\n/).slice(-3).join(" | "); +} + +// Restores the pinned tool and the pinned netstandard reference package, and +// returns the reference assemblies' directory. Both come from scripts/api-gate/ +// and need NuGet only until they are cached there. +function prepareGate() { + if (!existsSync(join(gateRoot, ".config", "dotnet-tools.json"))) { + return { failure: "the api-gate tool manifest (scripts/api-gate/.config/dotnet-tools.json) is missing" }; + } + for (const [what, args] of [["dotnet tool restore", ["tool", "restore"]], ["dotnet restore", ["restore", "ApiRefs.csproj", "--nologo", "-v", "q"]]]) { + const run = spawnSync("dotnet", args, { cwd: gateRoot, encoding: "utf8" }); + if (run.status !== 0) { + return { failure: `${what} failed: ${run.error ? run.error.message : lastLines(`${run.stdout}${run.stderr}`)}` }; + } + } + const packageDir = join(gateRoot, "packages", "netstandard.library.ref"); + const versions = existsSync(packageDir) ? readdirSync(packageDir).sort() : []; + const refs = versions.length > 0 ? join(packageDir, versions[0], "ref", "netstandard2.1") : null; + if (refs == null || !existsSync(join(refs, "netstandard.dll"))) { + return { failure: "the pinned netstandard reference assemblies were not restored under scripts/api-gate/packages" }; + } + return { refs }; +} + +// Whether a PE image is a managed assembly: its optional header's data directory 14 (the +// CLI header) is non-empty. A PE without one is native and ApiCompat cannot read it. A buffer +// that is not a PE image, or is cut short, throws: decided from the bytes, never from the +// file's name or place. +export function isManagedAssembly(bytes) { + const need = (end) => { + if (end > bytes.length) throw new RangeError("not a PE image"); + }; + need(0x40); + if (bytes.readUInt16LE(0) !== 0x5a4d) throw new RangeError("not a PE image"); + const pe = bytes.readUInt32LE(0x3c); + need(pe + 24); + if (bytes.readUInt32LE(pe) !== 0x00004550) throw new RangeError("not a PE image"); + const optionalSize = bytes.readUInt16LE(pe + 20); + const optional = pe + 24; + const magic = bytes.readUInt16LE(optional); + if (magic !== 0x10b && magic !== 0x20b) throw new RangeError("not a PE image"); + const directories = optional + (magic === 0x10b ? 96 : 112); + need(directories); + if (bytes.readUInt32LE(directories - 4) <= 14 || optionalSize < directories + 15 * 8 - optional) return false; + need(directories + 15 * 8); + return bytes.readUInt32LE(directories + 14 * 8) !== 0 && bytes.readUInt32LE(directories + 14 * 8 + 4) !== 0; +} + +// The managed assemblies the previous tag tracked anywhere under `dir`, as git blobs: no +// worktree, no network, and unaffected by what the working tree holds now. Each is written to +// `into` by file name. A git failure is a refusal that names the step, never git's own output. +function extractBaseline({ tag, dir, into, cwd }) { + const git = (args, options) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], ...options }); + const blobs = []; try { - const output = execFileSync("git", ["tag", "-l", `${tagPrefix}-v${version}`], { cwd, encoding: "utf8" }); - return output.trim().length > 0; + const paths = git(["ls-tree", "-r", "-z", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" }) + .split("\0") + .filter((path) => path.toLowerCase().endsWith(".dll")); + for (const path of paths) blobs.push({ path, bytes: git(["show", `${tag}:${path}`], { maxBuffer: 1 << 28 }) }); } catch { - return false; + return { failure: `git could not read the assemblies tracked under ${dir} at ${tag}` }; + } + const names = []; + for (const { path, bytes } of blobs) { + let managed; + try { + managed = isManagedAssembly(bytes); + } catch { + return { failure: `${path} at ${tag} is a .dll but not a PE image` }; + } + if (!managed) continue; + const name = basename(path); + if (names.includes(name)) return { failure: `${tag} tracks two managed assemblies named ${name} under ${dir}` }; + writeFileSync(join(into, name), bytes); + names.push(name); + } + return { names }; +} + +// Compares each built assembly with its namesake at the previous tag. Measuring nothing is a +// failure: the declared directory must hold a managed assembly at the tag. A managed assembly +// the tag tracked and the build no longer hands over is itself a break, whatever its name or +// place; a built assembly with no namesake is an addition and is not examined. +export function measureApiBreaks({ tagPrefix, previousVersion, assemblies, built, refs, cwd }) { + const tag = `${tagPrefix}-v${previousVersion}`; + const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-")); + try { + const left = join(work, "left"); + mkdirSync(left); + const baseline = extractBaseline({ tag, dir: assemblies, into: left, cwd }); + if (baseline.failure) return baseline; + const baselineNames = baseline.names; + if (baselineNames.length === 0) { + return { failure: `${tag} tracks no managed assembly under ${assemblies}: the declared directory is missing or moved, or that tag does not track the DLLs` }; + } + const gate = prepareGate(); + if (gate.failure) return gate; + const builtNames = new Set(built.map((path) => basename(path))); + const breaks = baselineNames + .filter((name) => !builtNames.has(name)) + .map((name) => `assembly ${name} exists at ${tag} but is no longer built`); + for (const path of built) { + const name = basename(path); + if (!baselineNames.includes(name)) continue; + const leftRefs = [left, gate.refs].join(","); + const rightRefs = [...new Set([dirname(path), ...refs, gate.refs])].join(","); + const run = runApiCompat(["--left-assembly", join(left, name), "--right-assembly", path, "--left-assembly-references", leftRefs, "--right-assembly-references", rightRefs]); + if (run.failure) return { failure: `${name}: ${run.failure}` }; + breaks.push(...run.breaks); + } + return { breaks }; + } finally { + rmSync(work, { recursive: true, force: true }); } } +// Repeatable options collect into arrays. Any other option is a usage error. +const KNOWN = new Set(["package", "version", "cwd", "api-built", "api-refs"]); +const REPEATABLE = new Set(["api-built", "api-refs"]); + function parseArgs(argv) { const args = {}; for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg.startsWith("--")) continue; const key = arg.slice(2); - args[key] = argv[i + 1]; + if (!KNOWN.has(key)) return { unknown: arg }; + if (REPEATABLE.has(key)) { + (args[key] ??= []).push(argv[i + 1]); + } else { + args[key] = argv[i + 1]; + } i += 1; } return args; } -function main() { +function refuse(message) { + console.error(message); + process.exitCode = 1; +} + +const UNMEASURED_NOTICE = + 'public API measured; changes ApiCompat cannot see (see "Unmeasured changes" in docs/semver-policy.md) must still be declared under "### Breaking"'; + +async function main() { const args = parseArgs(process.argv.slice(2)); - const { package: packageName, changelog, version, "tag-prefix": tagPrefix, cwd = process.cwd() } = args; - if (!packageName || !changelog || !version || !tagPrefix) { + const { package: packageName, version, cwd = process.cwd() } = args; + // The tool runs from scripts/api-gate, so a path given relative to this process is made absolute first. + const built = (args["api-built"] ?? []).map((path) => resolve(path)); + if (args.unknown || !packageName || !version) { console.error( - "usage: check-changelog-semver.mjs --package --changelog --version --tag-prefix [--cwd ]", + "usage: check-changelog-semver.mjs --package --version [--cwd ] [--api-built ... [--api-refs ...]]", ); process.exitCode = 2; return; } - if (tagAlreadyPublished(tagPrefix, version, cwd)) { - console.log(`${packageName}: ${version} is already published as ${tagPrefix}-v${version}; not re-checking a rebuild of immutable history`); + // Checked before anything reads tags: a malformed version is its own refusal, and the + // value is not repeated back. + if (!VERSION_PATTERN.test(version.trim())) { + refuse(`${packageName}: --version is not a MAJOR.MINOR.PATCH version`); return; } - if (!existsSync(changelog)) { - console.error(`${packageName}: changelog not found at ${changelog}`); - process.exitCode = 1; + // The package's release identity is declared once, in the repository being released. + let declared; + try { + declared = (await import(pathToFileURL(resolve(cwd, "scripts", "release-packages.mjs")).href)).default; + } catch { + refuse(`${packageName}: scripts/release-packages.mjs could not be loaded from --cwd`); return; } - const changelogText = readFileSync(changelog, "utf8"); - const previousVersion = resolvePreviousVersion(tagPrefix, version, cwd); - const result = evaluateRelease({ packageName, changelogText, version, previousVersion }); + const malformed = checkDeclaration(declared); + if (malformed) { + refuse(`${packageName}: scripts/release-packages.mjs is malformed: ${malformed}`); + return; + } + const entry = Object.hasOwn(declared ?? {}, packageName) ? declared[packageName] : null; + if (entry == null) { + refuse(`${packageName}: not a package declared in scripts/release-packages.mjs`); + return; + } + const { tagPrefix, assemblies } = entry; + const changelog = resolve(cwd, entry.changelog); + let changelogText; + try { + changelogText = readFileSync(changelog, "utf8"); + } catch (err) { + refuse(`${packageName}: ${err.code === "ENOENT" ? "changelog not found" : "changelog cannot be read"} at ${changelog}`); + return; + } + if (typeof assemblies === "string" && built.length === 0) { + refuse(`${packageName}: ships the assemblies under ${assemblies}, so the release must hand them over with --api-built`); + return; + } + if (typeof assemblies !== "string" && built.length > 0) { + refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`); + return; + } + const release = resolveRelease(tagPrefix, version, cwd, assemblies); + if (release.failure) { + refuse(`${packageName}: ${release.failure}`); + return; + } + const previousVersion = release.previous; + let measuredBreaks = []; + if (previousVersion != null && built.length > 0) { + const measurement = measureApiBreaks({ + tagPrefix, + previousVersion, + assemblies, + built, + refs: (args["api-refs"] ?? []).map((path) => resolve(path)), + cwd, + }); + if (measurement.failure) { + refuse(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`); + return; + } + measuredBreaks = measurement.breaks; + } + const result = evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks }); console.log(result.reason); if (!result.ok) { process.exitCode = 1; + return; + } + if (previousVersion != null && built.length > 0) { + console.log(UNMEASURED_NOTICE); } } const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; if (isMain) { - main(); + await main(); } diff --git a/scripts/check-changelog-semver.test.mjs b/scripts/check-changelog-semver.test.mjs index 9cc12ffd..62a78068 100644 --- a/scripts/check-changelog-semver.test.mjs +++ b/scripts/check-changelog-semver.test.mjs @@ -1,17 +1,21 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { 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 { BREAKING_HEADING, + checkDeclaration, classifyBump, evaluateRelease, hasBreakingSection, + isManagedAssembly, nextVersions, parseVersion, + readApiCompatRun, } from "./check-changelog-semver.mjs"; const checkerPath = fileURLToPath(new URL("./check-changelog-semver.mjs", import.meta.url)); @@ -186,9 +190,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-")); @@ -196,62 +213,1280 @@ 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 }); } } -function runChecker(cwd, args) { - return execFileSync(process.execPath, [checkerPath, ...args, "--cwd", cwd], { +// 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: processCwd, + 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; } } +const git = (dir, ...args) => execFileSync("git", args, { cwd: dir }); +const commitAndTag = (dir, ...tags) => { + 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)); +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: 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 }); - 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/); + 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/); + }); +}); + +test("CLI: a first release is derived from the tags, and nothing is measured for it", () => { + withTempGitRepo((dir) => { + // 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 { + 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 + } finally { + rmSync(notARepo, { recursive: true, force: true }); + } +}); + +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) => { + 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 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/); + }); +}); + +// 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); +} + +// 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. + 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" }, + ]; + for (const { name, released, next, file } of cases) { + const real = releasePackages[name]; + withTempGitRepo((dir) => { + declare(dir, { [name]: real }); + 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, "--api-built", junk]; + declare(dir, { [name]: { ...real, tagPrefix: real.tagPrefix.slice(0, -1) } }); + const refused = runCheckerExpectFailure(dir, args); + assert.ok(refused.includes(`${real.tagPrefix}-v${released} tracks the managed assembly ${real.assemblies}/${file}`), refused); + assert.doesNotMatch(refused, /first release \(/); + }); + } +}); + +test("CLI: only a managed assembly under the declared assemblies directory proves an earlier release", () => { + withTempGitRepo((dir) => { + declare(dir, { widget: { ...WIDGET, assemblies: PLUGINS } }); + tagWithFiles(dir, "other-v1.0.0", { "other/file.txt": "other" }); + // 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`]: peImage({ cli: CLI }) }); + assert.match(runChecker(dir, args), /first release \(1\.0\.0\)/); + // 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: 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" } }); + tagWithFiles(dir, "other-v1.0.0", { "elsewhere.txt": "x" }); + 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\)/); + // 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) => { + 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\)/); + }); +}); + +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 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\)/); + }); +}); + +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) => { + 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"); + 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 + }); +}); + +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" } }); + 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:/); + } + }); +}); + +// 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 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); + 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 (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(), 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`); + } +}); + +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(), 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")), 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 +// the SDK and compared by the pinned ApiCompat tool through the CLI, so every +// rule is observed where the release scripts observe it. --- + +// 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, references = []) { + const dir = join(fixtureRoot, createHash("sha256").update(`${assemblyName} +${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` + + `${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" }); + } + return dll; +} + +// 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) => { + 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)); + place(afterTag); + 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, refs = [] } = {}) { + writeChangelog(dir, version, { breaking }); + return [ + ...widgetArgs(version), + ...builtPaths.flatMap((path) => ["--api-built", path]), + ...refs.flatMap((refDir) => ["--api-refs", refDir]), + ]; +} + +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 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 }; + 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.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])); + assert.match(ok, /1\.0\.1 is a patch bump/); + }); +}); + +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])); + 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/); + }); +}); + +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/); + }); +}); + +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", () => { + // 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: 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, "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"), 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 }); + } + }); +}); + +test("measured: a local tag of the released version does not skip the measurement", () => { + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + execFileSync("git", ["tag", "widget-v1.1.0"], { cwd: dir }); + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()])); + assert.match(output, /Gone/); + }); +}); + +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: measuring nothing refuses, naming what is missing", () => { + const fail = (dir, args) => runCheckerExpectFailure(dir, args); + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + // 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 }); + 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/); + }); + // 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", () => { + 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) => { + 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 }); + 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 declared directory that git would take for an option + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": oldWidget() } }, (dir) => { + 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) => { + commitAndTag(dir, "widget-v1.0.0"); + mkdirSync(join(dir, "CHANGELOG.md")); + const output = clean(runCheckerExpectFailure(dir, widgetArgs("1.0.1"))); + 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()])); + 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) => { + 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/); + }); +}); + +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, /; \.\.\./); +}); + +test("every missing required option is a usage error", () => { + withTempGitRepo((dir) => { + 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-built/); + } + }); +}); + +test("CLI: a missing changelog fails and names the declared path; stray positional words are ignored", () => { + withTempGitRepo((dir) => { + 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/); + }); +}); + +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-")); + 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(leftovers(), []); + runCheckerExpectFailure(dir, measuredArgs(dir, "1.1.0", [trimmedWidget()]), { env }); + assert.deepEqual(leftovers(), []); + } finally { + rmSync(scratch, { 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) => { + // 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/); + assert.match(output, /dotnet tool restore failed: .*Json parsing error/); + }); + }); +}); + +// 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) => { + withFakeDotnet(null, (env) => { + const output = runCheckerExpectFailure(dir, measuredArgs(dir, "1.0.1", [oldWidget()]), { env }); + assert.match(output, /dotnet tool restore failed: .*ENOENT/); + }); + }); +}); + +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) => { + const notRestored = /the pinned netstandard reference assemblies were not restored under scripts\/api-gate\/packages/; + withFakeDotnet("exit 0", (env) => { + 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 /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); + }); + }); + }); +}); + +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\)/); +}); + +test("CLI: a malformed version is refused by name, without repeating it", () => { + withTempGitRepo((dir) => { + 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/); + }); +}); + +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 } = {}) => { + 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); + 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); + 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 not a PE image + withReleasedBaseline({ baseline: { "GameCult.Widget.dll": junk } }, (dir) => { + const args = measuredArgs(dir, "1.0.1", [oldWidget()]); + canaryChangelog(dir, "1.0.1"); + assert.match(refuse(dir, args), /could not be measured/); + }); + } finally { + 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 }); + } }); }); 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..17bd63ee 100644 --- a/scripts/verify-caching-unity-release.ps1 +++ b/scripts/verify-caching-unity-release.ps1 @@ -11,15 +11,18 @@ $ErrorActionPreference = "Stop" $repoRoot = Split-Path -Parent $PSScriptRoot $packageRoot = Join-Path $repoRoot "src\GameCult.Unity\Assets\Caching" $manifestPath = Join-Path $packageRoot "package.json" -$changelogPath = Join-Path $packageRoot "CHANGELOG.md" $version = (Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json).version +# The suite pins the checker's rules and the declared tag prefixes, so it runs first. +& node --test (Join-Path $repoRoot "scripts\check-changelog-semver.test.mjs") +if ($LASTEXITCODE -ne 0) { + throw "The release check suite failed (scripts/check-changelog-semver.test.mjs); not releasing." +} + & node (Join-Path $repoRoot "scripts\check-changelog-semver.mjs") ` --package "org.gamecult.caching.unity" ` - --changelog $changelogPath ` --version $version ` - --tag-prefix "caching-unity" ` --cwd $repoRoot if ($LASTEXITCODE -ne 0) { throw "org.gamecult.caching.unity failed the semver policy check (see docs/semver-policy.md)."