From ca8bed5d90f759e7d739ffed49b777c39d169f65 Mon Sep 17 00:00:00 2001 From: "cmeans-claude-dev[bot]" <3223881+cmeans-claude-dev[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 17:08:15 -0500 Subject: [PATCH] Make publish.yml github-release job idempotent (fixes #12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related problems addressed together per Option C in #12: 1. Not idempotent ----------------- The old github-release step ran ``gh release create`` unconditionally. If a Release for the tag already existed (e.g. pre-created with hand-written notes before the workflow ran), the step failed with HTTP 422 and the entire workflow was marked failed — even though the PyPI publish job had already succeeded. This is exactly what happened on the v0.5.0 publish (run 24263840766). The job now checks for an existing Release with ``gh release view`` and uses ``gh release edit`` to update it in place instead of failing. Create flow handles the no-existing-release case. Net: the job is a no-op in content terms when the release already exists with the same notes, and a silent content update when it exists with different notes. 2. Low-quality auto-generated notes ----------------------------------- The old step used ``--generate-notes``, which produces a plain commit list. That's significantly worse than hand-written CHANGELOG entries and created the incentive to pre-create releases manually, which in turn triggered problem #1. The new step reads the Release body from CHANGELOG.md directly. A small awk block extracts the section between the current ``## `` heading (skipped to avoid duplicating the title) and the next ``## `` heading. CHANGELOG.md is already the authoritative release narrative, so there's no duplication and no quality tradeoff. If the CHANGELOG has no matching entry (e.g. an emergency tag without doc prep), the step falls back to ``--generate-notes`` rather than creating an empty release body. A ::warning:: annotation surfaces the fallback in the workflow UI. Verification performed locally on CHANGELOG.md at HEAD: - awk extraction for v0.5.0 returns 34 lines of content starting with ``### Changed``, ending at the next ``## `` header. - Extraction for a nonexistent version produces an empty file, triggering the fallback path. - ``python3 -c 'import yaml; yaml.safe_load(...)'`` confirms the workflow file parses cleanly. The fix cannot be fully end-to-end tested without a tag push, so the next real release (or a manual workflow re-dispatch against an existing tag) will be the first full exercise of both the create and edit paths. The failure mode if something is wrong is loud (workflow red) and the PyPI publish step is independent and already succeeded before this step runs, so there's no risk to the actual package distribution. Closes #12 Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/publish.yml | 55 ++++++++++++++++++++++++++++++++--- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 571fea8..7fd4141 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -58,10 +58,57 @@ jobs: with: fetch-depth: 0 - - name: Create GitHub Release + - name: Extract release notes from CHANGELOG + id: notes + env: + TAG: ${{ github.ref_name }} + run: | + # CHANGELOG.md is the authoritative release narrative. Extract the + # section for the current tag and use it as the Release body so we + # don't duplicate content or rely on low-quality auto-generated + # commit lists. Falls back to --generate-notes if the CHANGELOG + # has no matching entry (e.g. an emergency tag without doc prep). + VERSION="${TAG#v}" + awk -v version="$VERSION" ' + $0 ~ "^## "version"( |\\()" {flag=1; next} + flag && /^## / {exit} + flag + ' CHANGELOG.md > release_notes.md + + # Strip leading blank lines so the body does not start with an + # empty paragraph. + sed -i '/./,$!d' release_notes.md + + if [ -s release_notes.md ]; then + echo "Found CHANGELOG entry for $VERSION ($(wc -l < release_notes.md) lines)" + echo "use_changelog=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::No CHANGELOG entry for $VERSION — falling back to auto-generated notes" + echo "use_changelog=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create or update GitHub Release env: GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} + USE_CHANGELOG: ${{ steps.notes.outputs.use_changelog }} run: | - gh release create "${{ github.ref_name }}" \ - --title "${{ github.ref_name }}" \ - --generate-notes + # Idempotent: if a release for the tag already exists (e.g. it was + # hand-crafted before the publish workflow ran), update its notes + # in place with `gh release edit` rather than failing with a 422. + if gh release view "$TAG" >/dev/null 2>&1; then + echo "Release $TAG already exists — updating notes in place" + if [ "$USE_CHANGELOG" = "true" ]; then + gh release edit "$TAG" --title "$TAG" --notes-file release_notes.md + else + # Cannot regenerate auto notes on edit — leave existing body. + gh release edit "$TAG" --title "$TAG" + fi + else + echo "Creating new release $TAG" + if [ "$USE_CHANGELOG" = "true" ]; then + gh release create "$TAG" --title "$TAG" --notes-file release_notes.md + else + gh release create "$TAG" --title "$TAG" --generate-notes + fi + fi