From 5640c5b77757a94f56a82425563b1fe7295e9ca1 Mon Sep 17 00:00:00 2001 From: Craig McChesney Date: Wed, 16 Sep 2026 10:30:14 -0600 Subject: [PATCH] ci: publish the release notes as the GitHub release body Releases carried only the artifact verification and install instructions, so the hand-written notes were not linked from the release page. Adopt dp-grpc's convention: one version-controlled document per release under doc/release-notes/rel-X.Y.Z.md, published via body_path. Two things differ from dp-grpc's single-job workflow and shape this change: action-gh-release treats `body_path` as an override of `body`, not a companion -- releaseBody() returns the file contents and falls back to `body` only if the read fails. Adding body_path beside the existing `body` would therefore have silently dropped the verification and install instructions from every release. They are concatenated with the notes instead, into dist/RELEASE_BODY.md. The publish job downloads artifacts but never checks out the repo, so a path under doc/ does not exist there. The body is assembled in the build job, which does check out, and rides along in the dist/ upload. It is excluded from the release `files` list (it is the body, not a download) and from the PyPI job's upload set, which would otherwise reject it. generate_release_notes still composes on top: the action appends its generated commit list after whatever body it resolves. The notes check runs before the build rather than being left to action-gh-release, which fails on a missing body_path only after everything has been built, signed, and uploaded -- a late failure that reads as unrelated to its cause. A workflow_dispatch rehearsal has no rel- tag to derive a filename from and so skips the check. Verified by extracting both step scripts from the YAML and running them: the assembled body renders correctly (heredoc indentation stripped, backslash continuations and the cert-identity interpolation intact), and the check passes with notes present and fails with the actionable message without them. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0151QFCHYMNBgEVNGLTgsoFL --- .github/workflows/release.yml | 95 +++++++++++++++++++++++++---------- CLAUDE.md | 17 +++++++ 2 files changed, 86 insertions(+), 26 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 90e8807..b9e7208 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,6 +73,24 @@ jobs: echo "Manual dispatch; version comes from setuptools-scm." fi + - name: Verify release notes exist + id: notes + # Checked here, before the build, rather than left to action-gh-release: that action + # fails on a missing body_path only after everything has been built, signed, and + # uploaded, which reads as a late and unrelated failure. A rehearsal (workflow_dispatch) + # has no rel- tag and so no notes to look for, hence the push-only guard. + # Every release from 1.16.0 on ships notes; see CLAUDE.md "Cutting a release". + if: github.event_name == 'push' + run: | + set -euo pipefail + NOTES="doc/release-notes/${GITHUB_REF_NAME}.md" + if [[ ! -f "$NOTES" ]]; then + echo "::error::Release notes not found at $NOTES." + echo "::error::Add the notes for ${{ steps.version.outputs.version }} and retag once they are on the tagged commit." + exit 1 + fi + echo "notes=$NOTES" >> "$GITHUB_OUTPUT" + - name: Build wheel and sdist run: | python -m pip install --upgrade pip build twine @@ -126,6 +144,49 @@ jobs: with: inputs: ./dist/*.whl ./dist/*.tar.gz ./dist/SHA256SUMS + - name: Assemble the release body + # The publish job downloads artifacts but never checks out the repo, so the notes + # have to travel with the dist/ upload. They are concatenated with the verification + # and install instructions here rather than passed to action-gh-release as `body`, + # because `body_path` takes precedence over `body` outright (it is a fallback, not a + # companion) -- setting both would silently drop these instructions from the release. + # action-gh-release appends its generated commit list after whatever body it resolves, + # so generate_release_notes still composes on top of this. + if: github.event_name == 'push' + env: + NOTES: ${{ steps.notes.outputs.notes }} + run: | + set -euo pipefail + { + cat "$NOTES" + cat <<'BODY' + + ## Verifying these artifacts + + Checksums: + + ```bash + sha256sum -c SHA256SUMS + ``` + + Signatures (keyless Sigstore; `pip install sigstore`): + + ```bash + sigstore verify identity \ + --cert-identity "https://github.com/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }}" \ + --cert-oidc-issuer "https://token.actions.githubusercontent.com" \ + dp_python_lib-*.whl + ``` + + ## Installing + + ```bash + pip install dp_python_lib-*.whl + ``` + BODY + } > dist/RELEASE_BODY.md + cat dist/RELEASE_BODY.md + - name: Upload build outputs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -163,29 +224,11 @@ jobs: dist/*.sigstore.json generate_release_notes: true fail_on_unmatched_files: true - body: | - ## Verifying these artifacts - - Checksums: - - ```bash - sha256sum -c SHA256SUMS - ``` - - Signatures (keyless Sigstore; `pip install sigstore`): - - ```bash - sigstore verify identity \ - --cert-identity "https://github.com/${{ github.repository }}/.github/workflows/release.yml@${{ github.ref }}" \ - --cert-oidc-issuer "https://token.actions.githubusercontent.com" \ - dp_python_lib-*.whl - ``` - - ## Installing - - ```bash - pip install dp_python_lib-*.whl - ``` + # Assembled in the build job: the hand-written doc/release-notes/rel-X.Y.Z.md + # followed by the verification and install instructions. RELEASE_BODY.md is + # deliberately absent from `files` above -- it is the release body, not an + # artifact to download. + body_path: dist/RELEASE_BODY.md # --------------------------------------------------------------------------------------- # PyPI publishing -- WIRED UP BUT INTENTIONALLY DISABLED. @@ -221,9 +264,9 @@ jobs: path: dist - name: Remove non-distribution files - # Only sdists and wheels may be uploaded; the checksums file and Sigstore bundles - # would be rejected. - run: rm -f dist/SHA256SUMS dist/*.sigstore.json + # Only sdists and wheels may be uploaded; the checksums file, the assembled release + # body, and the Sigstore bundles would all be rejected. + run: rm -f dist/SHA256SUMS dist/RELEASE_BODY.md dist/*.sigstore.json - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 diff --git a/CLAUDE.md b/CLAUDE.md index d5d5980..5f3fbcc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -85,6 +85,23 @@ The tag must be exactly `rel-X.Y.Z` with no suffix — prerelease shapes like `rel-1.15.0-rc1` are rejected up front, because setuptools-scm would normalize them (`1.15.0rc1`) and fail the tag-vs-built version assertion with a confusing error. +**Release notes** are version-controlled under `doc/release-notes/`, one document per +release (`rel-X.Y.Z.md`), starting with 1.16.0; the same convention as dp-grpc. Organize +a note by issue ticket rather than by PR, since a ticket often spans several PRs, and lead +a breaking release with an "Upgrading from " checklist that separates silent +behavior changes from outright errors. + +`release.yml` publishes the document as the GitHub release body: the build job concatenates +it with the artifact verification and install instructions into `dist/RELEASE_BODY.md`, +which the publish job passes as `body_path` (that job never checks out the repo, so the +notes travel with the `dist/` upload). Note `body_path` *overrides* `body` rather than +complementing it, so those instructions belong in the assembled file, not in a `body:` input. +GitHub's own generated commit list is appended after all of it. + +The build job fails early — before building — if `doc/release-notes/.md` is missing on +the tagged commit, so **write the notes and merge them before pushing the `rel-*` tag**. A +`workflow_dispatch` rehearsal skips the check, having no tag to derive a filename from. + ### Dependencies Core dependencies are managed in `pyproject.toml`: - `grpcio` - gRPC runtime