bump (9855B)
1 #!/usr/bin/env python3 2 # This file is in the public domain. 3 4 import sys 5 import os 6 import re 7 import argparse 8 import subprocess 9 from email.utils import formatdate 10 11 # The list of files this script is responsible for updating 12 FILES_TO_UPDATE = [ 13 "configure.ac", 14 "debian/changelog", 15 "doc/doxygen/taler.doxy" 16 ] 17 18 def run_cmd(cmd, capture=True): 19 """Runs a shell command. Can capture output or print it directly to the terminal.""" 20 if capture: 21 result = subprocess.run(cmd, capture_output=True, text=True, check=True) 22 return result.stdout.strip() 23 else: 24 subprocess.run(cmd, check=True) 25 26 def check_git_clean(): 27 """Checks if the git working directory is clean.""" 28 try: 29 # --porcelain outputs nothing if the working tree is completely clean 30 status = run_cmd(["git", "status", "--porcelain"]) 31 if status: 32 print("Error: Git working directory is not clean.", file=sys.stderr) 33 print("Please commit or stash your changes before bumping the version.", file=sys.stderr) 34 print(f"\nUncommitted changes:\n{status}\n", file=sys.stderr) 35 sys.exit(1) 36 except subprocess.CalledProcessError: 37 print("Error: Could not determine git status. Are you in a git repository?", file=sys.stderr) 38 sys.exit(1) 39 40 def confirm(prompt_text): 41 """Asks the user for a yes/no confirmation.""" 42 while True: 43 choice = input(f"{prompt_text} [y/N]: ").strip().lower() 44 if choice in ['y', 'yes']: 45 return True 46 elif choice in ['n', 'no', '']: 47 return False 48 print("Please answer 'y' or 'n'.") 49 50 def get_current_version(): 51 """Extracts the current version from configure.ac.""" 52 try: 53 with open("configure.ac", 'r') as f: 54 for line in f: 55 if 'AC_INIT' in line: 56 match = re.search(r'AC_INIT\(.*?,\[(.*?)\],', line) 57 if match: 58 return match.group(1) 59 except FileNotFoundError: 60 print("Error: configure.ac not found. Run this from the project root.", file=sys.stderr) 61 sys.exit(1) 62 63 print("Error: Could not parse current version from configure.ac", file=sys.stderr) 64 sys.exit(1) 65 66 def bump_version(current_version, bump_type): 67 """Increments the specified part of a semantic version string.""" 68 parts = current_version.split('.') 69 70 while len(parts) < 3: 71 parts.append('0') 72 73 try: 74 major, minor, patch = map(int, parts[:3]) 75 except ValueError: 76 print(f"Error: Current version '{current_version}' is not strictly numeric (X.Y.Z). " 77 "Cannot auto-bump.", file=sys.stderr) 78 sys.exit(1) 79 80 if bump_type == 'major': 81 return f"{major + 1}.0.0" 82 elif bump_type == 'minor': 83 return f"{major}.{minor + 1}.0" 84 elif bump_type == 'patch': 85 return f"{major}.{minor}.{patch + 1}" 86 87 def is_updated(filepath, version): 88 """Checks if the version string already exists in the file.""" 89 try: 90 with open(filepath, 'r') as f: 91 if version in f.read(): 92 print(f"{filepath} already in {version}") 93 return True 94 except FileNotFoundError: 95 print(f"Warning: {filepath} not found, skipping...", file=sys.stderr) 96 return True 97 return False 98 99 def configure_ac(version, dry_run=False): 100 filepath = FILES_TO_UPDATE[0] 101 if is_updated(filepath, version): 102 return 103 104 with open(filepath, 'r') as f: 105 content = f.read() 106 107 new_lines = [] 108 for line in content.splitlines(): 109 if 'AC_INIT' in line: 110 line = re.sub(r',\[.*?\],', f',[{version}],', line, count=1) 111 new_lines.append(line) 112 113 if dry_run: 114 print(f"[DRY RUN] Would update AC_INIT version in {filepath} to {version}") 115 else: 116 with open(filepath, 'w') as f: 117 f.write('\n'.join(new_lines) + '\n') 118 print(f"{filepath} {version}") 119 120 def debian_changelog(version, date_str, git_user, git_email, dry_run=False): 121 filepath = FILES_TO_UPDATE[1] 122 if is_updated(filepath, version): 123 return 124 125 changelog_entry = ( 126 f"taler-merchant ({version}) unstable; urgency=low\n\n" 127 f" * Release {version}.\n\n" 128 f" -- {git_user} <{git_email}> {date_str}\n\n" 129 ) 130 131 with open(filepath, 'r') as f: 132 existing_content = f.read() 133 134 if dry_run: 135 print(f"[DRY RUN] Would prepend new release block to {filepath} for {version}") 136 else: 137 with open(filepath, 'w') as f: 138 f.write(changelog_entry + existing_content) 139 print(f"debian/changelog {version}") 140 141 def doc_doxygen_taler_doxy(version, dry_run=False): 142 filepath = FILES_TO_UPDATE[2] 143 if is_updated(filepath, version): 144 return 145 146 with open(filepath, 'r') as f: 147 lines = f.readlines() 148 149 for i, line in enumerate(lines): 150 if 'PROJECT_NUMBER' in line: 151 lines[i] = re.sub(r'= .*', f'= {version}', line) 152 153 if dry_run: 154 print(f"[DRY RUN] Would update PROJECT_NUMBER in {filepath} to {version}") 155 else: 156 with open(filepath, 'w') as f: 157 f.writelines(lines) 158 print(f"doc/doxygen/taler.doxy {version}") 159 160 def git_release_workflow(version, dry_run=False): 161 """Handles committing, pushing, tagging, and pushing the tag safely.""" 162 print("\n--- Git Release Workflow ---") 163 164 if dry_run: 165 print(f"[DRY RUN] Would commit, push, tag, and push tag for version {version}") 166 return 167 168 # 1. Commit locally 169 if confirm(f"Stage changed files and commit as 'Bump version to v{version}'?"): 170 existing_files = [f for f in FILES_TO_UPDATE if os.path.exists(f)] 171 run_cmd(["git", "add"] + existing_files, capture=False) 172 run_cmd(["git", "commit", "-m", f"Bump version to v{version}"], capture=False) 173 print("Commit successful.") 174 else: 175 print("Skipping commit. (Aborting remaining git operations)") 176 return 177 178 # 2. Push the commit BEFORE tagging 179 push_successful = False 180 if confirm("Push commit to remote?"): 181 print("Pushing commit...") 182 try: 183 run_cmd(["git", "push"], capture=False) 184 print("Push successful.") 185 push_successful = True 186 except subprocess.CalledProcessError: 187 print("\nError: Push failed! You likely need to pull or rebase.") 188 print("Aborting the tagging process to prevent an orphaned local tag.") 189 return 190 else: 191 print("Skipping push.") 192 193 # 3. Tag (Only if push succeeded or user explicitly skipped push but still wants to tag) 194 tag_name = f"v{version}" 195 if confirm(f"Create git tag '{tag_name}'?"): 196 run_cmd(["git", "tag", "-a", tag_name, "-m", f"Release {version}"], capture=False) 197 print(f"Tag {tag_name} created locally.") 198 199 # 4. Push the Tag 200 if push_successful or confirm("The main commit wasn't pushed. Push the tag anyway?"): 201 if confirm(f"Push tag {tag_name} to remote?"): 202 print(f"Pushing tag {tag_name}...") 203 run_cmd(["git", "push", "origin", tag_name], capture=False) 204 print("Tag pushed successfully.") 205 else: 206 print("Skipping tag push.") 207 else: 208 print("Skipping tag.") 209 210 def main(): 211 parser = argparse.ArgumentParser(description="Update and bump project version strings.") 212 213 parser.add_argument('--dry-run', action='store_true', help="Simulate the changes without modifying files") 214 parser.add_argument('--commit', action='store_true', help="Interactively commit, push, and tag the version bump") 215 216 group = parser.add_mutually_exclusive_group() 217 group.add_argument('--major', action='store_true', help="Increment the major version (X.0.0)") 218 group.add_argument('--minor', action='store_true', help="Increment the minor version (x.Y.0)") 219 group.add_argument('--patch', action='store_true', help="Increment the patch version (x.y.Z)") 220 221 parser.add_argument('version', nargs='?', help="Explicitly specify the new version string (e.g., 1.2.3)") 222 223 args = parser.parse_args() 224 225 # Check for a clean git directory before making ANY changes 226 # We bypass this strictly if doing a dry-run so you can test bumping safely 227 if not args.dry_run: 228 check_git_clean() 229 230 bump_requested = args.major or args.minor or args.patch 231 232 if bump_requested and args.version: 233 parser.error("You cannot specify both a specific version string and a bump flag.") 234 elif not bump_requested and not args.version: 235 parser.error("You must specify either a specific version string or a bump flag (--major/--minor/--patch).") 236 237 if bump_requested: 238 current_version = get_current_version() 239 bump_type = 'major' if args.major else 'minor' if args.minor else 'patch' 240 version = bump_version(current_version, bump_type) 241 if args.dry_run: 242 print(f"[DRY RUN] Detected current version: {current_version}") 243 print(f"Bumping {bump_type} version: {current_version} -> {version}") 244 else: 245 version = args.version 246 247 date_str = formatdate(localtime=True) 248 try: 249 git_user = run_cmd(["git", "config", "user.name"]) 250 git_email = run_cmd(["git", "config", "user.email"]) 251 except subprocess.CalledProcessError: 252 print("Error: Could not retrieve git user details. Are you in a git repository?", file=sys.stderr) 253 sys.exit(1) 254 255 # Execute file updates 256 configure_ac(version, dry_run=args.dry_run) 257 debian_changelog(version, date_str, git_user, git_email, dry_run=args.dry_run) 258 doc_doxygen_taler_doxy(version, dry_run=args.dry_run) 259 260 # Execute git workflow if requested 261 if args.commit: 262 git_release_workflow(version, dry_run=args.dry_run) 263 264 if __name__ == '__main__': 265 main()