Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 50 additions & 13 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,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
Expand All @@ -109,39 +115,70 @@ jobs:
}
/^## Release/ && found { exit }
found { print }
' CHANGELOG.md > .changelog.md
' 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

- 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
release-notes.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 GitHub Environment / required-reviewer gate (small maintainer set).
permissions:
id-token: write
contents: read

steps:
- name: Download release artifacts
uses: actions/download-artifact@v7.0.0
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.0
with:
name: fit-tool-release
path: .

- name: Create GitHub Release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ github.ref_name }}
body_path: .changelog.md
body_path: release-notes.md
draft: false
prerelease: ${{ steps.ver.outputs.PRERELEASE }}
prerelease: ${{ needs.build.outputs.prerelease }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20 changes: 18 additions & 2 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down Expand Up @@ -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
Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion news/SHA-36.doc
Original file line number Diff line number Diff line change
@@ -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).
Loading