Skip to content

Commit 28a2a9c

Browse files
MarTrepodiclaude
andauthored
ci: switch to tag-driven releases via hatch-vcs (#97)
## Problem The release workflow has **never succeeded**. It bumps the version in `src/swgoh_comlink/version.py`, commits it, and runs `git push origin main --follow-tags`. Branch protection on `main` rejects that push: ``` remote: error: GH006: Protected branch update failed for refs/heads/main. - Changes must be made through a pull request. - 10 of 10 required status checks are expected. ! [remote rejected] main -> main (protected branch hook declined) ``` With `enforce_admins: true` and PRs required, **nothing** can push a commit directly to `main`. Worse, `--follow-tags` succeeds in pushing the *tag* even when the branch push is rejected, so each failed run leaves an **orphaned tag** pointing at a bump commit that never landed (this happened today with `v2.1.0`; I deleted it). ## Fix: tag-driven versioning Derive the version from the Git tag instead of committing it, so the release never touches `main`. - **`pyproject.toml`** — use `hatch-vcs` as the version source; write the resolved value to a git-ignored `_version.py` via the vcs build hook. Removed the dead `[tool.semantic_release]` block (it referenced the old `version.py` mechanism). - **`version.py`** — read `__version__` from the generated `_version.py`, falling back to `importlib.metadata`, then a sentinel for unbuilt trees. - **`.gitignore`** — ignore the generated `_version.py`. - **`release.yml`** — rewritten as **build → publish → tag**: 1. Compute the next version from the latest tag + a `bump` input (`patch`/`minor`/`major`). 2. Build with `SETUPTOOLS_SCM_PRETEND_VERSION` so no tag is needed yet. 3. Publish to PyPI via trusted publishing. 4. **Only after a successful publish**, create the tag + GitHub Release (`gh release create --target <sha> --generate-notes`). A failed run therefore never leaves an orphaned tag, and no step pushes to `main`. - **`CONTRIBUTING.md`** — documents the new process. ## Behavior change The repo no longer carries a committed version string, and `CHANGELOG.md` is no longer regenerated into the repo on release — release notes are auto-generated into the **GitHub Release body** instead. The existing `CHANGELOG.md` remains as historical record. ## Verification (local) - `hatch version` resolves `2.0.8.dev7+g…` from existing tags ✅ - `uv sync` runs the vcs build hook → generates `_version.py`; runtime chain (`version.py` → `__init__` → client repr) reports the derived version ✅ - `SETUPTOOLS_SCM_PRETEND_VERSION=2.1.0 hatch build` produces correctly named `swgoh_comlink-2.1.0.tar.gz` / `…-py3-none-any.whl` ✅ - Full unit suite: **598 passed** ✅ - `ruff check src/` clean; workflow YAML parses; job graph is build → pypi-publish → tag-release ✅ ## After merge Trigger the release from the Actions tab → "comlink-python release" → choose `bump`. It will publish to PyPI and tag `vX.Y.Z` on the `main` commit. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a456b1b commit 28a2a9c

5 files changed

Lines changed: 138 additions & 76 deletions

File tree

.github/workflows/release.yml

Lines changed: 90 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,52 @@
11
name: comlink-python release
22

3+
# Tag-driven release.
4+
#
5+
# The package version is derived from the Git tag at build time via hatch-vcs
6+
# (see [tool.hatch.version] in pyproject.toml), so a release does NOT commit a
7+
# version bump to the repository. This keeps the workflow compatible with the
8+
# protected `main` branch (which forbids direct pushes).
9+
#
10+
# Ordering is build -> publish -> tag so the Git tag / GitHub Release is created
11+
# only after a successful PyPI publish, which avoids leaving an orphaned tag
12+
# behind if the build or upload fails.
13+
314
on:
415
workflow_dispatch:
16+
inputs:
17+
bump:
18+
description: "Semantic version bump from the latest release tag"
19+
type: choice
20+
options:
21+
- patch
22+
- minor
23+
- major
24+
default: minor
25+
26+
permissions:
27+
contents: read
528

629
jobs:
7-
release:
8-
name: Semantic Release
30+
build:
31+
name: Build distributions
932
runs-on: ubuntu-latest
1033
if: github.ref == 'refs/heads/main'
1134

1235
concurrency:
1336
group: ${{ github.workflow }}-release-${{ github.ref_name }}
1437
cancel-in-progress: false
1538

16-
environment:
17-
name: pypi
18-
url: https://pypi.org/project/swgoh-comlink/
19-
20-
permissions:
21-
id-token: write
22-
contents: write
39+
outputs:
40+
version: ${{ steps.version.outputs.version }}
41+
tag: ${{ steps.version.outputs.tag }}
42+
sha: ${{ steps.version.outputs.sha }}
2343

2444
steps:
2545
- name: Check-out repository
2646
uses: actions/checkout@v6
2747
with:
2848
ref: ${{ github.ref_name }}
29-
fetch-depth: 0
49+
fetch-depth: 0 # full history + tags for version derivation
3050

3151
- name: Install uv
3252
uses: astral-sh/setup-uv@v7
@@ -36,48 +56,61 @@ jobs:
3656
with:
3757
python-version: "3.12"
3858

39-
# Update version
40-
- name: Update version
41-
run: uvx hatch version minor
42-
43-
# Update CHANGELOG.md
44-
- name: Update CHANGELOG.md
45-
run: uvx git-changelog -B auto -Tio CHANGELOG.md -c angular -s build,deps,fix,feat,refactor -n semver
46-
47-
# Commit version bump and changelog, then tag and push
48-
- name: Commit and tag
59+
- name: Compute next version from the latest tag
60+
id: version
4961
run: |
50-
VERSION=$(uvx hatch version)
51-
git config user.name "github-actions[bot]"
52-
git config user.email "github-actions[bot]@users.noreply.github.com"
53-
git add src/swgoh_comlink/version.py CHANGELOG.md
54-
git commit -m "chore(release): bump version to $VERSION [skip ci]"
55-
git tag -a "v$VERSION" -m "Release version $VERSION"
56-
git push origin ${{ github.ref_name }} --follow-tags
57-
env:
58-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
62+
set -euo pipefail
63+
latest="$(git tag --list 'v[0-9]*' --sort=-v:refname | head -n1)"
64+
latest="${latest:-v0.0.0}"
65+
base="${latest#v}"
66+
base="${base%%[-+]*}" # strip any pre-release / build metadata
67+
IFS='.' read -r major minor patch <<< "$base"
68+
case "${{ inputs.bump }}" in
69+
major) major=$((major + 1)); minor=0; patch=0 ;;
70+
minor) minor=$((minor + 1)); patch=0 ;;
71+
patch) patch=$((patch + 1)) ;;
72+
esac
73+
version="${major}.${minor}.${patch}"
74+
tag="v${version}"
75+
if git rev-parse "$tag" >/dev/null 2>&1; then
76+
echo "::error::Tag $tag already exists — choose a different bump or delete the tag."
77+
exit 1
78+
fi
79+
{
80+
echo "version=$version"
81+
echo "tag=$tag"
82+
echo "sha=$GITHUB_SHA"
83+
} >> "$GITHUB_OUTPUT"
84+
echo "Releasing $tag from $latest (bump=${{ inputs.bump }}) at $GITHUB_SHA"
5985
60-
# Build the package
6186
- name: Build the package
62-
run: uvx hatch build
87+
env:
88+
# Force the build version without needing the tag to exist yet, so the
89+
# tag can be created last (after a successful publish).
90+
SETUPTOOLS_SCM_PRETEND_VERSION: ${{ steps.version.outputs.version }}
91+
run: uvx --with hatch-vcs hatch build
92+
93+
- name: Verify built version matches the target
94+
run: |
95+
set -euo pipefail
96+
ls -1 dist/
97+
test -f "dist/swgoh_comlink-${{ steps.version.outputs.version }}.tar.gz"
98+
test -f "dist/swgoh_comlink-${{ steps.version.outputs.version }}-py3-none-any.whl"
6399
64-
# Upload build artifacts for the publish job
65100
- name: Upload build artifacts
66101
uses: actions/upload-artifact@v7
67102
with:
68103
name: dist
69104
path: dist/
70105

