11# /// script
2- # requires-python = ">=3.10"
2+ # requires-python = "{% if dynamic_version %} >=3.10{% else %} >=3.11 {% endif %} "
33# dependencies = [
4+ {% - if dynamic_version %}
45# "packaging>=24.2",
6+ {% - endif %}
57# "towncrier>=24.8",
68{% - if not dynamic_version %}
7- # "uv>=0.7.7 ",
9+ # "uv>=0.8.6 ",
810{% - endif %}
911# ]
1012# ///
11- """Automation for releases ."""
13+ """Make a release ."""
1214
13- # ruff: noqa: T201
15+ # ruff: noqa: S603, T201
1416
1517from __future__ import annotations
1618
1719import argparse
20+ {% - if not dynamic_version %}
21+ import enum
22+ {% - endif %}
23+ import json
1824import subprocess
1925import sys
26+ from contextlib import contextmanager
2027from functools import partial
2128from typing import TYPE_CHECKING
22-
29+ {% if dynamic_version %}
2330from packaging.version import Version
24-
31+ {% endif %}
2532if TYPE_CHECKING:
26- from collections.abc import Sequence
33+ from collections.abc import Generator, Sequence
2734
2835print_error = partial(print, file=sys.stderr)
2936
@@ -33,135 +40,192 @@ run = partial(
3340 stderr=subprocess.DEVNULL,
3441)
3542
36-
37- def main(argv: Sequence[str] | None = None) -> int: # noqa: PLR0911, PLR0915
38- """Prepare a new release."""
39- # Parse command-line arguments.
40- parser = argparse.ArgumentParser(description="prepare a new release")
43+ {% if not dynamic_version %}
44+ class Bump(enum.StrEnum):
45+ """The enumeration of supported version bump semantics."""
46+
47+ MAJOR = enum.auto()
48+ MINOR = enum.auto()
49+ PATCH = enum.auto()
50+ STABLE = enum.auto()
51+ ALPHA = enum.auto()
52+ BETA = enum.auto()
53+ RC = enum.auto()
54+ POST = enum.auto()
55+ DEV = enum.auto()
56+
57+ {% endif %}
58+ def create_parser() -> argparse.ArgumentParser:
59+ """Create the argument parser."""
60+ parser = argparse.ArgumentParser(description="make a release")
61+ {% - if dynamic_version %}
4162 parser.add_argument(
42- "version",
63+ "-- version",
4364 type=Version,
44- help="provide the release version",
65+ help="provide the version",
4566 )
67+ {% - else %}
68+ mutex = parser.add_mutually_exclusive_group(required=True)
69+ mutex.add_argument(
70+ "--version",
71+ help="provide the version",
72+ )
73+ mutex.add_argument(
74+ "--bump",
75+ type=Bump,
76+ choices=Bump,
77+ help="update the version using the given semantics",
78+ )
79+ {% - endif %}
4680 parser.add_argument(
4781 "--dry-run",
4882 action="store_true",
49- help="perform a dry run ",
83+ help="do not push changes and reset the repository ",
5084 )
51- args = parser.parse_args(argv)
85+ return parser
5286
53- # Get the public portion of the version.
54- version = args.version.public
5587
56- # Check if the Git repository is dirty.
88+ def check_repository() -> None:
89+ """Check whether the repository is dirty."""
5790 try:
5891 run(("git", "diff", "--quiet"))
5992
6093 except subprocess.CalledProcessError:
6194 print_error("The Git repository is dirty.")
62- return 1
95+ raise SystemExit(1) from None
96+
97+ {% if not dynamic_version %}
98+ def update_version(version: str | None = None, bump: str | None = None) -> str:
99+ """Update the version and return the new version."""
100+ args = ["uv", "version", "--no-sync", "--output-format", "json"]
101+
102+ if version:
103+ args.append(version)
63104
64- # Get the name of the base branch.
65- base_branch = subprocess.check_output(
105+ if bump:
106+ args.extend(("--bump", bump))
107+
108+ try:
109+ version_json = subprocess.check_output(args)
110+
111+ except subprocess.CalledProcessError:
112+ print_error("An error occurred while bumping the version.")
113+ raise SystemExit(1) from None
114+
115+ version = json.loads(version_json)
116+ return version["version"]
117+
118+ {% endif %}
119+ def get_branch() -> str:
120+ """Get the current branch."""
121+ return subprocess.check_output(
66122 ("git", "rev-parse", "--abbrev-ref", "HEAD"),
67- encoding="utf-8" ,
123+ text=True ,
68124 ).rstrip()
69125
70- # Create the release branch.
71- release_branch = f"release/{version}"
72126
127+ def create_branch(branch: str) -> None:
128+ """Create a new branch."""
73129 try:
74- run(("git", "branch", release_branch ))
130+ run(("git", "branch", branch ))
75131
76132 except subprocess.CalledProcessError:
77- print_error(f"The release branch already exists: {release_branch!r}.")
78- return 1
133+ print_error(f"The branch already exists: {branch!r}.")
134+ raise SystemExit(1) from None
135+
136+ print(f"Created new branch {branch!r}.")
79137
80- print(f"Created release branch {release_branch!r}.")
81138
82- # Switch to the release branch.
83- run(("git", "checkout", release_branch))
139+ @contextmanager
140+ def switch_to_branch(branch: str) -> Generator[None]:
141+ """Create a new branch and switch to it.
84142
85- print(f"Switched from branch {base_branch!r} to release branch {release_branch!r}.")
143+ It is removed on exit.
144+ """
145+ base_branch = get_branch()
146+ create_branch(branch)
86147
87- # Bump the version.
88148 try:
89- {% - if not dynamic_version %}
90- run(("uv", "version", "--no-sync", version))
91- {% - endif %}
92- run(("towncrier", "build", "--yes", "--version", version))
149+ run(("git", "checkout", branch))
150+ print(f"Switched from branch {base_branch!r} to branch {branch!r}.")
151+ yield
93152
94- except subprocess.CalledProcessError :
153+ finally :
95154 run(("git", "checkout", base_branch))
96- run(("git", "branch", "-D", release_branch))
97- print_error("An error occurred while bumping the version.")
98- print_error(
99- f"Removed release branch {release_branch!r} "
100- f"and switched back to {base_branch!r}."
101- )
102- return 1
155+ run(("git", "branch", "-D", branch))
156+ print(f"Removed branch {branch!r} and switched back to {base_branch!r}.")
103157
104- # Commit changes.
105- {% - if not dynamic_version %}
106- run(("git", "add", ":/pyproject.toml", ":/uv.lock"))
107- {% - endif %}
108- run(("git", "add", "-A", ":/changelog.d/*", ":/CHANGELOG.md"))
109- run(("git", "commit", "-m", f"chore: prepare release {version}", "--no-verify"))
110158
111- print(f"Committed changes on branch {release_branch!r}.")
159+ def build_changelog(version: str) -> None:
160+ """Build the changelog."""
161+ try:
162+ run(("towncrier", "build", "--yes", "--version", version))
163+
164+ except subprocess.CalledProcessError:
165+ print_error("An error occurred while building the changelog.")
166+ raise SystemExit(1) from None
167+
112168
113- # Create the release tag.
169+ def create_release_tag(version: str) -> str:
170+ """Create the release tag."""
114171 release_tag = f"v{version}"
172+ message = f"bump version to {version}"
115173
116174 try:
117- run(("git", "tag", "-a", release_tag, "-m", f"bump version to {version}" ))
175+ run(("git", "tag", "-a", release_tag, "-m", message ))
118176
119177 except subprocess.CalledProcessError:
120- run(("git", "checkout", base_branch))
121- run(("git", "branch", "-D", release_branch))
122178 print_error(f"The release tag already exists: {release_tag!r}.")
123- print_error(
124- f"Removed release branch {release_branch!r} "
125- f"and switched back to {base_branch!r}."
126- )
127- return 1
179+ raise SystemExit(1) from None
128180
129181 print(f"Created release tag {release_tag!r}.")
182+ return release_tag
130183
131- # Exit on dry run.
132- if args.dry_run:
133- print("Dry run success!")
134- run(("git", "checkout", base_branch))
135- run(("git", "branch", "-D", release_branch))
136- print(
137- f"Removed release branch {release_branch!r} "
138- f"and switched back to {base_branch!r}."
139- )
140- run(("git", "tag", "-d", release_tag))
141- print(f"Removed release tag {release_tag!r}.")
142- return 0
143-
144- # Push changes to the remote repository and push the release tag.
145- try:
146- run(("git", "push", "origin", f"{release_branch}:main", "--follow-tags"))
147184
148- except subprocess.CalledProcessError:
149- run(("git", "checkout", base_branch))
150- run(("git", "branch", "-D", release_branch))
151- print_error("An error occurred while pushing changes.")
152- print_error(
153- f"Removed release branch {release_branch!r} "
154- f"and switched back to {base_branch!r}."
155- )
156- run(("git", "tag", "-d", release_tag))
157- print_error(f"Removed release tag {release_tag!r}.")
158- return 1
159-
160- print(f"Pushed changes from {release_branch!r} to 'origin/main'.")
161-
162- # Remove the release branch and switch to the main branch.
185+ def push_changes(branch: str, remote_branch: str = "main") -> None:
186+ """Push the changes to the target branch."""
187+ run(("git", "push", "origin", f"{branch}:{remote_branch}", "--follow-tags"))
188+ print(f"Pushed changes from {branch!r} to 'origin/{remote_branch}'.")
189+
190+
191+ def main(argv: Sequence[str] | None = None) -> int:
192+ """Prepare a new release."""
193+ parser = create_parser()
194+ args = parser.parse_args(argv)
195+
196+ check_repository()
197+ {% if dynamic_version %}
198+ version = args.version.public
199+ {% else %}
200+ version = update_version(args.version, args.bump)
201+ {% endif %}
202+ release_branch = f"release/{version}"
203+
204+ with switch_to_branch(release_branch):
205+ build_changelog(version)
206+
207+ # Commit changes.
208+ {% - if not dynamic_version %}
209+ run(("git", "add", ":/pyproject.toml", ":/uv.lock"))
210+ {% - endif %}
211+ run(("git", "add", "-A", ":/changelog.d/*", ":/CHANGELOG.md"))
212+ message = f"chore: prepare release {version}"
213+ run(("git", "commit", "--no-verify", "-m", message))
214+ print(f"Committed changes on branch {release_branch!r}.")
215+
216+ release_tag = create_release_tag(version)
217+
218+ # Exit on dry run.
219+ if args.dry_run:
220+ print("Dry run success!")
221+ run(("git", "tag", "-d", release_tag))
222+ print(f"Removed release tag {release_tag!r}.")
223+ return 0
224+
225+ push_changes(release_branch, "main")
226+
227+ # Switch to the main branch.
163228 run(("git", "checkout", "main"))
164- run(("git", "branch", "-D", release_branch))
165229 run(("git", "fetch"))
166230 run(("git", "reset", "--hard", "origin/main"))
167231 return 0
0 commit comments