|
| 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 do_push(branch_name: str, dry_run: bool): |
| 138 | + print(f"Pushing branch {branch_name}") |
| 139 | + rc, out, err = run_git(["push"], dry_run) |
| 140 | + if rc != 0: |
| 141 | + print(f"Failed to push branch {branch_name}", file=sys.stderr) |
| 142 | + if out: |
| 143 | + print(out) |
| 144 | + if err: |
| 145 | + print(err) |
| 146 | + sys.exit(1) |
| 147 | + |
| 148 | +def tag_and_push(branch: str, version: str, push: bool, dry_run: bool): |
| 149 | + tag = f"{branch}-{version}" |
| 150 | + # check tag does not already exist |
| 151 | + rc, _, _ = run_git(["rev-parse", "-q", "--verify", f"refs/tags/{tag}"]) |
| 152 | + if rc == 0: |
| 153 | + print(f"Tag '{tag}' already exists. Aborting to avoid overwriting.", file=sys.stderr) |
| 154 | + sys.exit(1) |
| 155 | + print(f"Creating tag '{tag}'...") |
| 156 | + rc, out, err = run_git(["tag", tag], dry_run) |
| 157 | + if rc != 0: |
| 158 | + print(f"Failed to create tag {tag}.", file=sys.stderr) |
| 159 | + if out: |
| 160 | + print(out) |
| 161 | + if err: |
| 162 | + print(err) |
| 163 | + sys.exit(1) |
| 164 | + if push: |
| 165 | + print(f"Pushing tag '{tag}' to origin...") |
| 166 | + rc, out, err = run_git(["push", "origin", tag], dry_run) |
| 167 | + if rc != 0: |
| 168 | + print(f"Failed to push tag {tag} to origin.", file=sys.stderr) |
| 169 | + if out: |
| 170 | + print(out) |
| 171 | + if err: |
| 172 | + print(err) |
| 173 | + sys.exit(1) |
| 174 | + |
| 175 | +def fetch_origin(dry_run: bool): |
| 176 | + print("Fetching origin ...") |
| 177 | + rc, out, err = run_git(["fetch", "origin"], dry_run) |
| 178 | + if rc != 0: |
| 179 | + print("Warning: 'git fetch origin' returned non-zero.", file=sys.stderr) |
| 180 | + if out: |
| 181 | + print(out) |
| 182 | + if err: |
| 183 | + print(err) |
| 184 | + |
| 185 | +def parse_args(): |
| 186 | + p = argparse.ArgumentParser(description="Merge release branches and create/push tags non-interactively.") |
| 187 | + p.add_argument("version", help="version string to append to tags (used as <branch>-<version>)") |
| 188 | + p.add_argument("--branches", nargs="+", |
| 189 | + default=BRANCHES, |
| 190 | + help="space-separated list of branches in order (default: %(default)s)") |
| 191 | + p.add_argument("--dry-run", action="store_true", help="show commands without executing them") |
| 192 | + p.add_argument("--no-push", dest="push", action="store_false", help="do not push tags to origin") |
| 193 | + return p.parse_args() |
| 194 | + |
| 195 | +def main(): |
| 196 | + args = parse_args() |
| 197 | + version = args.version |
| 198 | + branches = args.branches |
| 199 | + dry_run = args.dry_run |
| 200 | + push = args.push |
| 201 | + |
| 202 | + if not sanity_check_file_versions(version): |
| 203 | + sys.exit(1) |
| 204 | + |
| 205 | + if not working_tree_clean(): |
| 206 | + print("Working tree is not clean. Please commit or stash changes before running this script.", file=sys.stderr) |
| 207 | + rc, status_out, _ = run_git(["status", "--porcelain"]) |
| 208 | + if status_out: |
| 209 | + print(status_out) |
| 210 | + sys.exit(1) |
| 211 | + |
| 212 | + fetch_origin(dry_run=dry_run) |
| 213 | + |
| 214 | + prev = "dev" |
| 215 | + for br in branches: |
| 216 | + print("\n" + "="*60) |
| 217 | + print(f"Processing branch: {br} (merge {prev} -> {br})") |
| 218 | + print("="*60) |
| 219 | + |
| 220 | + ensure_branch_local_or_origin(br, dry_run=dry_run) |
| 221 | + checkout_branch(br, dry_run=dry_run) |
| 222 | + pull_ff_only(br, dry_run=dry_run) |
| 223 | + |
| 224 | + merge_ok = merge_source_into_target(prev, br, dry_run=dry_run) |
| 225 | + if not merge_ok: |
| 226 | + print(f"Merge of {prev} into {br} failed. Aborting.", file=sys.stderr) |
| 227 | + sys.exit(1) |
| 228 | + |
| 229 | + if push: |
| 230 | + do_push(br, dry_run=dry_run) |
| 231 | + |
| 232 | + tag_and_push(br, version, push=push, dry_run=dry_run) |
| 233 | + |
| 234 | + prev = br |
| 235 | + |
| 236 | + print("\nSwitching back to dev branch...") |
| 237 | + checkout_branch("dev", dry_run=dry_run) |
| 238 | + |
| 239 | + print("\nAll done. Created tags for version", version, "on branches:", ", ".join(branches)) |
| 240 | + |
| 241 | +if __name__ == "__main__": |
| 242 | + main() |
0 commit comments