71-
# Publish the package to PyPI
72106
pypi-publish:
107+
name: Publish to PyPI
73108
runs-on: ubuntu-latest
74-
if: github.ref == 'refs/heads/main'
75109
needs:
76-
- release
110+
- build
77111
permissions:
78112
# IMPORTANT: this permission is mandatory for trusted publishing
79113
id-token: write
80-
contents: write
81114

82115
environment:
83116
name: pypi
@@ -92,3 +125,24 @@ jobs:
92125

93126
- name: Publish release distributions to PyPI
94127
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
128+
129+
tag-release:
130+
name: Tag and create GitHub Release
131+
runs-on: ubuntu-latest
132+
needs:
133+
- build
134+
- pypi-publish
135+
permissions:
136+
contents: write # create the tag + GitHub Release
137+
138+
steps:
139+
- name: Create tag and GitHub Release
140+
env:
141+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
142+
run: |
143+
set -euo pipefail
144+
gh release create "${{ needs.build.outputs.tag }}" \
145+
--repo "${{ github.repository }}" \
146+
--target "${{ needs.build.outputs.sha }}" \
147+
--title "${{ needs.build.outputs.tag }}" \
148+
--generate-notes

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,6 @@ Pipfile*
187187
pyvenv.cfg
188188
/src/swgoh_comlink/tools.py
189189
/docs/exhaustive-tests.md
190+
191+
# hatch-vcs generated version file (derived from Git tag at build time)
192+
/src/swgoh_comlink/_version.py

