commit b4159fe9d4faa6b9fd8398325774f577f644154f
parent 9b9a3e49b9ca473bcd16ecd3b92074dfc8dd0633
Author: Florian Dold <florian@dold.me>
Date: Wed, 25 Feb 2026 15:25:14 +0100
improve bump script
Diffstat:
| M | contrib/bump | | | 325 | ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------- |
1 file changed, 264 insertions(+), 61 deletions(-)
diff --git a/contrib/bump b/contrib/bump
@@ -1,62 +1,265 @@
-#!/usr/bin/env bash
+#!/usr/bin/env python3
# This file is in the public domain.
-set -eu
-
-if [ $# != 1 ]; then
- >&2 echo "Illegal number of arguments"
- >&2 echo "Usage: $0 <version>"
- exit -1
-fi
-
-VERSION="$1"
-DATE="$(date -R)"
-GIT_USER="$(git config user.name)"
-GIT_EMAIL="$(git config user.email)"
-
-function updated {
- local FILE=$1
- if [[ $(grep "${VERSION}" "${FILE}") ]]; then
- echo "${FILE} already in ${VERSION}"
- return -1
- fi
-}
-
-# update configure.ac
-function configure_ac {
- if [[ $(grep AC_INIT configure.ac | grep "${VERSION}") ]];
- then
- echo "configure.ac already in ${VERSION}"
- return 0
- fi
-
- sed -i "/AC_INIT/s/,\\[\\(.*\\)\\],/,[${VERSION}],/" configure.ac
- echo "configure.ac ${VERSION}"
-}
-
-# update debian/changelog
-function debian_changelog {
- updated debian/changelog || return 0
-
- cat <<EOF > ./debian/changelog.tmp
-taler-merchant (${VERSION}) unstable; urgency=low
-
- * Release ${VERSION}.
-
- -- ${GIT_USER} <${GIT_EMAIL}> ${DATE}
-
-EOF
- cat ./debian/changelog >> ./debian/changelog.tmp
- mv ./debian/changelog.tmp ./debian/changelog
- echo "debian/changelog ${VERSION}"
-}
-
-function doc_doxygen_taler_doxy {
- updated doc/doxygen/taler.doxy || return 0
-
- sed -i "/PROJECT_NUMBER/s/= \(.*\)/= ${VERSION}/" doc/doxygen/taler.doxy
- echo "doc/doxygen/taler.doxy ${VERSION}"
-}
-
-configure_ac
-debian_changelog
-doc_doxygen_taler_doxy
+
+import sys
+import os
+import re
+import argparse
+import subprocess
+from email.utils import formatdate
+
+# The list of files this script is responsible for updating
+FILES_TO_UPDATE = [
+ "configure.ac",
+ "debian/changelog",
+ "doc/doxygen/taler.doxy"
+]
+
+def run_cmd(cmd, capture=True):
+ """Runs a shell command. Can capture output or print it directly to the terminal."""
+ if capture:
+ result = subprocess.run(cmd, capture_output=True, text=True, check=True)
+ return result.stdout.strip()
+ else:
+ subprocess.run(cmd, check=True)
+
+def check_git_clean():
+ """Checks if the git working directory is clean."""
+ try:
+ # --porcelain outputs nothing if the working tree is completely clean
+ status = run_cmd(["git", "status", "--porcelain"])
+ if status:
+ print("Error: Git working directory is not clean.", file=sys.stderr)
+ print("Please commit or stash your changes before bumping the version.", file=sys.stderr)
+ print(f"\nUncommitted changes:\n{status}\n", file=sys.stderr)
+ sys.exit(1)
+ except subprocess.CalledProcessError:
+ print("Error: Could not determine git status. Are you in a git repository?", file=sys.stderr)
+ sys.exit(1)
+
+def confirm(prompt_text):
+ """Asks the user for a yes/no confirmation."""
+ while True:
+ choice = input(f"{prompt_text} [y/N]: ").strip().lower()
+ if choice in ['y', 'yes']:
+ return True
+ elif choice in ['n', 'no', '']:
+ return False
+ print("Please answer 'y' or 'n'.")
+
+def get_current_version():
+ """Extracts the current version from configure.ac."""
+ try:
+ with open("configure.ac", 'r') as f:
+ for line in f:
+ if 'AC_INIT' in line:
+ match = re.search(r'AC_INIT\(.*?,\[(.*?)\],', line)
+ if match:
+ return match.group(1)
+ except FileNotFoundError:
+ print("Error: configure.ac not found. Run this from the project root.", file=sys.stderr)
+ sys.exit(1)
+
+ print("Error: Could not parse current version from configure.ac", file=sys.stderr)
+ sys.exit(1)
+
+def bump_version(current_version, bump_type):
+ """Increments the specified part of a semantic version string."""
+ parts = current_version.split('.')
+
+ while len(parts) < 3:
+ parts.append('0')
+
+ try:
+ major, minor, patch = map(int, parts[:3])
+ except ValueError:
+ print(f"Error: Current version '{current_version}' is not strictly numeric (X.Y.Z). "
+ "Cannot auto-bump.", file=sys.stderr)
+ sys.exit(1)
+
+ if bump_type == 'major':
+ return f"{major + 1}.0.0"
+ elif bump_type == 'minor':
+ return f"{major}.{minor + 1}.0"
+ elif bump_type == 'patch':
+ return f"{major}.{minor}.{patch + 1}"
+
+def is_updated(filepath, version):
+ """Checks if the version string already exists in the file."""
+ try:
+ with open(filepath, 'r') as f:
+ if version in f.read():
+ print(f"{filepath} already in {version}")
+ return True
+ except FileNotFoundError:
+ print(f"Warning: {filepath} not found, skipping...", file=sys.stderr)
+ return True
+ return False
+
+def configure_ac(version, dry_run=False):
+ filepath = FILES_TO_UPDATE[0]
+ if is_updated(filepath, version):
+ return
+
+ with open(filepath, 'r') as f:
+ content = f.read()
+
+ new_lines = []
+ for line in content.splitlines():
+ if 'AC_INIT' in line:
+ line = re.sub(r',\[.*?\],', f',[{version}],', line, count=1)
+ new_lines.append(line)
+
+ if dry_run:
+ print(f"[DRY RUN] Would update AC_INIT version in {filepath} to {version}")
+ else:
+ with open(filepath, 'w') as f:
+ f.write('\n'.join(new_lines) + '\n')
+ print(f"{filepath} {version}")
+
+def debian_changelog(version, date_str, git_user, git_email, dry_run=False):
+ filepath = FILES_TO_UPDATE[1]
+ if is_updated(filepath, version):
+ return
+
+ changelog_entry = (
+ f"taler-merchant ({version}) unstable; urgency=low\n\n"
+ f" * Release {version}.\n\n"
+ f" -- {git_user} <{git_email}> {date_str}\n\n"
+ )
+
+ with open(filepath, 'r') as f:
+ existing_content = f.read()
+
+ if dry_run:
+ print(f"[DRY RUN] Would prepend new release block to {filepath} for {version}")
+ else:
+ with open(filepath, 'w') as f:
+ f.write(changelog_entry + existing_content)
+ print(f"debian/changelog {version}")
+
+def doc_doxygen_taler_doxy(version, dry_run=False):
+ filepath = FILES_TO_UPDATE[2]
+ if is_updated(filepath, version):
+ return
+
+ with open(filepath, 'r') as f:
+ lines = f.readlines()
+
+ for i, line in enumerate(lines):
+ if 'PROJECT_NUMBER' in line:
+ lines[i] = re.sub(r'= .*', f'= {version}', line)
+
+ if dry_run:
+ print(f"[DRY RUN] Would update PROJECT_NUMBER in {filepath} to {version}")
+ else:
+ with open(filepath, 'w') as f:
+ f.writelines(lines)
+ print(f"doc/doxygen/taler.doxy {version}")
+
+def git_release_workflow(version, dry_run=False):
+ """Handles committing, pushing, tagging, and pushing the tag safely."""
+ print("\n--- Git Release Workflow ---")
+
+ if dry_run:
+ print(f"[DRY RUN] Would commit, push, tag, and push tag for version {version}")
+ return
+
+ # 1. Commit locally
+ if confirm(f"Stage changed files and commit as 'Bump version to v{version}'?"):
+ existing_files = [f for f in FILES_TO_UPDATE if os.path.exists(f)]
+ run_cmd(["git", "add"] + existing_files, capture=False)
+ run_cmd(["git", "commit", "-m", f"Bump version to v{version}"], capture=False)
+ print("Commit successful.")
+ else:
+ print("Skipping commit. (Aborting remaining git operations)")
+ return
+
+ # 2. Push the commit BEFORE tagging
+ push_successful = False
+ if confirm("Push commit to remote?"):
+ print("Pushing commit...")
+ try:
+ run_cmd(["git", "push"], capture=False)
+ print("Push successful.")
+ push_successful = True
+ except subprocess.CalledProcessError:
+ print("\nError: Push failed! You likely need to pull or rebase.")
+ print("Aborting the tagging process to prevent an orphaned local tag.")
+ return
+ else:
+ print("Skipping push.")
+
+ # 3. Tag (Only if push succeeded or user explicitly skipped push but still wants to tag)
+ tag_name = f"v{version}"
+ if confirm(f"Create git tag '{tag_name}'?"):
+ run_cmd(["git", "tag", "-a", tag_name, "-m", f"Release {version}"], capture=False)
+ print(f"Tag {tag_name} created locally.")
+
+ # 4. Push the Tag
+ if push_successful or confirm("The main commit wasn't pushed. Push the tag anyway?"):
+ if confirm(f"Push tag {tag_name} to remote?"):
+ print(f"Pushing tag {tag_name}...")
+ run_cmd(["git", "push", "origin", tag_name], capture=False)
+ print("Tag pushed successfully.")
+ else:
+ print("Skipping tag push.")
+ else:
+ print("Skipping tag.")
+
+def main():
+ parser = argparse.ArgumentParser(description="Update and bump project version strings.")
+
+ parser.add_argument('--dry-run', action='store_true', help="Simulate the changes without modifying files")
+ parser.add_argument('--commit', action='store_true', help="Interactively commit, push, and tag the version bump")
+
+ group = parser.add_mutually_exclusive_group()
+ group.add_argument('--major', action='store_true', help="Increment the major version (X.0.0)")
+ group.add_argument('--minor', action='store_true', help="Increment the minor version (x.Y.0)")
+ group.add_argument('--patch', action='store_true', help="Increment the patch version (x.y.Z)")
+
+ parser.add_argument('version', nargs='?', help="Explicitly specify the new version string (e.g., 1.2.3)")
+
+ args = parser.parse_args()
+
+ # Check for a clean git directory before making ANY changes
+ # We bypass this strictly if doing a dry-run so you can test bumping safely
+ if not args.dry_run:
+ check_git_clean()
+
+ bump_requested = args.major or args.minor or args.patch
+
+ if bump_requested and args.version:
+ parser.error("You cannot specify both a specific version string and a bump flag.")
+ elif not bump_requested and not args.version:
+ parser.error("You must specify either a specific version string or a bump flag (--major/--minor/--patch).")
+
+ if bump_requested:
+ current_version = get_current_version()
+ bump_type = 'major' if args.major else 'minor' if args.minor else 'patch'
+ version = bump_version(current_version, bump_type)
+ if args.dry_run:
+ print(f"[DRY RUN] Detected current version: {current_version}")
+ print(f"Bumping {bump_type} version: {current_version} -> {version}")
+ else:
+ version = args.version
+
+ date_str = formatdate(localtime=True)
+ try:
+ git_user = run_cmd(["git", "config", "user.name"])
+ git_email = run_cmd(["git", "config", "user.email"])
+ except subprocess.CalledProcessError:
+ print("Error: Could not retrieve git user details. Are you in a git repository?", file=sys.stderr)
+ sys.exit(1)
+
+ # Execute file updates
+ configure_ac(version, dry_run=args.dry_run)
+ debian_changelog(version, date_str, git_user, git_email, dry_run=args.dry_run)
+ doc_doxygen_taler_doxy(version, dry_run=args.dry_run)
+
+ # Execute git workflow if requested
+ if args.commit:
+ git_release_workflow(version, dry_run=args.dry_run)
+
+if __name__ == '__main__':
+ main()