Skip to content

Commit 3bcaed7

Browse files
committed
feat: add enhanced release script for UV-based workflow
- Independent of Poetry, works directly with pyproject.toml - Better error handling and prerequisites checking - Automatic git user configuration if not set - Support for dry-run mode to preview changes - Option to skip GitHub release creation - Cleaner commit message formatting - Improved changelog extraction for release notes
1 parent c2b1308 commit 3bcaed7

File tree

1 file changed

+365
-0
lines changed

1 file changed

+365
-0
lines changed

release_uv.py

Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Enhanced release automation script for UV-based projects.
4+
Works without Poetry dependency, directly manipulating pyproject.toml.
5+
"""
6+
import argparse
7+
import json
8+
import os
9+
import re
10+
import subprocess
11+
import sys
12+
import urllib.error
13+
import urllib.request
14+
from datetime import datetime
15+
from pathlib import Path
16+
from typing import Literal
17+
18+
VERSION_TYPES = Literal["major", "minor", "patch"]
19+
20+
COMMIT_TYPES = {
21+
"feat": "✨ Features",
22+
"fix": "🐛 Bug Fixes",
23+
"docs": "📚 Documentation",
24+
"style": "💄 Styling",
25+
"refactor": "♻️ Code Refactoring",
26+
"perf": "⚡ Performance Improvements",
27+
"test": "✅ Tests",
28+
"build": "📦 Build System",
29+
"ci": "👷 CI",
30+
"chore": "🔧 Chores",
31+
}
32+
33+
def run_command(cmd: str, check: bool = True) -> str:
34+
"""Run a shell command and return output."""
35+
try:
36+
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, check=check)
37+
return result.stdout.strip()
38+
except subprocess.CalledProcessError as e:
39+
if check:
40+
print(f"❌ Command failed: {cmd}")
41+
print(f" Error: {e.stderr}")
42+
sys.exit(1)
43+
return ""
44+
45+
def get_current_version() -> str:
46+
"""Get current version from pyproject.toml."""
47+
pyproject_path = Path("pyproject.toml")
48+
with open(pyproject_path) as f:
49+
content = f.read()
50+
51+
match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE)
52+
if match:
53+
return match.group(1)
54+
55+
print("❌ Could not find version in pyproject.toml")
56+
sys.exit(1)
57+
58+
def bump_version(current_version: str, version_type: VERSION_TYPES) -> str:
59+
"""Bump version based on type."""
60+
parts = current_version.split('.')
61+
major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2])
62+
63+
if version_type == "major":
64+
return f"{major + 1}.0.0"
65+
elif version_type == "minor":
66+
return f"{major}.{minor + 1}.0"
67+
else: # patch
68+
return f"{major}.{minor}.{patch + 1}"
69+
70+
def update_version_in_files(new_version: str) -> None:
71+
"""Update version in pyproject.toml and __init__.py."""
72+
# Update pyproject.toml
73+
pyproject_path = Path("pyproject.toml")
74+
with open(pyproject_path) as f:
75+
content = f.read()
76+
77+
content = re.sub(
78+
r'^version\s*=\s*"[^"]+"',
79+
f'version = "{new_version}"',
80+
content,
81+
flags=re.MULTILINE
82+
)
83+
84+
with open(pyproject_path, 'w') as f:
85+
f.write(content)
86+
87+
# Update __init__.py
88+
init_path = Path("commitloom/__init__.py")
89+
if init_path.exists():
90+
with open(init_path) as f:
91+
content = f.read()
92+
93+
content = re.sub(
94+
r'__version__\s*=\s*"[^"]+"',
95+
f'__version__ = "{new_version}"',
96+
content
97+
)
98+
99+
with open(init_path, 'w') as f:
100+
f.write(content)
101+
102+
def parse_commit_message(commit: str) -> tuple[str, str]:
103+
"""Parse a commit message into type and description."""
104+
match = re.match(r'^(\w+)(?:\(.*?\))?: (.+)$', commit.strip())
105+
if match:
106+
return match.group(1), match.group(2)
107+
return "other", commit.strip()
108+
109+
def categorize_commits(commits: list[str]) -> dict[str, list[str]]:
110+
"""Categorize commits by type."""
111+
categorized: dict[str, list[str]] = {type_key: [] for type_key in COMMIT_TYPES}
112+
categorized["other"] = []
113+
114+
for commit in commits:
115+
if not commit.strip():
116+
continue
117+
commit_type, description = parse_commit_message(commit)
118+
if commit_type in categorized:
119+
categorized[commit_type].append(description)
120+
else:
121+
categorized["other"].append(description)
122+
123+
return {k: v for k, v in categorized.items() if v}
124+
125+
def update_changelog(version: str) -> None:
126+
"""Update CHANGELOG.md with new version entry."""
127+
changelog_path = Path("CHANGELOG.md")
128+
current_date = datetime.now().strftime("%Y-%m-%d")
129+
130+
# Get commits since last tag
131+
last_tag = run_command("git describe --tags --abbrev=0 2>/dev/null || echo ''", check=False)
132+
if last_tag:
133+
raw_commits = run_command(f"git log {last_tag}..HEAD --pretty=format:'%s'").split('\n')
134+
else:
135+
raw_commits = run_command("git log --pretty=format:'%s'").split('\n')
136+
137+
# Categorize commits
138+
categorized_commits = categorize_commits(raw_commits)
139+
140+
# Create new changelog entry
141+
new_entry = [f"## [{version}] - {current_date}\n"]
142+
143+
for commit_type, emoji_title in COMMIT_TYPES.items():
144+
if commit_type in categorized_commits and categorized_commits[commit_type]:
145+
new_entry.append(f"\n### {emoji_title}")
146+
for change in categorized_commits[commit_type]:
147+
new_entry.append(f"- {change}")
148+
149+
if "other" in categorized_commits and categorized_commits["other"]:
150+
new_entry.append("\n### 🔄 Other Changes")
151+
for change in categorized_commits["other"]:
152+
new_entry.append(f"- {change}")
153+
154+
new_entry.append("\n")
155+
new_entry_text = "\n".join(new_entry)
156+
157+
# Read existing changelog
158+
if changelog_path.exists():
159+
with open(changelog_path) as f:
160+
content = f.read()
161+
else:
162+
content = "# Changelog\n\n"
163+
164+
# Add new entry after header
165+
updated_content = re.sub(
166+
r"(# Changelog\n\n)",
167+
f"\\1{new_entry_text}",
168+
content
169+
)
170+
171+
with open(changelog_path, "w") as f:
172+
f.write(updated_content)
173+
174+
def create_version_commits(new_version: str) -> None:
175+
"""Create granular commits for version changes."""
176+
# Update version files
177+
update_version_in_files(new_version)
178+
179+
# Commit version bump
180+
run_command('git add pyproject.toml commitloom/__init__.py')
181+
run_command(f'git commit -m "build: bump version to {new_version}"')
182+
print("✅ Committed version bump")
183+
184+
# Update changelog
185+
update_changelog(new_version)
186+
run_command('git add CHANGELOG.md')
187+
run_command(f'git commit -m "docs: update changelog for {new_version}"')
188+
print("✅ Committed changelog update")
189+
190+
def get_changelog_entry(version: str) -> str:
191+
"""Extract changelog entry for a specific version."""
192+
changelog_path = Path("CHANGELOG.md")
193+
if not changelog_path.exists():
194+
return ""
195+
196+
with open(changelog_path) as f:
197+
content = f.read()
198+
199+
# Extract the entry for this version
200+
pattern = rf"## \[{re.escape(version)}\].*?\n\n(.*?)(?=\n## \[|\Z)"
201+
match = re.search(pattern, content, re.DOTALL)
202+
if match:
203+
return match.group(1).strip()
204+
return ""
205+
206+
def create_github_release(version: str, dry_run: bool = False) -> None:
207+
"""Create GitHub release with tag."""
208+
tag = f"v{version}"
209+
210+
if dry_run:
211+
print(f"[DRY RUN] Would create tag: {tag}")
212+
return
213+
214+
# Create and push tag
215+
changelog_content = get_changelog_entry(version)
216+
tag_message = f"Release {tag}\n\n{changelog_content}" if changelog_content else f"Release {tag}"
217+
218+
run_command(f'git tag -a {tag} -m "{tag_message}"')
219+
print(f"✅ Created tag {tag}")
220+
221+
# Push commits and tag
222+
run_command("git push origin main")
223+
print("✅ Pushed commits to main")
224+
225+
run_command("git push origin --tags")
226+
print("✅ Pushed tag to origin")
227+
228+
# Create GitHub Release via API
229+
github_token = os.getenv("GITHUB_TOKEN")
230+
if github_token:
231+
try:
232+
# Get repository info from git remote
233+
remote_url = run_command("git remote get-url origin")
234+
repo_match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", remote_url)
235+
if not repo_match:
236+
print("⚠️ Could not parse GitHub repository from remote URL")
237+
return
238+
239+
repo_path = repo_match.group(1)
240+
241+
# Prepare release data
242+
release_data = {
243+
"tag_name": tag,
244+
"name": f"Release {tag}",
245+
"body": changelog_content,
246+
"draft": False,
247+
"prerelease": False
248+
}
249+
250+
# Create release via GitHub API
251+
url = f"https://api.github.com/repos/{repo_path}/releases"
252+
headers = {
253+
"Authorization": f"token {github_token}",
254+
"Accept": "application/vnd.github.v3+json"
255+
}
256+
request = urllib.request.Request(
257+
url,
258+
data=json.dumps(release_data).encode(),
259+
headers=headers,
260+
method="POST"
261+
)
262+
263+
with urllib.request.urlopen(request) as response:
264+
if response.status == 201:
265+
print("✅ Created GitHub Release")
266+
else:
267+
print(f"⚠️ GitHub Release creation returned status {response.status}")
268+
269+
except Exception as e:
270+
print(f"⚠️ Could not create GitHub Release: {str(e)}")
271+
print(" You may need to set the GITHUB_TOKEN environment variable")
272+
else:
273+
print("ℹ️ GITHUB_TOKEN not found. Skipping GitHub Release creation")
274+
print(" Set GITHUB_TOKEN to enable automatic GitHub Release creation")
275+
276+
def check_prerequisites() -> None:
277+
"""Check that we can proceed with release."""
278+
# Ensure we're on main branch
279+
current_branch = run_command("git branch --show-current")
280+
if current_branch != "main":
281+
print(f"❌ Must be on main branch to release (currently on {current_branch})")
282+
sys.exit(1)
283+
284+
# Ensure working directory is clean
285+
if run_command("git status --porcelain"):
286+
print("❌ Working directory is not clean. Commit or stash changes first.")
287+
sys.exit(1)
288+
289+
# Ensure git user is configured
290+
user_name = run_command("git config user.name", check=False)
291+
user_email = run_command("git config user.email", check=False)
292+
if not user_name or not user_email:
293+
print("⚠️ Git user not configured. Setting default values...")
294+
if not user_name:
295+
run_command('git config user.name "Petru Arakiss"')
296+
if not user_email:
297+
run_command('git config user.email "petruarakiss@gmail.com"')
298+
299+
def main() -> None:
300+
parser = argparse.ArgumentParser(
301+
description="Enhanced release automation for UV-based projects"
302+
)
303+
parser.add_argument(
304+
"version_type",
305+
choices=["major", "minor", "patch"],
306+
help="Type of version bump"
307+
)
308+
parser.add_argument(
309+
"--dry-run",
310+
action="store_true",
311+
help="Show what would be done without making changes"
312+
)
313+
parser.add_argument(
314+
"--skip-github",
315+
action="store_true",
316+
help="Skip GitHub release creation"
317+
)
318+
319+
args = parser.parse_args()
320+
321+
print("🚀 Starting release process...")
322+
323+
# Check prerequisites
324+
check_prerequisites()
325+
326+
# Get current version and calculate new version
327+
old_version = get_current_version()
328+
new_version = bump_version(old_version, args.version_type)
329+
330+
print(f"📦 Version bump: {old_version}{new_version}")
331+
332+
if args.dry_run:
333+
print("\n[DRY RUN] Would perform the following actions:")
334+
print(f" 1. Update version to {new_version} in pyproject.toml and __init__.py")
335+
print(f" 2. Create commit: 'build: bump version to {new_version}'")
336+
print(f" 3. Update CHANGELOG.md with new entries")
337+
print(f" 4. Create commit: 'docs: update changelog for {new_version}'")
338+
print(f" 5. Create tag: v{new_version}")
339+
if not args.skip_github:
340+
print(f" 6. Push to origin and create GitHub Release")
341+
else:
342+
# Create version commits
343+
create_version_commits(new_version)
344+
345+
# Create release
346+
if not args.skip_github:
347+
create_github_release(new_version, dry_run=args.dry_run)
348+
else:
349+
# Just create local tag
350+
tag = f"v{new_version}"
351+
changelog_content = get_changelog_entry(new_version)
352+
tag_message = f"Release {tag}\n\n{changelog_content}" if changelog_content else f"Release {tag}"
353+
run_command(f'git tag -a {tag} -m "{tag_message}"')
354+
print(f"✅ Created tag {tag}")
355+
print("ℹ️ Skipped GitHub release (use --skip-github=false to enable)")
356+
357+
print(f"\n🎉 Release {new_version} is ready!")
358+
print("\nNext steps:")
359+
if args.skip_github:
360+
print(" 1. Push changes: git push origin main --tags")
361+
print(" 2. Create GitHub release manually if needed")
362+
print(" 3. Publish to PyPI: uv publish (or your CI/CD will do this)")
363+
364+
if __name__ == "__main__":
365+
main()

0 commit comments

Comments
 (0)