Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions .github/scripts/extract-release-notes.sh
Original file line number Diff line number Diff line change
@@ -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 "## [<version>]" 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 <version> [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 <version> [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 "## [<version>]" 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)."
124 changes: 124 additions & 0 deletions .github/scripts/test-extract-release-notes.sh
Original file line number Diff line number Diff line change
@@ -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 <name> <expected-exit> <version> <changelog> [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."
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/nightly-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
62 changes: 39 additions & 23 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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 "## [<version>]" 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:
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading
Loading