Skip to content

Commit 2c13c49

Browse files
Merge pull request #326 from marius-bughiu/fix/issue-315-release-gates
fix(release): gate breaking API changes and bad release notes before the NuGet push
2 parents 9610e9a + 3a982e0 commit 2c13c49

13 files changed

Lines changed: 410 additions & 29 deletions

.gitattributes

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Shell scripts must keep LF endings whatever a contributor's core.autocrlf is
2+
# set to: the release gates in .github/scripts run on ubuntu-latest, and a CRLF
3+
# shebang line makes bash fail with a bare "not found" (#315).
4+
*.sh text eol=lf
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env bash
2+
#
3+
# extract-release-notes.sh — pull one version's section out of CHANGELOG.md (#315).
4+
#
5+
# The release workflow uses the extracted section verbatim as the GitHub Release
6+
# body. Both failure modes below used to surface only in the `github-release`
7+
# job, which runs *after* `dotnet nuget push` — so a bad CHANGELOG meant six
8+
# packages irreversibly on NuGet.org and no release to go with them. This script
9+
# is called from the `build` job instead, before anything is published.
10+
#
11+
# Fails when:
12+
# * the CHANGELOG has no "## [<version>]" section (the block was never
13+
# promoted out of [Unreleased]); or
14+
# * the section is larger than GitHub's release-body cap. `## [1.5.0]` was
15+
# 183,300 chars and `## [2.0.0]` was 133,453 — this repo overruns the cap in
16+
# practice, which is why CONTRIBUTING.md calls terse entries a release-safety
17+
# rule rather than a style preference.
18+
#
19+
# Usage: extract-release-notes.sh <version> [changelog-path] [output-path]
20+
# e.g. ./.github/scripts/extract-release-notes.sh 2.4.0
21+
#
22+
# Runnable locally against any version to check a section before tagging.
23+
24+
set -euo pipefail
25+
26+
version="${1:-}"
27+
changelog="${2:-CHANGELOG.md}"
28+
output="${3:-release-notes.md}"
29+
30+
# GitHub caps release bodies at ~125,000 characters. Assert on bytes, which for
31+
# UTF-8 is never fewer than characters, so the check errs on the safe side.
32+
MAX_BYTES=120000
33+
34+
if [ -z "$version" ]; then
35+
echo "usage: $0 <version> [changelog-path] [output-path]" >&2
36+
exit 2
37+
fi
38+
39+
if [ ! -f "$changelog" ]; then
40+
echo "::error::Changelog not found: $changelog" >&2
41+
exit 1
42+
fi
43+
44+
# Extract the section under "## [<version>]" up to (but not including) the next
45+
# "## [" heading. We use index() rather than regex because matching a literal "["
46+
# in awk's POSIX regex is fiddly across implementations.
47+
awk -v ver="$version" '
48+
index($0, "## [" ver "]") == 1 { capture = 1; next }
49+
capture && index($0, "## [") == 1 { exit }
50+
capture { print }
51+
' "$changelog" > "$output"
52+
53+
if [ ! -s "$output" ]; then
54+
echo "::error::No $changelog section found for [$version]. Move the [Unreleased] block to [$version] and re-tag." >&2
55+
rm -f "$output"
56+
exit 1
57+
fi
58+
59+
bytes=$(wc -c < "$output" | tr -d '[:space:]')
60+
if [ "$bytes" -ge "$MAX_BYTES" ]; then
61+
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
62+
rm -f "$output"
63+
exit 1
64+
fi
65+
66+
echo "Release notes for [$version]: $bytes bytes (limit $MAX_BYTES)."
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
#!/usr/bin/env bash
2+
#
3+
# test-extract-release-notes.sh — regression tests for extract-release-notes.sh (#315).
4+
#
5+
# Runs in CI on every PR. The oversized-section case is the regression test for
6+
# the bug in #315: before that fix the release workflow had no size check at all,
7+
# so a CHANGELOG section over GitHub's ~125k release-body cap published six
8+
# packages to NuGet.org and only then failed to create the release. Case 3 below
9+
# fails against that behaviour.
10+
#
11+
# Usage: ./.github/scripts/test-extract-release-notes.sh
12+
13+
set -uo pipefail
14+
15+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
16+
extract="$script_dir/extract-release-notes.sh"
17+
workdir="$(mktemp -d)"
18+
trap 'rm -rf "$workdir"' EXIT
19+
20+
failures=0
21+
22+
# assert <name> <expected-exit> <version> <changelog> [extra-check...]
23+
assert_exit() {
24+
local name="$1" expected="$2" version="$3" changelog="$4"
25+
local out="$workdir/out.md"
26+
rm -f "$out"
27+
"$extract" "$version" "$changelog" "$out" > "$workdir/stdout.txt" 2> "$workdir/stderr.txt"
28+
local actual=$?
29+
if [ "$actual" -ne "$expected" ]; then
30+
echo "FAIL: $name — expected exit $expected, got $actual"
31+
sed 's/^/ /' "$workdir/stderr.txt"
32+
failures=$((failures + 1))
33+
return 1
34+
fi
35+
echo "pass: $name (exit $actual)"
36+
return 0
37+
}
38+
39+
# A changelog with a small section, a large section, and a decoy that must not
40+
# bleed into the section above it.
41+
big_changelog="$workdir/CHANGELOG.big.md"
42+
{
43+
echo "# Changelog"
44+
echo
45+
echo "## [Unreleased]"
46+
echo
47+
echo "- pending"
48+
echo
49+
echo "## [2.0.0]"
50+
echo
51+
# ~130k of body: over the 120000-byte guard, under no other limit.
52+
for _ in $(seq 1 1300); do
53+
printf -- '- %s\n' "$(printf 'x%.0s' $(seq 1 96))"
54+
done
55+
echo
56+
echo "## [1.0.0]"
57+
echo
58+
echo "- Initial release."
59+
} > "$big_changelog"
60+
61+
small_changelog="$workdir/CHANGELOG.small.md"
62+
{
63+
echo "# Changelog"
64+
echo
65+
echo "## [1.0.0]"
66+
echo
67+
echo "### Added"
68+
echo
69+
echo "- A thing."
70+
echo
71+
echo "## [0.9.0]"
72+
echo
73+
echo "- An older thing that must NOT be captured."
74+
} > "$small_changelog"
75+
76+
echo "== extract-release-notes.sh =="
77+
78+
# 1. Happy path: the section is extracted and stops at the next heading.
79+
if assert_exit "extracts a well-sized section" 0 1.0.0 "$small_changelog"; then
80+
if grep -q "older thing" "$workdir/out.md"; then
81+
echo "FAIL: capture bled into the next ## [ section"
82+
failures=$((failures + 1))
83+
elif ! grep -q "A thing." "$workdir/out.md"; then
84+
echo "FAIL: section body missing from the output"
85+
failures=$((failures + 1))
86+
else
87+
echo "pass: section is bounded by the next ## [ heading"
88+
fi
89+
fi
90+
91+
# 2. No section for the tag's version — the pre-existing check.
92+
assert_exit "rejects a missing section" 1 9.9.9 "$small_changelog"
93+
94+
# 3. Section over GitHub's release-body cap — the #315 regression test.
95+
assert_exit "rejects an oversized section" 1 2.0.0 "$big_changelog"
96+
97+
# 4. A failed run must not leave a partial release-notes.md behind for a later
98+
# step to publish.
99+
if [ -f "$workdir/out.md" ]; then
100+
echo "FAIL: output file left behind after a failing run"
101+
failures=$((failures + 1))
102+
else
103+
echo "pass: no output file left behind after a failing run"
104+
fi
105+
106+
# 5. Missing changelog path.
107+
assert_exit "rejects a missing changelog file" 1 1.0.0 "$workdir/nope.md"
108+
109+
# 6. No version argument at all.
110+
rm -f "$workdir/out.md"
111+
"$extract" > /dev/null 2>&1
112+
if [ $? -eq 2 ]; then
113+
echo "pass: rejects a missing version argument (exit 2)"
114+
else
115+
echo "FAIL: expected exit 2 with no version argument"
116+
failures=$((failures + 1))
117+
fi
118+
119+
echo
120+
if [ "$failures" -ne 0 ]; then
121+
echo "::error::$failures release-notes gate test(s) failed."
122+
exit 1
123+
fi
124+
echo "All release-notes gate tests passed."

