#!/usr/bin/env python3 """Dispatch protected package releases required by the govoplan meta-package.""" from __future__ import annotations import argparse from dataclasses import dataclass import json from pathlib import Path import re import subprocess import sys import tomllib from gitea_common import ( GiteaClient, GiteaError, RepoTarget, load_dotenv, org_path, quote_path, repo_path, require_token, ) META_ROOT = Path(__file__).resolve().parents[2] META_PROJECT = META_ROOT / "packages" / "govoplan-meta" / "pyproject.toml" WORKFLOW_ID = "module-package-release.yml" EXACT_REQUIREMENT = re.compile( r"^(?Pgovoplan-[a-z0-9-]+)(?:\[[a-z0-9_,.-]+\])?==(?P[0-9]+\.[0-9]+\.[0-9]+)$" ) ACTIVE_STATES = {"queued", "waiting", "in_progress", "running"} @dataclass(frozen=True, slots=True) class PackageTarget: distribution: str version: str repository: str tag_exists: bool has_webui: bool @property def tag(self) -> str: return f"v{self.version}" @property def webui_package(self) -> str | None: if not self.has_webui: return None return f"@govoplan/{self.distribution.removeprefix('govoplan-')}-webui" def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--url", default="https://git.add-ideas.de") parser.add_argument("--owner", default="GovOPlaN") parser.add_argument("--env-file", type=Path) parser.add_argument( "--repository", action="append", default=[], help="Limit dispatch to one repository; repeat as needed.", ) parser.add_argument( "--verify-existing", action="store_true", help="Also rerun exact versions already present in both registries.", ) parser.add_argument("--apply", action="store_true") return parser def package_targets(project_path: Path = META_PROJECT) -> tuple[PackageTarget, ...]: project = tomllib.loads(project_path.read_text(encoding="utf-8"))["project"] requirements = list(project.get("dependencies") or []) requirements.extend(project.get("optional-dependencies", {}).get("full") or []) parsed: dict[str, str] = {} for requirement in requirements: match = EXACT_REQUIREMENT.fullmatch(str(requirement)) if match is None: raise ValueError( f"Meta-package requirement is not an exact GovOPlaN version: {requirement!r}" ) name = match.group("name") version = match.group("version") previous = parsed.setdefault(name, version) if previous != version: raise ValueError(f"Meta-package selects conflicting versions for {name}") targets: list[PackageTarget] = [] for distribution, version in sorted(parsed.items()): repository = distribution repository_root = META_ROOT.parent / repository if not (repository_root / ".git").is_dir(): raise ValueError(f"Package repository is not checked out: {repository}") tag = f"v{version}" tag_exists = _tag_exists(repository_root, tag) has_webui = ( _tag_has_path(repository_root, tag, "webui/package.json") if tag_exists else False ) targets.append( PackageTarget( distribution=distribution, version=version, repository=repository, tag_exists=tag_exists, has_webui=has_webui, ) ) return tuple(targets) def _tag_exists(repository: Path, tag: str) -> bool: result = subprocess.run( ( "git", "-C", str(repository), "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}", ), check=False, capture_output=True, text=True, ) if result.returncode not in {0, 1}: raise ValueError( f"Could not inspect {repository.name}:{tag}: {result.stderr.strip()}" ) return result.returncode == 0 def _tag_has_path(repository: Path, tag: str, path: str) -> bool: result = subprocess.run( ("git", "-C", str(repository), "cat-file", "-e", f"{tag}:{path}"), check=False, capture_output=True, text=True, ) if result.returncode not in {0, 128}: raise ValueError( f"Could not inspect {repository.name}:{tag}:{path}: {result.stderr.strip()}" ) return result.returncode == 0 def _published_packages( client: GiteaClient, *, owner: str, package_type: str ) -> set[tuple[str, str]]: values = client.paginate( f"/packages/{quote_path(owner)}", query={"type": package_type, "q": "govoplan"}, ) return { (str(item.get("name") or ""), str(item.get("version") or "")) for item in values if item.get("type") == package_type } def _has_active_run( client: GiteaClient, *, owner: str, repository: str ) -> bool: payload = client.request_json( "GET", repo_path( owner, repository, f"/actions/workflows/{quote_path(WORKFLOW_ID)}/runs", ), query={"limit": 10}, ) runs = payload.get("workflow_runs") if isinstance(payload, dict) else None return isinstance(runs, list) and any( isinstance(run, dict) and str(run.get("status") or "") in ACTIVE_STATES for run in runs ) def dispatch( client: GiteaClient, *, owner: str, targets: tuple[PackageTarget, ...], published_pypi: set[tuple[str, str]], published_npm: set[tuple[str, str]], verify_existing: bool, apply: bool, ) -> tuple[int, int, int]: dispatched = 0 active = 0 complete = 0 for target in targets: wheel_exists = (target.distribution, target.version) in published_pypi npm_exists = target.webui_package is None or ( target.webui_package, target.version, ) in published_npm if wheel_exists and npm_exists and not verify_existing: complete += 1 print(f"complete {target.repository}:{target.tag}") continue if _has_active_run(client, owner=owner, repository=target.repository): active += 1 print(f"active {target.repository}:{target.tag}") continue action = "dispatching" if apply else "would dispatch" print( f"{action} {target.repository}:{target.tag} " f"(wheel={'present' if wheel_exists else 'missing'}, " f"webui={'present' if npm_exists else 'missing'})" ) if apply: client.request_json( "POST", repo_path( owner, target.repository, f"/actions/workflows/{quote_path(WORKFLOW_ID)}/dispatches", ), body={"ref": "main", "inputs": {"release_tag": target.tag}}, ) dispatched += 1 return dispatched, active, complete def main() -> int: args = build_parser().parse_args() try: load_dotenv(args.env_file) token = require_token() targets = package_targets() selected = set(args.repository) if selected: known = {target.repository for target in targets} unknown = sorted(selected - known) if unknown: raise ValueError( "Unknown meta-package repositories: " + ", ".join(unknown) ) targets = tuple( target for target in targets if target.repository in selected ) missing_tags = [ f"{target.repository}:{target.tag}" for target in targets if not target.tag_exists ] if missing_tags: raise ValueError( "Meta-package release tags are missing: " + ", ".join(missing_tags) ) target = RepoTarget(base_url=args.url, owner=args.owner, repo="govoplan") with GiteaClient(target, token) as client: secrets = client.request_json( "GET", org_path(args.owner, "/actions/secrets"), query={"limit": 50} ) secret_names = { str(item.get("name") or "") for item in secrets if isinstance(item, dict) } required = {"GOVOPLAN_PACKAGE_USERNAME", "GOVOPLAN_PACKAGE_TOKEN"} if not required <= secret_names: raise ValueError( "Organization package publisher secrets are not configured" ) published_pypi = _published_packages( client, owner=args.owner, package_type="pypi" ) published_npm = _published_packages( client, owner=args.owner, package_type="npm" ) counts = dispatch( client, owner=args.owner, targets=targets, published_pypi=published_pypi, published_npm=published_npm, verify_existing=args.verify_existing, apply=args.apply, ) action = "dispatched" if args.apply else "planned" print( f"Package set {action}: {counts[0]}; active: {counts[1]}; " f"already complete: {counts[2]}." ) return 0 except (GiteaError, OSError, ValueError, json.JSONDecodeError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())