From 52165397e5f1de1608d000267d4b32084b8249c1 Mon Sep 17 00:00:00 2001 From: Nathan Schepers <3958483+nathanschepers@users.noreply.github.com> Date: Thu, 23 Jul 2026 16:03:42 +0300 Subject: [PATCH] release.yml: create a GitHub Release from the CHANGELOG section on tag push Add an unconditional release job that runs after the publish jobs succeed. It extracts the section for the released version from CHANGELOG.md (tags carry a leading v, headings do not: v0.1.0 -> ## 0.1.0) and runs gh release create with those lines as the notes. A missing section or a missing CHANGELOG.md fails the job. The job needs whichever build and publish jobs the answer set produces (wheels+sdist or build, plus publish and/or image), so no release object is created when a publish job fails. It is the only job with contents: write; the workflow default stays contents: read. Scaffold template/CHANGELOG.md.jinja with an initial ## 0.0.1.dev0 section matching the pyproject version so a generated project passes the gate on its first release. Document the release-object step in README.md and SETUP.md.jinja. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++- template/.github/workflows/release.yml.jinja | 31 ++++++++++++++++++++ template/CHANGELOG.md.jinja | 5 ++++ template/SETUP.md.jinja | 11 ++++--- 4 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 template/CHANGELOG.md.jinja diff --git a/README.md b/README.md index a4df7e7..205509e 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,9 @@ Includes: - **GitHub Actions CI** — lint / types / docs / test matrix (`python_min` through 3.14) - **Release pipeline** — PyPI via Trusted Publishing; projects with a Dockerfile (web services, currently) also push a multi-arch image to GHCR. No tokens in either path — Trusted Publishing - and `GITHUB_TOKEN` respectively. + and `GITHUB_TOKEN` respectively. Once the publish jobs succeed, a tag push creates a GitHub + Release with notes from the matching `## ` section of `CHANGELOG.md`; the release fails + if that section is missing. - **Dependabot** for actions and dependencies - A generated **SETUP.md** — the go-public checklist (branch protection, RTD import, Trusted Publisher registration) diff --git a/template/.github/workflows/release.yml.jinja b/template/.github/workflows/release.yml.jinja index 5b46dfc..af0a938 100644 --- a/template/.github/workflows/release.yml.jinja +++ b/template/.github/workflows/release.yml.jinja @@ -123,3 +123,34 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} [% endif %] + release: + needs: [ [% if native_extension %]wheels, sdist[% else %]build[% endif %], check-version[% if publish_to_pypi %], publish[% endif %][% if web_service %], image[% endif %] ] + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + runs-on: ubuntu-latest + permissions: + contents: write + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7 + - name: Create GitHub release from CHANGELOG + run: | + set -eu + version="${GITHUB_REF_NAME#v}" + if [ ! -f CHANGELOG.md ]; then + echo "::error::CHANGELOG.md not found" + exit 1 + fi + notes="$(mktemp)" + awk -v want="$version" ' + /^## / { + if (found) { exit } + if ($2 == want) { found = 1; next } + } + found { print } + END { if (!found) { exit 1 } } + ' CHANGELOG.md > "$notes" || { + echo "::error::CHANGELOG.md has no section for $version" + exit 1 + } + gh release create "$GITHUB_REF_NAME" --verify-tag --title "$GITHUB_REF_NAME" --notes-file "$notes" diff --git a/template/CHANGELOG.md.jinja b/template/CHANGELOG.md.jinja new file mode 100644 index 0000000..69609bf --- /dev/null +++ b/template/CHANGELOG.md.jinja @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.1.dev0 + +- Initial project scaffold. diff --git a/template/SETUP.md.jinja b/template/SETUP.md.jinja index 8d09a4f..52037b8 100644 --- a/template/SETUP.md.jinja +++ b/template/SETUP.md.jinja @@ -51,15 +51,18 @@ Then create the GitHub environment: `gh api -X PUT repos/[[ github_org ]]/[[ rep ## 4. Release ``` -# bump pyproject version, THEN regenerate the lock (uv sync --locked fails otherwise): +# bump pyproject version and add a matching `## ` section to CHANGELOG.md, +# THEN regenerate the lock (uv sync --locked fails otherwise): uv lock git commit -am "Release 0.1.0" && git push # via PR if main is protected git tag -a v0.1.0 -m "0.1.0" && git push origin v0.1.0 ``` -`release.yml` runs check-version → build[% if native_extension %]/wheels[% endif %] → publish → smoke. A version is -permanent on PyPI once uploaded; re-pushing the same tag only works for *pre-upload* failures -(`skip-existing` covers a partial upload). README/example edits never need a re-release. +`release.yml` runs check-version → build[% if native_extension %]/wheels[% endif %] → publish → smoke → release. The release +job extracts the `## 0.1.0` section from CHANGELOG.md for the GitHub Release notes and fails if +that section is missing. A version is permanent on PyPI once uploaded; re-pushing the same tag +only works for *pre-upload* failures (`skip-existing` covers a partial upload). README/example +edits never need a re-release. [% endif %]## [[ '5' if publish_to_pypi else '3' ]]. Read the Docs