.github/workflows/ci.yml

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,56 @@ jobs:
5757
if-no-files-found: warn
5858
retention-days: 7
5959

60+
# A dry run of the guards that stand between a tag and NuGet.org (#315). They
61+
# all live on the release path, which no PR exercises — so without this job a
62+
# breaking API change or a mis-packed package is only discovered by the nightly
63+
# preview or by the release itself, long after review. Publishing is
64+
# irreversible; the feedback belongs in the PR that causes it.
65+
release-gates:
66+
name: release-gates
67+
runs-on: ubuntu-latest
68+
69+
defaults:
70+
run:
71+
working-directory: src
72+
73+
steps:
74+
- uses: actions/checkout@v4
75+
with:
76+
fetch-depth: 0
77+
filter: tree:0
78+
79+
# The CHANGELOG extraction is a shell script, so it sits outside
80+
# `dotnet test` and the coverage gate. Its size assertion is the one
81+
# guarding against an over-cap release body publishing six packages and
82+
# only then failing to create the release.
83+
- name: Test the release-notes extraction gate
84+
working-directory: ${{ github.workspace }}
85+
run: ./.github/scripts/test-extract-release-notes.sh
86+
87+
- name: Setup .NET
88+
uses: actions/setup-dotnet@v4
89+
with:
90+
dotnet-version: |
91+
8.0.x
92+
9.0.x
93+
10.0.x
94+
95+
- name: Restore workloads
96+
run: dotnet workload restore
97+
98+
# Packing is what runs package validation: each package is compared
99+
# against its published baseline across every TFM, and any breaking API
100+
# change fails here. See src/Directory.Build.props for the baseline-bump
101+
# ritual and the suppression escape hatch.
102+
- name: Pack (runs package validation against the published baseline)
103+
run: dotnet pack --configuration Release --output ${{ github.workspace }}/nuget -p:ContinuousIntegrationBuild=true
104+
105+
- name: Validate packages
106+
shell: pwsh
107+
working-directory: ${{ github.workspace }}
108+
run: ./.github/scripts/validate-packages.ps1 -NuGetDirectory "${{ github.workspace }}/nuget"
109+
60110
# The benchmark workflow only fires on `src/**`, so a dashboard-only change never
61111
# reaches the report-backed run of this same script in benchmarks.yml. These are the
62112
# structural checks, which need no measurements and cost seconds.

