1_github.py
1 #!/usr/bin/env python3 2 """ 3 Tag and push a GitHub release from the workspace version in Cargo.toml. 4 5 Usage: 6 python scripts/releases/1_github.py # dry-run (shows what would happen) 7 python scripts/releases/1_github.py --push # create tag and push to trigger release workflow 8 9 What it does: 10 1. Reads the version from [workspace.package] in Cargo.toml 11 2. Checks crates.io to make sure the version has been bumped 12 3. Checks that the git tag doesn't already exist on GitHub 13 4. Creates a git tag v{version} and pushes it to origin on GitHub 14 15 Requires: 16 - python3 (no external dependencies) 17 - git on PATH 18 - network access to crates.io 19 """ 20 21 import json 22 import re 23 import subprocess 24 import sys 25 import urllib.request 26 from pathlib import Path 27 28 CARGO_TOML = Path(__file__).resolve().parents[2] / "Cargo.toml" 29 CRATES_IO_URL = "https://crates.io/api/v1/crates/auths" 30 31 32 def get_workspace_version() -> str: 33 text = CARGO_TOML.read_text() 34 match = re.search(r'^\[workspace\.package\].*?^version\s*=\s*"([^"]+)"', text, re.MULTILINE | re.DOTALL) 35 if not match: 36 # Fallback: find version under [workspace.package] by scanning lines 37 in_workspace_package = False 38 for line in text.splitlines(): 39 stripped = line.strip() 40 if stripped == "[workspace.package]": 41 in_workspace_package = True 42 continue 43 if in_workspace_package and stripped.startswith("["): 44 break 45 if in_workspace_package: 46 m = re.match(r'version\s*=\s*"([^"]+)"', stripped) 47 if m: 48 return m.group(1) 49 print("ERROR: Could not find version in [workspace.package] in Cargo.toml", file=sys.stderr) 50 sys.exit(1) 51 return match.group(1) 52 53 54 def get_crates_io_version() -> str | None: 55 req = urllib.request.Request(CRATES_IO_URL, headers={"User-Agent": "auths-release-script/1.0"}) 56 try: 57 with urllib.request.urlopen(req, timeout=10) as resp: 58 data = json.loads(resp.read()) 59 return data["crate"]["max_version"] 60 except Exception: 61 return None 62 63 64 def git(*args: str) -> str: 65 result = subprocess.run( 66 ["git", *args], 67 capture_output=True, 68 text=True, 69 cwd=CARGO_TOML.parent, 70 ) 71 if result.returncode != 0: 72 print(f"ERROR: git {' '.join(args)} failed:\n{result.stderr.strip()}", file=sys.stderr) 73 sys.exit(1) 74 return result.stdout.strip() 75 76 77 def local_tag_exists(tag: str) -> bool: 78 result = subprocess.run( 79 ["git", "tag", "-l", tag], 80 capture_output=True, 81 text=True, 82 cwd=CARGO_TOML.parent, 83 ) 84 return bool(result.stdout.strip()) 85 86 87 def remote_tag_exists(tag: str) -> bool: 88 result = subprocess.run( 89 ["git", "ls-remote", "--tags", "origin", f"refs/tags/{tag}"], 90 capture_output=True, 91 text=True, 92 cwd=CARGO_TOML.parent, 93 ) 94 return bool(result.stdout.strip()) 95 96 97 def delete_local_tag(tag: str) -> None: 98 subprocess.run( 99 ["git", "tag", "-d", tag], 100 capture_output=True, 101 cwd=CARGO_TOML.parent, 102 ) 103 104 105 def main() -> None: 106 push = "--push" in sys.argv 107 108 version = get_workspace_version() 109 tag = f"v{version}" 110 print(f"Workspace version: {version}") 111 print(f"Git tag: {tag}") 112 113 # Check crates.io for version bump 114 published = get_crates_io_version() 115 if published: 116 print(f"crates.io version: {published}") 117 if published == version: 118 print(f"\nERROR: Version {version} is already published on crates.io.", file=sys.stderr) 119 print("Bump the version in Cargo.toml before releasing.", file=sys.stderr) 120 sys.exit(1) 121 else: 122 print("crates.io version: (not found or not published yet)") 123 124 # GitHub is the source of truth for tags. 125 # If the tag exists on the remote, abort — the release is already out. 126 # If it only exists locally (stale), clean it up automatically. 127 if remote_tag_exists(tag): 128 print(f"\nERROR: Git tag {tag} already exists on origin.", file=sys.stderr) 129 print("Bump the version in Cargo.toml or delete the remote tag/release first.", file=sys.stderr) 130 sys.exit(1) 131 132 if local_tag_exists(tag): 133 print(f"Local tag {tag} exists but not on origin — deleting stale local tag.") 134 delete_local_tag(tag) 135 136 # Check we're on a clean working tree 137 status = git("status", "--porcelain") 138 if status: 139 print(f"\nERROR: Working tree is not clean:\n{status}", file=sys.stderr) 140 print("Commit or stash changes before releasing.", file=sys.stderr) 141 sys.exit(1) 142 143 if not push: 144 print(f"\nDry run: would create and push tag {tag}") 145 print("Run with --push to execute.") 146 return 147 148 print(f"\nCreating tag {tag}...", flush=True) 149 result = subprocess.run( 150 ["git", "tag", "-a", tag, "-m", f"release: release for {version}"], 151 cwd=CARGO_TOML.parent, 152 ) 153 if result.returncode != 0: 154 print(f"\nERROR: git tag failed (exit {result.returncode})", file=sys.stderr) 155 sys.exit(1) 156 157 print(f"Pushing tag {tag} to origin (pre-push hooks may run)...", flush=True) 158 result = subprocess.run( 159 ["git", "push", "--no-verify", "origin", tag], 160 cwd=CARGO_TOML.parent, 161 ) 162 if result.returncode != 0: 163 print(f"\nERROR: git push failed (exit {result.returncode})", file=sys.stderr) 164 sys.exit(1) 165 166 print(f"\nDone. Release workflow will run at:") 167 print(f" https://github.com/auths-dev/auths/actions") 168 169 170 if __name__ == "__main__": 171 main()