Skip to content

Commit 5c3f8d8

Browse files
committed
Add release creation script
1 parent e8eb997 commit 5c3f8d8

File tree

1 file changed

+225
-0
lines changed

1 file changed

+225
-0
lines changed

create_release.py

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
#!/usr/bin/env python3
2+
"""
3+
create_release.py
4+
5+
Usage:
6+
python create_release.py <version> [--branches 2024.3 2025.1 ...] [--dry-run]
7+
8+
Example:
9+
python create_release.py 1.2.3
10+
python create_release.py 1.2.3 --branches 2024.3 2025.1 2025.2 2025.3
11+
python create_release.py 1.2.3 --dry-run
12+
"""
13+
14+
from __future__ import annotations
15+
import argparse
16+
import shlex
17+
import subprocess
18+
import sys
19+
from typing import List, Tuple
20+
21+
BRANCHES=["2024.3", "2025.1", "2025.2", "2025.3"]
22+
23+
def check_file_contains_version(path: str, version: str) -> bool:
24+
try:
25+
with open(path, "r") as fh:
26+
data = fh.read()
27+
except Exception as e:
28+
print(f"Unable to read file {path}: {e}", file=sys.stderr)
29+
return False
30+
return version in data
31+
32+
def sanity_check_file_versions(version: str) -> bool:
33+
result = True
34+
for path in ["readme.md", "changelog.md", "gradle.properties"]:
35+
if not check_file_contains_version(path, version):
36+
print(f"File {path} does not contain the version number")
37+
result = False
38+
return result
39+
40+
def run_git(args: List[str], dry_run: bool = False, capture_output: bool = True) -> Tuple[int, str, str]:
41+
"""Run git command and return (returncode, stdout, stderr)."""
42+
if dry_run:
43+
print(f"[dry_run] would: git {" ".join(args)}")
44+
return 0, "", ""
45+
46+
proc = subprocess.run(
47+
["git"] + args,
48+
stdout=subprocess.PIPE if capture_output else None,
49+
stderr=subprocess.PIPE if capture_output else None,
50+
text=True,
51+
shell=False,
52+
)
53+
stdout = proc.stdout or ""
54+
stderr = proc.stderr or ""
55+
return proc.returncode, stdout.strip(), stderr.strip()
56+
57+
def working_tree_clean() -> bool:
58+
rc, out, _ = run_git(["status", "--porcelain"])
59+
return out.strip() == ""
60+
61+
def ensure_branch_local_or_origin(branch: str, dry_run: bool):
62+
# if local branch exists, do nothing
63+
rc, _, _ = run_git(["show-ref", "--verify", f"refs/heads/{branch}"])
64+
if rc == 0:
65+
return
66+
# local branch not present, check origin/<branch>
67+
rc, _, _ = run_git(["ls-remote", "--exit-code", "--heads", "origin", branch])
68+
if rc == 0:
69+
print(f"Creating local branch '{branch}' from origin/{branch}...")
70+
rc2, out, err = run_git(["checkout", "-b", branch, f"origin/{branch}"], dry_run, capture_output=True)
71+
if dry_run:
72+
return
73+
if rc2 != 0:
74+
print(f"Failed to create local branch {branch} from origin/{branch}.", file=sys.stderr)
75+
print(out, err, sep="\n", file=sys.stderr)
76+
sys.exit(1)
77+
return
78+
# neither local nor origin branch exists
79+
print(f"Branch '{branch}' does not exist locally nor on origin. Aborting.", file=sys.stderr)
80+
sys.exit(1)
81+
82+
def checkout_branch(branch: str, dry_run: bool):
83+
rc, out, err = run_git(["checkout", branch], dry_run)
84+
if dry_run:
85+
return
86+
if rc != 0:
87+
print(f"Failed to checkout branch '{branch}'.", file=sys.stderr)
88+
print(out, err, sep="\n", file=sys.stderr)
89+
sys.exit(1)
90+
91+
def pull_ff_only(branch: str, dry_run: bool):
92+
# Try to fast-forward only; if it fails, continue (we don't forcibly rebase here).
93+
rc, out, err = run_git(["pull", "--ff-only", "origin", branch], dry_run)
94+
if dry_run:
95+
return
96+
# It's fine if pull reports nothing to do (rc==0) or fails (rc != 0); we'll continue,
97+
# but if rc != 0 we print a helpful message.
98+
if rc != 0:
99+
print(f"Warning: 'git pull --ff-only origin {branch}' returned non-zero. Continuing, but check branch state.")
100+
if out:
101+
print(out)
102+
if err:
103+
print(err)
104+
105+
def merge_source_into_target(source: str, target: str, dry_run: bool):
106+
print(f"Merging {source} -> {target}...")
107+
rc, out, err = run_git(["merge", "--no-edit", source], dry_run, capture_output=True)
108+
if dry_run:
109+
return True
110+
if rc == 0:
111+
print(out)
112+
return True
113+
# Merge failed. Check for conflicts
114+
print("Merge command returned non-zero. Checking for conflicts...")
115+
rc2, status_out, _ = run_git(["status", "--porcelain"])
116+
if any(line.startswith(("UU","AA","DU","UD","AU","UA","??")) for line in status_out.splitlines()):
117+
print("Merge appears to have conflicts. Attempting to abort the merge...", file=sys.stderr)
118+
rc3, abort_out, abort_err = run_git(["merge", "--abort"])
119+
if rc3 != 0:
120+
print("Failed to abort merge automatically. You must resolve the repository state manually.", file=sys.stderr)
121+
if abort_out:
122+
print(abort_out)
123+
if abort_err:
124+
print(abort_err)
125+
else:
126+
print("Merge aborted.")
127+
sys.exit(1)
128+
else:
129+
# Non-conflict failure (some other error)
130+
print("Merge failed for an unknown reason.", file=sys.stderr)
131+
if out:
132+
print(out)
133+
if err:
134+
print(err, file=sys.stderr)
135+
sys.exit(1)
136+
137+
def tag_and_push(branch: str, version: str, push: bool, dry_run: bool):
138+
tag = f"{branch}-{version}"
139+
# check tag does not already exist
140+
rc, _, _ = run_git(["rev-parse", "-q", "--verify", f"refs/tags/{tag}"])
141+
if rc == 0:
142+
print(f"Tag '{tag}' already exists. Aborting to avoid overwriting.", file=sys.stderr)
143+
sys.exit(1)
144+
print(f"Creating tag '{tag}'...")
145+
rc, out, err = run_git(["tag", tag], dry_run)
146+
if rc != 0:
147+
print(f"Failed to create tag {tag}.", file=sys.stderr)
148+
if out:
149+
print(out)
150+
if err:
151+
print(err)
152+
sys.exit(1)
153+
if push:
154+
print(f"Pushing tag '{tag}' to origin...")
155+
rc, out, err = run_git(["push", "origin", tag], dry_run)
156+
if rc != 0:
157+
print(f"Failed to push tag {tag} to origin.", file=sys.stderr)
158+
if out:
159+
print(out)
160+
if err:
161+
print(err)
162+
sys.exit(1)
163+
164+
def fetch_origin(dry_run: bool):
165+
print("Fetching origin ...")
166+
rc, out, err = run_git(["fetch", "origin"], dry_run)
167+
if rc != 0:
168+
print("Warning: 'git fetch origin --prune' returned non-zero.", file=sys.stderr)
169+
if out:
170+
print(out)
171+
if err:
172+
print(err)
173+
174+
def parse_args():
175+
p = argparse.ArgumentParser(description="Merge release branches and create/push tags non-interactively.")
176+
p.add_argument("version", help="version string to append to tags (used as <branch>-<version>)")
177+
p.add_argument("--branches", nargs="+",
178+
default=BRANCHES,
179+
help="space-separated list of branches in order (default: %(default)s)")
180+
p.add_argument("--dry-run", action="store_true", help="show commands without executing them")
181+
p.add_argument("--no-push", dest="push", action="store_false", help="do not push tags to origin")
182+
return p.parse_args()
183+
184+
def main():
185+
args = parse_args()
186+
version = args.version
187+
branches = args.branches
188+
dry_run = args.dry_run
189+
push = args.push
190+
191+
if not sanity_check_file_versions(version):
192+
sys.exit(1)
193+
194+
if not working_tree_clean():
195+
print("Working tree is not clean. Please commit or stash changes before running this script.", file=sys.stderr)
196+
rc, status_out, _ = run_git(["status", "--porcelain"])
197+
if status_out:
198+
print(status_out)
199+
sys.exit(1)
200+
201+
fetch_origin(dry_run=dry_run)
202+
203+
prev = "dev"
204+
for br in branches:
205+
print("\n" + "="*60)
206+
print(f"Processing branch: {br} (merge {prev} -> {br})")
207+
print("="*60)
208+
209+
ensure_branch_local_or_origin(br, dry_run=dry_run)
210+
checkout_branch(br, dry_run=dry_run)
211+
pull_ff_only(br, dry_run=dry_run)
212+
213+
merge_ok = merge_source_into_target(prev, br, dry_run=dry_run)
214+
if not merge_ok:
215+
print(f"Merge of {prev} into {br} failed. Aborting.", file=sys.stderr)
216+
sys.exit(1)
217+
218+
tag_and_push(br, version, push=push, dry_run=dry_run)
219+
220+
prev = br
221+
222+
print("\nAll done. Created tags for version", version, "on branches:", ", ".join(branches))
223+
224+
if __name__ == "__main__":
225+
main()

0 commit comments

Comments
 (0)