CONTRIBUTING.md

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ Be respectful, constructive, and patient. We're all here because we enjoy the ga
4848
gh auth login
4949
gh repo fork swgoh-utils/comlink-python
5050
```
51-
51+
5252
```bash
5353
git clone https://github.com/<your-username>/comlink-python.git
5454
cd comlink-python
@@ -60,7 +60,7 @@ Be respectful, constructive, and patient. We're all here because we enjoy the ga
6060
git config --local branch.main.remote upstream
6161
git remote set-url --push upstream github@github.com:<your-username>/comlink-python.git
6262
```
63-
63+
6464
2. **Install uv** (if you don't have it)
6565

6666
```bash
@@ -114,7 +114,7 @@ comlink-python/
114114
│ │ ├── commitlint.yml # Commit message validation (npm-based)
115115
│ │ ├── integration.yml # Integration tests against live comlink services
116116
│ │ ├── labeler.yml # Auto-label PRs by file path
117-
│ │ ├── release.yml # Semantic release → PyPI publish
117+
│ │ ├── release.yml # Tag-driven release (build → PyPI → tag) via hatch-vcs
118118
│ │ └── test.yml # Quick test + docs build
119119
│ ├── CODEOWNERS # Code ownership
120120
│ ├── dependabot.yml # Automated dependency updates
@@ -164,7 +164,7 @@ comlink-python/
164164
│ ├── globals.py # Logging configuration
165165
│ ├── swgoh_comlink.py # SwgohComlink (sync client)
166166
│ ├── swgoh_comlink_async.py # SwgohComlinkAsync (async client)
167-
│ └── version.py # Package version (managed by hatch)
167+
│ └── version.py # Exposes __version__ (derived from the Git tag via hatch-vcs)
168168
├── tests/
169169
│ ├── integration/ # Tests requiring a running comlink service
170170
│ ├── resources/ # Test fixture data (example-player.json, etc.)
@@ -191,7 +191,7 @@ comlink-python/
191191
| `helpers/` | `DataItems` IntFlag enum, `Constants` class, 25+ utility functions split across focused submodules |
192192
| `exceptions.py` | `SwgohComlinkException`, `SwgohComlinkValueError`, and `SwgohComlinkTypeError` |
193193
| `globals.py` | Shared logging setup (`get_logger()`) |
194-
| `version.py` | Single `__version__` string, managed by hatch during releases |
194+
| `version.py` | Exposes `__version__`, derived from the Git tag at build time via hatch-vcs |
195195

196196
---
197197

