-
Notifications
You must be signed in to change notification settings - Fork 0
fix(release): harden tag-based publish workflow and document process #62
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,12 @@ | ||
| 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). | ||
| on: | ||
| push: | ||
| tags: | ||
| - "*" | ||
| workflow_dispatch: | ||
| - "v*" | ||
|
|
||
| jobs: | ||
| release-pypi: | ||
|
|
@@ -22,39 +24,124 @@ jobs: | |
| with: | ||
| enable-cache: "auto" | ||
|
|
||
| - name: Check prerelease | ||
| id: check_version | ||
| - name: Parse tag and verify package version | ||
| id: ver | ||
| run: | | ||
| if [[ "${{ github.ref }}" =~ ^refs/tags/[0-9.]+$ ]]; then | ||
| echo "PRERELEASE=false" >> $GITHUB_OUTPUT | ||
| set -euo pipefail | ||
| TAG="${GITHUB_REF#refs/tags/}" | ||
|
|
||
| # Stable: v1.2.3 | ||
| if [[ "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]]; then | ||
| TAG_VERSION="${BASH_REMATCH[1]}" | ||
| PRERELEASE=false | ||
| # Prerelease (PEP 440-ish): v1.2.3a1, v1.2.3b2, v1.2.3rc1, v1.2.3.dev0, … | ||
| elif [[ "$TAG" =~ ^v([0-9]+\.[0-9]+\.[0-9]+((a|b|rc|alpha|beta)[0-9]*|\.dev[0-9]+))$ ]]; then | ||
| TAG_VERSION="${BASH_REMATCH[1]}" | ||
| PRERELEASE=true | ||
| else | ||
| echo "PRERELEASE=true" >> $GITHUB_OUTPUT | ||
| echo "Unsupported tag format: $TAG" >&2 | ||
| echo "Expected vX.Y.Z or a PEP 440 prerelease with a v prefix (e.g. v0.9.16a1)." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| PKG_VERSION=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])") | ||
| if [[ "$PKG_VERSION" != "$TAG_VERSION" ]]; then | ||
| echo "Version mismatch: tag=$TAG_VERSION pyproject=$PKG_VERSION" >&2 | ||
| echo "Bump pyproject.toml [project].version to match the tag before releasing." >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| { | ||
| echo "TAG_VERSION=$TAG_VERSION" | ||
| echo "PRERELEASE=$PRERELEASE" | ||
| } >> "$GITHUB_OUTPUT" | ||
| echo "tag=$TAG version=$TAG_VERSION prerelease=$PRERELEASE" | ||
|
|
||
| - name: Build package | ||
| run: uv build | ||
|
|
||
| - name: Assert dist version matches tag | ||
| run: | | ||
| set -euo pipefail | ||
| TAG_VERSION="${{ steps.ver.outputs.TAG_VERSION }}" | ||
| shopt -s nullglob | ||
| wheels=(dist/*.whl) | ||
| sdists=(dist/*.tar.gz) | ||
| if [[ ${#wheels[@]} -eq 0 ]]; then | ||
| echo "No wheel found under dist/" >&2 | ||
| exit 1 | ||
| fi | ||
| for path in "${wheels[@]}"; do | ||
| base=$(basename "$path") | ||
| if [[ ! "$base" =~ ^fit_tool-(.+)-py[0-9]+-none-any\.whl$ ]]; then | ||
| echo "Unexpected wheel name: $base" >&2 | ||
| exit 1 | ||
| fi | ||
| if [[ "${BASH_REMATCH[1]}" != "$TAG_VERSION" ]]; then | ||
| echo "Wheel version ${BASH_REMATCH[1]} != tag version $TAG_VERSION" >&2 | ||
| exit 1 | ||
| fi | ||
| done | ||
| for path in "${sdists[@]}"; do | ||
| base=$(basename "$path") | ||
| if [[ ! "$base" =~ ^fit[_-]tool-(.+)\.tar\.gz$ ]]; then | ||
| echo "Unexpected sdist name: $base" >&2 | ||
| exit 1 | ||
| fi | ||
| if [[ "${BASH_REMATCH[1]}" != "$TAG_VERSION" ]]; then | ||
| echo "Sdist version ${BASH_REMATCH[1]} != tag version $TAG_VERSION" >&2 | ||
| exit 1 | ||
| fi | ||
| done | ||
|
|
||
| - name: Extract changelog for this version | ||
| run: | | ||
| set -euo pipefail | ||
| 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. | ||
| awk -v ver="$TAG_VERSION" ' | ||
| $0 ~ ("^## Release v" ver " ") { | ||
| if (found) exit | ||
| found = 1 | ||
| next | ||
| } | ||
| /^## Release/ && found { exit } | ||
| found { print } | ||
| ' CHANGELOG.md > .changelog.md | ||
|
|
||
| if ! grep -Eq "^## Release v${TAG_VERSION} " .changelog.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 | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The fresh issue is that this new AGENTS.md reference: AGENTS.md:L129-L139 Useful? React with 👍 / 👎. |
||
| echo "Extracted changelog is empty" >&2 | ||
| exit 1 | ||
| fi | ||
|
|
||
| - name: Upload artifacts | ||
| uses: actions/upload-artifact@v7.0.1 | ||
| with: | ||
| name: fit-tool-wheel | ||
| path: dist/*.whl | ||
| path: | | ||
| dist/*.whl | ||
| dist/*.tar.gz | ||
| 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. | ||
| - name: Publish to PyPI | ||
| uses: pypa/gh-action-pypi-publish@release/v1 | ||
|
|
||
| - name: Get Changelog | ||
| id: get-changelog | ||
| run: | | ||
| awk '/## Release/{if (flag==1)exit;else;flag=1;next} flag' CHANGELOG.md > .changelog.md | ||
|
|
||
| - name: Create Release | ||
| - name: Create GitHub Release | ||
| uses: softprops/action-gh-release@v3 | ||
| with: | ||
| tag_name: ${{ github.ref_name }} | ||
| body_path: .changelog.md | ||
| draft: false | ||
| prerelease: ${{ steps.check_version.outputs.PRERELEASE }} | ||
| prerelease: ${{ steps.ver.outputs.PRERELEASE }} | ||
| env: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,74 @@ | ||
| # Releasing `fit-tool` | ||
|
|
||
| This document is the checklist for publishing a new version to PyPI and GitHub | ||
| Releases. Day-to-day contribution rules live in [`AGENTS.md`](../AGENTS.md). | ||
|
|
||
| ## How release works | ||
|
|
||
| | Piece | Role | | ||
| | --- | --- | | ||
| | `pyproject.toml` → `[project].version` | **Source of truth** for the package version (wheel / sdist / PyPI) | | ||
| | Git tag `vX.Y.Z` | Triggers [`.github/workflows/publish.yml`](../.github/workflows/publish.yml) | | ||
| | `news/*` + Towncrier | Builds the new section in `CHANGELOG.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 workflow **does not** rewrite the package version from the tag. It only | ||
| checks that they match. If they differ, the job fails before publish. | ||
|
|
||
| **Tag rules** | ||
|
|
||
| - Stable: `v0.9.16` (must match `version = "0.9.16"` in `pyproject.toml`) | ||
| - Prerelease: `v0.9.16a1`, `v0.9.16rc1`, `v0.9.16.dev0` (same string without `v` in `pyproject.toml`) | ||
| - Do not use bare `0.9.16` or legacy `version/0.9.x` tags for new releases | ||
|
|
||
| ## Checklist | ||
|
|
||
| 1. **`main` is green** — CI (Python 3.9–3.14 + Garmin JS interop) passes on the commit you will tag. | ||
| 2. **News fragments are ready** — user-visible changes have `news/<id>.<type>` files (see `AGENTS.md`). Do not hand-edit an already-released section of `CHANGELOG.md`. | ||
| 3. **Choose the version** — e.g. `0.9.16` (patch) or `0.10.0` (broader capability signal). PyPI will not accept re-uploading an existing version. | ||
| 4. **Bump the package version** in `pyproject.toml`: | ||
| ```toml | ||
| version = "0.9.16" | ||
| ``` | ||
| 5. **Build the changelog**: | ||
| ```bash | ||
| uv run towncrier build --version 0.9.16 | ||
| # optional dry-run first: | ||
| uv run towncrier build --version 0.9.16 --draft | ||
| ``` | ||
| This prepends `## Release v0.9.16 (YYYY-MM-DD)` to `CHANGELOG.md` and removes consumed `news/*` fragments (keep `news/.gitkeep`). | ||
| 6. **Review** the new `CHANGELOG.md` section and the diff (version + changelog + deleted news files only, unless the release intentionally includes other commits already on `main`). | ||
| 7. **Commit and merge to `main`** (via PR if that is your normal process). | ||
| 8. **Tag and push the tag** on the release commit: | ||
| ```bash | ||
| git checkout main | ||
| git pull | ||
| 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.) | ||
| 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 | ||
| - GitHub Release page body matches the changelog section and is not marked Pre-release for stable tags | ||
|
|
||
| ## What the workflow rejects | ||
|
|
||
| - Tags that are not `v…` | ||
| - Tag version ≠ `pyproject.toml` version | ||
| - Built wheel/sdist version ≠ tag version | ||
| - Missing `## Release vX.Y.Z` section in `CHANGELOG.md` | ||
| - Empty extracted changelog body | ||
|
|
||
| ## 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. | ||
| - **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. | ||
|
|
||
| ## Related docs | ||
|
|
||
| - [`AGENTS.md`](../AGENTS.md) — news fragment types and agent constraints | ||
| - [`CHANGELOG.md`](../CHANGELOG.md) — published history | ||
| - [`EPIC_SHA12_RELEASE_NOTES.md`](EPIC_SHA12_RELEASE_NOTES.md) — narrative rollup for the protocol epic (not a substitute for Towncrier) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| Document the release checklist and harden the tag-based PyPI/GitHub Release workflow (version gate, ``v``-prefix tags, changelog extraction). | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This fragment is named AGENTS.md reference: AGENTS.md:L129-L137 Useful? React with 👍 / 👎. |
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a maintainer tags a matching version but forgets to build the Towncrier section, the missing/empty changelog checks in this step fail only after
pypa/gh-action-pypi-publishhas already uploaded immutable files. That leaves a partial release and a normal rerun will hit PyPI's duplicate version/file rejection, so the changelog extraction and validation need to run before the PyPI publish step.Useful? React with 👍 / 👎.