#!/usr/bin/env python3 """ bump-service-version.py - Bump service version in centralized version storage Sprint: CI/CD Enhancement - Per-Service Auto-Versioning This script manages service versions stored in src/Directory.Versions.props and devops/releases/service-versions.json. Usage: python bump-service-version.py [options] python bump-service-version.py authority patch python bump-service-version.py scanner minor --dry-run python bump-service-version.py cli major --commit Arguments: service Service name (authority, attestor, concelier, scanner, etc.) bump-type Version bump type: major, minor, patch, or explicit version (e.g., 2.0.0) Options: --dry-run Show what would be changed without modifying files --commit Commit changes to git after updating --no-manifest Skip updating service-versions.json manifest --git-sha SHA Git SHA to record in manifest (defaults to HEAD) --docker-tag TAG Docker tag to record in manifest """ import argparse import json import os import re import subprocess import sys from datetime import datetime, timezone from pathlib import Path from typing import Optional, Tuple # Repository paths SCRIPT_DIR = Path(__file__).parent REPO_ROOT = SCRIPT_DIR.parent.parent.parent VERSIONS_FILE = REPO_ROOT / "src" / "Directory.Versions.props" MANIFEST_FILE = REPO_ROOT / "devops" / "releases" / "service-versions.json" # Service name mapping (lowercase key -> property suffix) SERVICE_MAP = { "authority": "Authority", "attestor": "Attestor", "concelier": "Concelier", "scanner": "Scanner", "policy": "Policy", "signer": "Signer", "excititor": "Excititor", "gateway": "Gateway", "scheduler": "Scheduler", "cli": "Cli", "orchestrator": "Orchestrator", "notify": "Notify", "sbomservice": "SbomService", "vexhub": "VexHub", "evidencelocker": "EvidenceLocker", } def parse_version(version_str: str) -> Tuple[int, int, int]: """Parse semantic version string into tuple.""" match = re.match(r"^(\d+)\.(\d+)\.(\d+)$", version_str) if not match: raise ValueError(f"Invalid version format: {version_str}") return int(match.group(1)), int(match.group(2)), int(match.group(3)) def format_version(major: int, minor: int, patch: int) -> str: """Format version tuple as string.""" return f"{major}.{minor}.{patch}" def bump_version(current: str, bump_type: str) -> str: """Bump version according to bump type.""" # Check if bump_type is an explicit version if re.match(r"^\d+\.\d+\.\d+$", bump_type): return bump_type major, minor, patch = parse_version(current) if bump_type == "major": return format_version(major + 1, 0, 0) elif bump_type == "minor": return format_version(major, minor + 1, 0) elif bump_type == "patch": return format_version(major, minor, patch + 1) else: raise ValueError(f"Invalid bump type: {bump_type}") def read_version_from_props(service_key: str) -> Optional[str]: """Read current version from Directory.Versions.props.""" if not VERSIONS_FILE.exists(): return None property_name = f"StellaOps{SERVICE_MAP[service_key]}Version" pattern = rf"<{property_name}>(\d+\.\d+\.\d+)" content = VERSIONS_FILE.read_text(encoding="utf-8") match = re.search(pattern, content) return match.group(1) if match else None def update_version_in_props(service_key: str, new_version: str, dry_run: bool = False) -> bool: """Update version in Directory.Versions.props.""" if not VERSIONS_FILE.exists(): print(f"Error: {VERSIONS_FILE} not found", file=sys.stderr) return False property_name = f"StellaOps{SERVICE_MAP[service_key]}Version" pattern = rf"(<{property_name}>)\d+\.\d+\.\d+()" replacement = rf"\g<1>{new_version}\g<2>" content = VERSIONS_FILE.read_text(encoding="utf-8") new_content, count = re.subn(pattern, replacement, content) if count == 0: print(f"Error: Property {property_name} not found in {VERSIONS_FILE}", file=sys.stderr) return False if dry_run: print(f"[DRY-RUN] Would update {VERSIONS_FILE}") print(f"[DRY-RUN] {property_name}: {new_version}") else: VERSIONS_FILE.write_text(new_content, encoding="utf-8") print(f"Updated {VERSIONS_FILE}") print(f" {property_name}: {new_version}") return True def update_manifest( service_key: str, new_version: str, git_sha: Optional[str] = None, docker_tag: Optional[str] = None, dry_run: bool = False, ) -> bool: """Update service-versions.json manifest.""" if not MANIFEST_FILE.exists(): print(f"Warning: {MANIFEST_FILE} not found, skipping manifest update", file=sys.stderr) return True try: manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8")) except json.JSONDecodeError as e: print(f"Error parsing {MANIFEST_FILE}: {e}", file=sys.stderr) return False if service_key not in manifest.get("services", {}): print(f"Warning: Service '{service_key}' not found in manifest", file=sys.stderr) return True # Update service entry now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") service = manifest["services"][service_key] service["version"] = new_version service["releasedAt"] = now if git_sha: service["gitSha"] = git_sha if docker_tag: service["dockerTag"] = docker_tag # Update manifest timestamp manifest["lastUpdated"] = now if dry_run: print(f"[DRY-RUN] Would update {MANIFEST_FILE}") print(f"[DRY-RUN] {service_key}.version: {new_version}") if docker_tag: print(f"[DRY-RUN] {service_key}.dockerTag: {docker_tag}") else: MANIFEST_FILE.write_text( json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8", ) print(f"Updated {MANIFEST_FILE}") return True def get_git_sha() -> Optional[str]: """Get current git HEAD SHA.""" try: result = subprocess.run( ["git", "rev-parse", "HEAD"], capture_output=True, text=True, cwd=REPO_ROOT, check=True, ) return result.stdout.strip()[:12] # Short SHA except subprocess.CalledProcessError: return None def commit_changes(service_key: str, old_version: str, new_version: str) -> bool: """Commit version changes to git.""" try: # Stage the files subprocess.run( ["git", "add", str(VERSIONS_FILE), str(MANIFEST_FILE)], cwd=REPO_ROOT, check=True, ) # Create commit commit_msg = f"""chore({service_key}): bump version {old_version} -> {new_version} Automated version bump via bump-service-version.py Co-Authored-By: github-actions[bot] """ subprocess.run( ["git", "commit", "-m", commit_msg], cwd=REPO_ROOT, check=True, ) print(f"Committed version bump: {old_version} -> {new_version}") return True except subprocess.CalledProcessError as e: print(f"Error committing changes: {e}", file=sys.stderr) return False def generate_docker_tag(version: str) -> str: """Generate Docker tag with datetime suffix: {version}+{YYYYMMDDHHmmss}.""" timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") return f"{version}+{timestamp}" def main(): parser = argparse.ArgumentParser( description="Bump service version in centralized version storage", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: %(prog)s authority patch # Bump authority from 1.0.0 to 1.0.1 %(prog)s scanner minor --dry-run # Preview bumping scanner minor version %(prog)s cli 2.0.0 --commit # Set CLI to 2.0.0 and commit %(prog)s gateway patch --docker-tag # Bump and generate docker tag """, ) parser.add_argument( "service", choices=list(SERVICE_MAP.keys()), help="Service name to bump", ) parser.add_argument( "bump_type", help="Bump type: major, minor, patch, or explicit version (e.g., 2.0.0)", ) parser.add_argument( "--dry-run", action="store_true", help="Show what would be changed without modifying files", ) parser.add_argument( "--commit", action="store_true", help="Commit changes to git after updating", ) parser.add_argument( "--no-manifest", action="store_true", help="Skip updating service-versions.json manifest", ) parser.add_argument( "--git-sha", help="Git SHA to record in manifest (defaults to HEAD)", ) parser.add_argument( "--docker-tag", nargs="?", const="auto", help="Docker tag to record in manifest (use 'auto' to generate)", ) parser.add_argument( "--output-version", action="store_true", help="Output only the new version (for CI scripts)", ) parser.add_argument( "--output-docker-tag", action="store_true", help="Output only the docker tag (for CI scripts)", ) args = parser.parse_args() # Read current version current_version = read_version_from_props(args.service) if not current_version: print(f"Error: Could not read current version for {args.service}", file=sys.stderr) sys.exit(1) # Calculate new version try: new_version = bump_version(current_version, args.bump_type) except ValueError as e: print(f"Error: {e}", file=sys.stderr) sys.exit(1) # Generate docker tag if requested docker_tag = None if args.docker_tag: docker_tag = generate_docker_tag(new_version) if args.docker_tag == "auto" else args.docker_tag # Output mode for CI scripts if args.output_version: print(new_version) sys.exit(0) if args.output_docker_tag: print(docker_tag or generate_docker_tag(new_version)) sys.exit(0) # Print summary print(f"Service: {args.service}") print(f"Current version: {current_version}") print(f"New version: {new_version}") if docker_tag: print(f"Docker tag: {docker_tag}") print() # Update version in props file if not update_version_in_props(args.service, new_version, args.dry_run): sys.exit(1) # Update manifest if not skipped if not args.no_manifest: git_sha = args.git_sha or get_git_sha() if not update_manifest(args.service, new_version, git_sha, docker_tag, args.dry_run): sys.exit(1) # Commit if requested if args.commit and not args.dry_run: if not commit_changes(args.service, current_version, new_version): sys.exit(1) print() print(f"Successfully bumped {args.service}: {current_version} -> {new_version}") if __name__ == "__main__": main()