#!/usr/bin/env python3 """Fail when backend, manifest, frontend, lock, or release-ref versions drift.""" from __future__ import annotations import argparse from dataclasses import asdict import json from pathlib import Path import sys META_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(META_ROOT / "tools" / "release")) from govoplan_release.version_alignment import ( # noqa: E402 release_composition_issues, repository_version_issues, selected_repository_version_issues, ) from govoplan_release.workspace import ( # noqa: E402 load_repository_specs, resolve_repo_path, resolve_workspace_root, ) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--workspace-root", type=Path, default=None) parser.add_argument("--repo", action="append", default=[], help="Repository name to check; may be repeated.") parser.add_argument( "--repo-version", action="append", default=[], metavar="REPO=VERSION", help="Require a repository's aligned source metadata to equal VERSION; may be repeated.", ) parser.add_argument( "--release-composition", action="store_true", help="Also compare tagged backend and WebUI references in the release composition.", ) parser.add_argument( "--source-metadata-only", action="store_true", help="Skip lockfile checks for the pre-commit source gate; never use for publication.", ) parser.add_argument("--json", action="store_true", help="Print machine-readable output.") args = parser.parse_args() workspace_root = resolve_workspace_root(args.workspace_root) expected_versions = _parse_repo_versions(parser, args.repo_version) selected = {*args.repo, *expected_versions} known = {spec.name for spec in load_repository_specs()} unknown = sorted(selected - known) if unknown: parser.error(f"unknown repository name(s): {', '.join(unknown)}") checked: list[str] = [] issues = list( selected_repository_version_issues( repo_versions=expected_versions, workspace=workspace_root, include_lockfiles=not args.source_metadata_only, ) ) for spec in load_repository_specs(): if selected and spec.name not in selected: continue repo_path = resolve_repo_path(spec, workspace_root) if not repo_path.exists(): continue checked.append(spec.name) if spec.name in expected_versions: continue issues.extend( repository_version_issues( repo_path, include_lockfiles=not args.source_metadata_only, ) ) if args.release_composition: issues.extend( release_composition_issues( META_ROOT, core_root=workspace_root / "govoplan-core", ) ) payload = { "workspace_root": str(workspace_root), "repositories_checked": checked, "release_composition_checked": args.release_composition, "issues": [asdict(issue) for issue in issues], } if args.json: print(json.dumps(payload, indent=2, sort_keys=True)) elif issues: print("Version alignment failed:", file=sys.stderr) for issue in issues: print( f"- {issue.repo}: {issue.source}: {issue.actual!r}; expected {issue.expected!r} " f"({issue.message})", file=sys.stderr, ) else: suffix = " including release composition" if args.release_composition else "" print(f"Version alignment passed for {len(checked)} repositories{suffix}.") return 1 if issues else 0 def _parse_repo_versions(parser: argparse.ArgumentParser, values: list[str]) -> dict[str, str]: result: dict[str, str] = {} for value in values: if "=" not in value: parser.error(f"--repo-version must use REPO=VERSION: {value}") repo, version = (item.strip() for item in value.split("=", 1)) version = version.removeprefix("v") if not repo or not version: parser.error(f"--repo-version must use REPO=VERSION: {value}") if repo in result and result[repo] != version: parser.error(f"conflicting versions requested for {repo}") result[repo] = version return result if __name__ == "__main__": raise SystemExit(main())