1+ #!/usr/bin/env python3
2+ """
3+ alpha-release - Automated multi-repo alpha release tagging script
4+
5+ Usage:
6+ ./alpha-release [--dry-run]
7+
8+ Examples:
9+ ./alpha-release
10+ ./alpha-release --dry-run
11+
12+ Behavior:
13+ - Creates a fresh 'clean-repos' directory.
14+ - Clones ot3-firmware, buildroot, oe-core, opentrons into that directory.
15+ - Detects the latest `chore_release-X.Y.Z` branch from the opentrons repo.
16+ - Asks you to confirm using that branch for all repos.
17+ - Checks out that branch in each repo.
18+ - For each repo, in this order:
19+ 1. ot3-firmware
20+ 2. buildroot
21+ 3. oe-core
22+ 4. opentrons
23+ - Finds the last tag matching the configured tag pattern.
24+ - If there are new commits since that tag, increments the tag and (unless dry-run) pushes it.
25+
26+ Tag increment logic:
27+ - ot3-firmware: v67 → v68 (numeric bump)
28+ - buildroot: v1.19.6 → v1.19.7 (patch bump)
29+ - oe-core: v0.9.7 → v0.9.8 (patch bump)
30+ - opentrons:
31+ - v8.8.0-alpha.6 → v8.8.0-alpha.7
32+ - v8.8.0 → v8.9.0-alpha.0
33+ """
34+
35+ import argparse
36+ import re
37+ import shutil
38+ import subprocess
39+ import sys
40+ from pathlib import Path
41+ from typing import Optional , Tuple , List
42+
43+
44+ # Root directory where clean clones will live
45+ CLEAN_ROOT = Path ("./clean-repos" )
46+
47+ # Configuration: repo info, tag patterns, and clone URLs
48+ # Branch will be determined dynamically as the latest chore_release-X.Y.Z from opentrons
49+ REPOS = [
50+ {
51+ "name" : "ot3-firmware" ,
52+ "dir_name" : "ot3-firmware" ,
53+ "clone_url" :
"[email protected] :opentrons/ot3-firmware.git" ,
54+ "tag_pattern" : "v*" ,
55+ "tag_type" : "numeric" , # v67 → v68
56+ },
57+ {
58+ "name" : "buildroot" ,
59+ "dir_name" : "buildroot" ,
60+ "clone_url" :
"[email protected] :opentrons/buildroot.git" ,
61+ "tag_pattern" : "v*" ,
62+ "tag_type" : "semver" , # v1.19.6 → v1.19.7
63+ },
64+ {
65+ "name" : "oe-core" ,
66+ "dir_name" : "oe-core" ,
67+ "clone_url" :
"[email protected] :opentrons/oe-core.git" ,
68+ "tag_pattern" : "v*" ,
69+ "tag_type" : "semver" , # v0.9.7 → v0.9.8
70+ },
71+ {
72+ "name" : "opentrons" ,
73+ "dir_name" : "opentrons" ,
74+ "clone_url" :
"[email protected] :opentrons/opentrons.git" ,
75+ "tag_pattern" : "v*" , # Match all v* tags (both alpha and non-alpha)
76+ "tag_type" : "alpha" , # alpha logic described below
77+ },
78+ ]
79+
80+
81+ def run_command (cmd : List [str ], cwd : Optional [Path ] = None , check : bool = True ) -> str :
82+ """Run a shell command and return stdout (stripped)."""
83+ result = subprocess .run (
84+ cmd ,
85+ cwd = cwd ,
86+ capture_output = True ,
87+ text = True ,
88+ )
89+ if check and result .returncode != 0 :
90+ print (f"❌ Command failed: { ' ' .join (cmd )} " )
91+ print (f"stdout:\n { result .stdout } " )
92+ print (f"stderr:\n { result .stderr } " )
93+ raise subprocess .CalledProcessError (
94+ result .returncode , cmd , output = result .stdout , stderr = result .stderr
95+ )
96+ return result .stdout .strip ()
97+
98+
99+ def get_last_tag (repo_path : Path , tag_pattern : str ) -> Optional [str ]:
100+ """Get the most recent tag matching the pattern."""
101+ try :
102+ tags = run_command (
103+ ["git" , "tag" , "-l" , tag_pattern , "--sort=-version:refname" ],
104+ cwd = repo_path ,
105+ )
106+ if tags :
107+ return tags .split ("\n " )[0 ]
108+ return None
109+ except subprocess .CalledProcessError :
110+ return None
111+
112+
113+ def count_commits_since_tag (repo_path : Path , tag : str ) -> int :
114+ """Count commits between tag and HEAD."""
115+ try :
116+ count = run_command (
117+ ["git" , "rev-list" , f"{ tag } ..HEAD" , "--count" ],
118+ cwd = repo_path ,
119+ )
120+ return int (count )
121+ except (subprocess .CalledProcessError , ValueError ):
122+ return 0
123+
124+
125+ def increment_tag (last_tag : str , tag_type : str ) -> str :
126+ """
127+ Increment a tag based on its type.
128+
129+ - semver: v1.19.6 → v1.19.7 (increments patch)
130+ - alpha:
131+ - v8.8.0-alpha.6 → v8.8.0-alpha.7 (increments alpha number)
132+ - v8.8.0 → v8.9.0-alpha.0 (increments minor, starts alpha series)
133+ - numeric:
134+ - v67 → v68 (increments the integer after 'v')
135+ """
136+ if tag_type == "semver" :
137+ match = re .match (r"^v(\d+)\.(\d+)\.(\d+)$" , last_tag )
138+ if not match :
139+ raise ValueError (f"Cannot parse semver tag: { last_tag } " )
140+ major , minor , patch = match .groups ()
141+ new_patch = int (patch ) + 1
142+ return f"v{ major } .{ minor } .{ new_patch } "
143+
144+ elif tag_type == "alpha" :
145+ alpha_match = re .match (r"^(v\d+\.\d+\.\d+-alpha\.)(\d+)$" , last_tag )
146+ if alpha_match :
147+ prefix , alpha_num = alpha_match .groups ()
148+ new_alpha_num = int (alpha_num ) + 1
149+ return f"{ prefix } { new_alpha_num } "
150+
151+ semver_match = re .match (r"^v(\d+)\.(\d+)\.(\d+)$" , last_tag )
152+ if semver_match :
153+ major , minor , _patch = semver_match .groups ()
154+ new_minor = int (minor ) + 1
155+ return f"v{ major } .{ new_minor } .0-alpha.0"
156+
157+ raise ValueError (f"Cannot parse tag for alpha increment: { last_tag } " )
158+
159+ elif tag_type == "numeric" :
160+ m = re .match (r"^v(\d+)$" , last_tag )
161+ if not m :
162+ raise ValueError (f"Cannot parse numeric tag: { last_tag } " )
163+ n = int (m .group (1 )) + 1
164+ return f"v{ n } "
165+
166+ else :
167+ raise ValueError (f"Unknown tag_type: { tag_type } " )
168+
169+
170+ def parse_chore_release_branch (name : str ) -> Optional [Tuple [int , int , int ]]:
171+ """Parse 'chore_release-X.Y.Z' → (X, Y, Z) as ints, or None if no match."""
172+ m = re .match (r"^chore_release-(\d+)\.(\d+)\.(\d+)$" , name )
173+ if not m :
174+ return None
175+ major , minor , patch = map (int , m .groups ())
176+ return major , minor , patch
177+
178+
179+ def find_latest_chore_release_branch (clone_url : str ) -> str :
180+ """
181+ Clone opentrons repo to a temp dir, list all remote chore_release-* branches,
182+ and return the one with the highest X.Y.Z version.
183+ """
184+ from tempfile import TemporaryDirectory
185+
186+ print ("Finding latest chore_release-* branch from opentrons..." )
187+
188+ with TemporaryDirectory () as tmpdir :
189+ tmp_path = Path (tmpdir )
190+ run_command (
191+ ["git" , "clone" , "--origin" , "origin" , clone_url , "tmp-opentrons" ],
192+ cwd = tmp_path ,
193+ )
194+ repo_path = tmp_path / "tmp-opentrons"
195+
196+ branches_raw = run_command (
197+ ["git" , "branch" , "-r" , "--list" , "origin/chore_release-*" ],
198+ cwd = repo_path ,
199+ )
200+ branches = [b .strip () for b in branches_raw .splitlines () if b .strip ()]
201+
202+ if not branches :
203+ print ("❌ No remote chore_release-* branches found in opentrons." )
204+ sys .exit (1 )
205+
206+ best_branch = None
207+ best_version : Optional [Tuple [int , int , int ]] = None
208+
209+ for full in branches :
210+ short = full .replace ("origin/" , "" , 1 ) # origin/chore_release-8.8.0 → chore_release-8.8.0
211+ ver = parse_chore_release_branch (short )
212+ if ver is None :
213+ continue
214+ if best_version is None or ver > best_version :
215+ best_version = ver
216+ best_branch = short
217+
218+ if best_branch is None :
219+ print ("❌ No valid chore_release-X.Y.Z branches found in opentrons." )
220+ sys .exit (1 )
221+
222+ print (f"Detected latest chore release branch: { best_branch } " )
223+ return best_branch
224+
225+
226+ def clone_clean_repos (branch_name : str ) -> None :
227+ """Delete existing clean-repos dir (if any) and clone all repos fresh on the given branch."""
228+ if CLEAN_ROOT .exists ():
229+ print (f"Removing existing directory: { CLEAN_ROOT } " )
230+ shutil .rmtree (CLEAN_ROOT )
231+
232+ CLEAN_ROOT .mkdir (parents = True , exist_ok = True )
233+ print (f"Created clean root directory: { CLEAN_ROOT } " )
234+ print (f"Using branch: { branch_name } " )
235+ print ()
236+
237+ for repo in REPOS :
238+ target_dir = CLEAN_ROOT / repo ["dir_name" ]
239+ print ("=" * 60 )
240+ print (f"Cloning { repo ['name' ]} into { target_dir } " )
241+ print ("=" * 60 )
242+
243+ run_command (
244+ ["git" , "clone" , "--origin" , "origin" , repo ["clone_url" ], str (target_dir )]
245+ )
246+
247+ print (f"Checking out branch '{ branch_name } ' for { repo ['name' ]} ..." )
248+ run_command (["git" , "fetch" , "origin" ], cwd = target_dir )
249+ try :
250+ run_command (["git" , "checkout" , branch_name ], cwd = target_dir )
251+ except subprocess .CalledProcessError :
252+ print (f"❌ Branch '{ branch_name } ' not found in { repo ['name' ]} ." )
253+ sys .exit (1 )
254+ run_command (["git" , "pull" , "origin" , branch_name ], cwd = target_dir )
255+
256+
257+ def tag_repo_if_updated (
258+ repo_name : str ,
259+ repo_path : Path ,
260+ tag_pattern : str ,
261+ tag_type : str ,
262+ dry_run : bool ,
263+ ) -> None :
264+ """Tag a repo if there are new commits since the last matching tag."""
265+ print ("=" * 60 )
266+ print (f"Processing: { repo_name } " )
267+ print (f"Path: { repo_path } " )
268+ print (f"Dry run: { dry_run } " )
269+ print ("=" * 60 )
270+
271+ if not repo_path .exists ():
272+ print (f"❌ Error: Repository path { repo_path } does not exist." )
273+ sys .exit (1 )
274+
275+ print ("Fetching tags..." )
276+ run_command (["git" , "fetch" , "--tags" , "origin" ], cwd = repo_path )
277+
278+ last_tag = get_last_tag (repo_path , tag_pattern )
279+
280+ if not last_tag :
281+ print (f"❌ No previous tag found matching '{ tag_pattern } ' in { repo_name } ." )
282+ print ("Cannot auto-increment. Please create an initial tag manually." )
283+ sys .exit (1 )
284+
285+ print (f"Last tag: { last_tag } " )
286+ commits_since = count_commits_since_tag (repo_path , last_tag )
287+
288+ if commits_since > 0 :
289+ print (f"Found { commits_since } new commit(s) since { last_tag } ." )
290+
291+ try :
292+ new_tag = increment_tag (last_tag , tag_type )
293+ except ValueError as e :
294+ print (f"❌ Error incrementing tag: { e } " )
295+ sys .exit (1 )
296+
297+ print (f"New tag would be: { new_tag } " )
298+ if dry_run :
299+ print ("[DRY RUN] Would run:" )
300+ print (f" git tag { new_tag } " )
301+ print (f" git push origin { new_tag } " )
302+ else :
303+ print (f"Tagging HEAD as { new_tag } ..." )
304+ run_command (["git" , "tag" , new_tag ], cwd = repo_path )
305+ run_command (["git" , "push" , "origin" , new_tag ], cwd = repo_path )
306+ print (f"✅ Tagged and pushed { new_tag } for { repo_name } " )
307+ else :
308+ print (f"No new commits since { last_tag } . Skipping tag for { repo_name } ." )
309+
310+ print ()
311+
312+
313+ def parse_args () -> argparse .Namespace :
314+ parser = argparse .ArgumentParser (
315+ description = "Automated multi-repo alpha release tagging script."
316+ )
317+ parser .add_argument (
318+ "-n" ,
319+ "--dry-run" ,
320+ action = "store_true" ,
321+ help = "Show what would be tagged/pushed without making changes." ,
322+ )
323+ return parser .parse_args ()
324+
325+
326+ def main ():
327+ args = parse_args ()
328+
329+ print ("Starting alpha release process" )
330+ print (f"Dry run: { args .dry_run } " )
331+ print ()
332+
333+ # 1) Determine latest chore_release-* branch from opentrons
334+ opentrons_clone_url = next (r ["clone_url" ] for r in REPOS if r ["name" ] == "opentrons" )
335+ latest_branch = find_latest_chore_release_branch (opentrons_clone_url )
336+
337+ # 2) Confirm with user
338+ answer = input (f"Use branch '{ latest_branch } ' for all repos? [Y/n]: " ).strip ().lower ()
339+ if answer not in ("" , "y" , "yes" ):
340+ print ("Aborting by user request." )
341+ sys .exit (0 )
342+
343+ # 3) Prepare clean clones on that branch
344+ clone_clean_repos (branch_name = latest_branch )
345+
346+ # 4) Tag each repo if updated (in the configured order:
347+ # ot3-firmware → buildroot → oe-core → opentrons)
348+ for repo in REPOS :
349+ repo_path = CLEAN_ROOT / repo ["dir_name" ]
350+ tag_repo_if_updated (
351+ repo_name = repo ["name" ],
352+ repo_path = repo_path ,
353+ tag_pattern = repo ["tag_pattern" ],
354+ tag_type = repo ["tag_type" ],
355+ dry_run = args .dry_run ,
356+ )
357+
358+ print ("=" * 60 )
359+ print ("Alpha release process complete!" )
360+ print ("=" * 60 )
361+
362+
363+ if __name__ == "__main__" :
364+ main ()
0 commit comments