diff --git a/.github/workflows/nuget.release.yml b/.github/workflows/nuget.release.yml index e0cbb2a4..e78e2f4b 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,72 @@ 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 + + # 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 + 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 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 + 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 + 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 +119,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."