Skip to content

Commit 7caf8d3

Browse files
committed
build: git tag helper script
1 parent f4e7c34 commit 7caf8d3

File tree

1 file changed

+181
-0
lines changed

1 file changed

+181
-0
lines changed

tools/git-tag.py

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Git tag release script to simplify creating git tags with changelog since last release.
4+
Copyright (c) 2025 Unfolded Circle.
5+
"""
6+
7+
import os
8+
import subprocess
9+
import sys
10+
import tempfile
11+
import re
12+
import json
13+
14+
15+
def run_command(command, check=True):
16+
"""Run a shell command and return the output."""
17+
try:
18+
result = subprocess.run(command, shell=True, check=check, capture_output=True, text=True)
19+
return result.stdout.strip()
20+
except subprocess.CalledProcessError as e:
21+
print(f"Error running command: {command}")
22+
print(f"Stdout: {e.stdout}")
23+
print(f"Stderr: {e.stderr}")
24+
if check:
25+
sys.exit(1)
26+
return None
27+
28+
29+
def get_latest_tag():
30+
"""Get the latest git tag."""
31+
return run_command("git describe --tags --abbrev=0", check=False)
32+
33+
34+
def get_commits_since(tag):
35+
"""Get commits since the specified tag."""
36+
format_str = '--pretty=format:"%s|%an"'
37+
if tag:
38+
return run_command(f"git log {tag}..HEAD {format_str}")
39+
else:
40+
return run_command(f"git log {format_str}")
41+
42+
43+
def is_valid_semver(tag):
44+
"""Check if the version is a valid semver format (X.Y.Z)."""
45+
return re.match(r"^\d+\.\d+\.\d+$", tag) is not None
46+
47+
48+
def check_version_match(version):
49+
"""Check if the version matches the version in driver.json."""
50+
driver_json_path = os.path.join(os.path.dirname(__file__), "..", "driver.json")
51+
if not os.path.exists(driver_json_path):
52+
print(f"Error: {driver_json_path} not found.")
53+
sys.exit(1)
54+
55+
with open(driver_json_path, "r") as f:
56+
try:
57+
data = json.load(f)
58+
driver_version = data.get("version")
59+
except json.JSONDecodeError:
60+
print(f"Error: Could not parse {driver_json_path}")
61+
sys.exit(1)
62+
63+
if driver_version != version:
64+
print(f"Error: Provided version '{version}' does not match driver.json version '{driver_version}'.")
65+
sys.exit(1)
66+
67+
68+
import argparse
69+
70+
71+
def main():
72+
parser = argparse.ArgumentParser(description="Git tag script to simplify creating git tags.")
73+
parser.add_argument("version", help="The new version in semver format (e.g., 0.21.0)")
74+
parser.add_argument("--dry-run", action="store_true", help="Do not create or push the tag")
75+
args = parser.parse_args()
76+
77+
version = args.version
78+
dry_run = args.dry_run
79+
if not is_valid_semver(version):
80+
print(f"Error: Version '{version}' is not in valid semver format (X.Y.Z)")
81+
sys.exit(1)
82+
83+
check_version_match(version)
84+
85+
new_tag = f"v{version}"
86+
87+
# Check if tag already exists
88+
existing_tags = run_command("git tag").split("\n")
89+
if new_tag in existing_tags:
90+
print(f"Error: Tag '{new_tag}' already exists.")
91+
sys.exit(1)
92+
93+
latest_tag = get_latest_tag()
94+
print(f"Latest tag: {latest_tag}")
95+
96+
if latest_tag:
97+
commits = get_commits_since(latest_tag)
98+
else:
99+
commits = get_commits_since(None)
100+
101+
if not commits:
102+
print("No commits since last tag.")
103+
sys.exit(0)
104+
105+
# Process commits
106+
formatted_pr_commits = []
107+
formatted_other_commits = []
108+
109+
for line in commits.split("\n"):
110+
if not line:
111+
continue
112+
parts = line.split("|")
113+
if len(parts) < 2:
114+
continue
115+
message = parts[0]
116+
author = parts[1]
117+
118+
# Extract PR number if present
119+
pr_match = re.search(r"\(#(\d+)\)", message)
120+
if pr_match:
121+
pr_num = pr_match.group(1)
122+
# Remove the (#num) part from message
123+
clean_message = re.sub(r"\s*\(#\d+\)", "", message).strip()
124+
formatted_line = f"{clean_message} by @{author} in #{pr_num}"
125+
formatted_pr_commits.append(formatted_line)
126+
else:
127+
formatted_line = f"{message} by @{author}"
128+
formatted_other_commits.append(formatted_line)
129+
130+
initial_message = f"Release {new_tag}\n\n"
131+
if formatted_pr_commits:
132+
initial_message += "Pull Requests:\n"
133+
for line in formatted_pr_commits:
134+
initial_message += f"- {line}\n"
135+
initial_message += "\n"
136+
137+
if formatted_other_commits:
138+
initial_message += "Other Changes:\n"
139+
for line in formatted_other_commits:
140+
initial_message += f"- {line}\n"
141+
142+
# Create temporary file for editing
143+
with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
144+
tmp.write(initial_message.encode("utf-8"))
145+
tmp_path = tmp.name
146+
147+
editor = os.environ.get("EDITOR", "vim")
148+
subprocess.call([editor, tmp_path])
149+
150+
with open(tmp_path, "r") as f:
151+
tag_message = f.read().strip()
152+
153+
os.unlink(tmp_path)
154+
155+
if not tag_message:
156+
print("Tag message is empty. Aborting.")
157+
sys.exit(1)
158+
159+
print("\n--- Tag Message ---")
160+
print(tag_message)
161+
print("-------------------\n")
162+
163+
confirm = input(f"Create and push tag {new_tag}? (y/n): ")
164+
if confirm.lower() == "y":
165+
if dry_run:
166+
print(f"[DRY-RUN] Would create annotated tag: {new_tag}")
167+
print(f"[DRY-RUN] Would push tag {new_tag} to origin.")
168+
else:
169+
# Create annotated tag
170+
run_command(f'git tag -a {new_tag} -m "{tag_message}"')
171+
print(f"Tag {new_tag} created locally.")
172+
173+
# Push tag
174+
run_command(f"git push origin {new_tag}")
175+
print(f"Tag {new_tag} pushed to origin.")
176+
else:
177+
print("Aborted.")
178+
179+
180+
if __name__ == "__main__":
181+
main()

0 commit comments

Comments
 (0)