Merge hands/semver-measured: measured semver for every package, verified by Soul s7
The release check measures each package against its predecessor tag instead of trusting caller-declared flags. First releases are proved by a managed assembly, the suite runs in CI and the Unity scripts before publish, and the Caching Unity prefix is its own tag family. Verdict asura:verdict:cut-semver-measured.s7 holds; one Low finding (suite-step-condition-unpinned) is deferred. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
162bd1fcce
11 changed files with 1922 additions and 162 deletions
34
.github/workflows/publish-packages.yml
vendored
34
.github/workflows/publish-packages.yml
vendored
|
|
@ -55,6 +55,17 @@ jobs:
|
|||
node-version: 24.x
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
# The suite pins the release checker's rules and the declared tag prefixes
|
||||
# (docs/semver-policy.md). It runs before any check or publish step, on a
|
||||
# checkout that holds every tag.
|
||||
- name: Set up .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Test the release checks
|
||||
run: node --test scripts/check-changelog-semver.test.mjs
|
||||
|
||||
- name: Install workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -21,23 +21,29 @@ caught before it ships instead of written up afterward.
|
|||
|
||||
## Packages in scope
|
||||
|
||||
| Package | Kind | Tag prefix | Published by |
|
||||
| --- | --- | --- | --- |
|
||||
| `org.gamecult.cultlib` | Unity UPM | `cultlib-unity` | `scripts/build-unity-package.ps1`, consumed by git tag |
|
||||
| `org.gamecult.caching.unity` | Unity UPM | `caching-unity` | tagged directly (no build script; see "Coverage gaps") |
|
||||
| `org.gamecult.cultmath` | Unity UPM | `cultmath-unity` | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
|
||||
| `@gamecult/cultcache-ts` | npm | `cultcache-ts` | `.github/workflows/publish-packages.yml` |
|
||||
| `cultcache-py` | PyPI | `cultcache-py` | `.github/workflows/publish-packages.yml` |
|
||||
| `cultnet-py` | PyPI | `cultnet-py` | `.github/workflows/publish-packages.yml` |
|
||||
| `cultmesh-py` | PyPI | `cultmesh-py` | `.github/workflows/publish-packages.yml` |
|
||||
| Package | Kind | Published by |
|
||||
| --- | --- | --- |
|
||||
| `org.gamecult.cultlib` | Unity UPM | `scripts/build-unity-package.ps1`, consumed by git tag |
|
||||
| `org.gamecult.caching.unity` | Unity UPM | tagged directly (no build script; see "Coverage gaps") |
|
||||
| `org.gamecult.cultmath` | Unity UPM | `packages/cultmath/scripts/build-unity-package.ps1`, consumed by git tag |
|
||||
| `@gamecult/cultcache-ts` | npm | `.github/workflows/publish-packages.yml` |
|
||||
| `cultcache-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
| `cultnet-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
| `cultmesh-py` | PyPI | `.github/workflows/publish-packages.yml` |
|
||||
|
||||
Each package's tag prefix, changelog path and, for the packages that ship
|
||||
assemblies, the directory of tracked DLLs are declared in
|
||||
`scripts/release-packages.mjs`, the one copy. The check reads them by package
|
||||
name; no release script or workflow passes one.
|
||||
|
||||
Rust crates are consumed as pinned git revisions (see `publish-packages.yml`'s
|
||||
own header comment) and are out of scope for tagged-release semver policing;
|
||||
a `Cargo.toml` version still exists and should follow this policy by
|
||||
convention, but nothing in this repo checks it mechanically today.
|
||||
|
||||
Tag naming stays `<package>-v<version>` for every package above, matching the
|
||||
prefixes already in use.
|
||||
Releases are tags named `<tagPrefix>-v<version>`, with the prefix declared in
|
||||
`scripts/release-packages.mjs`. The check refuses a declaration in which two
|
||||
packages share a prefix or an entry lacks a string prefix or changelog.
|
||||
|
||||
## What counts as breaking
|
||||
|
||||
|
|
@ -137,32 +143,138 @@ mislabelled one.
|
|||
## Enforcement
|
||||
|
||||
`scripts/check-changelog-semver.mjs` is the mechanical check. Given a
|
||||
package name, its `CHANGELOG.md` path, the version being released, and a git
|
||||
tag prefix, it:
|
||||
package name and the version being released, it reads the package's
|
||||
declaration from `scripts/release-packages.mjs` in `--cwd` (an unknown package
|
||||
or a missing declaration refuses), and:
|
||||
|
||||
1. resolves the previous published version from the highest existing
|
||||
`<prefix>-v*` tag older than the version being released (or treats the
|
||||
release as a package's first if none exists);
|
||||
1. derives the release record from `git tag -l` in `--cwd`. A same-prefix tag
|
||||
is one that starts `<prefix>-v`; one that is not
|
||||
`<prefix>-vMAJOR.MINOR.PATCH` refuses, naming the tag, and is never
|
||||
skipped. The previous release is the newest same-prefix tag strictly older
|
||||
than the version being released, by version order, that is also an ancestor
|
||||
of the commit being checked: a backport tagged later on another branch is
|
||||
not part of that history, so it never moves a rebuild's predecessor. A tag
|
||||
equal to the version does not exempt
|
||||
it and does not count as a previous release: there is no "already
|
||||
published" skip, so a rebuild or a CI re-run at a tagged version is
|
||||
measured against that version's predecessor, exactly as at release, and
|
||||
gets the same verdict. With no older tag, a newer one refuses (the version
|
||||
is older than every release), and a repository that holds no tag other than
|
||||
the version's own refuses, because a first release cannot be told from a
|
||||
missing record. Older tags of which none is an ancestor leave the
|
||||
predecessor unknown and refuse. Otherwise it is a first release, which no
|
||||
caller declares. A package that declares an assemblies directory is a first
|
||||
release only when no tag in the repository, under any prefix, tracks a
|
||||
managed assembly under that directory: such a tag means the package has
|
||||
shipped, so the declared prefix is wrong and the check refuses, naming the
|
||||
tag and the assembly. A tag snapshots the whole tree of this monorepo, so a
|
||||
tag tracking a package's directory proves nothing; only a managed assembly in
|
||||
the declared directory does. This is read from git, never from the changelog's
|
||||
prose. The version's own tag does not count, so a first release can be
|
||||
rebuilt. Only the declared directory counts: a package whose directory moved
|
||||
after its releases has none of its old path's tags counted, so its release
|
||||
under a misspelled prefix reads as new, and the move itself is caught only
|
||||
under the right prefix, where the declared directory holds no managed assembly
|
||||
at the previous tag. A package that declares no assemblies directory (the
|
||||
Python and TypeScript packages and the Caching Unity package) has no such
|
||||
proof: with no tag of its own prefix it is a first release. The suite test
|
||||
that pins every declared prefix guards it: each prefix is declared once, each
|
||||
workflow tag trigger must be a declared prefix, and each such package's prefix
|
||||
must be a workflow trigger or, for the Caching Unity package the workflow does
|
||||
not build, the prefix of tags that hold its own manifest at the tag's own
|
||||
version, so a typo or a copied prefix fails the suite. The workflow and the
|
||||
three Unity release scripts run the suite before any check or publish step.
|
||||
Releases run from a full clone with every tag (`fetch-depth: 0` in CI), the
|
||||
recorded `partial-tags-first-release`.
|
||||
Tags that cannot be read (a `--cwd` that is not a git repository, or a tag
|
||||
whose tree cannot be listed) refuse;
|
||||
2. requires a `## [<version>]` changelog entry to exist at all;
|
||||
3. classifies the version bump (major/minor/patch) against the previous
|
||||
version, refusing anything that is not exactly the next version in some
|
||||
lane — a skip, a reversal, or a repeat are all refused, not just wrong
|
||||
bump sizes;
|
||||
4. if the changelog entry has a `### Breaking` section, requires the bump to
|
||||
be at least major (or, pre-1.0, at least minor);
|
||||
5. fails loudly, naming the package, the previous and new version, and (when
|
||||
relevant) the changelog heading that triggered the requirement.
|
||||
4. when the release script supplies built assemblies (below), treats the
|
||||
release as breaking if the public API measurably lost or changed a member
|
||||
against the previous tag, whether or not the changelog says so; a
|
||||
measured break with no `### Breaking` section is refused, naming the
|
||||
count, the first diagnostics, and the heading that must declare them;
|
||||
5. if the release is breaking (a `### Breaking` section, a measured break, or
|
||||
both), requires the bump to be at least major (or, pre-1.0, at least
|
||||
minor); a `### Breaking` section with nothing measured is valid, because
|
||||
behavioural breaks are invisible to a signature comparison;
|
||||
6. fails loudly, naming the package, the previous and new version, and (when
|
||||
relevant) the changelog heading or the measured diagnostics that
|
||||
triggered the requirement.
|
||||
|
||||
Additions are never a rule: adding a public member is not breaking (see
|
||||
above), and the check neither refuses nor demands a bump for one.
|
||||
|
||||
### Measured input
|
||||
|
||||
A package whose declaration names an `assemblies` directory is measured, and
|
||||
the release must hand over one `--api-built <dll>` per assembly it built (plus
|
||||
`--api-refs <dir>` for directories that hold the built assemblies'
|
||||
dependencies). A package with no such directory takes no `--api-built`, and one
|
||||
that has it cannot omit it. The measured set is every managed assembly tracked
|
||||
under that directory, at any depth, at the previous tag, read as git blobs (no
|
||||
worktree, no network, unaffected by what the working tree tracks now) and keyed
|
||||
by file name. A DLL is managed when its PE header carries a CLI header; a
|
||||
native DLL has none and is not measured, and a tracked `.dll` that is not a PE
|
||||
image refuses. Two managed assemblies with one name refuse. The checker
|
||||
compares each built assembly with its namesake using
|
||||
`Microsoft.DotNet.ApiCompat.Tool`; each `CPnnnn` diagnostic is one measured
|
||||
break. Every managed assembly the previous tag tracked that the build no
|
||||
longer hands over is itself a measured break, whatever its name or place, so a
|
||||
release script passes every assembly it ships. A built assembly with no
|
||||
namesake at the tag is an addition and is not examined.
|
||||
|
||||
Measuring nothing is a refusal, never a pass. The check refuses, naming what
|
||||
is missing, when the previous tag tracks no managed assembly under the
|
||||
declared directory (a wrong or moved directory, or a tag that predates it);
|
||||
when git cannot read the tracked assemblies; and when the tool fails to
|
||||
measure (not installed, an unreadable assembly, a crash: a non-zero exit with
|
||||
no diagnostic line). A first release measures nothing.
|
||||
|
||||
The tool and the `NETStandard.Library.Ref` 2.1.0 reference assemblies are
|
||||
pinned in `scripts/api-gate/` (`.config/dotnet-tools.json` and
|
||||
`ApiRefs.csproj`) and restored there by the check itself, so a release does
|
||||
not depend on any other tool in the repository's manifest. NuGet is needed
|
||||
until both are cached under `scripts/api-gate`. Both sides of every
|
||||
comparison resolve netstandard types through those reference assemblies, and the
|
||||
new side also through the built DLL's own directory and every `--api-refs`
|
||||
directory. No suppression file is kept: a suppression would be a second, silent
|
||||
declaration of a break, so a break is declared only under `### Breaking`.
|
||||
|
||||
A passing measured run prints that unmeasured changes still need declaring.
|
||||
|
||||
#### Unmeasured changes
|
||||
|
||||
ApiCompat does not report these changes to a public member, and the check
|
||||
therefore passes them. Each is a breaking change under the rules above and is
|
||||
declared under `### Breaking`, as behavioural breaks are:
|
||||
|
||||
- a member gaining or losing `static`;
|
||||
- a parameter gaining or losing `ref`, `out` or `in`;
|
||||
- a `const` field's value changing;
|
||||
- an enum member's underlying value changing;
|
||||
- a field gaining or losing `readonly`;
|
||||
- a default parameter value changing.
|
||||
|
||||
The release scripts run the check once the assemblies under judgement exist,
|
||||
after the build and before anything is copied into the package.
|
||||
|
||||
Run it directly: `node scripts/check-changelog-semver.mjs --package <name>
|
||||
--changelog <path> --version <x.y.z> --tag-prefix <prefix>`. Its own tests
|
||||
live in `scripts/check-changelog-semver.test.mjs` (`node --test
|
||||
--version <x.y.z> [--cwd <dir>] [--api-built <dll>... [--api-refs <dir>...]]`.
|
||||
Any other option is a usage error (exit 2). Its own tests live in
|
||||
`scripts/check-changelog-semver.test.mjs` (`node --test
|
||||
scripts/check-changelog-semver.test.mjs`).
|
||||
|
||||
It is wired into:
|
||||
|
||||
- `.github/workflows/publish-packages.yml`, right after each job's existing
|
||||
"tag matches manifest version" check, for `cultcache-ts`, `cultcache-py`,
|
||||
`cultnet-py`, and `cultmesh-py`;
|
||||
`cultnet-py`, and `cultmesh-py`, with `fetch-depth: 0` so the checkout holds
|
||||
the tags;
|
||||
- `scripts/build-unity-package.ps1`, for `org.gamecult.cultlib`;
|
||||
- `packages/cultmath/scripts/build-unity-package.ps1`, for
|
||||
`org.gamecult.cultmath`;
|
||||
|
|
@ -193,6 +305,10 @@ existing assembly-version check does. A tag pushed by hand without running
|
|||
the release script bypasses both checks equally; closing that gap is a CI
|
||||
trigger on tag push for the Unity packages, not something this change adds.
|
||||
|
||||
What stays unmeasured: behaviour behind an unchanged signature, wire formats,
|
||||
native exports of the QUIC bridge, and the Caching Unity package's source
|
||||
(it has no assembly). Those rest on the `### Breaking` declaration.
|
||||
|
||||
Rust crates (consumed as pinned git revisions, not tagged releases in the
|
||||
usual sense) and the CultNet protocol surface itself (no automated wire
|
||||
compatibility test currently gates a release) are policy by convention only —
|
||||
|
|
@ -200,14 +316,13 @@ described above, not mechanically checked. Extending the checker to a Rust
|
|||
crate's `Cargo.toml`/`CHANGELOG.md` or to an automated CultNet interop
|
||||
compatibility gate is future work, not part of this change.
|
||||
|
||||
`@gamecult/cultcache-ts`, `cultcache-py`, `cultnet-py`, `cultmesh-py`, and
|
||||
`org.gamecult.cultmath` currently have **no `CHANGELOG.md` file at all**. This
|
||||
is a real, pre-existing gap, not something introduced by this policy: their
|
||||
next tagged release will fail the new check for exactly that reason ("no
|
||||
changelog entry for version X") until each package's `CHANGELOG.md` exists.
|
||||
That failure is intended — a release with no changelog is not a smaller
|
||||
problem than a mislabelled one — but it does mean the very next release of
|
||||
any of these five packages needs a `CHANGELOG.md` created first. Backfilling
|
||||
changelog content for their past releases is not part of this change: it
|
||||
would mean inventing release-note prose for history this pass has no
|
||||
first-hand knowledge of, which is worse than leaving the gap named.
|
||||
`@gamecult/cultcache-ts`, `cultcache-py`, `cultnet-py`, and `cultmesh-py`
|
||||
currently have **no `CHANGELOG.md` file at all**; the check refuses their next
|
||||
tagged release ("changelog not found at <declared path>") until each
|
||||
package's file exists. A release with no changelog is not a smaller problem
|
||||
than a mislabelled one. The next release of each creates its file:
|
||||
`packages/cultcache-ts/CHANGELOG.md` arrives with the document-variants work
|
||||
(`hands/variants-c2a`), and whoever cuts the next release of `cultcache-py`,
|
||||
`cultnet-py` or `cultmesh-py` creates that package's file. Backfilling
|
||||
changelog content for past releases would mean inventing release-note prose
|
||||
for history nobody here knows first-hand, so the gap stays named.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
13
scripts/api-gate/.config/dotnet-tools.json
Normal file
13
scripts/api-gate/.config/dotnet-tools.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"microsoft.dotnet.apicompat.tool": {
|
||||
"version": "10.0.202",
|
||||
"commands": [
|
||||
"apicompat"
|
||||
],
|
||||
"rollForward": false
|
||||
}
|
||||
}
|
||||
}
|
||||
1
scripts/api-gate/.gitignore
vendored
Normal file
1
scripts/api-gate/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
packages/
|
||||
11
scripts/api-gate/ApiRefs.csproj
Normal file
11
scripts/api-gate/ApiRefs.csproj
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Restores the netstandard2.1 reference assemblies for scripts/check-changelog-semver.mjs.
|
||||
Not built: PackageDownload only fetches the package into ./packages. -->
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<RestorePackagesPath>$(MSBuildThisFileDirectory)packages</RestorePackagesPath>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageDownload Include="NETStandard.Library.Ref" Version="[2.1.0]" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,14 @@
|
|||
// enforces; this script is only the mechanical check.
|
||||
//
|
||||
// Pure, testable logic lives in the exported functions below. `main()` is the
|
||||
// CLI wrapper that reads the changelog file and asks git for the previous
|
||||
// published tag, then calls `evaluateRelease`.
|
||||
// CLI wrapper that reads the package's declaration (scripts/release-packages.mjs),
|
||||
// its changelog and the repository's tags, then calls `evaluateRelease`.
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
// The exact heading spelling the policy mandates for a breaking-change
|
||||
// section. `unity/org.gamecult.cultlib/CHANGELOG.md`'s 1.0.60 entry set this
|
||||
|
|
@ -100,12 +102,15 @@ function isPreOneZero(prev) {
|
|||
|
||||
// Evaluates one release. `previousVersion` may be null for a package's first
|
||||
// ever release (nothing to compare against, so the bump rule does not apply,
|
||||
// but a changelog entry is still required).
|
||||
export function evaluateRelease({ packageName, changelogText, version, previousVersion }) {
|
||||
// but a changelog entry is still required). `measuredBreaks` lists the public
|
||||
// API diagnostics measured against the previous tag's assemblies; a non-empty
|
||||
// list makes the release breaking whether or not the changelog says so, and
|
||||
// the changelog must then declare it.
|
||||
export function evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks = [] }) {
|
||||
const next = parseVersion(version);
|
||||
let breaking;
|
||||
let declared;
|
||||
try {
|
||||
breaking = hasBreakingSection(changelogText, version);
|
||||
declared = hasBreakingSection(changelogText, version);
|
||||
} catch (err) {
|
||||
return { ok: false, reason: `${packageName}: ${err.message}` };
|
||||
}
|
||||
|
|
@ -127,9 +132,21 @@ export function evaluateRelease({ packageName, changelogText, version, previousV
|
|||
};
|
||||
}
|
||||
|
||||
if (!breaking) {
|
||||
const measured = measuredBreaks.length > 0;
|
||||
if (measured && !declared) {
|
||||
const shown = measuredBreaks.slice(0, 5).join("; ") + (measuredBreaks.length > 5 ? "; ..." : "");
|
||||
return {
|
||||
ok: false,
|
||||
reason: `${packageName}: ${version} breaks ${measuredBreaks.length} public API member(s) measured against ` +
|
||||
`${previousVersion} (${shown}); declare them under "${BREAKING_HEADING}" in the changelog and bump accordingly`,
|
||||
};
|
||||
}
|
||||
if (!declared) {
|
||||
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion}, no breaking section` };
|
||||
}
|
||||
const evidence = measured
|
||||
? `a "${BREAKING_HEADING}" section and ${measuredBreaks.length} measured public API break(s)`
|
||||
: `a "${BREAKING_HEADING}" section`;
|
||||
|
||||
const pre1 = isPreOneZero(prev);
|
||||
const requiredLanes = pre1 ? ["major", "minor"] : ["major"];
|
||||
|
|
@ -139,102 +156,401 @@ export function evaluateRelease({ packageName, changelogText, version, previousV
|
|||
: `a major bump`;
|
||||
return {
|
||||
ok: false,
|
||||
reason: `${packageName}: ${version}'s changelog has a "${BREAKING_HEADING}" section but is only a ${bump} bump ` +
|
||||
reason: `${packageName}: ${version} has ${evidence} but is only a ${bump} bump ` +
|
||||
`over ${previousVersion}; a breaking release needs ${requirement}`,
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and its "${BREAKING_HEADING}" section agrees` };
|
||||
return { ok: true, reason: `${packageName}: ${version} is a ${bump} bump over ${previousVersion} and has ${evidence}, which agree` };
|
||||
}
|
||||
|
||||
// --- CLI wrapper: resolves the previous published tag via git and reads the
|
||||
// changelog off disk, then delegates to the pure functions above. ---
|
||||
// --- CLI wrapper: reads the package's declaration, the release record (git tags) and the
|
||||
// changelog, then delegates to the pure functions above. ---
|
||||
|
||||
function resolvePreviousVersion(tagPrefix, currentVersion, cwd) {
|
||||
// The release record, derived from `git tag -l` in --cwd. The previous release is the
|
||||
// newest '<prefix>-vMAJOR.MINOR.PATCH' tag strictly older than the version, by version order
|
||||
// (never git's string order), that is also an ancestor of the commit being checked (HEAD of
|
||||
// --cwd): a backport tagged later on another branch is not in this commit's history, so it
|
||||
// never changes a rebuild's predecessor. The version's own tag is ignored, so a rebuild at a
|
||||
// tagged version gets its release-time verdict. A same-prefix tag that does not parse is never
|
||||
// skipped. A first release is the absence of any older tag in a checkout that holds other tags (a
|
||||
// checkout with none cannot tell a first release from a missing record), none of which, under
|
||||
// any prefix, tracks a managed assembly under the package's declared assemblies directory. A
|
||||
// package that declares none has no such proof: the prefix of an assembly-less package is
|
||||
// pinned by the suite test that ties every declared prefix to its tag trigger.
|
||||
function isAncestorOfHead(tag, cwd) {
|
||||
const run = spawnSync("git", ["merge-base", "--is-ancestor", `refs/tags/${tag}`, "HEAD"], { cwd, stdio: "ignore" });
|
||||
return run.status === 0 ? true : run.status === 1 ? false : null;
|
||||
}
|
||||
|
||||
function resolveRelease(tagPrefix, versionText, cwd, assemblies) {
|
||||
let tags;
|
||||
try {
|
||||
tags = execFileSync("git", ["tag", "-l", `${tagPrefix}-v*`], { cwd, encoding: "utf8" })
|
||||
tags = execFileSync("git", ["tag", "-l"], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
} catch {
|
||||
return null;
|
||||
return { failure: "the tags of --cwd could not be read (not a git repository?), so the previous release is unknown" };
|
||||
}
|
||||
const prefix = `${tagPrefix}-v`;
|
||||
const current = parseVersion(currentVersion);
|
||||
let best = null;
|
||||
const current = parseVersion(versionText);
|
||||
const own = `${prefix}${formatVersion(current)}`;
|
||||
const older = [];
|
||||
let newer = null;
|
||||
for (const tag of tags) {
|
||||
if (!tag.startsWith(prefix)) continue;
|
||||
const versionText = tag.slice(prefix.length);
|
||||
let candidate;
|
||||
try {
|
||||
candidate = parseVersion(versionText);
|
||||
} catch {
|
||||
continue;
|
||||
const text = tag.slice(prefix.length);
|
||||
if (!VERSION_PATTERN.test(text)) {
|
||||
return { failure: `${tag} starts with ${prefix} but is not ${prefix}MAJOR.MINOR.PATCH, so it cannot be ordered among the releases` };
|
||||
}
|
||||
if (sameVersion(candidate, current)) continue; // the tag being released itself
|
||||
if (compareVersions(candidate, current) >= 0) continue; // ignore anything not older
|
||||
if (best === null || compareVersions(candidate, best) > 0) {
|
||||
best = candidate;
|
||||
const candidate = parseVersion(text);
|
||||
const order = compareVersions(candidate, current);
|
||||
if (order === 0) continue; // the tag being released itself
|
||||
if (order > 0) {
|
||||
newer ??= tag;
|
||||
} else {
|
||||
older.push({ tag, version: candidate });
|
||||
}
|
||||
}
|
||||
return best ? formatVersion(best) : null;
|
||||
older.sort((a, b) => compareVersions(b.version, a.version));
|
||||
for (const { tag, version } of older) {
|
||||
const ancestor = isAncestorOfHead(tag, cwd);
|
||||
if (ancestor === null) return { failure: `whether ${tag} is an ancestor of the commit being checked could not be read` };
|
||||
if (ancestor) return { previous: formatVersion(version) };
|
||||
}
|
||||
if (older.length > 0) {
|
||||
return { failure: `${older.length} older ${prefix} tag(s) exist but none is an ancestor of the commit being checked, so the previous release is unknown` };
|
||||
}
|
||||
if (newer !== null) {
|
||||
return { failure: `${newer} exists but no ${prefix} tag is older than ${formatVersion(current)}: the version is older than every release` };
|
||||
}
|
||||
if (!tags.some((tag) => tag !== own)) {
|
||||
return { failure: "this checkout holds no tags; a first release cannot be told from a missing record (fetch the tags)" };
|
||||
}
|
||||
if (typeof assemblies === "string") {
|
||||
// Under every other prefix too: a package whose assemblies some tag has shipped is not new,
|
||||
// whatever the declared prefix says. This is read from git, never from the changelog's prose.
|
||||
const tracked = firstTagTrackingManaged(tags.filter((tag) => tag !== own), assemblies, cwd);
|
||||
if (tracked.failure) return tracked;
|
||||
if (tracked.tag) {
|
||||
return { failure: `no ${prefix} tag is an earlier release, but ${tracked.tag} tracks the managed assembly ${tracked.path}: the package has shipped before, so the declared tag prefix is wrong` };
|
||||
}
|
||||
}
|
||||
return { previous: null };
|
||||
}
|
||||
|
||||
// True when `<tagPrefix>-v<version>` already exists. The check gates a NEW
|
||||
// release; rebuilding an already-tagged, already-immutable version (routine
|
||||
// local iteration, CI re-runs) is not a release action and should not keep
|
||||
// re-litigating history on every build.
|
||||
function tagAlreadyPublished(tagPrefix, version, cwd) {
|
||||
// The first of the tags that tracks a managed assembly anywhere under `dir`, or none. Whether a
|
||||
// blob is managed is decided by its bytes once per blob id, however many tags track it. A tag
|
||||
// that cannot be read, or a .dll that is not a PE image, is a refusal, never a tag that tracks
|
||||
// nothing.
|
||||
function firstTagTrackingManaged(tags, dir, cwd) {
|
||||
const managedBlobs = new Map();
|
||||
for (const tag of tags) {
|
||||
try {
|
||||
const entries = execFileSync("git", ["ls-tree", "-r", "-z", `refs/tags/${tag}`, "--", `${dir}/`], {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
})
|
||||
.split("\0")
|
||||
.filter((entry) => entry.toLowerCase().endsWith(".dll"));
|
||||
for (const entry of entries) {
|
||||
const tab = entry.indexOf("\t");
|
||||
const id = entry.slice(0, tab).split(" ")[2];
|
||||
const path = entry.slice(tab + 1);
|
||||
if (!managedBlobs.has(id)) {
|
||||
const bytes = execFileSync("git", ["cat-file", "blob", id], { cwd, stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 28 });
|
||||
try {
|
||||
managedBlobs.set(id, isManagedAssembly(bytes));
|
||||
} catch {
|
||||
return { failure: `${path} at ${tag} is a .dll but not a PE image, so a first release cannot be told from a mistyped prefix` };
|
||||
}
|
||||
}
|
||||
if (managedBlobs.get(id)) return { tag, path };
|
||||
}
|
||||
} catch {
|
||||
return { failure: `the assemblies ${tag} tracks under ${dir} could not be read, so a first release cannot be told from a mistyped prefix` };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
// The declaration as a whole: every entry names a string tagPrefix and changelog, and no two
|
||||
// packages share a prefix (each would be measured against the other's releases). Returns a
|
||||
// refusal text, or null.
|
||||
export function checkDeclaration(declared) {
|
||||
const seen = new Map();
|
||||
for (const [name, entry] of Object.entries(declared ?? {})) {
|
||||
if (entry == null || typeof entry.tagPrefix !== "string" || typeof entry.changelog !== "string") {
|
||||
return `${name} must declare a string tagPrefix and a string changelog`;
|
||||
}
|
||||
if (seen.has(entry.tagPrefix)) return `${seen.get(entry.tagPrefix)} and ${name} share one tag prefix`;
|
||||
seen.set(entry.tagPrefix, name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// --- Measured public API: the built DLLs are compared with the DLLs the
|
||||
// previous tag tracked, by Microsoft.DotNet.ApiCompat.Tool. The tool, and the
|
||||
// netstandard reference assemblies it needs to resolve inherited members, are
|
||||
// pinned in scripts/api-gate/ and restored there; nothing here compares
|
||||
// members itself. ---
|
||||
|
||||
const gateRoot = join(dirname(fileURLToPath(import.meta.url)), "api-gate");
|
||||
const DIAGNOSTIC_LINE = /^CP\d{4}: /;
|
||||
|
||||
// One ApiCompat run's verdict. A diagnostic line is a measured break. A
|
||||
// non-zero exit with no diagnostic line means the tool did not measure
|
||||
// anything (missing tool, unreadable input, crash): that is a failure, never a
|
||||
// pass.
|
||||
export function readApiCompatRun({ status, output }) {
|
||||
const breaks = output.split(/\r?\n/).filter((line) => DIAGNOSTIC_LINE.test(line));
|
||||
if (breaks.length === 0 && status !== 0) {
|
||||
return { failure: `apicompat exited ${status} without measuring: ${output.trim().split(/\r?\n/).slice(0, 5).join(" | ")}` };
|
||||
}
|
||||
return { breaks };
|
||||
}
|
||||
|
||||
function runApiCompat(args) {
|
||||
const run = spawnSync("dotnet", ["tool", "run", "apicompat", ...args], { cwd: gateRoot, encoding: "utf8" });
|
||||
return readApiCompatRun({ status: run.status, output: `${run.stdout}${run.stderr}` });
|
||||
}
|
||||
|
||||
function lastLines(text) {
|
||||
return text.trim().split(/\r?\n/).slice(-3).join(" | ");
|
||||
}
|
||||
|
||||
// Restores the pinned tool and the pinned netstandard reference package, and
|
||||
// returns the reference assemblies' directory. Both come from scripts/api-gate/
|
||||
// and need NuGet only until they are cached there.
|
||||
function prepareGate() {
|
||||
if (!existsSync(join(gateRoot, ".config", "dotnet-tools.json"))) {
|
||||
return { failure: "the api-gate tool manifest (scripts/api-gate/.config/dotnet-tools.json) is missing" };
|
||||
}
|
||||
for (const [what, args] of [["dotnet tool restore", ["tool", "restore"]], ["dotnet restore", ["restore", "ApiRefs.csproj", "--nologo", "-v", "q"]]]) {
|
||||
const run = spawnSync("dotnet", args, { cwd: gateRoot, encoding: "utf8" });
|
||||
if (run.status !== 0) {
|
||||
return { failure: `${what} failed: ${run.error ? run.error.message : lastLines(`${run.stdout}${run.stderr}`)}` };
|
||||
}
|
||||
}
|
||||
const packageDir = join(gateRoot, "packages", "netstandard.library.ref");
|
||||
const versions = existsSync(packageDir) ? readdirSync(packageDir).sort() : [];
|
||||
const refs = versions.length > 0 ? join(packageDir, versions[0], "ref", "netstandard2.1") : null;
|
||||
if (refs == null || !existsSync(join(refs, "netstandard.dll"))) {
|
||||
return { failure: "the pinned netstandard reference assemblies were not restored under scripts/api-gate/packages" };
|
||||
}
|
||||
return { refs };
|
||||
}
|
||||
|
||||
// Whether a PE image is a managed assembly: its optional header's data directory 14 (the
|
||||
// CLI header) is non-empty. A PE without one is native and ApiCompat cannot read it. A buffer
|
||||
// that is not a PE image, or is cut short, throws: decided from the bytes, never from the
|
||||
// file's name or place.
|
||||
export function isManagedAssembly(bytes) {
|
||||
const need = (end) => {
|
||||
if (end > bytes.length) throw new RangeError("not a PE image");
|
||||
};
|
||||
need(0x40);
|
||||
if (bytes.readUInt16LE(0) !== 0x5a4d) throw new RangeError("not a PE image");
|
||||
const pe = bytes.readUInt32LE(0x3c);
|
||||
need(pe + 24);
|
||||
if (bytes.readUInt32LE(pe) !== 0x00004550) throw new RangeError("not a PE image");
|
||||
const optionalSize = bytes.readUInt16LE(pe + 20);
|
||||
const optional = pe + 24;
|
||||
const magic = bytes.readUInt16LE(optional);
|
||||
if (magic !== 0x10b && magic !== 0x20b) throw new RangeError("not a PE image");
|
||||
const directories = optional + (magic === 0x10b ? 96 : 112);
|
||||
need(directories);
|
||||
if (bytes.readUInt32LE(directories - 4) <= 14 || optionalSize < directories + 15 * 8 - optional) return false;
|
||||
need(directories + 15 * 8);
|
||||
return bytes.readUInt32LE(directories + 14 * 8) !== 0 && bytes.readUInt32LE(directories + 14 * 8 + 4) !== 0;
|
||||
}
|
||||
|
||||
// The managed assemblies the previous tag tracked anywhere under `dir`, as git blobs: no
|
||||
// worktree, no network, and unaffected by what the working tree holds now. Each is written to
|
||||
// `into` by file name. A git failure is a refusal that names the step, never git's own output.
|
||||
function extractBaseline({ tag, dir, into, cwd }) {
|
||||
const git = (args, options) => execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], ...options });
|
||||
const blobs = [];
|
||||
try {
|
||||
const output = execFileSync("git", ["tag", "-l", `${tagPrefix}-v${version}`], { cwd, encoding: "utf8" });
|
||||
return output.trim().length > 0;
|
||||
const paths = git(["ls-tree", "-r", "-z", "--name-only", tag, "--", `${dir}/`], { encoding: "utf8" })
|
||||
.split("\0")
|
||||
.filter((path) => path.toLowerCase().endsWith(".dll"));
|
||||
for (const path of paths) blobs.push({ path, bytes: git(["show", `${tag}:${path}`], { maxBuffer: 1 << 28 }) });
|
||||
} catch {
|
||||
return false;
|
||||
return { failure: `git could not read the assemblies tracked under ${dir} at ${tag}` };
|
||||
}
|
||||
const names = [];
|
||||
for (const { path, bytes } of blobs) {
|
||||
let managed;
|
||||
try {
|
||||
managed = isManagedAssembly(bytes);
|
||||
} catch {
|
||||
return { failure: `${path} at ${tag} is a .dll but not a PE image` };
|
||||
}
|
||||
if (!managed) continue;
|
||||
const name = basename(path);
|
||||
if (names.includes(name)) return { failure: `${tag} tracks two managed assemblies named ${name} under ${dir}` };
|
||||
writeFileSync(join(into, name), bytes);
|
||||
names.push(name);
|
||||
}
|
||||
return { names };
|
||||
}
|
||||
|
||||
// Compares each built assembly with its namesake at the previous tag. Measuring nothing is a
|
||||
// failure: the declared directory must hold a managed assembly at the tag. A managed assembly
|
||||
// the tag tracked and the build no longer hands over is itself a break, whatever its name or
|
||||
// place; a built assembly with no namesake is an addition and is not examined.
|
||||
export function measureApiBreaks({ tagPrefix, previousVersion, assemblies, built, refs, cwd }) {
|
||||
const tag = `${tagPrefix}-v${previousVersion}`;
|
||||
const work = mkdtempSync(join(tmpdir(), "cultlib-apicompat-"));
|
||||
try {
|
||||
const left = join(work, "left");
|
||||
mkdirSync(left);
|
||||
const baseline = extractBaseline({ tag, dir: assemblies, into: left, cwd });
|
||||
if (baseline.failure) return baseline;
|
||||
const baselineNames = baseline.names;
|
||||
if (baselineNames.length === 0) {
|
||||
return { failure: `${tag} tracks no managed assembly under ${assemblies}: the declared directory is missing or moved, or that tag does not track the DLLs` };
|
||||
}
|
||||
const gate = prepareGate();
|
||||
if (gate.failure) return gate;
|
||||
const builtNames = new Set(built.map((path) => basename(path)));
|
||||
const breaks = baselineNames
|
||||
.filter((name) => !builtNames.has(name))
|
||||
.map((name) => `assembly ${name} exists at ${tag} but is no longer built`);
|
||||
for (const path of built) {
|
||||
const name = basename(path);
|
||||
if (!baselineNames.includes(name)) continue;
|
||||
const leftRefs = [left, gate.refs].join(",");
|
||||
const rightRefs = [...new Set([dirname(path), ...refs, gate.refs])].join(",");
|
||||
const run = runApiCompat(["--left-assembly", join(left, name), "--right-assembly", path, "--left-assembly-references", leftRefs, "--right-assembly-references", rightRefs]);
|
||||
if (run.failure) return { failure: `${name}: ${run.failure}` };
|
||||
breaks.push(...run.breaks);
|
||||
}
|
||||
return { breaks };
|
||||
} finally {
|
||||
rmSync(work, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
// Repeatable options collect into arrays. Any other option is a usage error.
|
||||
const KNOWN = new Set(["package", "version", "cwd", "api-built", "api-refs"]);
|
||||
const REPEATABLE = new Set(["api-built", "api-refs"]);
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {};
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const arg = argv[i];
|
||||
if (!arg.startsWith("--")) continue;
|
||||
const key = arg.slice(2);
|
||||
args[key] = argv[i + 1];
|
||||
if (!KNOWN.has(key)) return { unknown: arg };
|
||||
if (REPEATABLE.has(key)) {
|
||||
(args[key] ??= []).push(argv[i + 1]);
|
||||
} else {
|
||||
args[key] = argv[i + 1];
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function main() {
|
||||
function refuse(message) {
|
||||
console.error(message);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
const UNMEASURED_NOTICE =
|
||||
'public API measured; changes ApiCompat cannot see (see "Unmeasured changes" in docs/semver-policy.md) must still be declared under "### Breaking"';
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const { package: packageName, changelog, version, "tag-prefix": tagPrefix, cwd = process.cwd() } = args;
|
||||
if (!packageName || !changelog || !version || !tagPrefix) {
|
||||
const { package: packageName, version, cwd = process.cwd() } = args;
|
||||
// The tool runs from scripts/api-gate, so a path given relative to this process is made absolute first.
|
||||
const built = (args["api-built"] ?? []).map((path) => resolve(path));
|
||||
if (args.unknown || !packageName || !version) {
|
||||
console.error(
|
||||
"usage: check-changelog-semver.mjs --package <name> --changelog <path> --version <x.y.z> --tag-prefix <prefix> [--cwd <dir>]",
|
||||
"usage: check-changelog-semver.mjs --package <name> --version <x.y.z> [--cwd <dir>] [--api-built <dll>... [--api-refs <dir>...]]",
|
||||
);
|
||||
process.exitCode = 2;
|
||||
return;
|
||||
}
|
||||
if (tagAlreadyPublished(tagPrefix, version, cwd)) {
|
||||
console.log(`${packageName}: ${version} is already published as ${tagPrefix}-v${version}; not re-checking a rebuild of immutable history`);
|
||||
// Checked before anything reads tags: a malformed version is its own refusal, and the
|
||||
// value is not repeated back.
|
||||
if (!VERSION_PATTERN.test(version.trim())) {
|
||||
refuse(`${packageName}: --version is not a MAJOR.MINOR.PATCH version`);
|
||||
return;
|
||||
}
|
||||
if (!existsSync(changelog)) {
|
||||
console.error(`${packageName}: changelog not found at ${changelog}`);
|
||||
process.exitCode = 1;
|
||||
// The package's release identity is declared once, in the repository being released.
|
||||
let declared;
|
||||
try {
|
||||
declared = (await import(pathToFileURL(resolve(cwd, "scripts", "release-packages.mjs")).href)).default;
|
||||
} catch {
|
||||
refuse(`${packageName}: scripts/release-packages.mjs could not be loaded from --cwd`);
|
||||
return;
|
||||
}
|
||||
const changelogText = readFileSync(changelog, "utf8");
|
||||
const previousVersion = resolvePreviousVersion(tagPrefix, version, cwd);
|
||||
const result = evaluateRelease({ packageName, changelogText, version, previousVersion });
|
||||
const malformed = checkDeclaration(declared);
|
||||
if (malformed) {
|
||||
refuse(`${packageName}: scripts/release-packages.mjs is malformed: ${malformed}`);
|
||||
return;
|
||||
}
|
||||
const entry = Object.hasOwn(declared ?? {}, packageName) ? declared[packageName] : null;
|
||||
if (entry == null) {
|
||||
refuse(`${packageName}: not a package declared in scripts/release-packages.mjs`);
|
||||
return;
|
||||
}
|
||||
const { tagPrefix, assemblies } = entry;
|
||||
const changelog = resolve(cwd, entry.changelog);
|
||||
let changelogText;
|
||||
try {
|
||||
changelogText = readFileSync(changelog, "utf8");
|
||||
} catch (err) {
|
||||
refuse(`${packageName}: ${err.code === "ENOENT" ? "changelog not found" : "changelog cannot be read"} at ${changelog}`);
|
||||
return;
|
||||
}
|
||||
if (typeof assemblies === "string" && built.length === 0) {
|
||||
refuse(`${packageName}: ships the assemblies under ${assemblies}, so the release must hand them over with --api-built`);
|
||||
return;
|
||||
}
|
||||
if (typeof assemblies !== "string" && built.length > 0) {
|
||||
refuse(`${packageName}: declares no assemblies directory, so --api-built has nothing to be measured against`);
|
||||
return;
|
||||
}
|
||||
const release = resolveRelease(tagPrefix, version, cwd, assemblies);
|
||||
if (release.failure) {
|
||||
refuse(`${packageName}: ${release.failure}`);
|
||||
return;
|
||||
}
|
||||
const previousVersion = release.previous;
|
||||
let measuredBreaks = [];
|
||||
if (previousVersion != null && built.length > 0) {
|
||||
const measurement = measureApiBreaks({
|
||||
tagPrefix,
|
||||
previousVersion,
|
||||
assemblies,
|
||||
built,
|
||||
refs: (args["api-refs"] ?? []).map((path) => resolve(path)),
|
||||
cwd,
|
||||
});
|
||||
if (measurement.failure) {
|
||||
refuse(`${packageName}: public API could not be measured against ${tagPrefix}-v${previousVersion}: ${measurement.failure}`);
|
||||
return;
|
||||
}
|
||||
measuredBreaks = measurement.breaks;
|
||||
}
|
||||
const result = evaluateRelease({ packageName, changelogText, version, previousVersion, measuredBreaks });
|
||||
console.log(result.reason);
|
||||
if (!result.ok) {
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
if (previousVersion != null && built.length > 0) {
|
||||
console.log(UNMEASURED_NOTICE);
|
||||
}
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
||||
if (isMain) {
|
||||
main();
|
||||
await main();
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
38
scripts/release-packages.mjs
Normal file
38
scripts/release-packages.mjs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// The one declaration of what each released package is, read by
|
||||
// scripts/check-changelog-semver.mjs (docs/semver-policy.md) and by nothing a caller types.
|
||||
// Keyed by the --package name. Paths are repo-relative.
|
||||
// tagPrefix releases are tags named '<tagPrefix>-vMAJOR.MINOR.PATCH'
|
||||
// changelog the package's CHANGELOG.md
|
||||
// assemblies the directory whose tracked DLLs the package ships; absent when it ships none
|
||||
export default {
|
||||
"org.gamecult.cultlib": {
|
||||
tagPrefix: "cultlib-unity",
|
||||
changelog: "unity/org.gamecult.cultlib/CHANGELOG.md",
|
||||
assemblies: "unity/org.gamecult.cultlib/Runtime/Plugins",
|
||||
},
|
||||
"org.gamecult.cultmath": {
|
||||
tagPrefix: "cultmath-unity",
|
||||
changelog: "packages/cultmath/unity/org.gamecult.cultmath/CHANGELOG.md",
|
||||
assemblies: "packages/cultmath/unity/org.gamecult.cultmath/Runtime/Plugins",
|
||||
},
|
||||
"org.gamecult.caching.unity": {
|
||||
tagPrefix: "caching-unity",
|
||||
changelog: "src/GameCult.Unity/Assets/Caching/CHANGELOG.md",
|
||||
},
|
||||
"@gamecult/cultcache-ts": {
|
||||
tagPrefix: "cultcache-ts",
|
||||
changelog: "packages/cultcache-ts/CHANGELOG.md",
|
||||
},
|
||||
"cultcache-py": {
|
||||
tagPrefix: "cultcache-py",
|
||||
changelog: "packages/cultcache-py/CHANGELOG.md",
|
||||
},
|
||||
"cultnet-py": {
|
||||
tagPrefix: "cultnet-py",
|
||||
changelog: "packages/cultnet-py/CHANGELOG.md",
|
||||
},
|
||||
"cultmesh-py": {
|
||||
tagPrefix: "cultmesh-py",
|
||||
changelog: "packages/cultmesh-py/CHANGELOG.md",
|
||||
},
|
||||
};
|
||||
|
|
@ -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)."
|
||||
|
|
|
|||
Loading…
Reference in a new issue