Skip to content

Commit cf9baee

Browse files
committed
feat: introduce a release script for automated version bumping and tagging, update release documentation to reflect its usage, and ignore __pycache__.
1 parent 508b077 commit cf9baee

4 files changed

Lines changed: 163 additions & 6 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ build/
33
dist/
44
benchmark
55
*.ipc
6+
__pycache__

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,14 @@ requests validate the package, and version tags build Linux and Windows wheels
3131
for CPython 3.12, 3.13, and 3.14. Tag builds publish the validated artifacts
3232
to PyPI via Trusted Publishing after a strict version check.
3333

34+
For releases, use the helper script so the package version and tag stay in
35+
lockstep:
36+
37+
```bash
38+
uv run python scripts/release.py X.Y.Z
39+
git push origin HEAD vX.Y.Z
40+
```
41+
3442
## Minimal Usage
3543

3644
```python

RELEASE.md

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ Production releases must satisfy all of the following:
99
- `[project].version` in `pyproject.toml` is `X.Y.Z`
1010
- `X.Y.Z` is not already published on PyPI
1111

12+
Do not bump `pyproject.toml` and create tags as separate manual steps. Use the
13+
release script so the version bump, commit, and tag come from the same commit:
14+
15+
```bash
16+
uv run python scripts/release.py X.Y.Z
17+
git push origin HEAD vX.Y.Z
18+
```
19+
20+
Or let the script push both:
21+
22+
```bash
23+
uv run python scripts/release.py X.Y.Z --push
24+
```
25+
1226
## Local Validation
1327

1428
1. Clean old artifacts.
@@ -42,17 +56,17 @@ Production releases must satisfy all of the following:
4256

4357
## GitHub Source Release
4458

45-
1. Push commit to `main`.
46-
2. Create and push tag.
47-
- `git tag vX.Y.Z`
48-
- `git push origin vX.Y.Z`
59+
1. Run the release script from a clean working tree.
60+
- `uv run python scripts/release.py X.Y.Z`
61+
2. Push the release commit and tag.
62+
- `git push origin HEAD vX.Y.Z`
4963
3. Wait for the release workflow to finish and download the generated artifacts if needed.
5064
4. Create GitHub release from the tag and include changelog notes.
5165

5266
## PyPI Publish
5367

5468
1. Configure a Trusted Publisher for the repository on PyPI.
55-
2. Bump `[project].version` in `pyproject.toml`.
56-
3. Push a matching version tag (`vX.Y.Z`).
69+
2. Run the release script so `pyproject.toml` and the tag are created together.
70+
3. Push the resulting commit and matching version tag (`vX.Y.Z`).
5771
4. Wait for the `Release Artifacts` workflow to finish.
5872
5. Verify the package page and an install from PyPI.

scripts/release.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
"""Prepare a release by bumping version, committing, and tagging in one step."""
2+
3+
from __future__ import annotations
4+
5+
import argparse
6+
import re
7+
import subprocess
8+
import sys
9+
from pathlib import Path
10+
import tomllib
11+
12+
13+
VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$")
14+
PYPROJECT_VERSION_RE = re.compile(r'(?m)^version = "([^"]+)"$')
15+
16+
17+
def fail(message: str) -> int:
18+
print(f"ERROR: {message}", file=sys.stderr)
19+
return 1
20+
21+
22+
def run(*args: str, capture: bool = False) -> str:
23+
result = subprocess.run(
24+
args,
25+
check=False,
26+
text=True,
27+
capture_output=capture,
28+
)
29+
if result.returncode != 0:
30+
if capture and result.stderr:
31+
print(result.stderr, file=sys.stderr, end="")
32+
raise SystemExit(result.returncode)
33+
return result.stdout if capture else ""
34+
35+
36+
def parse_version(version: str) -> tuple[int, int, int]:
37+
if not VERSION_RE.fullmatch(version):
38+
raise SystemExit(fail(f"Invalid version '{version}'. Expected X.Y.Z."))
39+
major, minor, patch = version.split(".")
40+
return int(major), int(minor), int(patch)
41+
42+
43+
def read_project_version(pyproject: Path) -> str:
44+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
45+
return data["project"]["version"]
46+
47+
48+
def ensure_clean_tree() -> None:
49+
status = run("git", "status", "--short", capture=True).strip()
50+
if status:
51+
raise SystemExit(
52+
fail("Git working tree is not clean. Commit or stash changes first.")
53+
)
54+
55+
56+
def ensure_no_existing_tag(tag: str) -> None:
57+
existing = run("git", "tag", "--list", tag, capture=True).strip()
58+
if existing:
59+
raise SystemExit(fail(f"Tag {tag} already exists locally."))
60+
61+
62+
def update_pyproject_version(pyproject: Path, target_version: str) -> None:
63+
text = pyproject.read_text(encoding="utf-8")
64+
matches = PYPROJECT_VERSION_RE.findall(text)
65+
if len(matches) != 1:
66+
raise SystemExit(
67+
fail("Expected exactly one top-level version entry in pyproject.toml.")
68+
)
69+
updated = PYPROJECT_VERSION_RE.sub(f'version = "{target_version}"', text, count=1)
70+
pyproject.write_text(updated, encoding="utf-8")
71+
72+
73+
def main() -> int:
74+
parser = argparse.ArgumentParser(
75+
description="Bump pyproject version, commit it, and create a matching tag."
76+
)
77+
parser.add_argument("version", help="Target release version in X.Y.Z format.")
78+
parser.add_argument(
79+
"--push",
80+
action="store_true",
81+
help="Also push HEAD and the new tag to origin.",
82+
)
83+
args = parser.parse_args()
84+
85+
pyproject = Path("pyproject.toml")
86+
target_version = args.version
87+
target_tuple = parse_version(target_version)
88+
current_version = read_project_version(pyproject)
89+
current_tuple = parse_version(current_version)
90+
tag = f"v{target_version}"
91+
92+
print(f"Current version: {current_version}")
93+
print(f"Target version: {target_version}")
94+
95+
if target_tuple <= current_tuple:
96+
return fail(
97+
"Target version must be greater than current version "
98+
f"({target_version} <= {current_version})."
99+
)
100+
101+
ensure_clean_tree()
102+
ensure_no_existing_tag(tag)
103+
104+
update_pyproject_version(pyproject, target_version)
105+
updated_version = read_project_version(pyproject)
106+
if updated_version != target_version:
107+
return fail(
108+
"pyproject.toml update did not persist the expected version "
109+
f"({updated_version} != {target_version})."
110+
)
111+
112+
commit_message = f"chore(release): {target_version}"
113+
tag_message = f"Release {target_version}"
114+
115+
run("git", "add", "pyproject.toml")
116+
run("git", "commit", "-m", commit_message)
117+
run("git", "tag", "-a", tag, "-m", tag_message)
118+
119+
print(f"Created commit: {commit_message}")
120+
print(f"Created tag: {tag}")
121+
122+
if args.push:
123+
run("git", "push", "origin", "HEAD")
124+
run("git", "push", "origin", tag)
125+
print("Pushed commit and tag to origin.")
126+
else:
127+
print("Next step:")
128+
print(f" git push origin HEAD {tag}")
129+
130+
return 0
131+
132+
133+
if __name__ == "__main__":
134+
raise SystemExit(main())

0 commit comments

Comments
 (0)