#!/usr/bin/env python3 """ collect_versions.py - Collect service versions for suite release Sprint: CI/CD Enhancement - Suite Release Pipeline Gathers all service versions from Directory.Versions.props and service-versions.json. Usage: python collect_versions.py [options] python collect_versions.py --format json python collect_versions.py --format yaml --output versions.yaml Options: --format FMT Output format: json, yaml, markdown, env (default: json) --output FILE Output file (defaults to stdout) --include-unreleased Include services with no Docker tag --registry URL Container registry URL """ import argparse import json import os import re import sys from dataclasses import dataclass, asdict from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional # 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" # Default registry DEFAULT_REGISTRY = "git.stella-ops.org/stella-ops.org" @dataclass class ServiceVersion: name: str version: str docker_tag: Optional[str] = None released_at: Optional[str] = None git_sha: Optional[str] = None image: Optional[str] = None def read_versions_from_props() -> Dict[str, str]: """Read versions from Directory.Versions.props.""" if not VERSIONS_FILE.exists(): print(f"Warning: {VERSIONS_FILE} not found", file=sys.stderr) return {} content = VERSIONS_FILE.read_text(encoding="utf-8") versions = {} # Pattern: X.Y.Z pattern = r"(\d+\.\d+\.\d+)" for match in re.finditer(pattern, content): service_name = match.group(1) version = match.group(2) versions[service_name.lower()] = version return versions def read_manifest() -> Dict[str, dict]: """Read service metadata from manifest file.""" if not MANIFEST_FILE.exists(): print(f"Warning: {MANIFEST_FILE} not found", file=sys.stderr) return {} try: manifest = json.loads(MANIFEST_FILE.read_text(encoding="utf-8")) return manifest.get("services", {}) except json.JSONDecodeError as e: print(f"Warning: Failed to parse {MANIFEST_FILE}: {e}", file=sys.stderr) return {} def collect_all_versions( registry: str = DEFAULT_REGISTRY, include_unreleased: bool = False, ) -> List[ServiceVersion]: """Collect all service versions.""" props_versions = read_versions_from_props() manifest_services = read_manifest() services = [] # Merge data from both sources all_service_keys = set(props_versions.keys()) | set(manifest_services.keys()) for key in sorted(all_service_keys): version = props_versions.get(key, "0.0.0") manifest = manifest_services.get(key, {}) docker_tag = manifest.get("dockerTag") released_at = manifest.get("releasedAt") git_sha = manifest.get("gitSha") # Skip unreleased if not requested if not include_unreleased and not docker_tag: continue # Build image reference if docker_tag: image = f"{registry}/{key}:{docker_tag}" else: image = f"{registry}/{key}:{version}" service = ServiceVersion( name=manifest.get("name", key.title()), version=version, docker_tag=docker_tag, released_at=released_at, git_sha=git_sha, image=image, ) services.append(service) return services def format_json(services: List[ServiceVersion]) -> str: """Format as JSON.""" data = { "generatedAt": datetime.now(timezone.utc).isoformat(), "services": [asdict(s) for s in services], } return json.dumps(data, indent=2, ensure_ascii=False) def format_yaml(services: List[ServiceVersion]) -> str: """Format as YAML.""" lines = [ "# Service Versions", f"# Generated: {datetime.now(timezone.utc).isoformat()}", "", "services:", ] for s in services: lines.extend([ f" {s.name.lower()}:", f" name: {s.name}", f" version: \"{s.version}\"", ]) if s.docker_tag: lines.append(f" dockerTag: \"{s.docker_tag}\"") if s.image: lines.append(f" image: \"{s.image}\"") if s.released_at: lines.append(f" releasedAt: \"{s.released_at}\"") if s.git_sha: lines.append(f" gitSha: \"{s.git_sha}\"") return "\n".join(lines) def format_markdown(services: List[ServiceVersion]) -> str: """Format as Markdown table.""" lines = [ "# Service Versions", "", f"Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}", "", "| Service | Version | Docker Tag | Released |", "|---------|---------|------------|----------|", ] for s in services: released = s.released_at[:10] if s.released_at else "-" docker_tag = f"`{s.docker_tag}`" if s.docker_tag else "-" lines.append(f"| {s.name} | {s.version} | {docker_tag} | {released} |") return "\n".join(lines) def format_env(services: List[ServiceVersion]) -> str: """Format as environment variables.""" lines = [ "# Service Versions as Environment Variables", f"# Generated: {datetime.now(timezone.utc).isoformat()}", "", ] for s in services: name_upper = s.name.upper().replace(" ", "_") lines.append(f"STELLAOPS_{name_upper}_VERSION={s.version}") if s.docker_tag: lines.append(f"STELLAOPS_{name_upper}_DOCKER_TAG={s.docker_tag}") if s.image: lines.append(f"STELLAOPS_{name_upper}_IMAGE={s.image}") return "\n".join(lines) def main(): parser = argparse.ArgumentParser( description="Collect service versions for suite release", ) parser.add_argument( "--format", choices=["json", "yaml", "markdown", "env"], default="json", help="Output format", ) parser.add_argument("--output", "-o", help="Output file") parser.add_argument( "--include-unreleased", action="store_true", help="Include services without Docker tags", ) parser.add_argument( "--registry", default=DEFAULT_REGISTRY, help="Container registry URL", ) args = parser.parse_args() # Collect versions services = collect_all_versions( registry=args.registry, include_unreleased=args.include_unreleased, ) if not services: print("No services found", file=sys.stderr) if not args.include_unreleased: print("Hint: Use --include-unreleased to show all services", file=sys.stderr) sys.exit(0) # Format output formatters = { "json": format_json, "yaml": format_yaml, "markdown": format_markdown, "env": format_env, } output = formatters[args.format](services) # Write output if args.output: Path(args.output).write_text(output, encoding="utf-8") print(f"Versions written to: {args.output}", file=sys.stderr) else: print(output) if __name__ == "__main__": main()