feat(release): enforce aligned trusted publications

This commit is contained in:
2026-07-21 12:25:06 +02:00
parent c34892f6ea
commit a15a74c54c
18 changed files with 1359 additions and 51 deletions

View File

@@ -13,7 +13,9 @@ from govoplan_core.core.module_package_catalog import validate_module_package_ca
from .catalog import canonical_hash
from .model import CatalogPublishResult, CatalogPublishStep
from .module_directory import write_module_directory
from .selective_catalog import trusted_keys_from_keyring
from .version_alignment import candidate_catalog_version_issues
from .workspace import DEFAULT_WORKSPACE_ROOT, resolve_workspace_root, website_root
@@ -41,7 +43,6 @@ def publish_catalog_candidate(
resolved_web_root = Path(web_root).expanduser().resolve() if web_root is not None else website_root(workspace)
candidate_catalog = candidate_root / "channels" / f"{channel}.json"
candidate_keyring = candidate_root / "keyring.json"
candidate_modules = candidate_root / "modules"
target_catalog = resolved_web_root / "public" / "catalogs" / "v1" / "channels" / f"{channel}.json"
target_keyring = resolved_web_root / "public" / "catalogs" / "v1" / "keyring.json"
target_modules = resolved_web_root / "public" / "catalogs" / "v1" / "modules"
@@ -72,11 +73,36 @@ def publish_catalog_candidate(
validation_warnings: tuple[str, ...] = ()
if candidate_payload is not None and keyring_payload is not None:
version_issues = candidate_catalog_version_issues(candidate_payload)
blockers.extend(
f"candidate version alignment: {issue.source}: {issue.actual!r}; "
f"expected {issue.expected!r} ({issue.message})"
for issue in version_issues
)
candidate_keys = trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {})
target_keyring_payload = read_json(target_keyring) if target_keyring.exists() else None
publication_trust = trusted_keys_from_keyring(
target_keyring_payload if isinstance(target_keyring_payload, dict) else {}
)
if not publication_trust:
blockers.append(
"publication trust anchor is missing; bootstrap a trusted website keyring through a separate reviewed process"
)
for key_id in sorted(set(candidate_keys) & set(publication_trust)):
if candidate_keys[key_id] != publication_trust[key_id]:
blockers.append(f"candidate keyring changes the public key for trusted key id {key_id!r}")
if candidate_keyring_hash != target_keyring_hash_before:
release = candidate_payload.get("release") if isinstance(candidate_payload, dict) else None
signed_keyring_hash = release.get("keyring_sha256") if isinstance(release, dict) else None
if signed_keyring_hash != candidate_keyring_hash:
blockers.append(
"candidate keyring differs from the publication trust anchor without a matching signed keyring_sha256"
)
validation = validate_module_package_catalog(
candidate_catalog,
require_trusted=True,
approved_channels=(channel,),
trusted_keys=trusted_keys_from_keyring(keyring_payload if isinstance(keyring_payload, dict) else {}),
trusted_keys=publication_trust,
)
validation_valid = validation.get("valid") is True
validation_error = str(validation["error"]) if validation.get("error") else None
@@ -115,14 +141,12 @@ def publish_catalog_candidate(
steps.append(
CatalogPublishStep(
id="copy",
title="Copy candidate catalog, keyring, and module directory",
title="Copy candidate catalog/keyring and derive the module directory",
detail=f"{candidate_root} -> {resolved_web_root / 'public' / 'catalogs' / 'v1'}",
mutating=True,
status="planned" if not apply else "blocked" if blockers else "done",
)
)
if not candidate_modules.exists():
notes.append("Candidate has no module-directory tree; only catalog and keyring will be copied.")
if build_web:
steps.append(
CatalogPublishStep(
@@ -224,10 +248,15 @@ def publish_catalog_candidate(
target_catalog.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(candidate_catalog, target_catalog)
shutil.copy2(candidate_keyring, target_keyring)
if candidate_modules.exists():
if target_modules.exists():
shutil.rmtree(target_modules)
shutil.copytree(candidate_modules, target_modules)
if target_modules.exists():
shutil.rmtree(target_modules)
write_module_directory(
catalog_payload=candidate_payload,
keyring_payload=keyring_payload,
output_root=target_catalog.parent.parent,
channel=channel,
public_base_url=catalog_public_base_url(candidate_payload),
)
completed_steps = [replace_status(step, "done") if step.id == "copy" else step for step in steps]
if build_web:
@@ -282,6 +311,14 @@ def publish_catalog_candidate(
)
def catalog_public_base_url(payload: dict[str, object]) -> str:
release = payload.get("release")
catalog_url = release.get("catalog_url") if isinstance(release, dict) else None
if isinstance(catalog_url, str) and "/catalogs/" in catalog_url:
return catalog_url.split("/catalogs/", 1)[0]
return "https://govoplan.add-ideas.de"
def result(
*,
status: str,