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 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 c9f28e90..fd797826 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,6 +57,56 @@ jobs: if-no-files-found: warn retention-days: 7 + # 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" + # The benchmark workflow only fires on `src/**`, so a dashboard-only change never # reaches the report-backed run of this same script in benchmarks.yml. These are the # structural checks, which need no measurements and cost seconds. 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/.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/.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/CHANGELOG.md b/CHANGELOG.md index 8a3f9d39..1caf5d38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,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. 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 `EnumMap` and `EnumSet` cards on the benchmark dashboard rendered empty.** The page required an `(ItemCount: N)` suffix on every result name, and both benchmarks deliberately declare no item-count sweep, so their measurements were published and then discarded at render time. Both cards now chart their real numbers, and unparameterized benchmarks are excluded from the headline speedup stats. Closes [#301](https://github.com/marius-bughiu/Celerity/issues/301). - A blank dashboard card is now a red CI check rather than a silent gap: `scripts/check_dashboard_coverage.js` fails when a published result name is unparseable, when a card has no measurements behind it, or when a charted collection is missing from either `COLLECTIONS` array or from the CI benchmark suite. Closes [#301](https://github.com/marius-bughiu/Celerity/issues/301). - **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/CONTRIBUTING.md b/CONTRIBUTING.md index 9d0c1d37..01392530 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,16 +149,35 @@ 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 +``` `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. + +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. + +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 Celerity is narrowly scoped: specialized high-performance collections, hashers, and the minimal supporting utilities they need. We are unlikely to accept: diff --git a/ROADMAP.md b/ROADMAP.md index 9e4894da..a5974731 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.** 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). - No guard on the benchmark dashboard. The site parses BenchmarkDotNet result *names*, so a benchmark it cannot parse is dropped at render time — the data publishes correctly and the card just goes blank, with no CI signal. `EnumMap` and `EnumSet` had rendered empty since they shipped (they declare no `[Params]` sweep, by design), and `DisjointSet` blanked for five runs when its params property was briefly named `ElementCount`. Status: `done` — the parser now treats the `ItemCount` suffix as optional and renders an unparameterized class as a single bucket, excluded from the headline stats; `scripts/check_dashboard_coverage.js` fails CI on an unparseable name, a card with no measurements behind it, a collection missing from either `COLLECTIONS` array, or one not registered in the CI benchmark suite. It lifts those tables and the parsers out of the dashboard HTML rather than reimplementing them, so the check cannot drift from the page it guards. Tracked in [#301](https://github.com/marius-bughiu/Celerity/issues/301). 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..4aab40a7 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 `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/). @@ -105,6 +106,29 @@ 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`. 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) | + +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 + +# 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) + + +