#!/usr/bin/env python3
"""
Version Bump Script for Pomera AI Commander
CORRECT Workflow (FIXED):
1. Validate version preconditions
2. Update package.json
3. Commit version changes
4. Create Git tag pointing to that commit
5. Push commit and tag
6. Create GitHub release (optional)
This ensures setuptools_scm sees a clean tag with no commits after it.
Usage:
python bump_version.py --patch # 1.2.4 -> 1.2.5
python bump_version.py --minor # 1.2.4 -> 1.3.0
python bump_version.py --major # 1.2.4 -> 2.0.0
python bump_version.py 1.3.0 # Direct version
# With --release flag to create GitHub release:
python bump_version.py --patch --release
"""
import subprocess
import json
import sys
from pathlib import Path
def get_current_version() -> str:
"""Get current version from latest Git tag."""
try:
result = subprocess.run(
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True, text=True
)
if result.returncode == 0:
return result.stdout.strip().lstrip('v')
except Exception:
pass
return "0.0.0"
def normalize_version(version: str) -> str:
"""Ensure version is X.Y.Z format (3 parts)."""
parts = version.split('.')
# Handle .dev* suffix
if '.dev' in version:
print(f"ERROR: Version cannot contain .dev suffix: {version}")
sys.exit(1)
# Convert 2-part to 3-part (1.3 -> 1.3.0)
if len(parts) == 2:
return f"{parts[0]}.{parts[1]}.0"
elif len(parts) == 3:
return version
else:
print(f"ERROR: Invalid version format: {version}")
print("Version must be X.Y or X.Y.Z format")
sys.exit(1)
def bump_version(current: str, bump_type: str) -> str:
"""Bump version based on type (major, minor, patch)."""
# Normalize current version first
current = normalize_version(current)
parts = [int(x) for x in current.split(".")[:3]]
if bump_type == "major":
parts = [parts[0] + 1, 0, 0]
elif bump_type == "minor":
parts = [parts[0], parts[1] + 1, 0]
elif bump_type == "patch":
parts = [parts[0], parts[1], parts[2] + 1]
return ".".join(str(x) for x in parts)
def run_validation(version: str) -> bool:
"""Run validation script to check preconditions."""
print(f"\n1. Validating version {version}...")
print("-" * 60)
try:
result = subprocess.run(
[sys.executable, "tools/validate_version.py", version],
capture_output=True,
text=True
)
# Print validation output
print(result.stdout)
if result.returncode != 0:
print("\n❌ Validation failed!")
print(result.stderr)
return False
print("✅ Validation passed!")
return True
except Exception as e:
print(f"❌ Failed to run validation: {e}")
return False
def update_package_json(version: str) -> bool:
"""Update package.json version."""
print(f"\n2. Updating package.json...")
pkg_path = Path("package.json")
if not pkg_path.exists():
print(" ❌ package.json not found")
return False
try:
with open(pkg_path, 'r', encoding='utf-8') as f:
data = json.load(f)
data["version"] = version
with open(pkg_path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=4, ensure_ascii=False)
f.write('\n')
print(f" ✅ Updated package.json to {version}")
return True
except Exception as e:
print(f" ❌ Failed: {e}")
return False
def update_python_version_file(version: str) -> bool:
"""Update pomera/_version.py file."""
print(f"\n3. Updating pomera/_version.py...")
version_file = Path("pomera/_version.py")
if not version_file.exists():
print(" ⚠️ pomera/_version.py not found (will be auto-generated by setuptools_scm)")
return True # Not fatal - file will be generated on install
try:
content = f"# AUTO-GENERATED by setuptools_scm - DO NOT EDIT\n__version__ = \"{version}\"\n"
with open(version_file, 'w', encoding='utf-8') as f:
f.write(content)
print(f" ✅ Updated pomera/_version.py to {version}")
return True
except Exception as e:
print(f" ⚠️ Failed to update pomera/_version.py: {e}")
print(" (Not fatal - will be auto-generated on install)")
return True # Not fatal - continue with release
def commit_version_changes(version: str) -> bool:
"""Commit package.json and pomera/_version.py changes."""
print(f"\n4. Committing version changes...")
try:
# Stage package.json and pomera/_version.py
subprocess.run(["git", "add", "package.json"], check=True)
# Try to stage pomera/_version.py (may be gitignored, that's ok)
try:
subprocess.run(["git", "add", "-f", "pomera/_version.py"], check=False)
except Exception:
pass # Gitignored file, skip silently
# Check if there are changes
result = subprocess.run(
["git", "diff", "--cached", "--quiet"],
capture_output=True
)
if result.returncode != 0: # There are changes
subprocess.run(
["git", "commit", "-m", f"chore: Bump version to {version}"],
check=True
)
print(f" ✅ Committed version bump to {version}")
else:
print(" ℹ️ No changes to commit")
return True
except subprocess.CalledProcessError as e:
print(f" ❌ Git commit failed: {e}")
return False
def create_git_tag(version: str) -> bool:
"""Create Git tag pointing to current commit."""
tag = f"v{version}"
print(f"\n5. Creating Git tag {tag}...")
try:
subprocess.run(
["git", "tag", "-a", tag, "-m", f"Release {tag}"],
check=True
)
print(f" ✅ Created tag {tag}")
return True
except subprocess.CalledProcessError as e:
print(f" ❌ Failed to create tag: {e}")
return False
def push_changes(version: str) -> bool:
"""Push commit and tag to remote."""
tag = f"v{version}"
print(f"\n6. Pushing to remote...")
try:
# Push commits
subprocess.run(["git", "push"], check=True)
print(" ✅ Pushed commits")
# Push tag
subprocess.run(["git", "push", "origin", tag], check=True)
print(f" ✅ Pushed tag {tag}")
return True
except subprocess.CalledProcessError as e:
print(f" ❌ Push failed: {e}")
return False
def create_github_release(version: str) -> bool:
"""Create GitHub release."""
tag = f"v{version}"
print(f"\n7. Creating GitHub release {tag}...")
try:
subprocess.run(
["gh", "release", "create", tag, "--generate-notes", "--title", tag],
check=True,
timeout=60
)
print(f" ✅ Release created: https://github.com/matbanik/Pomera-AI-Commander/releases/tag/{tag}")
return True
except subprocess.CalledProcessError as e:
print(f" ❌ Failed to create release: {e}")
return False
except Exception as e:
print(f" ❌ Error: {e}")
return False
def main():
current = get_current_version()
print(f"\n🔄 Pom Era Version Bump")
print(f"====================================")
print(f"Current version: {current}\n")
# Parse arguments
args = sys.argv[1:]
create_release = "--release" in args
if create_release:
args.remove("--release")
# Determine new version
if len(args) > 0:
arg = args[0]
if arg == "--patch":
new_version = bump_version(current, "patch")
elif arg == "--minor":
new_version = bump_version(current, "minor")
elif arg == "--major":
new_version = bump_version(current, "major")
elif arg.replace(".", "").replace("v", "").isdigit():
new_version = normalize_version(arg.lstrip('v'))
else:
print(f"Usage: {sys.argv[0]} [VERSION | --patch | --minor | --major] [--release]")
print("\nExamples:")
print(" python bump_version.py --patch --release")
print(" python bump_version.py --minor")
print(" python bump_version.py 1.3.0 --release")
sys.exit(1)
else:
print("ERROR: No version specified")
print(f"Usage: {sys.argv[0]} [VERSION | --patch | --minor | --major] [--release]")
sys.exit(1)
print(f"New version: {new_version}")
print("=" * 60)
# Execute workflow
if not run_validation(new_version):
sys.exit(1)
if not update_package_json(new_version):
sys.exit(1)
if not update_python_version_file(new_version):
sys.exit(1)
if not commit_version_changes(new_version):
sys.exit(1)
if not create_git_tag(new_version):
sys.exit(1)
if not push_changes(new_version):
sys.exit(1)
print("\n" + "=" * 60)
print(f"✅ Version bumped successfully to {new_version}!")
print("=" * 60)
# Create GitHub release if requested
if create_release:
if create_github_release(new_version):
print("\n🎉 Release complete! GitHub Actions will now:")
print(" - Build PyPI package")
print(" - Publish to PyPI")
print(" - Publish to npm")
print(" - Publish to MCP Registry")
else:
print("\n⚠️ Warning: Release creation failed, but version was bumped.")
print(" Create release manually with: gh release create v{new_version} --generate-notes")
if __name__ == "__main__":
main()