Bind installed releases to signed artifact receipts

This commit is contained in:
2026-07-22 20:42:05 +02:00
parent 4dc0c8d013
commit 9c0650b2ed
13 changed files with 2179 additions and 52 deletions

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import base64
from datetime import UTC, datetime, timedelta
import json
import os
from pathlib import Path
import re
from typing import Any
@@ -15,6 +16,11 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
from .catalog import DEFAULT_PUBLIC_BASE_URL, canonical_hash, fetch_json
from .artifact_identity import inspect_python_wheel
from .candidate_artifact import (
harden_private_candidate_tree,
validate_release_channel,
)
from .catalog_entry_synthesis import (
synthesize_repository_catalog_entries,
validate_initial_entry_closure,
@@ -52,7 +58,9 @@ def build_selective_catalog_candidate(
sequence: int | None = None,
check_public: bool = True,
write_summary: bool = True,
python_artifacts: dict[str, Path | str] | None = None,
) -> SelectiveCatalogCandidate:
channel = validate_release_channel(channel)
if not repo_versions:
raise ValueError("At least one repo version must be provided.")
parsed_keys = tuple(parse_signing_key(value) for value in signing_keys)
@@ -107,6 +115,13 @@ def build_selective_catalog_candidate(
repository_base=repository_base.rstrip("/"),
workspace=workspace,
)
changes.extend(
apply_python_artifact_identities(
candidate,
repo_versions=repo_versions,
python_artifacts=python_artifacts or {},
)
)
enforce_complete_catalog_source_provenance(
payload=candidate,
workspace=workspace,
@@ -128,10 +143,15 @@ def build_selective_catalog_candidate(
candidate.pop("signatures", None)
candidate["signatures"] = [signature(candidate, key_id=key_id, private_key=private_key) for key_id, private_key in parsed_keys]
catalog_path.parent.mkdir(parents=True, exist_ok=True)
output_root.mkdir(mode=0o700, parents=True, exist_ok=True)
harden_private_candidate_tree(output_root)
catalog_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
os.chmod(catalog_path.parent, 0o700, follow_symlinks=False)
catalog_path.write_text(json.dumps(candidate, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.chmod(catalog_path, 0o600, follow_symlinks=False)
keyring_path.write_text(json.dumps(keyring_payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
os.chmod(keyring_path, 0o600, follow_symlinks=False)
module_directory_files = write_module_directory(
catalog_payload=candidate,
keyring_payload=keyring_payload,
@@ -196,6 +216,7 @@ def build_selective_catalog_candidate(
)
if summary_path is not None:
summary_path.write_text(json.dumps(dataclass_payload(result), indent=2, sort_keys=True) + "\n", encoding="utf-8")
harden_private_candidate_tree(output_root)
return result
@@ -412,6 +433,95 @@ def module_entry_repo(entry: dict[str, Any]) -> str | None:
return str(package).split("[", 1)[0] if isinstance(package, str) and package.startswith("govoplan-") else None
def apply_python_artifact_identities(
payload: dict[str, Any],
*,
repo_versions: dict[str, str],
python_artifacts: dict[str, Path | str],
) -> list[CatalogEntryChange]:
"""Replace selected release identities with hashes computed from built wheels.
A version update without a corresponding built artifact deliberately removes
any stale identity for that package. Callers can still prepare a source-only
candidate, but it cannot later establish catalog-anchored installed origin.
"""
unknown = sorted(set(python_artifacts) - set(repo_versions))
if unknown:
raise ValueError(
"Built Python artifacts were supplied for unselected repositories: "
+ ", ".join(unknown)
)
package_by_repo: dict[str, str] = {}
core = payload.get("core_release")
if isinstance(core, dict):
package = core.get("python_package")
if isinstance(package, str):
package_by_repo["govoplan-core"] = package
modules = payload.get("modules")
if isinstance(modules, list):
for entry in modules:
if not isinstance(entry, dict):
continue
repo = module_entry_repo(entry)
package = entry.get("python_package")
if repo in repo_versions and isinstance(package, str):
package_by_repo[repo] = package
unmapped_artifacts = sorted(set(python_artifacts) - set(package_by_repo))
if unmapped_artifacts:
raise ValueError(
"Built artifacts have no catalog Python package mapping: "
+ ", ".join(unmapped_artifacts)
)
release = payload.get("release")
if not isinstance(release, dict):
raise ValueError("Catalog payload has no release object.")
existing = release.get("artifacts", [])
if not isinstance(existing, list) or any(not isinstance(item, dict) for item in existing):
raise ValueError("Catalog release.artifacts must be a list of objects.")
selected_packages = {
package_by_repo[repo] for repo in repo_versions if repo in package_by_repo
}
retained = [
item
for item in existing
if str(item.get("package_name") or "") not in selected_packages
]
changes: list[CatalogEntryChange] = []
for repo, artifact_path in sorted(python_artifacts.items()):
identity = inspect_python_wheel(artifact_path)
expected_package = package_by_repo[repo]
expected_version = repo_versions[repo].removeprefix("v")
if identity.package_name != expected_package or identity.package_version != expected_version:
raise ValueError(
f"Built artifact for {repo} identifies {identity.package_name} "
f"{identity.package_version}, expected {expected_package} {expected_version}."
)
retained.append(identity.catalog_payload())
changes.append(
CatalogEntryChange(
repo=repo,
module_id=None,
field="release.artifact",
before=None,
after=identity.archive_sha256,
)
)
retained.sort(
key=lambda item: (
str(item.get("package_name") or ""),
str(item.get("package_version") or ""),
str(item.get("archive_sha256") or ""),
)
)
if retained:
release["artifacts"] = retained
else:
release.pop("artifacts", None)
return changes
def update_field(target: dict[str, Any], *, repo: str, module_id: str | None, field: str, value: str) -> list[CatalogEntryChange]:
before = target.get(field)
before_text = before if isinstance(before, str) else None
@@ -533,7 +643,13 @@ def resolve_output_dir(*, output_dir: Path | str | None, channel: str, generated
if output_dir is not None:
return Path(output_dir).expanduser()
stamp = generated_at.strftime("%Y%m%d-%H%M%S")
return Path.cwd() / "runtime" / "release-candidates" / f"{channel}-{stamp}"
configured_state = os.getenv("XDG_STATE_HOME", "").strip()
state_root = (
Path(configured_state).expanduser()
if configured_state
else Path.home() / ".local" / "state"
)
return state_root / "govoplan" / "release-candidates" / f"{channel}-{stamp}"
def json_datetime(value: datetime) -> str:

View File

@@ -23,8 +23,25 @@ def main() -> int:
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="Catalog path or URL to use as the source. Defaults to local channel, then public channel.")
selective.add_argument("--output-dir", type=Path, help="Candidate output directory. Defaults to runtime/release-candidates/<channel>-<timestamp>.")
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/add-ideas")
selective.add_argument("--source-remote", default="origin", help="Configured Git remote containing immutable source tags.")
@@ -74,6 +91,7 @@ def main() -> int:
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:
@@ -144,6 +162,20 @@ def parse_repo_versions(values: tuple[str, ...]) -> dict[str, str]:
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 (