Skip to content

Commit e9c66aa

Browse files
committed
moved the script around
1 parent cdd0c57 commit e9c66aa

File tree

1 file changed

+364
-0
lines changed

1 file changed

+364
-0
lines changed
Lines changed: 364 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,364 @@
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

Comments
 (0)