Add registry-backed module package releases
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate the exact registry package set for a GovOPlaN runtime release."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tomllib
|
||||
|
||||
|
||||
META_ROOT = Path(__file__).resolve().parents[2]
|
||||
NAME = re.compile(r"^govoplan-[a-z0-9-]+$")
|
||||
VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,127}$")
|
||||
GIT_REQUIREMENT = re.compile(
|
||||
r"^(?P<package>govoplan-[a-z0-9-]+)(?:\[(?P<extras>[^]]+)\])?\s+@\s+"
|
||||
r"(?P<url>git\+[^\s]+/GovOPlaN/(?P<repo>govoplan-[a-z0-9-]+)\.git@v"
|
||||
r"(?P<version>[A-Za-z0-9._+!-]+))$"
|
||||
)
|
||||
LOCAL_CORE = re.compile(r"^(?:-e\s+)?\.\./govoplan-core(?:\[(?P<extras>[^]]+)\])?$")
|
||||
|
||||
|
||||
class PackageSetError(ValueError):
|
||||
"""Release source references cannot form an immutable package set."""
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
help="Core/meta release version. Defaults to the workspace Core version.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--requirements",
|
||||
type=Path,
|
||||
default=META_ROOT / "requirements-release.txt",
|
||||
)
|
||||
parser.add_argument("--workspace", type=Path, default=META_ROOT.parent)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
return parser
|
||||
|
||||
|
||||
def parse_release_requirements(path: Path, *, core_version: str) -> tuple[dict[str, object], ...]:
|
||||
values: list[dict[str, object]] = []
|
||||
for line_number, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
local = LOCAL_CORE.fullmatch(line)
|
||||
if local:
|
||||
values.append(
|
||||
{
|
||||
"name": "govoplan-core",
|
||||
"version": core_version.removeprefix("v"),
|
||||
"repository": "govoplan-core",
|
||||
"extras": _extras(local.group("extras")),
|
||||
}
|
||||
)
|
||||
continue
|
||||
match = GIT_REQUIREMENT.fullmatch(line)
|
||||
if match is None:
|
||||
raise PackageSetError(
|
||||
f"unsupported release requirement at {path}:{line_number}: {line!r}"
|
||||
)
|
||||
values.append(
|
||||
{
|
||||
"name": match.group("package"),
|
||||
"version": match.group("version"),
|
||||
"repository": match.group("repo"),
|
||||
"extras": _extras(match.group("extras")),
|
||||
}
|
||||
)
|
||||
names = [str(item["name"]) for item in values]
|
||||
if not values or names.count("govoplan-core") != 1 or len(names) != len(set(names)):
|
||||
raise PackageSetError("release requirements must contain one Core and unique packages")
|
||||
return tuple(values)
|
||||
|
||||
|
||||
def generate_package_set(
|
||||
*,
|
||||
core_version: str,
|
||||
requirements: Path,
|
||||
workspace: Path,
|
||||
) -> dict[str, object]:
|
||||
core_version = core_version.removeprefix("v")
|
||||
if VERSION.fullmatch(core_version) is None:
|
||||
raise PackageSetError("release version is invalid")
|
||||
python_packages: list[dict[str, object]] = []
|
||||
webui_packages: list[dict[str, object]] = []
|
||||
seen_webui: set[str] = set()
|
||||
for requirement in parse_release_requirements(requirements, core_version=core_version):
|
||||
repository = workspace / str(requirement["repository"])
|
||||
tag = f"v{requirement['version']}"
|
||||
if not (repository / ".git").is_dir():
|
||||
raise PackageSetError(f"release repository is missing: {repository}")
|
||||
commit = _git(repository, "rev-list", "-n", "1", tag)
|
||||
if not commit:
|
||||
raise PackageSetError(f"release tag is missing: {repository.name}@{tag}")
|
||||
project = tomllib.loads(_git(repository, "show", f"{tag}:pyproject.toml"))["project"]
|
||||
if project.get("name") != requirement["name"] or project.get("version") != requirement["version"]:
|
||||
raise PackageSetError(f"tag metadata does not match {repository.name}@{tag}")
|
||||
entry = {
|
||||
**requirement,
|
||||
"tag": tag,
|
||||
"commit": commit,
|
||||
}
|
||||
python_packages.append(entry)
|
||||
try:
|
||||
webui_raw = _git(repository, "show", f"{tag}:webui/package.json")
|
||||
except subprocess.CalledProcessError:
|
||||
continue
|
||||
webui = json.loads(webui_raw)
|
||||
webui_name = webui.get("name")
|
||||
if (
|
||||
not isinstance(webui_name, str)
|
||||
or not webui_name.startswith("@govoplan/")
|
||||
or webui.get("version") != requirement["version"]
|
||||
or webui_name in seen_webui
|
||||
):
|
||||
raise PackageSetError(f"WebUI tag metadata does not match {repository.name}@{tag}")
|
||||
seen_webui.add(webui_name)
|
||||
webui_packages.append(
|
||||
{
|
||||
"name": webui_name,
|
||||
"version": requirement["version"],
|
||||
"repository": requirement["repository"],
|
||||
"tag": tag,
|
||||
"commit": commit,
|
||||
}
|
||||
)
|
||||
payload: dict[str, object] = {
|
||||
"schema_version": "1",
|
||||
"release_version": core_version,
|
||||
"registries": {
|
||||
"python": "https://git.add-ideas.de/api/packages/GovOPlaN/pypi/simple",
|
||||
"npm": "https://git.add-ideas.de/api/packages/GovOPlaN/npm/",
|
||||
},
|
||||
"python": python_packages,
|
||||
"webui": webui_packages,
|
||||
}
|
||||
payload["package_set_sha256"] = _canonical_sha256(payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _extras(value: str | None) -> list[str]:
|
||||
if not value:
|
||||
return []
|
||||
extras = sorted({item.strip() for item in value.split(",") if item.strip()})
|
||||
if any(re.fullmatch(r"[a-z][a-z0-9_-]*", item) is None for item in extras):
|
||||
raise PackageSetError("release requirement contains an invalid extra")
|
||||
return extras
|
||||
|
||||
|
||||
def _git(repository: Path, *arguments: str) -> str:
|
||||
return subprocess.check_output(
|
||||
["git", "-C", str(repository), *arguments],
|
||||
text=True,
|
||||
stderr=subprocess.DEVNULL,
|
||||
).strip()
|
||||
|
||||
|
||||
def _canonical_sha256(value: object) -> str:
|
||||
encoded = json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
try:
|
||||
workspace = args.workspace.expanduser().resolve()
|
||||
version = args.version
|
||||
if not version:
|
||||
core = tomllib.loads(
|
||||
(workspace / "govoplan-core/pyproject.toml").read_text(encoding="utf-8")
|
||||
)
|
||||
version = str(core["project"]["version"])
|
||||
payload = generate_package_set(
|
||||
core_version=version,
|
||||
requirements=args.requirements.expanduser().resolve(),
|
||||
workspace=workspace,
|
||||
)
|
||||
except (PackageSetError, OSError, ValueError, subprocess.CalledProcessError) as exc:
|
||||
print(f"error: {exc}")
|
||||
return 1
|
||||
output = args.output.expanduser()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"Release package set written to {output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user