Skip to content

Commit 8d750f8

Browse files
committed
chore: add release script
1 parent 945a41f commit 8d750f8

4 files changed

Lines changed: 188 additions & 2 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,9 @@ lint = [
6363
"pre-commit-uv>=4.1.4",
6464
]
6565
release = [
66+
"packaging>=24.2",
6667
"towncrier>=24.8",
68+
"uv>=0.7.7",
6769
]
6870

6971
[tool.ruff]

scripts/release.py

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# /// script
2+
# requires-python = ">=3.10"
3+
# dependencies = [
4+
# "packaging>=24.2",
5+
# "towncrier>=24.8",
6+
# "uv>=0.7.7",
7+
# ]
8+
# ///
9+
"""Automation for releases."""
10+
11+
# ruff: noqa: T201
12+
13+
from __future__ import annotations
14+
15+
import argparse
16+
import subprocess
17+
import sys
18+
from functools import partial
19+
from typing import TYPE_CHECKING
20+
21+
from packaging.version import Version
22+
23+
if TYPE_CHECKING:
24+
from collections.abc import Sequence
25+
26+
print_error = partial(print, file=sys.stderr)
27+
28+
run = partial(
29+
subprocess.check_call,
30+
stdout=subprocess.DEVNULL,
31+
stderr=subprocess.DEVNULL,
32+
)
33+
34+
35+
def main(argv: Sequence[str] | None = None) -> int: # noqa: PLR0911, PLR0915
36+
"""Prepare a new release."""
37+
# Parse command-line arguments.
38+
parser = argparse.ArgumentParser(description="prepare a new release")
39+
parser.add_argument(
40+
"--version",
41+
type=Version,
42+
required=True,
43+
help="provide the release version",
44+
)
45+
parser.add_argument(
46+
"--dry-run",
47+
action="store_true",
48+
help="perform a dry run",
49+
)
50+
args = parser.parse_args(argv)
51+
52+
# Get the public portion of the version.
53+
version = args.version.public
54+
55+
# Check if the Git repository is dirty.
56+
try:
57+
run(("git", "diff", "--quiet"))
58+
59+
except subprocess.CalledProcessError:
60+
print_error("The Git repository is dirty.")
61+
return 1
62+
63+
# Get the name of the base branch.
64+
base_branch = subprocess.check_output(
65+
("git", "rev-parse", "--abbrev-ref", "HEAD"),
66+
encoding="utf-8",
67+
).rstrip()
68+
69+
# Create the release branch.
70+
release_branch = f"release/{version}"
71+
72+
try:
73+
run(("git", "branch", release_branch))
74+
75+
except subprocess.CalledProcessError:
76+
print_error(f"The release branch already exists: {release_branch!r}.")
77+
return 1
78+
79+
print(f"Created release branch {release_branch!r}.")
80+
81+
# Switch to the release branch.
82+
run(("git", "checkout", release_branch))
83+
84+
print(f"Switched from branch {base_branch!r} to release branch {release_branch!r}.")
85+
86+
# Bump the version.
87+
try:
88+
run(("uv", "version", "--no-sync", version))
89+
run(("towncrier", "build", "--yes", "--version", version))
90+
91+
except subprocess.CalledProcessError:
92+
run(("git", "checkout", base_branch))
93+
run(("git", "branch", "-D", release_branch))
94+
print_error("An error occurred while bumping the version.")
95+
print_error(
96+
f"Removed release branch {release_branch!r} "
97+
f"and switched back to {base_branch!r}."
98+
)
99+
return 1
100+
101+
# Commit changes.
102+
run(("git", "add", ":/pyproject.toml", ":/uv.lock"))
103+
run(("git", "add", "-A", ":/changelog.d/*", ":/CHANGELOG.md"))
104+
run(("git", "commit", "-m", f"chore: prepare release {version}", "--no-verify"))
105+
106+
print(f"Committed changes on branch {release_branch!r}.")
107+
108+
# Create the release tag.
109+
release_tag = f"v{version}"
110+
111+
try:
112+
run(("git", "tag", "-a", release_tag, "-m", f"bump version to {version}"))
113+
114+
except subprocess.CalledProcessError:
115+
run(("git", "checkout", base_branch))
116+
run(("git", "branch", "-D", release_branch))
117+
print_error(f"The release tag already exists: {release_tag!r}.")
118+
print_error(
119+
f"Removed release branch {release_branch!r} "
120+
f"and switched back to {base_branch!r}."
121+
)
122+
return 1
123+
124+
# Exit on dry run.
125+
if args.dry_run:
126+
print("Dry run success!")
127+
run(("git", "checkout", base_branch))
128+
run(("git", "branch", "-D", release_branch))
129+
print(
130+
f"Removed release branch {release_branch!r} "
131+
f"and switched back to {base_branch!r}."
132+
)
133+
run(("git", "tag", "-d", release_tag))
134+
print(f"Removed release tag {release_tag!r}.")
135+
return 0
136+
137+
# Push changes to the remote repository and push the release tag.
138+
try:
139+
run(("git", "push", "origin", f"{release_branch}:main", "--follow-tags"))
140+
141+
except subprocess.CalledProcessError:
142+
run(("git", "checkout", base_branch))
143+
run(("git", "branch", "-D", release_branch))
144+
print_error("An error occurred while pushing changes.")
145+
print_error(
146+
f"Removed release branch {release_branch!r} "
147+
f"and switched back to {base_branch!r}."
148+
)
149+
return 1
150+
151+
print(f"Pushed changes from {release_branch!r} to 'origin/main'.")
152+
153+
# Remove the release branch and switch to the main branch.
154+
run(("git", "checkout", "main"))
155+
run(("git", "branch", "-D", release_branch))
156+
run(("git", "fetch"))
157+
run(("git", "reset", "--hard", "origin/main"))
158+
return 0
159+
160+
161+
if __name__ == "__main__":
162+
raise SystemExit(main())

tox.toml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,15 @@ commands = [
6666
{ replace = "posargs", extend = true },
6767
],
6868
]
69+
70+
[env.release]
71+
description = "prepare a new release"
72+
skip_install = true
73+
dependency_groups = [ "release" ]
74+
commands = [
75+
[
76+
"python",
77+
"{tox_root}{/}scripts{/}release.py",
78+
{ replace = "posargs", extend = true },
79+
],
80+
]

uv.lock

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

0 commit comments

Comments
 (0)