303 lines
9.6 KiB
Python
303 lines
9.6 KiB
Python
#!/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"^(?P<name>govoplan-[a-z0-9-]+)(?:\[[a-z0-9_,.-]+\])?==(?P<version>[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())
|