.github/workflows/nightly-preview.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ jobs:
4848
# Deterministic build + .snupkg symbol packages come from the shared props
4949
# (#190); -p:ContinuousIntegrationBuild=true is passed explicitly so the
5050
# preview build is reproducible without relying on the CI env var.
51+
#
52+
# Packing also runs package validation (#315), so this job fails if `main`
53+
# has drifted into a breaking API change against the published baseline —
54+
# a night's notice before a tag would have shipped it.
5155
- name: Create nuget package
5256
run: dotnet pack --configuration Release --output ${{ env.NuGetDirectory }} -p:ContinuousIntegrationBuild=true
5357

.github/workflows/release.yml

Lines changed: 39 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,31 @@ jobs:
3737
9.0.x
3838
10.0.x
3939
40+
# Runs before the build, and therefore long before `deploy` pushes to
41+
# NuGet.org (#315). A missing or over-long CHANGELOG section used to fail in
42+
# `github-release`, i.e. after all six packages were already published and
43+
# unremovable. Failing here means nothing shipped: fix the CHANGELOG, re-tag.
44+
# The extracted file is handed to `github-release` as an artifact rather than
45+
# re-extracted there, so the release body is exactly what was validated.
46+
- name: Extract and validate release notes
47+
if: startsWith(github.ref, 'refs/tags/v')
48+
shell: bash
49+
working-directory: ${{ github.workspace }}
50+
run: |
51+
tag="${GITHUB_REF_NAME}"
52+
./.github/scripts/extract-release-notes.sh "${tag#v}"
53+
echo "--- Release notes for $tag ---"
54+
cat release-notes.md
55+
56+
- name: Upload release notes
57+
if: startsWith(github.ref, 'refs/tags/v')
58+
uses: actions/upload-artifact@v4
59+
with:
60+
name: release-notes
61+
if-no-files-found: error
62+
retention-days: 7
63+
path: ${{ github.workspace }}/release-notes.md
64+
4065
- name: Restore workloads
4166
run: dotnet workload restore
4267

