merchant

Merchant backend to process payments, run by merchants
Log | Files | Refs | Submodules | README | LICENSE

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()