Harden catalog publication transaction

This commit is contained in:
2026-07-22 21:20:08 +02:00
parent fdee766993
commit 349a099e5a
4 changed files with 1807 additions and 178 deletions

View File

@@ -334,6 +334,7 @@ class CatalogPublishResult:
target_keyring_path: str
branch: str | None
tag_name: str | None
remote: str
validation_valid: bool
validation_error: str | None = None
validation_warnings: tuple[str, ...] = ()
@@ -343,6 +344,9 @@ class CatalogPublishResult:
target_keyring_hash_before: str | None = None
catalog_changed: bool = False
keyring_changed: bool = False
publication_commit_sha: str | None = None
publication_tag_object_sha: str | None = None
publication_tag_commit_sha: str | None = None
steps: tuple[CatalogPublishStep, ...] = ()
notes: tuple[str, ...] = ()

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,10 @@ 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 publish_catalog_candidate
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
@@ -18,11 +21,27 @@ 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 = 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(
"--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",
@@ -56,41 +75,76 @@ def main() -> int:
),
)
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/add-ideas")
selective.add_argument("--source-remote", default="origin", help="Configured Git remote containing immutable source tags.")
selective.add_argument(
"--repository-base", default="git+ssh://git@git.add-ideas.de/add-ideas"
)
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.")
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 = 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(
"--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(
"--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(
"--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.")
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 = 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.")
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,
@@ -114,6 +168,8 @@ def main() -> int:
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,
@@ -138,6 +194,7 @@ def main() -> int:
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(
@@ -147,7 +204,11 @@ def main() -> int:
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]}
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:
@@ -162,6 +223,12 @@ def main() -> int:
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:
@@ -185,7 +252,9 @@ def parse_repo_paths(values: tuple[str, ...]) -> dict[str, Path]:
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}")
raise SystemExit(
f"--python-artifact must uniquely use REPO=/path/to/wheel: {value}"
)
result[repo] = Path(path_text).expanduser()
return result