#!/usr/bin/env python3 """Fail the release gate when ODS version authorities drift.""" from __future__ import annotations import json import re import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SEMVER = re.compile(r"^\d+\.\d+\.\d+$") def read_text(path: Path) -> str: return path.read_text(encoding="utf-8") def first_match(path: Path, pattern: str, label: str) -> str: match = re.search(pattern, read_text(path), re.MULTILINE | re.DOTALL) if not match: raise ValueError(f"{label}: could not find version in {path.relative_to(ROOT)}") return match.group(1) def latest_changelog_release() -> tuple[str, str]: changelog = read_text(ROOT / "CHANGELOG.md") for match in re.finditer(r"^## \[([^\]]+)\](?: - ([0-9]{4}-[0-9]{2}-[0-9]{2}))?", changelog, re.MULTILINE): version, date = match.group(1), match.group(2) or "" if version.lower() != "unreleased": return version, date raise ValueError("CHANGELOG.md: could not find a released version heading") def optional_version_file() -> str | None: path = ROOT / ".version" if not path.exists(): return None raw = read_text(path).strip() if not raw: return None try: data = json.loads(raw) except json.JSONDecodeError: return raw if isinstance(data, dict) and data.get("version"): return str(data["version"]) return raw def add_regex_check( checks: list[tuple[str, str]], errors: list[str], label: str, path: Path, pattern: str, ) -> None: try: checks.append((label, first_match(path, pattern, label))) except ValueError as exc: errors.append(str(exc)) def main() -> int: errors: list[str] = [] manifest_path = ROOT / "manifest.json" try: manifest = json.loads(read_text(manifest_path)) expected = str(manifest.get("ods_version", "")).strip() release = manifest.get("release") if isinstance(manifest.get("release"), dict) else {} release_version = str(release.get("version", "")).strip() release_date = str(release.get("date", "")).strip() except Exception as exc: # noqa: BLE001 - gate should report cleanly print(f"[FAIL] version consistency: cannot read manifest.json: {exc}") return 1 if not expected: errors.append("manifest.json ods_version is empty") elif not SEMVER.match(expected): errors.append(f"manifest.json ods_version must be x.y.z, got {expected!r}") checks = [("manifest.json release.version", release_version)] add_regex_check( checks, errors, "extensions/services/dashboard-api/main.py FastAPI version", ROOT / "extensions/services/dashboard-api/main.py", r"app\s*=\s*FastAPI\([^)]*?version\s*=\s*[\"']([^\"']+)[\"']", ) add_regex_check( checks, errors, "installers/lib/constants.sh VERSION", ROOT / "installers/lib/constants.sh", r'^VERSION="([^"]+)"', ) add_regex_check( checks, errors, "installers/macos/lib/constants.sh ODS_VERSION", ROOT / "installers/macos/lib/constants.sh", r'^ODS_VERSION="([^"]+)"', ) add_regex_check( checks, errors, "installers/windows/lib/constants.ps1 ODS_VERSION", ROOT / "installers/windows/lib/constants.ps1", r'^\$script:ODS_VERSION\s*=\s*"([^"]+)"', ) add_regex_check( checks, errors, "installers/phases/06-directories.sh ODS_VERSION fallback", ROOT / "installers/phases/06-directories.sh", r"^ODS_VERSION=\$\{VERSION:-([^}]+)\}", ) add_regex_check( checks, errors, "ARCHITECTURE.md version", ROOT.parent / "ARCHITECTURE.md", r"^> Version ([0-9]+\.[0-9]+\.[0-9]+)\s+\|", ) version_file = optional_version_file() if version_file is not None: checks.append((".version version", version_file)) try: changelog_version, changelog_date = latest_changelog_release() checks.append(("CHANGELOG.md latest release", changelog_version)) if release_date and changelog_date and release_date != changelog_date: errors.append( f"manifest.json release.date {release_date!r} != CHANGELOG.md latest release date {changelog_date!r}" ) except ValueError as exc: errors.append(str(exc)) for label, value in checks: if value != expected: errors.append(f"{label} {value!r} != manifest.json ods_version {expected!r}") if errors: print("[FAIL] version consistency") for error in errors: print(f" - {error}") return 1 print(f"[PASS] version consistency ({expected})") return 0 if __name__ == "__main__": sys.exit(main())