From e80eb9a023cf7c8826504e01864c07e2ecd396b8 Mon Sep 17 00:00:00 2001 From: friendsa Date: Wed, 5 Aug 2026 03:09:30 +0000 Subject: [PATCH 1/5] ci(release): split publish workflow into build, publish, and release jobs Separate PyPI upload from GitHub Release so a failed release step can be re-run without re-uploading to PyPI. No Environment approval gate. Co-authored-by: multica-agent --- .github/workflows/publish.yml | 54 +++++++++++++++++++++++++++++------ docs/RELEASING.md | 20 +++++++++++-- news/SHA-36.doc | 2 +- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 94cb58a..a03b3d7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -3,18 +3,23 @@ name: Release # Formal releases are tag-only. Tags must be vX.Y.Z (stable) or a PEP 440 # prerelease with a v prefix (e.g. v0.9.16a1). The package version in # pyproject.toml is the source of truth and must match the tag (without v). +# +# Jobs are split so a failed GitHub Release can be re-run without re-uploading +# to PyPI. No GitHub Environment / approval gate (small maintainer set). on: push: tags: - "v*" jobs: - release-pypi: - name: Release to PyPI + build: + name: Build and validate runs-on: ubuntu-latest permissions: - id-token: write - contents: write + contents: read + outputs: + tag_version: ${{ steps.ver.outputs.TAG_VERSION }} + prerelease: ${{ steps.ver.outputs.PRERELEASE }} steps: - uses: actions/checkout@v7.0.1 @@ -121,20 +126,51 @@ jobs: exit 1 fi - - name: Upload artifacts + - name: Upload release artifacts uses: actions/upload-artifact@v7.0.1 with: - name: fit-tool-wheel + name: fit-tool-release path: | dist/*.whl dist/*.tar.gz + .changelog.md if-no-files-found: error retention-days: 15 - # Publish only after version + changelog gates pass so a missing - # Towncrier section never leaves an orphan PyPI upload. + publish: + name: Publish to PyPI + needs: build + runs-on: ubuntu-latest + # No environment: approval gate intentionally omitted for a small maintainer set. + permissions: + id-token: write + contents: read + + steps: + - name: Download release artifacts + uses: actions/download-artifact@v7.0.1 + with: + name: fit-tool-release + path: . + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + with: + packages-dir: dist/ + + github-release: + name: Create GitHub Release + needs: [build, publish] + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Download release artifacts + uses: actions/download-artifact@v7.0.1 + with: + name: fit-tool-release + path: . - name: Create GitHub Release uses: softprops/action-gh-release@v3 @@ -142,6 +178,6 @@ jobs: tag_name: ${{ github.ref_name }} body_path: .changelog.md draft: false - prerelease: ${{ steps.ver.outputs.PRERELEASE }} + prerelease: ${{ needs.build.outputs.prerelease }} env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 638d53d..c72ca81 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -13,9 +13,19 @@ Releases. Day-to-day contribution rules live in [`AGENTS.md`](../AGENTS.md). | PyPI | Trusted Publishing (OIDC); no long-lived API token in the repo | | GitHub Release | Created by the same workflow; body = the matching `CHANGELOG.md` section | +The Release workflow is **three jobs** (no approval Environment): + +1. **`build`** — tag / `pyproject` gate, `uv build`, dist version check, changelog extraction, upload artifact +2. **`publish`** — download artifact → upload to PyPI +3. **`github-release`** — download artifact → create GitHub Release + The workflow **does not** rewrite the package version from the tag. It only checks that they match. If they differ, the job fails before publish. +There is **no** GitHub Environment required-reviewer gate: push of a valid `v*` +tag runs the full pipeline automatically (appropriate for a small maintainer +set). + **Tag rules** - Stable: `v0.9.16` (must match `version = "0.9.16"` in `pyproject.toml`) @@ -47,7 +57,11 @@ checks that they match. If they differ, the job fails before publish. git tag v0.9.16 git push origin v0.9.16 ``` -9. **Watch** Actions → workflow **Release**. Expect: version gate → `uv build` → dist version check → **changelog section gate** → PyPI publish → GitHub Release with changelog body and `prerelease=false` for stable tags. (Changelog is validated *before* PyPI so a missing Towncrier section cannot leave an orphan upload.) +9. **Watch** Actions → workflow **Release**: + - `build` → version / dist / changelog gates + - `publish` → PyPI + - `github-release` → GitHub Release (`prerelease=false` for stable tags) + Changelog is validated in `build` *before* PyPI so a missing Towncrier section cannot leave an orphan upload. 10. **Verify**: - https://pypi.org/project/fit-tool/ shows the new version - `pip install fit-tool==X.Y.Z` (or `uv add fit-tool==X.Y.Z`) works @@ -63,7 +77,9 @@ checks that they match. If they differ, the job fails before publish. ## Failure notes -- **PyPI succeeded, GitHub Release failed**: do **not** re-run the whole job blindly (PyPI will reject the same files). Create or edit the GitHub Release for that tag and paste the matching changelog section, or fix the Release step and re-run only that step if the workflow is later split. +- **`build` failed**: fix the tag, `pyproject.toml` version, or changelog; push a corrected tag/commit as needed. Nothing was uploaded to PyPI. +- **`publish` failed**: inspect the job log (Trusted Publishing / network). After a successful fix, use **Re-run failed jobs** so only `publish` (and then `github-release`) re-run. +- **`publish` succeeded, `github-release` failed**: use **Re-run failed jobs** (re-runs only `github-release`). Do **not** use **Re-run all jobs** — that would re-enter `publish` and hit PyPI duplicate-file errors for the same version. - **Version already on PyPI**: bump to a new version; never try to overwrite. - **Never** push a release tag or publish to PyPI from an automated agent unless a human explicitly requested that release. diff --git a/news/SHA-36.doc b/news/SHA-36.doc index 1ac1015..f93a3b2 100644 --- a/news/SHA-36.doc +++ b/news/SHA-36.doc @@ -1 +1 @@ -Document the release checklist and harden the tag-based PyPI/GitHub Release workflow (version gate, ``v``-prefix tags, changelog extraction). +Document the release checklist and harden the tag-based PyPI/GitHub Release workflow (version gate, v-prefix tags, changelog extraction, split build/publish/github-release jobs). From bce61958e31de98a2679a7061173e6e1f7a04473 Mon Sep 17 00:00:00 2001 From: friendsa Date: Wed, 5 Aug 2026 03:10:04 +0000 Subject: [PATCH 2/5] ci(release): clarify no Environment gate comment Co-authored-by: multica-agent --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a03b3d7..75557e1 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -141,7 +141,7 @@ jobs: name: Publish to PyPI needs: build runs-on: ubuntu-latest - # No environment: approval gate intentionally omitted for a small maintainer set. + # No GitHub Environment / required-reviewer gate (small maintainer set). permissions: id-token: write contents: read From dd5c0c706f5a09a5441ea318e6924779d9c23b0d Mon Sep 17 00:00:00 2001 From: friendsa Date: Wed, 5 Aug 2026 03:12:16 +0000 Subject: [PATCH 3/5] fix(release): ship release-notes.md as a non-hidden artifact upload-artifact ignores dotfiles by default, so .changelog.md never reached the github-release job. Use release-notes.md instead. Co-authored-by: multica-agent --- .github/workflows/publish.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 75557e1..dd5120b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -114,14 +114,15 @@ jobs: } /^## Release/ && found { exit } found { print } - ' CHANGELOG.md > .changelog.md + # Non-dotfile name: upload-artifact ignores hidden files by default. + ' CHANGELOG.md > release-notes.md - if ! grep -Eq "^## Release v${TAG_VERSION} " .changelog.md; then + if ! grep -Eq "^## Release v${TAG_VERSION} " release-notes.md; then echo "CHANGELOG.md has no section header matching '## Release v${TAG_VERSION} …'" >&2 echo "Run: uv run towncrier build --version ${TAG_VERSION}" >&2 exit 1 fi - if [[ ! -s .changelog.md ]]; then + if [[ ! -s release-notes.md ]]; then echo "Extracted changelog is empty" >&2 exit 1 fi @@ -133,7 +134,7 @@ jobs: path: | dist/*.whl dist/*.tar.gz - .changelog.md + release-notes.md if-no-files-found: error retention-days: 15 @@ -176,7 +177,7 @@ jobs: uses: softprops/action-gh-release@v3 with: tag_name: ${{ github.ref_name }} - body_path: .changelog.md + body_path: release-notes.md draft: false prerelease: ${{ needs.build.outputs.prerelease }} env: From 20d605884b90d5ca2cc3dcd37cbffa93818f8a8a Mon Sep 17 00:00:00 2001 From: friendsa Date: Wed, 5 Aug 2026 03:12:30 +0000 Subject: [PATCH 4/5] fix(release): keep shell comment outside awk program Co-authored-by: multica-agent --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index dd5120b..b0eec9e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -105,6 +105,7 @@ jobs: TAG_VERSION="${{ steps.ver.outputs.TAG_VERSION }}" # Match Towncrier headers exactly: "## Release v{version} ({date})". # Require a space after the version so v0.9.1 does not match v0.9.15. + # Non-dotfile name: upload-artifact ignores hidden files by default. awk -v ver="$TAG_VERSION" ' $0 ~ ("^## Release v" ver " ") { if (found) exit @@ -114,7 +115,6 @@ jobs: } /^## Release/ && found { exit } found { print } - # Non-dotfile name: upload-artifact ignores hidden files by default. ' CHANGELOG.md > release-notes.md if ! grep -Eq "^## Release v${TAG_VERSION} " release-notes.md; then From 518a1d2420f30a9df547284863e9d6a4ec506552 Mon Sep 17 00:00:00 2001 From: friendsa Date: Wed, 5 Aug 2026 03:23:57 +0000 Subject: [PATCH 5/5] fix(release): pin download-artifact to an existing v7.0.0 tag actions/download-artifact has no v7.0.1 release (404). Use v7.0.0 so the publish and github-release jobs can resolve the action after build. Co-authored-by: multica-agent --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index b0eec9e..100ee2a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -149,7 +149,7 @@ jobs: steps: - name: Download release artifacts - uses: actions/download-artifact@v7.0.1 + uses: actions/download-artifact@v7.0.0 with: name: fit-tool-release path: . @@ -168,7 +168,7 @@ jobs: steps: - name: Download release artifacts - uses: actions/download-artifact@v7.0.1 + uses: actions/download-artifact@v7.0.0 with: name: fit-tool-release path: .