From fcb0bdf518b6d2efa7ec0e35cdb690d8941b59cb Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Tue, 25 Aug 2026 23:28:05 -0300 Subject: [PATCH 1/2] ci(release): publish a GitHub release from the changelog section Pushing to release published packages and stopped there - no tag, no GitHub release. CLAUDE.md lists creating one as step 7 of the process, by hand, and it has not happened since v10.9.0: 10.10.0.0, 10.11.0.0, 10.11.1.0 and 10.12.0.0 all shipped to nuget.org without either. A manual step that is skipped four times running is a step that wants automating. The notes are the CHANGES.md section for the version in Xrpl.csproj, read from the "## " heading to the next heading. Two details worth stating, because both are deliberate: Resolving the version and extracting the notes happens BEFORE the build, not next to the release step where it would read more naturally. A changelog still titled "Unreleased" then fails the run while nothing has been published, rather than after the push to nuget.org - which cannot be taken back, and whose version number cannot be reused. That case nearly happened on 11.0.0.0. Tags follow NuGet's own normalisation, so 11.0.0.0 becomes v11.0.0, matching both the published package and the v10.9.0 / v10.8.0 tags made by hand. A fourth component that is not zero is kept, because then it carries meaning. The release step runs last, so a release only appears for packages that actually shipped, and skips an existing tag instead of failing - the pushes above are already idempotent through --skip-duplicate, and a re-run should be able to finish. Release bodies are capped at 125000 characters by GitHub and this changelog runs long - 11.0.0.0 came to 98638. Over the limit it is truncated with a pointer to CHANGES.md rather than failing the release. Needs contents: write, which the workflow did not have. --- .github/workflows/nuget.release.yml | 76 ++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/.github/workflows/nuget.release.yml b/.github/workflows/nuget.release.yml index e0cbb2a4..626083d2 100644 --- a/.github/workflows/nuget.release.yml +++ b/.github/workflows/nuget.release.yml @@ -5,7 +5,7 @@ on: branches: [ release ] permissions: - contents: read + contents: write packages: write id-token: write @@ -16,6 +16,57 @@ jobs: steps: - uses: actions/checkout@v4 + # Deliberately before the build. A CHANGES.md section that is missing or + # still titled "Unreleased" fails the run here, while nothing has been + # published yet - rather than after the push to nuget.org, which cannot be + # taken back and whose version number cannot be reused. + - name: Resolve version and release notes + id: notes + run: | + set -euo pipefail + + version=$(sed -n 's:.*\(.*\).*:\1:p' Xrpl/Xrpl.csproj | head -1) + if [ -z "$version" ]; then + echo "::error file=Xrpl/Xrpl.csproj::No found." + exit 1 + fi + + # Tags follow NuGet's own normalisation: 11.0.0.0 is published as + # 11.0.0, and v10.9.0 / v10.8.0 were tagged that way by hand. A + # non-zero fourth component is kept, because then it means something. + if [[ "$version" =~ ^([0-9]+\.[0-9]+\.[0-9]+)\.0$ ]]; then + tag="v${BASH_REMATCH[1]}" + else + tag="v${version}" + fi + + # From the "## " heading to the next "## " heading. + awk -v v="$version" ' + $0 ~ "^## " v "( |$)" { found = 1; next } + found && /^## / { exit } + found { print } + ' CHANGES.md > release-notes.md + + if [ ! -s release-notes.md ]; then + echo "::error file=CHANGES.md::No section headed '## $version' - the release heading is not stamped, or the version does not match." + exit 1 + fi + + # GitHub caps release bodies at 125000 characters and this changelog + # runs long: 11.0.0.0 was 98638. Truncate rather than fail, and say so. + limit=120000 + if [ "$(wc -c < release-notes.md)" -gt "$limit" ]; then + head -c "$limit" release-notes.md > release-notes.trimmed + printf '\n\n---\n\n*Truncated at %s characters. The full entry is in [CHANGES.md](https://github.com/%s/blob/release/CHANGES.md).*\n' \ + "$limit" "$GITHUB_REPOSITORY" >> release-notes.trimmed + mv release-notes.trimmed release-notes.md + echo "::warning::Release notes truncated to $limit characters." + fi + + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "tag=$tag" >> "$GITHUB_OUTPUT" + echo "Release $tag from version $version, $(wc -c < release-notes.md) characters of notes." + - name: Setup .NET uses: actions/setup-dotnet@v4 with: @@ -53,3 +104,26 @@ jobs: - name: Publish to NuGet.org run: dotnet nuget push "**/*.nupkg" --skip-duplicate -s https://api.nuget.org/v3/index.json -k ${{ steps.nuget-login.outputs.NUGET_API_KEY }} + + # Last, so a release only ever appears for packages that actually shipped. + # Skips an existing tag rather than failing: a re-run of this workflow + # should be able to finish, and --skip-duplicate above means the pushes + # are already idempotent. + - name: Publish GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.notes.outputs.tag }} + run: | + set -euo pipefail + + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists - nothing to do." + exit 0 + fi + + gh release create "$TAG" \ + --target "$GITHUB_SHA" \ + --title "$TAG" \ + --notes-file release-notes.md + + echo "Created release $TAG." From 678d10583a1c303a8c8d09dcd58a96fc6f599e30 Mon Sep 17 00:00:00 2001 From: Aleksandr Platonenkov Date: Tue, 25 Aug 2026 23:33:44 -0300 Subject: [PATCH 2/2] ci(release): resolve the version and trim notes without the two CI-shaped traps Self-review of the step added in the previous commit. The version came from `sed ... | head -1` under `set -o pipefail`. That works only while Xrpl.csproj holds a single - there is exactly one today, so sed finishes before head closes the pipe. Add a second one and sed takes a SIGPIPE, the pipeline reports 141, and the release stops for a reason that reads like anything but the truth. awk takes the first match and exits. Truncation used head -c. These notes carry non-ASCII characters, so a byte cut can land inside one and hand the API a body that is not valid UTF-8 - a failure in the path taken least often and understood slowest. Now trimmed by whole lines, with LC_ALL=C so awk's length() counts bytes, which is the unit the cap is expressed in. Checked: a 50000-byte budget yields 49082 bytes over 185 lines, and the result decodes as UTF-8. --- .github/workflows/nuget.release.yml | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/.github/workflows/nuget.release.yml b/.github/workflows/nuget.release.yml index 626083d2..e78e2f4b 100644 --- a/.github/workflows/nuget.release.yml +++ b/.github/workflows/nuget.release.yml @@ -25,7 +25,16 @@ jobs: run: | set -euo pipefail - version=$(sed -n 's:.*\(.*\).*:\1:p' Xrpl/Xrpl.csproj | head -1) + # No pipe into head: under `set -o pipefail` that only holds while the + # file has a single , and would start failing the day a + # second one appears. awk takes the first match and exits. + version=$(awk 'match($0, /[^<]+<\/PackageVersion>/) { + s = substr($0, RSTART, RLENGTH) + sub(//, "", s) + sub(/<\/PackageVersion>/, "", s) + print s + exit + }' Xrpl/Xrpl.csproj) if [ -z "$version" ]; then echo "::error file=Xrpl/Xrpl.csproj::No found." exit 1 @@ -52,11 +61,17 @@ jobs: exit 1 fi - # GitHub caps release bodies at 125000 characters and this changelog - # runs long: 11.0.0.0 was 98638. Truncate rather than fail, and say so. + # GitHub caps release bodies at 125000 characters and this changelog runs + # long - 11.0.0.0 came to 98638 - so truncate rather than fail, and say + # so. By whole lines, not bytes: these notes carry non-ASCII characters + # and a byte cut would split one, handing the API a body that is not + # valid UTF-8. LC_ALL=C makes awk's length() count bytes, which is the + # unit the cap is expressed in. limit=120000 if [ "$(wc -c < release-notes.md)" -gt "$limit" ]; then - head -c "$limit" release-notes.md > release-notes.trimmed + LC_ALL=C awk -v limit="$limit" ' + { n += length($0) + 1; if (n > limit) exit; print } + ' release-notes.md > release-notes.trimmed printf '\n\n---\n\n*Truncated at %s characters. The full entry is in [CHANGES.md](https://github.com/%s/blob/release/CHANGES.md).*\n' \ "$limit" "$GITHUB_REPOSITORY" >> release-notes.trimmed mv release-notes.trimmed release-notes.md