Files
govoplan/tools/release/release-catalog.py
Albrecht Degering ba88c574b9
Some checks failed
Dependency Audit / dependency-audit (push) Has been cancelled
Security Audit / security-audit (push) Has been cancelled
chore: move repositories to GovOPlaN organization
2026-07-27 15:46:51 +02:00

334 lines
12 KiB
Python

#!/usr/bin/env python3
"""Build GovOPlaN release catalog candidates."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from govoplan_release.module_directory import write_module_directory
from govoplan_release.model import to_jsonable
from govoplan_release.publisher import (
publication_runtime_trust_issues,
publish_catalog_candidate,
)
from govoplan_release.selective_catalog import build_selective_catalog_candidate
from govoplan_release.workspace import DEFAULT_WORKSPACE_ROOT
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
selective = subparsers.add_parser(
"selective", help="Build a signed selective channel catalog candidate."
)
selective.add_argument(
"--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT
)
selective.add_argument("--channel", default="stable")
selective.add_argument(
"--repo-version",
action="append",
default=[],
metavar="REPO=VERSION",
required=True,
)
selective.add_argument(
"--catalog-signing-key",
action="append",
default=[],
metavar="KEY_ID=PRIVATE_KEY",
required=True,
)
selective.add_argument(
"--python-artifact",
action="append",
default=[],
metavar="REPO=/PATH/TO/WHEEL",
help=(
"Built immutable wheel whose archive and install-stable payload identity "
"will be computed into the signed catalog; may be repeated."
),
)
selective.add_argument(
"--base-catalog",
help=(
"Authenticated catalog path or URL to use as the source; must be "
"paired with --base-keyring. Defaults to the local pair, then public pair."
),
)
selective.add_argument(
"--base-keyring",
help=(
"Exact keyring path or URL pinned by --base-catalog and matching "
"the configured catalog signer set."
),
)
selective.add_argument(
"--output-dir",
type=Path,
help=(
"Private candidate output directory. Defaults below "
"$XDG_STATE_HOME/govoplan/release-candidates (or ~/.local/state)."
),
)
selective.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
selective.add_argument(
"--repository-base", default="git+ssh://git@git.add-ideas.de/GovOPlaN"
)
selective.add_argument(
"--source-remote",
default="origin",
help="Configured Git remote containing immutable source tags.",
)
selective.add_argument("--expires-days", type=int, default=90)
selective.add_argument("--sequence", type=int)
selective.add_argument("--skip-public-check", action="store_true")
selective.add_argument(
"--json", action="store_true", help="Print machine-readable summary JSON."
)
publish = subparsers.add_parser(
"publish-candidate",
help="Publish a reviewed catalog candidate into the website repo.",
)
publish.add_argument("--candidate-dir", type=Path, required=True)
publish.add_argument("--workspace-root", type=Path, default=DEFAULT_WORKSPACE_ROOT)
publish.add_argument("--web-root", type=Path)
publish.add_argument("--channel", default="stable")
publish.add_argument(
"--apply",
action="store_true",
help="Copy candidate files into the website repo. Without this, only preview.",
)
publish.add_argument("--build-web", action="store_true")
publish.add_argument("--commit", action="store_true")
publish.add_argument(
"--tag",
action="store_true",
help="Create a website catalog publication tag. Implies --commit.",
)
publish.add_argument(
"--push",
action="store_true",
help="Push the website branch and tag. Implies --commit.",
)
publish.add_argument("--remote", default="origin")
publish.add_argument(
"--source-remote",
default="origin",
help="Configured Git remote containing catalog source tags.",
)
publish.add_argument("--branch")
publish.add_argument("--tag-name")
publish.add_argument("--npm", default="npm")
publish.add_argument("--allow-dirty-website", action="store_true")
publish.add_argument(
"--json", action="store_true", help="Print machine-readable summary JSON."
)
directory = subparsers.add_parser(
"module-directory",
help="Generate browsable module-directory files from a catalog and keyring.",
)
directory.add_argument("--catalog", type=Path, required=True)
directory.add_argument("--keyring", type=Path, required=True)
directory.add_argument("--output-dir", type=Path, required=True)
directory.add_argument("--channel", default="stable")
directory.add_argument("--public-base-url", default="https://govoplan.add-ideas.de")
directory.add_argument(
"--json", action="store_true", help="Print machine-readable summary JSON."
)
args = parser.parse_args()
if args.command == "selective":
require_release_runtime_trust()
result = build_selective_catalog_candidate(
repo_versions=parse_repo_versions(tuple(args.repo_version)),
channel=args.channel,
workspace_root=args.workspace_root,
output_dir=args.output_dir,
base_catalog=args.base_catalog,
base_keyring=args.base_keyring,
signing_keys=tuple(args.catalog_signing_key),
public_base_url=args.public_base_url,
repository_base=args.repository_base,
source_remote=args.source_remote,
expires_days=args.expires_days,
sequence=args.sequence,
check_public=not args.skip_public_check,
python_artifacts=parse_repo_paths(tuple(args.python_artifact)),
)
payload = to_jsonable(result)
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print_text_summary(payload)
return 0 if result.status == "ready" else 1
if args.command == "publish-candidate":
if args.apply:
require_release_runtime_trust()
result = publish_catalog_candidate(
candidate_dir=args.candidate_dir,
workspace_root=args.workspace_root,
web_root=args.web_root,
channel=args.channel,
apply=args.apply,
build_web=args.build_web,
commit=args.commit,
tag=args.tag,
push=args.push,
remote=args.remote,
source_remote=args.source_remote,
branch=args.branch,
npm=args.npm,
tag_name=args.tag_name,
allow_dirty_website=args.allow_dirty_website,
)
payload = to_jsonable(result)
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print_publish_summary(payload)
return 0 if result.status in {"ready", "applied", "published"} else 1
if args.command == "module-directory":
require_release_runtime_trust()
catalog_payload = json.loads(args.catalog.read_text(encoding="utf-8"))
keyring_payload = json.loads(args.keyring.read_text(encoding="utf-8"))
files = write_module_directory(
catalog_payload=catalog_payload,
keyring_payload=keyring_payload,
output_root=args.output_dir,
channel=args.channel,
public_base_url=args.public_base_url,
)
payload = {
"status": "generated",
"output_dir": str(args.output_dir),
"files": [str(path) for path in files],
}
if args.json:
print(json.dumps(payload, indent=2, sort_keys=True))
else:
print("Module directory")
print(f" output_dir: {args.output_dir}")
print(f" files: {len(files)}")
for path in files[:20]:
print(f" {path}")
if len(files) > 20:
print(f" ... {len(files) - 20} more")
return 0
return 2
def require_release_runtime_trust() -> None:
issues = publication_runtime_trust_issues()
if issues:
raise SystemExit("Release tooling trust gate failed: " + "; ".join(issues))
def parse_repo_versions(values: tuple[str, ...]) -> dict[str, str]:
result: dict[str, str] = {}
for value in values:
if "=" not in value:
raise SystemExit(f"--repo-version must use REPO=VERSION: {value}")
repo, version = value.split("=", 1)
repo = repo.strip()
version = version.strip().removeprefix("v")
if not repo or not version:
raise SystemExit(f"--repo-version must use REPO=VERSION: {value}")
result[repo] = version
return result
def parse_repo_paths(values: tuple[str, ...]) -> dict[str, Path]:
result: dict[str, Path] = {}
for value in values:
if "=" not in value:
raise SystemExit(f"--python-artifact must use REPO=/path/to/wheel: {value}")
repo, path_text = value.split("=", 1)
repo = repo.strip()
path_text = path_text.strip()
if not repo or not path_text or repo in result:
raise SystemExit(
f"--python-artifact must uniquely use REPO=/path/to/wheel: {value}"
)
result[repo] = Path(path_text).expanduser()
return result
def print_text_summary(payload: dict[str, object]) -> None:
print("Selective catalog candidate")
for key in (
"status",
"channel",
"sequence",
"catalog_path",
"keyring_path",
"summary_path",
"validation_valid",
"validation_error",
"candidate_matches_published_catalog",
"candidate_matches_published_keyring",
):
print(f" {key}: {payload.get(key)}")
changes = payload.get("changes")
if isinstance(changes, list):
print(f" changes: {len(changes)}")
for item in changes[:20]:
if not isinstance(item, dict):
continue
print(
" "
f"{item.get('repo')}:{item.get('module_id') or '-'} "
f"{item.get('field')} {item.get('before')!r} -> {item.get('after')!r}"
)
if len(changes) > 20:
print(f" ... {len(changes) - 20} more")
warnings = payload.get("validation_warnings")
if isinstance(warnings, list) and warnings:
print(" validation warnings:")
for warning in warnings:
print(f" {warning}")
def print_publish_summary(payload: dict[str, object]) -> None:
print("Catalog publish candidate")
for key in (
"status",
"applied",
"channel",
"candidate_dir",
"web_root",
"target_catalog_path",
"target_keyring_path",
"branch",
"tag_name",
"validation_valid",
"validation_error",
"catalog_changed",
"keyring_changed",
):
print(f" {key}: {payload.get(key)}")
steps = payload.get("steps")
if isinstance(steps, list):
print(" steps:")
for item in steps:
if not isinstance(item, dict):
continue
print(f" [{item.get('status')}] {item.get('title')}")
command = item.get("command")
if command:
print(f" {command}")
notes = payload.get("notes")
if isinstance(notes, list) and notes:
print(" notes:")
for note in notes:
print(f" {note}")
if __name__ == "__main__":
raise SystemExit(main())