@@ -506,13 +506,13 @@ Before opening an issue, check the [existing issues](https://github.com/swgoh-ut
506506

507507
Releases are handled by the maintainer through the GitHub Actions `release.yml` workflow. Contributors don't need to manage versioning or releases, but here's how it works for reference:
508508

509-
1. The release workflow is triggered manually (`workflow_dispatch`)
510-
2. [Hatch](https://hatch.pypa.io/) bumps the version in `src/swgoh_comlink/version.py`
511-
3. [git-changelog](https://pawamoy.github.io/git-changelog/) regenerates `CHANGELOG.md` from commit history
512-
4. The new version is tagged and pushed
513-
5. The package is built with `hatch build` and published to [PyPI](https://pypi.org/project/swgoh-comlink/)
509+
1. The release workflow is triggered manually (`workflow_dispatch`) with a `bump` input (`patch`, `minor`, or `major`)
510+
2. The next version is computed from the latest release tag and the chosen bump level
511+
3. The package is built — [hatch-vcs](https://github.com/ofek/hatch-vcs) derives the version from that target tag, so **no version string is committed to the repo**
512+
4. The distributions are published to [PyPI](https://pypi.org/project/swgoh-comlink/) via trusted publishing
513+
5. Only after a successful publish is the Git tag created and a GitHub Release cut, with release notes auto-generated from the commit/PR history
514514

515-
This is why conventional commit messages matter — they become the release notes automatically.
515+
Because the version comes from the Git tag (not a committed file), the release workflow never pushes to the protected `main` branch. This is why conventional commit messages matter — they become the auto-generated release notes.
516516

517517
**Version scheme:** [Semantic Versioning](https://semver.org/)
518518

pyproject.toml

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[build-system]
2-
requires = ["hatchling>=1.27.0"]
2+
requires = ["hatchling>=1.27.0", "hatch-vcs>=0.4.0"]
33
build-backend = "hatchling.build"
44

55
[project]
@@ -55,8 +55,16 @@ markers = [
5555
"cat_stress: load endurance and connection pool tests",
5656
]
5757

58+
# Version is derived from the Git tag (e.g. v2.1.0) at build time.
59+
# No version string is committed to the repo — tag the release and the
60+
# build reads it. See .github/workflows/release.yml.
5861
[tool.hatch.version]
59-
path = 'src/swgoh_comlink/version.py'
62+
source = "vcs"
63+
64+
# Write the resolved version to a generated, git-ignored module so it is
65+
# importable at runtime (consumed by src/swgoh_comlink/version.py).
66+
[tool.hatch.build.hooks.vcs]
67+
version-file = "src/swgoh_comlink/_version.py"
6068

6169
[tool.hatch.build.targets.wheel]
6270
packages = ["src/swgoh_comlink"]
@@ -150,29 +158,3 @@ exclude_lines = [
150158
"if TYPE_CHECKING:",
151159
"if __name__ == .__main__.",
152160
]
153-
154-
# ── Semantic Release ─────────────────────────────────────────────────────
155-
156-
[tool.semantic_release]
157-
version_variable = [
158-
"src/swgoh_comlink/version.py"
159-
]
160-
version_pattern = "src/swgoh_comlink/version.py:__version__ = '{version}'"
161-
version_source = "commit"
162-
branch = "main"
163-
changelog_file = "CHANGELOG.md"
164-
build_command = "uv build"
165-
dist_path = "dist/"
166-
upload_to_release = true
167-
upload_to_pypi = false
168-
remove_dist = true
169-
patch_without_tag = true
170-
171-
[tool.semantic_release.branches.main]
172-
match = "main"
173-
prerelease = false
174-
175-
[tool.semantic_release.branches.tools]
176-
match = "tools"
177-
prerelease = true
178-
prerelease_token = "rc"

src/swgoh_comlink/version.py

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,25 @@
11
# coding=utf-8
2-
__version__ = "2.0.7"
2+
"""Expose the installed package version as ``__version__``.
3+
4+
The version is derived from the Git tag at build time via ``hatch-vcs``
5+
(see ``[tool.hatch.version]`` in ``pyproject.toml``). The build writes the
6+
resolved value to the git-ignored ``_version.py``; we read it from there,
7+
falling back to installed package metadata, then to a sentinel for an
8+
unbuilt source tree.
9+
"""
10+
11+
from __future__ import annotations
12+
13+
try:
14+
# Generated at build time by hatch-vcs (git-ignored).
15+
from swgoh_comlink._version import __version__
16+
except ImportError: # pragma: no cover - exercised only in unbuilt trees
17+
from importlib.metadata import PackageNotFoundError
18+
from importlib.metadata import version as _package_version
19+
20+
try:
21+
__version__ = _package_version("swgoh_comlink")
22+
except PackageNotFoundError:
23+
__version__ = "0.0.0+unknown"
24+
25+
__all__ = ["__version__"]

0 commit comments

Comments
 (0)