@@ -52,10 +77,16 @@ jobs:
5277
# so a reproducible build never depends on an env var. IncludeSymbols +
5378
# SymbolPackageFormat=snupkg (also from the shared props) emit a .snupkg
5479
# next to every .nupkg.
80+
#
81+
# This is also where package validation runs (#315): the shared props/targets
82+
# set EnablePackageValidation + a baseline version, so `pack` downloads each
83+
# package's published predecessor and fails on any breaking API change,
84+
# across every TFM. See src/Directory.Build.props for the baseline-bump
85+
# ritual and the suppression escape hatch.
5586
- name: Create nuget package
5687
run: dotnet pack --configuration Release --output ${{ env.NuGetDirectory }} -p:ContinuousIntegrationBuild=true
5788

58-
# Fail loudly if any of the three packages is missing a symbol package or
89+
# Fail loudly if any of the six packages is missing a symbol package or
5990
# the required NuGet metadata (license, README, icon, repository URL), so a
6091
# mis-packed release never reaches NuGet.org.
6192
- name: Validate packages
@@ -122,28 +153,13 @@ jobs:
122153
name: nuget
123154
path: ${{ env.NuGetDirectory }}
124155

125-
- name: Extract release notes from CHANGELOG.md
126-
shell: bash
127-
run: |
128-
set -euo pipefail
129-
tag="${GITHUB_REF_NAME}"
130-
version="${tag#v}"
131-
# Extract the section under "## [<version>]" up to (but not including)
132-
# the next "## [" heading. Empty output means the CHANGELOG was not
133-
# updated for this version — fail loudly so the maintainer notices.
134-
# We use index() rather than regex because matching a literal "[" in
135-
# awk's POSIX regex is fiddly across implementations.
136-
awk -v ver="$version" '
137-
index($0, "## [" ver "]") == 1 { capture = 1; next }
138-
capture && index($0, "## [") == 1 { exit }
139-
capture { print }
140-
' CHANGELOG.md > release-notes.md
141-
if [ ! -s release-notes.md ]; then
142-
echo "::error::No CHANGELOG.md section found for [$version]. Move the [Unreleased] block to [$version] and re-tag." >&2
143-
exit 1
144-
fi
145-
echo "--- Release notes for $tag ---"
146-
cat release-notes.md
156+
# Already extracted and size-checked in `build`, before the NuGet push.
157+
# Downloading it rather than re-running the extraction keeps the release
158+
# body identical to the one that passed the gate.
159+
- uses: actions/download-artifact@v4
160+
with:
161+
name: release-notes
162+
path: ${{ github.workspace }}
147163

148164
- name: Create GitHub Release
149165
env:

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,3 +401,6 @@ FodyWeavers.xsd
401401

402402
# JetBrains Rider
403403
*.sln.iml
404+
405+
# Local output of .github/scripts/extract-release-notes.sh (previewing a release body).
406+
release-notes.md

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ All notable changes to Celerity are documented here. This project follows [Keep
2323

2424
### Fixed
2525

26+
- **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).
27+
- **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).
2628
- **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).
2729
- 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).
2830
- **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).

0 commit comments

Comments
 (0)