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 ("\n Next 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