77import sys
88import tomllib
99from pathlib import Path
10+ from typing import NamedTuple
1011
1112ROOT = Path (__file__ ).resolve ().parents [1 ]
13+ VERSION_PATTERN = re .compile (
14+ r"(?P<major>\d+)\.(?P<minor>\d+)\.(?P<patch>\d+)"
15+ r"(?:(?P<stage>a|b|rc)(?P<stage_number>\d+))?"
16+ )
17+ STAGE_ORDER = {"a" : 0 , "b" : 1 , "rc" : 2 , None : 3 }
18+
19+
20+ class Version (NamedTuple ):
21+ major : int
22+ minor : int
23+ patch : int
24+ stage : int
25+ stage_number : int
1226
1327
1428def run (
@@ -30,11 +44,37 @@ def project_version() -> str:
3044 return tomllib .load (file )["project" ]["version" ]
3145
3246
33- def require_supported_version (version : str ) -> None :
34- if not re .fullmatch (r"\d+\.\d+\.\d+((a|b|rc)\d+)?" , version ):
47+ def parse_version (version : str ) -> Version | None :
48+ match = VERSION_PATTERN .fullmatch (version )
49+ if not match :
50+ return None
51+ stage = match .group ("stage" )
52+ stage_number = match .group ("stage_number" )
53+ return Version (
54+ int (match .group ("major" )),
55+ int (match .group ("minor" )),
56+ int (match .group ("patch" )),
57+ STAGE_ORDER [stage ],
58+ int (stage_number or 0 ),
59+ )
60+
61+
62+ def require_supported_version (version : str ) -> Version :
63+ parsed = parse_version (version )
64+ if parsed is None :
3565 print (f"Unsupported release version: { version !r} " )
3666 print ("Expected X.Y.Z, X.Y.ZaN, X.Y.ZbN, or X.Y.ZrcN." )
3767 sys .exit (1 )
68+ return parsed
69+
70+
71+ def require_next_version (current : str , next_version : str ) -> None :
72+ if require_supported_version (next_version ) <= require_supported_version (current ):
73+ print (
74+ f"Next version must be higher than the current version: "
75+ f"{ next_version } <= { current } ."
76+ )
77+ sys .exit (1 )
3878
3979
4080def require_clean_worktree () -> None :
@@ -47,6 +87,15 @@ def require_clean_worktree() -> None:
4787 sys .exit (1 )
4888
4989
90+ def require_branch (branch : str ) -> None :
91+ current = run (["git" , "branch" , "--show-current" ], capture = True ).stdout .strip ()
92+ if current != branch :
93+ print (
94+ f"Release must be run from { branch !r} , but current branch is { current !r} ."
95+ )
96+ sys .exit (1 )
97+
98+
5099def require_tag_available (tag : str ) -> None :
51100 local = subprocess .run (
52101 ["git" , "rev-parse" , "--verify" , "--quiet" , f"refs/tags/{ tag } " ],
@@ -71,28 +120,41 @@ def require_tag_available(tag: str) -> None:
71120 sys .exit (1 )
72121
73122
123+ def commit_version_bump (tag : str ) -> None :
124+ run (["git" , "add" , "pyproject.toml" , "uv.lock" ])
125+ staged = run (["git" , "diff" , "--cached" , "--name-only" ], capture = True ).stdout
126+ if not staged .strip ():
127+ print ("Version bump did not change pyproject.toml or uv.lock." )
128+ sys .exit (1 )
129+ run (["git" , "commit" , "-m" , f"Release { tag } " ])
130+
131+
74132def main () -> None :
75133 parser = argparse .ArgumentParser (
76134 description = "Run local checks and build distributions before releasing."
77135 )
78136 parser .add_argument (
79137 "--version" ,
80- help = "Expected version. Defaults to the version in pyproject.toml ." ,
138+ help = "Next release version. Prompts when omitted ." ,
81139 )
82140 args = parser .parse_args ()
83141
84- version = project_version ()
85- require_supported_version (version )
86- if args .version and args .version != version :
87- print (
88- f"Expected version { args .version } , but pyproject.toml contains { version } ."
89- )
142+ current_version = project_version ()
143+ require_supported_version (current_version )
144+ print (f"Current version: { current_version } " )
145+
146+ next_version = args .version or input ("Next version: " ).strip ()
147+ if not next_version :
148+ print ("A next version is required." )
90149 sys .exit (1 )
91150
92- tag = f"v{ version } "
151+ require_next_version (current_version , next_version )
152+ tag = f"v{ next_version } "
93153
94154 require_clean_worktree ()
155+ require_branch ("main" )
95156 require_tag_available (tag )
157+ run (["uv" , "version" , next_version , "--no-sync" ])
96158
97159 dist = ROOT / "dist"
98160 if dist .exists ():
@@ -104,8 +166,12 @@ def main() -> None:
104166 run (["uv" , "run" , "pytest" , "-n" , "auto" ])
105167 run (["uv" , "build" ])
106168
169+ commit_version_bump (tag )
170+ run (["git" , "push" , "origin" , "main" ])
171+ run (["git" , "push" , "origin" , "HEAD:releases" ])
172+
107173 print (f"Release checks passed for { tag } ." )
108- print ("Publish with: git push origin HEAD: releases" )
174+ print ("Release pushed to main and releases. " )
109175
110176
111177if __name__ == "__main__" :
0 commit comments