Skip to content

Commit c05ac46

Browse files
authored
Add smoother release workflow with bumpver (#23) (#28)
- Make version dynamic via hatchling regex source (single source of truth: tidydraws/__init__.py), removing the literal version from pyproject.toml so it can never drift - Add bumpver config scoped to __init__.py with pre_commit_hook that re-derives uv.lock into the same commit - Add make release-patch/minor/major targets - Add release.yml workflow: tag push creates GitHub Release with auto-generated notes, which then triggers publish.yml - Simplify publish.yml: removed fragile inline version assertion - Add scripts/pre-bump.sh (uv lock + git add uv.lock) - Add venv-*/ to .gitignore to prevent stray venvs breaking uv build - Add Releasing section to CONTRIBUTING.md - GitHub: enforce_admins off (admin direct-push for bumps), tag protection ruleset (admin-only version tags), release environment requires admin approval for PyPI upload
1 parent df56838 commit c05ac46

8 files changed

Lines changed: 165 additions & 17 deletions

File tree

.github/workflows/publish.yml

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
name: PyPI release
22

3+
# Builds the package on every PR/push to main to catch breakages early, but
4+
# only publishes when a GitHub Release is published (which is created by
5+
# release.yml in response to a v* tag push).
36
on:
47
workflow_dispatch:
58
pull_request:
@@ -30,20 +33,14 @@ jobs:
3033
run: |
3134
uv venv venv-sdist
3235
uv pip install --python venv-sdist/bin/python dist/tidydraws*.tar.gz
33-
echo "Checking import and version number (on release)"
34-
venv-sdist/bin/python -c "import tidydraws; assert tidydraws.__version__ == '${REF_NAME}' if '${REF_TYPE}' == 'tag' else tidydraws.__version__; print(tidydraws.__version__)"
35-
env:
36-
REF_NAME: ${{ github.ref_name }}
37-
REF_TYPE: ${{ github.ref_type }}
36+
echo "Checking import and version number"
37+
venv-sdist/bin/python -c "import tidydraws; print(tidydraws.__version__)"
3838
- name: Check the bdist installs and imports
3939
run: |
4040
uv venv venv-bdist
4141
uv pip install --python venv-bdist/bin/python dist/tidydraws*.whl
42-
echo "Checking import and version number (on release)"
43-
venv-bdist/bin/python -c "import tidydraws; assert tidydraws.__version__ == '${REF_NAME}' if '${REF_TYPE}' == 'tag' else tidydraws.__version__; print(tidydraws.__version__)"
44-
env:
45-
REF_NAME: ${{ github.ref_name }}
46-
REF_TYPE: ${{ github.ref_type }}
42+
echo "Checking import and version number"
43+
venv-bdist/bin/python -c "import tidydraws; print(tidydraws.__version__)"
4744
- uses: actions/upload-artifact@v4
4845
with:
4946
name: artifact

.github/workflows/release.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
name: Release
2+
3+
# Triggered by a version tag push (e.g. 0.5.0) created by `make release-*`.
4+
# Creates a GitHub Release with auto-generated notes. The "release published"
5+
# event then fires publish.yml → build → TestPyPI → PyPI.
6+
#
7+
# Only admins can push version tags (enforced by the tag protection ruleset),
8+
# so this workflow cannot be triggered by non-admin collaborators.
9+
on:
10+
push:
11+
tags:
12+
- "*.*.*"
13+
14+
permissions:
15+
contents: write
16+
17+
jobs:
18+
create-release:
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v4
22+
with:
23+
persist-credentials: false
24+
- name: Create GitHub Release
25+
env:
26+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27+
run: gh release create "${GITHUB_REF_NAME}" --generate-notes --title "${GITHUB_REF_NAME}"

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ build/
88
*.spec
99
.env/
1010
.venv/
11+
venv-*/
1112
.scratch/
1213

1314
# Great Docs build directory (ephemeral, do not commit)

CONTRIBUTING.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,3 +136,43 @@ This repo ships the [Great Docs Agent Skills](https://posit-dev.github.io/great-
136136
- Ensure all tests pass
137137
- Add or update documentation as needed
138138
- Keep changes focused and atomic
139+
140+
## Releasing
141+
142+
Releases are cut by a repo admin running a single `make` target. The version lives in one place — `tidydraws/__init__.py` (`__version__`) — and is read dynamically by hatchling at build time, so `pyproject.toml` never carries a version literal.
143+
144+
### To cut a release (admin only)
145+
146+
```bash
147+
make release-patch # 0.4.0 -> 0.4.1
148+
make release-minor # 0.4.0 -> 0.5.0
149+
make release-major # 0.4.0 -> 1.0.0
150+
```
151+
152+
This runs `bumpver`, which:
153+
154+
1. Bumps `__version__` in `tidydraws/__init__.py` (the only version literal in the repo).
155+
2. Runs `scripts/pre-bump.sh`, which re-derives `uv.lock` and stages it so the lockfile lands in the same commit.
156+
3. Commits with message `Bump version 0.4.0 -> 0.5.0`.
157+
4. Creates and pushes tag `0.5.0`.
158+
159+
The tag push then triggers the automated cascade:
160+
161+
```mermaid
162+
flowchart LR
163+
A[make release-minor] --> B[bumpver: bump, lock, commit, tag, push]
164+
B --> C[release.yml: GitHub Release]
165+
C --> D[publish.yml: build sdist+wheel]
166+
D --> E[TestPyPI + verify install]
167+
E --> F[PyPI upload — gated by release environment review]
168+
```
169+
170+
The final PyPI upload runs in the `release` [environment](https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment), which requires admin approval in the Actions UI. Nothing reaches PyPI without that click.
171+
172+
### Permissions and gating
173+
174+
- **Admin direct-push to `main`**: `enforce_admins` is off, so admins can push the bump commit directly. Non-admin collaborators still need a PR.
175+
- **Tag protection**: only admins can push version tags (e.g. `0.5.0`), so only admins can trigger a release.
176+
- **PyPI environment**: the `release` environment requires admin review before upload.
177+
178+
Non-admin collaborators and external contributors cannot cut releases at any stage.

Makefile

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Makefile for tidydraws development workflow
22

3-
.PHONY: help install test lint type-check precommit docs docs-preview cleandocs build clean
3+
.PHONY: help install test lint type-check precommit docs docs-preview cleandocs build clean release release-patch release-minor release-major
44

55
# Help target to show available commands
66
help:
@@ -14,6 +14,11 @@ help:
1414
@echo " docs-preview - Build and serve the docs locally with live reload"
1515
@echo " cleandocs - Remove the ephemeral great-docs/ build directory"
1616
@echo " clean - Clean build artifacts"
17+
@echo ""
18+
@echo "Releasing (admin only; see CONTRIBUTING.md > Releasing):"
19+
@echo " release-patch - Bump patch (0.4.0 -> 0.4.1) and cut a release"
20+
@echo " release-minor - Bump minor (0.4.0 -> 0.5.0) and cut a release"
21+
@echo " release-major - Bump major (0.4.0 -> 1.0.0) and cut a release"
1722

1823
# Install dependencies
1924
install:
@@ -52,3 +57,18 @@ clean: cleandocs
5257
rm -rf .pytest_cache/
5358
find . -name "*.pyc" -delete
5459
find . -name "__pycache__" -type d -exec rm -rf {} +
60+
61+
# ── Release ─────────────────────────────────────────────────────────────────
62+
# Admin only. Bumps the version (single source: tidydraws/__init__.py),
63+
# re-derives uv.lock, commits, tags, and pushes. The tag push triggers the
64+
# release.yml workflow → GitHub Release → publish.yml → TestPyPI → PyPI.
65+
# Requires admin push rights to main (enforce_admins is off) and tag-push
66+
# rights (tag protection rule on v*).
67+
release-patch:
68+
uv run bumpver update --patch
69+
70+
release-minor:
71+
uv run bumpver update --minor
72+
73+
release-major:
74+
uv run bumpver update --major

pyproject.toml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "tidydraws"
3-
version = "0.4.0"
3+
dynamic = ["version"]
44
readme = "README.md"
55
license = "MIT"
66
license-files = ["LICENSE"]
@@ -82,6 +82,23 @@ Documentation = "https://drbenvincent.github.io/tidydraws/"
8282
requires = ["hatchling"]
8383
build-backend = "hatchling.build"
8484

85+
[tool.hatch.version]
86+
source = "regex"
87+
path = "tidydraws/__init__.py"
88+
pattern = '__version__ = "(?P<version>[^"]+)"'
89+
90+
[tool.bumpver]
91+
current_version = "0.4.0"
92+
version_pattern = "MAJOR.MINOR.PATCH"
93+
commit_message = "Bump version {old_version} -> {new_version}"
94+
commit = true
95+
tag = true
96+
push = true
97+
pre_commit_hook = "scripts/pre-bump.sh"
98+
99+
[tool.bumpver.file_patterns]
100+
"tidydraws/__init__.py" = ['__version__ = "{version}"']
101+
85102
[dependency-groups]
86103
dev = [
87104
"pytest>=9.1.1",
@@ -92,6 +109,7 @@ dev = [
92109
"altair>=6.2.2",
93110
"vegafusion>=2.0.3",
94111
"vl-convert-python>=1.9.0",
112+
"bumpver",
95113
]
96114

97115
[tool.ruff]

scripts/pre-bump.sh

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/bin/bash
2+
# bumpver pre_commit_hook: re-derive uv.lock from the new __version__ and
3+
# stage it so the version bump, lockfile, and tag all land in one commit.
4+
# See https://brtkwr.com/posts/2026-01-14-bumpver-with-uv/ for the rationale.
5+
set -euo pipefail
6+
uv lock
7+
git add uv.lock

uv.lock

Lines changed: 43 additions & 5 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)