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
117 changes: 102 additions & 15 deletions .github/workflows/publish.yml
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:
Expand All @@ -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
Comment on lines +97 to +99

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate changelog before publishing to PyPI

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-publish has 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 👍 / 👎.

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
print
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check changelog content after the header

The fresh issue is that this new -s check counts the extracted header itself as content; when CHANGELOG.md contains the matching ## Release header but no release-note lines (for example a manually added header or otherwise empty release section), .changelog.md is still non-empty, so the workflow can publish to PyPI and create a GitHub Release with no user-facing notes. Validate that there is content after the header instead of checking only file size.

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 }}
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ The project uses setuptools through `pyproject.toml`, `uv` for dependency and lo
- `fit_tool/profile/profile_type.py` and `fit_tool/profile/messages/`: generated profile code.
- `news/`: Towncrier fragments named `<issue>.<type>`.
- `.github/workflows/`: CI and PyPI release workflows.
- `docs/RELEASING.md`: maintainer checklist for tagging and publishing a new version.

## Setup and common commands

Expand Down Expand Up @@ -139,6 +140,12 @@ Write fragments as concise, user-facing descriptions. Do not edit a released sec

Update `README.md` when installation, CLI usage, supported workflows, or runnable examples change. Keep packaging metadata and console entry points in `pyproject.toml` aligned with the code and documentation.

## Releasing

Package version is static in `pyproject.toml` (`[project].version`). Git tags use a `v` prefix (`v0.9.15`) and must match that version (without `v`). Pushing a matching `v*` tag runs the Release workflow (PyPI via Trusted Publishing + GitHub Release from `CHANGELOG.md`).

Full checklist, tag rules, and failure notes: [`docs/RELEASING.md`](docs/RELEASING.md).

## Change discipline

- Keep changes focused and preserve unrelated user modifications in the working tree.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,5 @@ uv run python fit_tool/examples/write_workout_example.py
| [`docs/CAPABILITY_BOUNDARY.md`](docs/CAPABILITY_BOUNDARY.md) | Supported / partial / incomplete matrix; validation & encode details |
| [`docs/FIT_CONFORMANCE_DESIGN.md`](docs/FIT_CONFORMANCE_DESIGN.md) | Target architecture and roadmap |
| [`docs/EPIC_SHA12_RELEASE_NOTES.md`](docs/EPIC_SHA12_RELEASE_NOTES.md) | Protocol-capability epic rollup notes |
| [`docs/RELEASING.md`](docs/RELEASING.md) | Maintainer checklist for PyPI / GitHub releases |
| [`fit_tool/tests/data/README.md`](fit_tool/tests/data/README.md) | Fixture inventory and gap map |
74 changes: 74 additions & 0 deletions docs/RELEASING.md
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)
1 change: 1 addition & 0 deletions news/SHA-36.doc
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).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a numeric Towncrier fragment id

This fragment is named SHA-36.doc, but the repository's Towncrier guidance expects an issue number; with the configured GitHub issue URL format, building the changelog will render a #SHA-36 link to /issues/SHA-36 rather than a valid numeric issue. Please rename the fragment to the corresponding numeric issue id, or change the configured linking scheme first.

AGENTS.md reference: AGENTS.md:L129-L137

Useful? React with 👍 / 👎.

Loading