351 lines
11 KiB
Python
351 lines
11 KiB
Python
#!/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 <service> <bump-type> [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+)</{property_name}>"
|
|
|
|
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+(</{property_name}>)"
|
|
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] <github-actions[bot]@users.noreply.github.com>"""
|
|
|
|
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()
|