From 5a2af161344d59a78bccadf76e161acbf15f75c8 Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Mon, 27 Jul 2026 10:02:01 +0300 Subject: [PATCH 1/5] fix(release): gate breaking API changes and bad release notes before the NuGet push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two guards this repo needed were on the wrong side of the irreversible `dotnet nuget push`. Package validation. Six packages shipped on a tag with no ApiCompat / PackageValidation / public-API-baseline check anywhere in the repo — in a project that already needed a hand-written TypeForwarders.cs to survive one assembly split. Deleting a public member produced a green CI run and a silently breaking package. `EnablePackageValidation` plus a pinned baseline now fails `pack` on any break, across all three TFMs. The enablement lives in a new src/Directory.Build.targets rather than in Directory.Build.props, because props is imported before the project body: at that point IsPackable is unset on every project, so the baseline PackageDownload lands on the test / benchmark / fuzz / AOT-smoke projects too and restore dies with "Invalid framework identifier ''". Targets is imported after the project body, where IsPackable=false is visible. Release notes. The CHANGELOG extraction and its only check lived in `github-release`, which needs `deploy` — so an over-long body failed after all six packages were already public. `## [1.5.0]` is 183,991 bytes against GitHub's ~125k cap, so this is a failure the repo has actually hit. The extraction moves into `build`, gains a body-size assertion, and is handed forward as an artifact so the release body is exactly what passed the gate. The extraction is pure shell and therefore outside `dotnet test`, so it gets its own test script and a `release-gates` CI job on every PR. The oversized-section case is the regression test: it fails against the old behaviour. Closes #315. --- .github/scripts/extract-release-notes.sh | 66 ++++++++++ .github/scripts/test-extract-release-notes.sh | 124 ++++++++++++++++++ .github/workflows/ci.yml | 15 +++ .github/workflows/release.yml | 62 +++++---- CHANGELOG.md | 2 + CONTRIBUTING.md | 23 +++- ROADMAP.md | 6 +- docs/testing.md | 22 ++++ src/Directory.Build.props | 36 +++++ src/Directory.Build.targets | 33 +++++ 10 files changed, 360 insertions(+), 29 deletions(-) create mode 100755 .github/scripts/extract-release-notes.sh create mode 100755 .github/scripts/test-extract-release-notes.sh create mode 100644 src/Directory.Build.targets diff --git a/.github/scripts/extract-release-notes.sh b/.github/scripts/extract-release-notes.sh new file mode 100755 index 00000000..9ac0c223 --- /dev/null +++ b/.github/scripts/extract-release-notes.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# extract-release-notes.sh — pull one version's section out of CHANGELOG.md (#315). +# +# The release workflow uses the extracted section verbatim as the GitHub Release +# body. Both failure modes below used to surface only in the `github-release` +# job, which runs *after* `dotnet nuget push` — so a bad CHANGELOG meant six +# packages irreversibly on NuGet.org and no release to go with them. This script +# is called from the `build` job instead, before anything is published. +# +# Fails when: +# * the CHANGELOG has no "## []" section (the block was never +# promoted out of [Unreleased]); or +# * the section is larger than GitHub's release-body cap. `## [1.5.0]` was +# 183,300 chars and `## [2.0.0]` was 133,453 — this repo overruns the cap in +# practice, which is why CONTRIBUTING.md calls terse entries a release-safety +# rule rather than a style preference. +# +# Usage: extract-release-notes.sh [changelog-path] [output-path] +# e.g. ./.github/scripts/extract-release-notes.sh 2.4.0 +# +# Runnable locally against any version to check a section before tagging. + +set -euo pipefail + +version="${1:-}" +changelog="${2:-CHANGELOG.md}" +output="${3:-release-notes.md}" + +# GitHub caps release bodies at ~125,000 characters. Assert on bytes, which for +# UTF-8 is never fewer than characters, so the check errs on the safe side. +MAX_BYTES=120000 + +if [ -z "$version" ]; then + echo "usage: $0 [changelog-path] [output-path]" >&2 + exit 2 +fi + +if [ ! -f "$changelog" ]; then + echo "::error::Changelog not found: $changelog" >&2 + exit 1 +fi + +# Extract the section under "## []" up to (but not including) the next +# "## [" heading. We use index() rather than regex because matching a literal "[" +# in awk's POSIX regex is fiddly across implementations. +awk -v ver="$version" ' + index($0, "## [" ver "]") == 1 { capture = 1; next } + capture && index($0, "## [") == 1 { exit } + capture { print } +' "$changelog" > "$output" + +if [ ! -s "$output" ]; then + echo "::error::No $changelog section found for [$version]. Move the [Unreleased] block to [$version] and re-tag." >&2 + rm -f "$output" + exit 1 +fi + +bytes=$(wc -c < "$output" | tr -d '[:space:]') +if [ "$bytes" -ge "$MAX_BYTES" ]; then + echo "::error::Release notes for [$version] are $bytes bytes, at or over the $MAX_BYTES-byte guard for GitHub's ~125k release-body cap. Condense the CHANGELOG section (see CONTRIBUTING.md, 'Changelog entries') and re-tag." >&2 + rm -f "$output" + exit 1 +fi + +echo "Release notes for [$version]: $bytes bytes (limit $MAX_BYTES)." diff --git a/.github/scripts/test-extract-release-notes.sh b/.github/scripts/test-extract-release-notes.sh new file mode 100755 index 00000000..f5349773 --- /dev/null +++ b/.github/scripts/test-extract-release-notes.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# +# test-extract-release-notes.sh — regression tests for extract-release-notes.sh (#315). +# +# Runs in CI on every PR. The oversized-section case is the regression test for +# the bug in #315: before that fix the release workflow had no size check at all, +# so a CHANGELOG section over GitHub's ~125k release-body cap published six +# packages to NuGet.org and only then failed to create the release. Case 3 below +# fails against that behaviour. +# +# Usage: ./.github/scripts/test-extract-release-notes.sh + +set -uo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +extract="$script_dir/extract-release-notes.sh" +workdir="$(mktemp -d)" +trap 'rm -rf "$workdir"' EXIT + +failures=0 + +# assert [extra-check...] +assert_exit() { + local name="$1" expected="$2" version="$3" changelog="$4" + local out="$workdir/out.md" + rm -f "$out" + "$extract" "$version" "$changelog" "$out" > "$workdir/stdout.txt" 2> "$workdir/stderr.txt" + local actual=$? + if [ "$actual" -ne "$expected" ]; then + echo "FAIL: $name — expected exit $expected, got $actual" + sed 's/^/ /' "$workdir/stderr.txt" + failures=$((failures + 1)) + return 1 + fi + echo "pass: $name (exit $actual)" + return 0 +} + +# A changelog with a small section, a large section, and a decoy that must not +# bleed into the section above it. +big_changelog="$workdir/CHANGELOG.big.md" +{ + echo "# Changelog" + echo + echo "## [Unreleased]" + echo + echo "- pending" + echo + echo "## [2.0.0]" + echo + # ~130k of body: over the 120000-byte guard, under no other limit. + for _ in $(seq 1 1300); do + printf -- '- %s\n' "$(printf 'x%.0s' $(seq 1 96))" + done + echo + echo "## [1.0.0]" + echo + echo "- Initial release." +} > "$big_changelog" + +small_changelog="$workdir/CHANGELOG.small.md" +{ + echo "# Changelog" + echo + echo "## [1.0.0]" + echo + echo "### Added" + echo + echo "- A thing." + echo + echo "## [0.9.0]" + echo + echo "- An older thing that must NOT be captured." +} > "$small_changelog" + +echo "== extract-release-notes.sh ==" + +# 1. Happy path: the section is extracted and stops at the next heading. +if assert_exit "extracts a well-sized section" 0 1.0.0 "$small_changelog"; then + if grep -q "older thing" "$workdir/out.md"; then + echo "FAIL: capture bled into the next ## [ section" + failures=$((failures + 1)) + elif ! grep -q "A thing." "$workdir/out.md"; then + echo "FAIL: section body missing from the output" + failures=$((failures + 1)) + else + echo "pass: section is bounded by the next ## [ heading" + fi +fi + +# 2. No section for the tag's version — the pre-existing check. +assert_exit "rejects a missing section" 1 9.9.9 "$small_changelog" + +# 3. Section over GitHub's release-body cap — the #315 regression test. +assert_exit "rejects an oversized section" 1 2.0.0 "$big_changelog" + +# 4. A failed run must not leave a partial release-notes.md behind for a later +# step to publish. +if [ -f "$workdir/out.md" ]; then + echo "FAIL: output file left behind after a failing run" + failures=$((failures + 1)) +else + echo "pass: no output file left behind after a failing run" +fi + +# 5. Missing changelog path. +assert_exit "rejects a missing changelog file" 1 1.0.0 "$workdir/nope.md" + +# 6. No version argument at all. +rm -f "$workdir/out.md" +"$extract" > /dev/null 2>&1 +if [ $? -eq 2 ]; then + echo "pass: rejects a missing version argument (exit 2)" +else + echo "FAIL: expected exit 2 with no version argument" + failures=$((failures + 1)) +fi + +echo +if [ "$failures" -ne 0 ]; then + echo "::error::$failures release-notes gate test(s) failed." + exit 1 +fi +echo "All release-notes gate tests passed." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e3bede3..ba817dc9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,21 @@ jobs: if-no-files-found: warn retention-days: 7 + # The release workflow's CHANGELOG extraction is a shell script, so it is + # outside `dotnet test` and the coverage gate. Exercising it on every PR keeps + # the pre-push release gate (#315) honest — in particular the size assertion, + # without which an over-cap CHANGELOG section publishes six packages to + # NuGet.org and only then fails to create the release. + release-gates: + name: release-gates + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Test the release-notes extraction gate + run: ./.github/scripts/test-extract-release-notes.sh + aot-publish: name: aot-publish (linux-x64, ${{ matrix.tfm }}) runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 03b00608..65476755 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,6 +37,31 @@ jobs: 9.0.x 10.0.x + # Runs before the build, and therefore long before `deploy` pushes to + # NuGet.org (#315). A missing or over-long CHANGELOG section used to fail in + # `github-release`, i.e. after all six packages were already published and + # unremovable. Failing here means nothing shipped: fix the CHANGELOG, re-tag. + # The extracted file is handed to `github-release` as an artifact rather than + # re-extracted there, so the release body is exactly what was validated. + - name: Extract and validate release notes + if: startsWith(github.ref, 'refs/tags/v') + shell: bash + working-directory: ${{ github.workspace }} + run: | + tag="${GITHUB_REF_NAME}" + ./.github/scripts/extract-release-notes.sh "${tag#v}" + echo "--- Release notes for $tag ---" + cat release-notes.md + + - name: Upload release notes + if: startsWith(github.ref, 'refs/tags/v') + uses: actions/upload-artifact@v4 + with: + name: release-notes + if-no-files-found: error + retention-days: 7 + path: ${{ github.workspace }}/release-notes.md + - name: Restore workloads run: dotnet workload restore @@ -52,10 +77,16 @@ jobs: # so a reproducible build never depends on an env var. IncludeSymbols + # SymbolPackageFormat=snupkg (also from the shared props) emit a .snupkg # next to every .nupkg. + # + # This is also where package validation runs (#315): the shared props/targets + # set EnablePackageValidation + a baseline version, so `pack` downloads each + # package's published predecessor and fails on any breaking API change, + # across every TFM. See src/Directory.Build.props for the baseline-bump + # ritual and the suppression escape hatch. - name: Create nuget package run: dotnet pack --configuration Release --output ${{ env.NuGetDirectory }} -p:ContinuousIntegrationBuild=true - # Fail loudly if any of the three packages is missing a symbol package or + # Fail loudly if any of the six packages is missing a symbol package or # the required NuGet metadata (license, README, icon, repository URL), so a # mis-packed release never reaches NuGet.org. - name: Validate packages @@ -122,28 +153,13 @@ jobs: name: nuget path: ${{ env.NuGetDirectory }} - - name: Extract release notes from CHANGELOG.md - shell: bash - run: | - set -euo pipefail - tag="${GITHUB_REF_NAME}" - version="${tag#v}" - # Extract the section under "## []" up to (but not including) - # the next "## [" heading. Empty output means the CHANGELOG was not - # updated for this version — fail loudly so the maintainer notices. - # We use index() rather than regex because matching a literal "[" in - # awk's POSIX regex is fiddly across implementations. - awk -v ver="$version" ' - index($0, "## [" ver "]") == 1 { capture = 1; next } - capture && index($0, "## [") == 1 { exit } - capture { print } - ' CHANGELOG.md > release-notes.md - if [ ! -s release-notes.md ]; then - echo "::error::No CHANGELOG.md section found for [$version]. Move the [Unreleased] block to [$version] and re-tag." >&2 - exit 1 - fi - echo "--- Release notes for $tag ---" - cat release-notes.md + # Already extracted and size-checked in `build`, before the NuGet push. + # Downloading it rather than re-running the extraction keeps the release + # body identical to the one that passed the gate. + - uses: actions/download-artifact@v4 + with: + name: release-notes + path: ${{ github.workspace }} - name: Create GitHub Release env: diff --git a/CHANGELOG.md b/CHANGELOG.md index e5f99343..1f8d3165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ All notable changes to Celerity are documented here. This project follows [Keep ### Fixed +- **A breaking API change could ship silently.** `dotnet pack` now validates every package against its last published version across all three TFMs and fails on any break, so a removed or narrowed public member can no longer reach NuGet.org with CI green. Intentional breaks are recorded in a reviewed suppression file. CI-only; no consumer-visible behaviour change. Closes [#315](https://github.com/marius-bughiu/Celerity/issues/315). +- **A bad `CHANGELOG.md` could half-publish a release.** The release-notes check ran after the irreversible NuGet push, so a missing section — or one over GitHub's ~125k release-body cap, which this repo has overrun before — left six packages published and no release. It now runs before anything is pushed, with a body-size assertion added, and the validated notes are handed to the release step verbatim. Closes [#315](https://github.com/marius-bughiu/Celerity/issues/315). - **The coverage gate measured only one of the six shipped packages.** Coverlet's assembly filter is exact-match, so `Celerity.Hashing`, `Celerity.Primitives`, and the three showcase packages had been outside the gate since the 2.0.0 package split — any of them could have dropped to 0% with CI green. All six are now measured, the gaps that exposed are backfilled to **100% line and branch** coverage, and the floor is raised from 95%/90% to match. Closes [#314](https://github.com/marius-bughiu/Celerity/issues/314). ## [2.4.0] - 2026-07-26 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6ba9b4d2..32f635b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -129,13 +129,30 @@ Releases are automated. Pushing a `v`-prefixed tag fires `.github/workflows/rele ```bash # 1. Move the CHANGELOG [Unreleased] block to [X.Y.Z] (with today's date if you -# want one — the workflow does not require a date), commit, and merge to main. -# 2. Tag the merge commit and push the tag. +# want one — the workflow does not require a date). +# 2. In the SAME commit, bump in +# src/Directory.Build.props to X.Y.Z. See "Package validation" below. +# 3. Commit, merge to main, then tag the merge commit and push the tag. git tag -a v1.2.0 -m "Release 1.2.0" git push origin v1.2.0 ``` -The workflow extracts the `## [X.Y.Z]` section of `CHANGELOG.md` and uses it as the GitHub Release body. If no matching section exists for the tag's version, the workflow fails loudly — the fix is to update `CHANGELOG.md` and re-tag. +The workflow extracts the `## [X.Y.Z]` section of `CHANGELOG.md` and uses it as the GitHub Release body. Two things can go wrong with that — no section exists for the tag's version, or the section exceeds GitHub's ~125k release-body cap — and both are checked in the `build` job, **before** anything is pushed to NuGet.org. A failure there means nothing shipped: fix `CHANGELOG.md` and re-tag. You can check a section before tagging: + +```bash +./.github/scripts/extract-release-notes.sh 1.2.0 +``` + +### Package validation + +Every `dotnet pack` validates each package against its last published version and **fails the build on any breaking API change**, across all three TFMs. Since the NuGet push is irreversible, this guard has to run before it — so it runs on every release build, and locally whenever you pack. + +The baseline is one property, `` in `src/Directory.Build.props`, shared by all six packages. **Bump it to the version you are about to tag, in the same commit that moves the CHANGELOG block.** This is the part that rots: a stale baseline keeps validating against an older surface, so a break introduced after it slips through. + +Two situations need a deliberate decision rather than a workaround: + +- **An intentional break.** Run `dotnet pack -p:ApiCompatGenerateSuppressionFile=true` on the offending project, which writes a `CompatibilitySuppressions.xml` next to its `.csproj`. Commit it with a comment explaining each entry, so the break is reviewed in the PR instead of discovered by a consumer. +- **A package's first release.** There is no published predecessor to validate against, and asking for one fails the restore. Set `true` in that package's `.csproj`, ship it once, then delete the property. `workflow_dispatch` is still wired up as a manual fallback for ad-hoc re-publishes (e.g. if a NuGet push fails partway through), but the normal flow is tag-push. diff --git a/ROADMAP.md b/ROADMAP.md index 118af84b..0ad1ff66 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -227,9 +227,9 @@ The 2026-Q3 review surveyed the shipped surface against the current .NET 8/9/10 **Build- and release-pipeline integrity.** Three guards the repo advertises but does not have. -- The coverage gate measures one of the six shipping assemblies. `src/coverage.runsettings` filters to `[Celerity]*` with the comment "Measure only the shipping library assembly" — written when there was one. `Celerity.Hashing`, `Celerity.Primitives` and the three showcase packages are unmeasured, while `CONTRIBUTING.md` and `CLAUDE.md` describe the 95%/90% gate as library-wide. Status: `planned`. -- Nothing can fail after the NuGet push. `release.yml` pushes six packages irreversibly, *then* extracts the release notes and creates the GitHub Release — so an over-long release body (a failure this repo has actually hit) leaves a half-published release. The notes check should be hoisted ahead of the push. Status: `planned`. -- No API-compatibility gate. Six packages publish on a tag with no `ApiCompat` / `PackageValidation` / public-API-baseline check anywhere in the repo — in a project that already needed a hand-written `TypeForwarders.cs` to survive one assembly split. Status: `planned`. +- The coverage gate measures one of the six shipping assemblies. `src/coverage.runsettings` filters to `[Celerity]*` with the comment "Measure only the shipping library assembly" — written when there was one. `Celerity.Hashing`, `Celerity.Primitives` and the three showcase packages are unmeasured, while `CONTRIBUTING.md` and `CLAUDE.md` describe the 95%/90% gate as library-wide. Status: `done` — all six are now measured and the floor is 100% line / 100% branch. Tracked in [#314](https://github.com/marius-bughiu/Celerity/issues/314). +- Nothing can fail after the NuGet push. `release.yml` pushes six packages irreversibly, *then* extracts the release notes and creates the GitHub Release — so an over-long release body (a failure this repo has actually hit) leaves a half-published release. The notes check should be hoisted ahead of the push. Status: `done` — extraction, the empty-section check and a new body-size assertion all run in `build`. Tracked in [#315](https://github.com/marius-bughiu/Celerity/issues/315). +- No API-compatibility gate. Six packages publish on a tag with no `ApiCompat` / `PackageValidation` / public-API-baseline check anywhere in the repo — in a project that already needed a hand-written `TypeForwarders.cs` to survive one assembly split. Status: `done` — `EnablePackageValidation` against a pinned baseline now fails `pack` on any breaking change. Tracked in [#315](https://github.com/marius-bughiu/Celerity/issues/315). Two areas were judged real but deliberately deferred rather than rostered: a `Celerity.Statistics` package (DDSketch / reservoir sampling / running moments — a coherent fourth axis, but two new packages in one cycle is too much at once), and a batch of fuzz-target and AOT-smoke-coverage gaps (real, but low expected defect yield; better folded into whichever collection PR lands next than pursued on their own). diff --git a/docs/testing.md b/docs/testing.md index daa12bb4..e9332765 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,6 +11,7 @@ Celerity's first guiding principle is *correctness first* — "a fast collection | Property-based tests | `Celerity.Tests/Properties/` | Across thousands of randomized operation sequences, every collection stays observably equal to its BCL oracle. | `dotnet test` | | Differential fuzzer | `Celerity.Fuzz` | A long random walk finds no divergence from the BCL; failures replay deterministically from a seed. | `dotnet run -c Release` | | Native AOT smoke test | `Celerity.AotSmokeTest` | Every collection/hasher works in a trimmed, AOT-compiled native binary. | see [aot.md](aot.md) | +| Release gates | `.github/scripts/` | The pre-publish guards hold: a breaking API change fails `pack`, and a missing or over-cap `CHANGELOG` section fails before anything reaches NuGet.org. | `./.github/scripts/test-extract-release-notes.sh` | All of these run in CI. Coverage is measured on all six shipping assemblies and gated at 100% line and branch; the rendered report is published to [the coverage dashboard](https://marius-bughiu.github.io/Celerity/coverage/). @@ -105,6 +106,27 @@ In CI the fuzzer runs as a **nightly** job (`.github/workflows/fuzz.yml`) with a Add an entry to `Differential.All` in `src/Celerity.Fuzz/Differential.cs` and write a method that drives your collection against a BCL oracle, calling `Check(condition, message)` on every observable. The driver discovers it automatically (including in `--list`). +## Release gates + +Publishing to NuGet.org is irreversible, so the checks that decide whether a release is well-formed all run *before* the push, in the `build` job of `.github/workflows/release.yml` ([#315](https://github.com/marius-bughiu/Celerity/issues/315)). Three of them: + +| Gate | Fails on | Where | +|---|---|---| +| Package validation | Any breaking public-API change against the last published version of that package, on any TFM. | `dotnet pack`, via `EnablePackageValidation` in `src/Directory.Build.props` / `Directory.Build.targets` | +| Package metadata | A missing symbol package, license, README, icon, or SourceLink stamp; a missing or unexpected package id. | [`validate-packages.ps1`](../.github/scripts/validate-packages.ps1) | +| Release notes | No `## [X.Y.Z]` section for the tag, or one at/over the 120,000-byte guard for GitHub's ~125k release-body cap. | [`extract-release-notes.sh`](../.github/scripts/extract-release-notes.sh) | + +Only the last of these is pure shell, so it is the one outside `dotnet test`. [`test-extract-release-notes.sh`](../.github/scripts/test-extract-release-notes.sh) covers it — happy path, section boundaries, missing section, oversized section, and that a failed run leaves no partial file for a later step to publish — and runs as the `release-gates` job on every PR. + +```bash +./.github/scripts/test-extract-release-notes.sh + +# preview the notes for a version before tagging +./.github/scripts/extract-release-notes.sh 2.4.0 +``` + +The baseline-bump ritual that keeps package validation meaningful is in [CONTRIBUTING.md](../CONTRIBUTING.md#package-validation). + ## Code coverage Coverage is collected with [coverlet](https://github.com/coverlet-coverage/coverlet) and scoped to all six shipping assemblies — `Celerity`, `Celerity.Hashing`, `Celerity.Primitives`, `Celerity.Ring`, `Celerity.Sentinel`, `Celerity.Cardinality` — via [`src/coverage.runsettings`](../src/coverage.runsettings). The test, benchmark, fuzz, and AOT-smoke assemblies are tooling, not the subject under measurement. diff --git a/src/Directory.Build.props b/src/Directory.Build.props index ead57a22..9f5b5334 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -75,4 +75,40 @@ true + + + 2.4.0 + + diff --git a/src/Directory.Build.targets b/src/Directory.Build.targets new file mode 100644 index 00000000..22e9f94e --- /dev/null +++ b/src/Directory.Build.targets @@ -0,0 +1,33 @@ + + + + + true + $(CelerityPackageValidationBaseline) + + + From 7d7a4e872db8a13a3a89b4ef5bde7c835e1e93d0 Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Mon, 27 Jul 2026 10:02:21 +0300 Subject: [PATCH 2/5] chore(git): pin *.sh to LF so the release-gate scripts run on the Linux runners --- .gitattributes | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..efb18f5f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +# Shell scripts must keep LF endings whatever a contributor's core.autocrlf is +# set to: the release gates in .github/scripts run on ubuntu-latest, and a CRLF +# shebang line makes bash fail with a bare "not found" (#315). +*.sh text eol=lf From de08e8380480c4529abb9b4c65544c21f89d75e1 Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Mon, 27 Jul 2026 10:06:39 +0300 Subject: [PATCH 3/5] ci(release-gates): run the pack/package-validation gate on every PR too Nothing in the PR matrix packs, so package validation and validate-packages.ps1 only ran on the nightly preview and on the release itself. That leaves a breaking API change discoverable a night late at best and after an irreversible push at worst. The release-gates job now does a full release dry run: extract-release-notes self-test, pack (which is what runs the baseline comparison), then validate-packages.ps1. --- .github/workflows/ci.yml | 45 ++++++++++++++++++++++++--- .github/workflows/nightly-preview.yml | 4 +++ CHANGELOG.md | 2 +- docs/testing.md | 6 ++-- 4 files changed, 49 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba817dc9..77bfa3b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,21 +57,56 @@ jobs: if-no-files-found: warn retention-days: 7 - # The release workflow's CHANGELOG extraction is a shell script, so it is - # outside `dotnet test` and the coverage gate. Exercising it on every PR keeps - # the pre-push release gate (#315) honest — in particular the size assertion, - # without which an over-cap CHANGELOG section publishes six packages to - # NuGet.org and only then fails to create the release. + # A dry run of the guards that stand between a tag and NuGet.org (#315). They + # all live on the release path, which no PR exercises — so without this job a + # breaking API change or a mis-packed package is only discovered by the nightly + # preview or by the release itself, long after review. Publishing is + # irreversible; the feedback belongs in the PR that causes it. release-gates: name: release-gates runs-on: ubuntu-latest + defaults: + run: + working-directory: src + steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + filter: tree:0 + # The CHANGELOG extraction is a shell script, so it sits outside + # `dotnet test` and the coverage gate. Its size assertion is the one + # guarding against an over-cap release body publishing six packages and + # only then failing to create the release. - name: Test the release-notes extraction gate + working-directory: ${{ github.workspace }} run: ./.github/scripts/test-extract-release-notes.sh + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: | + 8.0.x + 9.0.x + 10.0.x + + - name: Restore workloads + run: dotnet workload restore + + # Packing is what runs package validation: each package is compared + # against its published baseline across every TFM, and any breaking API + # change fails here. See src/Directory.Build.props for the baseline-bump + # ritual and the suppression escape hatch. + - name: Pack (runs package validation against the published baseline) + run: dotnet pack --configuration Release --output ${{ github.workspace }}/nuget -p:ContinuousIntegrationBuild=true + + - name: Validate packages + shell: pwsh + working-directory: ${{ github.workspace }} + run: ./.github/scripts/validate-packages.ps1 -NuGetDirectory "${{ github.workspace }}/nuget" + aot-publish: name: aot-publish (linux-x64, ${{ matrix.tfm }}) runs-on: ubuntu-latest diff --git a/.github/workflows/nightly-preview.yml b/.github/workflows/nightly-preview.yml index 37690b37..90814fb9 100644 --- a/.github/workflows/nightly-preview.yml +++ b/.github/workflows/nightly-preview.yml @@ -48,6 +48,10 @@ jobs: # Deterministic build + .snupkg symbol packages come from the shared props # (#190); -p:ContinuousIntegrationBuild=true is passed explicitly so the # preview build is reproducible without relying on the CI env var. + # + # Packing also runs package validation (#315), so this job fails if `main` + # has drifted into a breaking API change against the published baseline — + # a night's notice before a tag would have shipped it. - name: Create nuget package run: dotnet pack --configuration Release --output ${{ env.NuGetDirectory }} -p:ContinuousIntegrationBuild=true diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f8d3165..5eed2bef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ All notable changes to Celerity are documented here. This project follows [Keep ### Fixed -- **A breaking API change could ship silently.** `dotnet pack` now validates every package against its last published version across all three TFMs and fails on any break, so a removed or narrowed public member can no longer reach NuGet.org with CI green. Intentional breaks are recorded in a reviewed suppression file. CI-only; no consumer-visible behaviour change. Closes [#315](https://github.com/marius-bughiu/Celerity/issues/315). +- **A breaking API change could ship silently.** `dotnet pack` now validates every package against its last published version across all three TFMs and fails on any break, so a removed or narrowed public member can no longer reach NuGet.org with CI green. It runs on every PR, not just at release, and intentional breaks are recorded in a reviewed suppression file. CI-only; no consumer-visible behaviour change. Closes [#315](https://github.com/marius-bughiu/Celerity/issues/315). - **A bad `CHANGELOG.md` could half-publish a release.** The release-notes check ran after the irreversible NuGet push, so a missing section — or one over GitHub's ~125k release-body cap, which this repo has overrun before — left six packages published and no release. It now runs before anything is pushed, with a body-size assertion added, and the validated notes are handed to the release step verbatim. Closes [#315](https://github.com/marius-bughiu/Celerity/issues/315). - **The coverage gate measured only one of the six shipped packages.** Coverlet's assembly filter is exact-match, so `Celerity.Hashing`, `Celerity.Primitives`, and the three showcase packages had been outside the gate since the 2.0.0 package split — any of them could have dropped to 0% with CI green. All six are now measured, the gaps that exposed are backfilled to **100% line and branch** coverage, and the floor is raised from 95%/90% to match. Closes [#314](https://github.com/marius-bughiu/Celerity/issues/314). diff --git a/docs/testing.md b/docs/testing.md index e9332765..ac60db4e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -11,7 +11,7 @@ Celerity's first guiding principle is *correctness first* — "a fast collection | Property-based tests | `Celerity.Tests/Properties/` | Across thousands of randomized operation sequences, every collection stays observably equal to its BCL oracle. | `dotnet test` | | Differential fuzzer | `Celerity.Fuzz` | A long random walk finds no divergence from the BCL; failures replay deterministically from a seed. | `dotnet run -c Release` | | Native AOT smoke test | `Celerity.AotSmokeTest` | Every collection/hasher works in a trimmed, AOT-compiled native binary. | see [aot.md](aot.md) | -| Release gates | `.github/scripts/` | The pre-publish guards hold: a breaking API change fails `pack`, and a missing or over-cap `CHANGELOG` section fails before anything reaches NuGet.org. | `./.github/scripts/test-extract-release-notes.sh` | +| Release gates | `.github/scripts/`, the `release-gates` CI job | The pre-publish guards hold: a breaking API change fails `pack`, and a missing or over-cap `CHANGELOG` section fails before anything reaches NuGet.org. | `dotnet pack -c Release`; `./.github/scripts/test-extract-release-notes.sh` | All of these run in CI. Coverage is measured on all six shipping assemblies and gated at 100% line and branch; the rendered report is published to [the coverage dashboard](https://marius-bughiu.github.io/Celerity/coverage/). @@ -116,7 +116,9 @@ Publishing to NuGet.org is irreversible, so the checks that decide whether a rel | Package metadata | A missing symbol package, license, README, icon, or SourceLink stamp; a missing or unexpected package id. | [`validate-packages.ps1`](../.github/scripts/validate-packages.ps1) | | Release notes | No `## [X.Y.Z]` section for the tag, or one at/over the 120,000-byte guard for GitHub's ~125k release-body cap. | [`extract-release-notes.sh`](../.github/scripts/extract-release-notes.sh) | -Only the last of these is pure shell, so it is the one outside `dotnet test`. [`test-extract-release-notes.sh`](../.github/scripts/test-extract-release-notes.sh) covers it — happy path, section boundaries, missing section, oversized section, and that a failed run leaves no partial file for a later step to publish — and runs as the `release-gates` job on every PR. +All three also run on **every PR**, as the `release-gates` job in `ci.yml`. Nothing else in CI packs, so without that job a breaking API change or a mis-packed package would only surface in the nightly preview or in the release itself — long after review, and for the release, too late. + +The release-notes gate is the one piece that is pure shell and therefore outside `dotnet test`. [`test-extract-release-notes.sh`](../.github/scripts/test-extract-release-notes.sh) covers it — happy path, section boundaries, missing section, oversized section, and that a failed run leaves no partial file for a later step to publish. ```bash ./.github/scripts/test-extract-release-notes.sh From 9a0f3648591d7dfe580734f6ed8baaa6603f00b4 Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Mon, 27 Jul 2026 10:08:25 +0300 Subject: [PATCH 4/5] docs(testing): name the exact file each package-validation property lives in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Copilot review on #326: the gates table listed both Directory.Build.props and Directory.Build.targets for EnablePackageValidation, but the switch is only in targets — props carries the baseline version. Split the two out and link both files. Also gitignore release-notes.md, the local output of extract-release-notes.sh when previewing a release body. --- .gitignore | 3 +++ docs/testing.md | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 706b0851..f6a75686 100644 --- a/.gitignore +++ b/.gitignore @@ -401,3 +401,6 @@ FodyWeavers.xsd # JetBrains Rider *.sln.iml + +# Local output of .github/scripts/extract-release-notes.sh (previewing a release body). +release-notes.md diff --git a/docs/testing.md b/docs/testing.md index ac60db4e..4aab40a7 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -112,7 +112,7 @@ Publishing to NuGet.org is irreversible, so the checks that decide whether a rel | Gate | Fails on | Where | |---|---|---| -| Package validation | Any breaking public-API change against the last published version of that package, on any TFM. | `dotnet pack`, via `EnablePackageValidation` in `src/Directory.Build.props` / `Directory.Build.targets` | +| Package validation | Any breaking public-API change against the last published version of that package, on any TFM. | `dotnet pack`. The switch is `EnablePackageValidation` in [`src/Directory.Build.targets`](../src/Directory.Build.targets); the baseline version it compares against is `CelerityPackageValidationBaseline` in [`src/Directory.Build.props`](../src/Directory.Build.props). | | Package metadata | A missing symbol package, license, README, icon, or SourceLink stamp; a missing or unexpected package id. | [`validate-packages.ps1`](../.github/scripts/validate-packages.ps1) | | Release notes | No `## [X.Y.Z]` section for the tag, or one at/over the 120,000-byte guard for GitHub's ~125k release-body cap. | [`extract-release-notes.sh`](../.github/scripts/extract-release-notes.sh) | From e372b2e8362d88d01aa6165541dbf99eeb6e4b27 Mon Sep 17 00:00:00 2001 From: Marius Bughiu Date: Mon, 27 Jul 2026 10:11:56 +0300 Subject: [PATCH 5/5] docs(contributing): keep the workflow_dispatch note under 'Cutting a release' Inserting the Package validation section pushed that sentence under the wrong heading, where it read as a note about validation. Moved it back above the new heading and pointed the section at the release-gates job. --- CONTRIBUTING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 32f635b6..b23a33cf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,6 +143,8 @@ The workflow extracts the `## [X.Y.Z]` section of `CHANGELOG.md` and uses it as ./.github/scripts/extract-release-notes.sh 1.2.0 ``` +`workflow_dispatch` is still wired up as a manual fallback for ad-hoc re-publishes (e.g. if a NuGet push fails partway through), but the normal flow is tag-push. + ### Package validation Every `dotnet pack` validates each package against its last published version and **fails the build on any breaking API change**, across all three TFMs. Since the NuGet push is irreversible, this guard has to run before it — so it runs on every release build, and locally whenever you pack. @@ -154,7 +156,7 @@ Two situations need a deliberate decision rather than a workaround: - **An intentional break.** Run `dotnet pack -p:ApiCompatGenerateSuppressionFile=true` on the offending project, which writes a `CompatibilitySuppressions.xml` next to its `.csproj`. Commit it with a comment explaining each entry, so the break is reviewed in the PR instead of discovered by a consumer. - **A package's first release.** There is no published predecessor to validate against, and asking for one fails the restore. Set `true` in that package's `.csproj`, ship it once, then delete the property. -`workflow_dispatch` is still wired up as a manual fallback for ad-hoc re-publishes (e.g. if a NuGet push fails partway through), but the normal flow is tag-push. +Both gates, plus the package-metadata check, also run on every PR as the `release-gates` job — see [docs/testing.md](docs/testing.md#release-gates). ## Scope