Files
govoplan/tools/release/govoplan_release/publisher.py
Albrecht Degering b6e9a9bd81
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 15s
Security Audit / security-audit (push) Failing after 13s
Pin the website publication toolchain
2026-07-22 22:20:09 +02:00

2084 lines
73 KiB
Python

"""Publish reviewed release catalog candidates into the website repository."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import hashlib
import json
import os
from pathlib import Path
import re
import secrets
import shlex
import shutil
import stat
import subprocess
import sys
import tempfile
import cryptography
import govoplan_core
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
from .catalog import canonical_hash
from .artifact_identity import selected_artifact_identity_issues
from .candidate_artifact import validate_release_channel
from .model import CatalogPublishResult, CatalogPublishStep
from .module_directory import module_directory_payloads
from .selective_catalog import read_bounded_json_source, trusted_keys_from_keyring
from .source_provenance import catalog_source_selection, source_tag_provenance_issues
from .version_alignment import candidate_catalog_version_issues
from .workspace import (
DEFAULT_WORKSPACE_ROOT,
META_ROOT,
load_repository_specs,
resolve_workspace_root,
website_root,
)
_GIT_OBJECT = re.compile(r"^[0-9a-f]{40,64}$")
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
_REMOTE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
_MAX_GIT_OUTPUT_BYTES = 16 * 1024 * 1024
_MAX_REMOTE_METADATA_BYTES = 16 * 1024
_MAX_REMOVED_TREE_ENTRIES = 100_000
_MAX_REMOVED_TREE_DEPTH = 64
class CatalogPublicationAmbiguousError(RuntimeError):
"""A remote publication may have occurred but failed independent proof."""
@dataclass(frozen=True, slots=True)
class FrozenPublicationRemote:
"""One registered fetch/push endpoint frozen before publication."""
url: str
sha256: str
def publish_catalog_candidate(
*,
candidate_dir: Path | str,
workspace_root: Path | str | None = None,
web_root: Path | str | None = None,
channel: str = "stable",
apply: bool = False, # noqa: A002 - CLI uses --apply.
build_web: bool = False,
commit: bool = False,
tag: bool = False,
push: bool = False,
remote: str = "origin",
source_remote: str = "origin",
branch: str | None = None,
npm: str = "npm",
tag_name: str | None = None,
allow_dirty_website: bool = False,
expected_website_head: str | None = None,
expected_website_branch: str | None = None,
expected_remote_sha256: str | None = None,
) -> CatalogPublishResult:
channel = validate_release_channel(channel)
if _REMOTE_NAME.fullmatch(remote) is None:
raise ValueError("publication remote must be a bounded configured name")
workspace = resolve_workspace_root(workspace_root or DEFAULT_WORKSPACE_ROOT)
candidate_root = Path(candidate_dir).expanduser()
if not candidate_root.is_absolute():
candidate_root = Path.cwd() / candidate_root
resolved_web_root = (
Path(web_root).expanduser().absolute()
if web_root is not None
else website_root(workspace).absolute()
)
candidate_catalog = candidate_root / "channels" / f"{channel}.json"
candidate_keyring = candidate_root / "keyring.json"
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"
steps: list[CatalogPublishStep] = []
notes: list[str] = []
blockers: list[str] = []
resolved_npm = npm
if build_web:
try:
resolved_npm = _trusted_npm_command(npm)
except (OSError, RuntimeError) as exc:
blockers.append(f"website build command is not trusted: {exc}")
if not candidate_catalog.exists():
blockers.append(f"candidate catalog is missing: {candidate_catalog}")
if not candidate_keyring.exists():
blockers.append(f"candidate keyring is missing: {candidate_keyring}")
if not resolved_web_root.exists():
blockers.append(f"website root is missing: {resolved_web_root}")
if push:
tag = True
commit = True
if tag:
commit = True
candidate_payload = (
read_json(candidate_catalog) if candidate_catalog.exists() else None
)
keyring_payload = (
read_json(candidate_keyring) if candidate_keyring.exists() else None
)
target_catalog_payload = (
read_json(target_catalog) if target_catalog.exists() else None
)
target_keyring_payload = (
read_json(target_keyring) if target_keyring.exists() else None
)
candidate_catalog_hash = (
canonical_hash(candidate_payload) if candidate_payload is not None else None
)
candidate_keyring_hash = (
canonical_hash(keyring_payload) if keyring_payload is not None else None
)
target_catalog_hash_before = (
canonical_hash(target_catalog_payload)
if target_catalog_payload is not None
else None
)
target_keyring_hash_before = (
canonical_hash(target_keyring_payload)
if target_keyring_payload is not None
else None
)
validation_valid = False
validation_error: str | None = None
validation_warnings: tuple[str, ...] = ()
if candidate_payload is not None and keyring_payload is not None:
if (
not isinstance(candidate_payload, dict)
or candidate_payload.get("channel") != channel
):
blockers.append(
"candidate catalog channel does not match the publication channel"
)
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
)
source_selection = catalog_source_selection(candidate_payload)
blockers.extend(
f"source provenance: {issue.describe()}"
for issue in source_selection.issues
)
artifact_issues = selected_artifact_identity_issues(candidate_payload)
if apply or commit or tag or push:
blockers.extend(
f"release artifact identity: {issue}" for issue in artifact_issues
)
else:
notes.extend(
f"Source-only preview; publication would block: {issue}"
for issue in artifact_issues
)
source_issues = source_tag_provenance_issues(
repo_versions=source_selection.all_versions,
workspace=workspace,
remote=source_remote,
require_head_repos=source_selection.selected_versions,
expected_commits=source_selection.selected_commits,
expected_tag_objects=source_selection.selected_tag_objects,
)
blockers.extend(
f"source provenance: {issue.describe()}" for issue in source_issues
)
candidate_keys = trusted_keys_from_keyring(
keyring_payload if isinstance(keyring_payload, dict) else {}
)
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_catalog_payload(
candidate_payload,
require_trusted=True,
approved_channels=(channel,),
trusted_keys=publication_trust,
)
validation_valid = validation.get("valid") is True
validation_error = str(validation["error"]) if validation.get("error") else None
validation_warnings = tuple(
str(item) for item in validation.get("warnings") or ()
)
if not validation_valid:
blockers.append(validation_error or "candidate catalog validation failed")
catalog_changed = candidate_catalog_hash != target_catalog_hash_before
keyring_changed = candidate_keyring_hash != target_keyring_hash_before
if not catalog_changed and not keyring_changed:
notes.append(
"Candidate catalog and keyring already match the website repository."
)
if apply or commit or tag or push:
if not (resolved_web_root / ".git").exists():
blockers.append(
f"website root is not a Git repository: {resolved_web_root}"
)
if not allow_dirty_website and website_dirty(resolved_web_root):
blockers.append("website repository has uncommitted changes")
if apply:
blockers.extend(publication_runtime_trust_issues())
blockers.extend(
publication_mutation_trust_issues(
web_root=resolved_web_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
target_catalog=target_catalog,
target_keyring=target_keyring,
target_modules=target_modules,
)
)
if not git_success(resolved_web_root, "diff", "--cached", "--quiet"):
blockers.append(
"website Git index has staged changes; publication requires a clean index"
)
effective_branch = (
branch or git_text(resolved_web_root, "branch", "--show-current") or None
)
effective_tag_name = tag_name or default_tag_name(
candidate_payload, channel=channel
)
frozen_website_head: str | None = None
frozen_remote: FrozenPublicationRemote | None = None
expected_binding = (
expected_website_head,
expected_website_branch,
expected_remote_sha256,
)
if any(value is not None for value in expected_binding) and not all(
isinstance(value, str) and value for value in expected_binding
):
blockers.append(
"immutable website head, branch, and remote binding must be supplied together"
)
elif all(value is not None for value in expected_binding) and (
_GIT_OBJECT.fullmatch(expected_website_head or "") is None
or _SHA256.fullmatch(expected_remote_sha256 or "") is None
):
blockers.append("immutable website release binding is malformed")
if (tag or push) and not effective_tag_name:
blockers.append("could not infer catalog tag name; pass --tag-name")
if (commit or push) and not effective_branch:
blockers.append("could not infer website branch; pass --branch")
for value, prefix, label in (
(effective_branch, "refs/heads", "website branch"),
(effective_tag_name, "refs/tags", "website publication tag"),
):
if value:
try:
if value.startswith("-"):
raise ValueError(f"{label} must not begin with an option prefix")
_validated_git_ref(
resolved_web_root,
f"{prefix}/{value}",
label=label,
)
except (RuntimeError, subprocess.SubprocessError, ValueError) as exc:
blockers.append(f"{label} is not safe: {exc}")
if (
tag
and effective_tag_name
and git_success(
resolved_web_root,
"rev-parse",
"--quiet",
"--verify",
f"refs/tags/{effective_tag_name}",
)
):
blockers.append(f"website tag already exists: {effective_tag_name}")
if apply and commit and effective_branch:
frozen_website_head = git_text(
resolved_web_root, "rev-parse", "--verify", "HEAD^{commit}"
)
frozen_branch_head = git_text(
resolved_web_root,
"rev-parse",
"--verify",
f"refs/heads/{effective_branch}^{{commit}}",
)
if (
_GIT_OBJECT.fullmatch(frozen_website_head) is None
or frozen_branch_head != frozen_website_head
):
blockers.append(
"website branch and HEAD do not identify one frozen publication parent"
)
if (
expected_website_head is not None
and frozen_website_head != expected_website_head
):
blockers.append("website HEAD differs from the immutable release binding")
if (
expected_website_branch is not None
and effective_branch != expected_website_branch
):
blockers.append("website branch differs from the immutable release binding")
if apply:
try:
frozen_remote = bind_publication_remote(
web_root=resolved_web_root,
remote=remote,
registered_remote=registered_website_remote(),
)
if (
expected_remote_sha256 is not None
and frozen_remote.sha256 != expected_remote_sha256
):
raise RuntimeError(
"website remote differs from the immutable release binding"
)
if commit and effective_branch and frozen_website_head:
verify_remote_branch_head(
web_root=resolved_web_root,
remote_url=frozen_remote.url,
branch=effective_branch,
expected_commit=frozen_website_head,
)
except (
OSError,
RuntimeError,
subprocess.SubprocessError,
UnicodeError,
ValueError,
) as exc:
blockers.append(f"website publication remote cannot be frozen: {exc}")
steps.append(
CatalogPublishStep(
id="validate",
title="Validate candidate catalog",
detail="Validate signature, approved channel, freshness, interface closure, and keyring trust.",
status="done" if validation_valid else "blocked",
)
)
steps.append(
CatalogPublishStep(
id="copy",
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 build_web:
steps.append(
CatalogPublishStep(
id="build-web",
title="Build website",
detail=(
"Run the website build after copying catalog files with "
f"{resolved_npm}."
),
command=shlex.join(
[
resolved_npm,
"--prefix",
str(resolved_web_root),
"run",
"build",
]
),
mutating=True,
status="planned" if not apply else "blocked" if blockers else "pending",
)
)
if commit:
steps.append(
CatalogPublishStep(
id="commit",
title="Commit website catalog update",
detail="Commit generated catalog/keyring files in the website repository.",
command=shlex.join(
[
"git",
"-C",
str(resolved_web_root),
"commit",
"-m",
commit_message(candidate_payload, channel=channel),
]
),
mutating=True,
status="planned" if not apply else "blocked" if blockers else "pending",
)
)
if tag:
steps.append(
CatalogPublishStep(
id="tag",
title=f"Tag website catalog publication {effective_tag_name}",
detail="Create an annotated website publication tag.",
command=shlex.join(
[
"git",
"-C",
str(resolved_web_root),
"tag",
"-a",
effective_tag_name,
"-m",
commit_message(candidate_payload, channel=channel),
]
),
mutating=True,
status="planned" if not apply else "blocked" if blockers else "pending",
)
)
if push:
steps.append(
CatalogPublishStep(
id="push",
title="Push website catalog publication",
detail="Push website branch and tag if a tag was requested.",
command=push_command(
resolved_web_root,
remote=remote,
branch=effective_branch,
tag_name=effective_tag_name if tag else None,
),
mutating=True,
status="planned" if not apply else "blocked" if blockers else "pending",
)
)
if blockers:
return result(
status="blocked",
applied=False,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
remote=remote,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
steps=tuple(steps),
notes=tuple([*notes, *blockers]),
)
if not apply:
notes.append(
"Dry run only. Pass --apply to copy files; pass --commit/--tag/--push for Git publication steps."
)
return result(
status="ready",
applied=False,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
remote=remote,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
steps=tuple(steps),
notes=tuple(notes),
)
directory_payloads = module_directory_payloads(
catalog_payload=candidate_payload,
keyring_payload=keyring_payload,
channel=channel,
public_base_url=catalog_public_base_url(candidate_payload),
)
_atomic_write_public_json(
target_catalog, candidate_payload, trusted_root=resolved_web_root
)
_atomic_write_public_json(
target_keyring, keyring_payload, trusted_root=resolved_web_root
)
if target_modules.exists():
_remove_public_tree(target_modules, trusted_root=resolved_web_root)
for relative_path, payload in directory_payloads:
_atomic_write_public_json(
target_catalog.parent.parent / relative_path,
payload,
trusted_root=resolved_web_root,
)
expected_blobs = _expected_publication_blobs(
web_root=resolved_web_root,
target_catalog=target_catalog,
candidate_payload=candidate_payload,
target_keyring=target_keyring,
keyring_payload=keyring_payload,
module_root=target_catalog.parent.parent,
directory_payloads=directory_payloads,
)
completed_steps = [
replace_status(step, "done") if step.id == "copy" else step for step in steps
]
if build_web:
run_checked(
[resolved_npm, "--prefix", str(resolved_web_root), "run", "build"],
cwd=resolved_web_root,
)
completed_steps = [
replace_status(step, "done") if step.id == "build-web" else step
for step in completed_steps
]
publication_commit: str | None = None
if commit:
if frozen_website_head is None or effective_branch is None:
raise RuntimeError("publication parent was not frozen before mutation")
publication_commit = commit_publication_tree(
web_root=resolved_web_root,
frozen_head=frozen_website_head,
branch=effective_branch,
expected_blobs=expected_blobs,
module_root=target_modules,
message=commit_message(candidate_payload, channel=channel),
)
completed_steps = [
replace_status(step, "done") if step.id == "commit" else step
for step in completed_steps
]
publication_tag_object: str | None = None
publication_tag_commit: str | None = None
if tag and effective_tag_name and publication_commit:
_git_checked(
resolved_web_root,
"tag",
"--no-sign",
"-a",
"-m",
commit_message(candidate_payload, channel=channel),
"--",
effective_tag_name,
publication_commit,
)
publication_tag_object = git_text(
resolved_web_root,
"rev-parse",
"--verify",
f"refs/tags/{effective_tag_name}",
)
publication_tag_commit = git_text(
resolved_web_root,
"rev-parse",
"--verify",
f"refs/tags/{effective_tag_name}^{{commit}}",
)
publication_tag_type = git_text(
resolved_web_root,
"cat-file",
"-t",
f"refs/tags/{effective_tag_name}",
)
if (
_GIT_OBJECT.fullmatch(publication_tag_object) is None
or publication_tag_type != "tag"
or publication_tag_commit != publication_commit
):
raise RuntimeError(
"website publication tag does not bind the verified commit"
)
completed_steps = [
replace_status(step, "done") if step.id == "tag" else step
for step in completed_steps
]
if push and effective_branch and publication_commit:
if frozen_remote is None:
raise RuntimeError("publication remote was not frozen before mutation")
push_args = ["-c", "core.hooksPath=/dev/null", "push"]
if tag and effective_tag_name and publication_tag_object:
push_args.extend(
[
"--atomic",
frozen_remote.url,
f"{publication_commit}:refs/heads/{effective_branch}",
f"{publication_tag_object}:refs/tags/{effective_tag_name}",
]
)
else:
push_args.extend(
[
frozen_remote.url,
f"{publication_commit}:refs/heads/{effective_branch}",
]
)
try:
_git_checked(
resolved_web_root,
*push_args,
isolate_config=True,
)
if (
effective_tag_name is None
or publication_tag_object is None
or publication_tag_commit is None
):
raise RuntimeError("published release has no complete tag identity")
verify_remote_publication(
web_root=resolved_web_root,
remote_url=frozen_remote.url,
branch=effective_branch,
tag_name=effective_tag_name,
expected_commit=publication_commit,
expected_tag_object=publication_tag_object,
)
except Exception as exc:
raise CatalogPublicationAmbiguousError(
"website push may have completed but remote identity verification failed"
) from exc
completed_steps = [
replace_status(step, "done") if step.id == "push" else step
for step in completed_steps
]
return result(
status="published" if push else "applied",
applied=True,
candidate_root=candidate_root,
candidate_catalog=candidate_catalog,
candidate_keyring=candidate_keyring,
resolved_web_root=resolved_web_root,
target_catalog=target_catalog,
target_keyring=target_keyring,
channel=channel,
branch=effective_branch,
tag_name=effective_tag_name,
remote=remote,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
publication_commit_sha=publication_commit,
publication_tag_object_sha=publication_tag_object,
publication_tag_commit_sha=publication_tag_commit,
steps=tuple(completed_steps),
notes=tuple(notes),
)
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,
applied: bool,
candidate_root: Path,
candidate_catalog: Path,
candidate_keyring: Path,
resolved_web_root: Path,
target_catalog: Path,
target_keyring: Path,
channel: str,
branch: str | None,
tag_name: str | None,
remote: str,
validation_valid: bool,
validation_error: str | None,
validation_warnings: tuple[str, ...],
candidate_catalog_hash: str | None,
candidate_keyring_hash: str | None,
target_catalog_hash_before: str | None,
target_keyring_hash_before: str | None,
catalog_changed: bool,
keyring_changed: bool,
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, ...],
) -> CatalogPublishResult:
if status == "published" and (
_GIT_OBJECT.fullmatch(publication_commit_sha or "") is None
or _GIT_OBJECT.fullmatch(publication_tag_object_sha or "") is None
or publication_tag_commit_sha != publication_commit_sha
or _SHA256.fullmatch(candidate_catalog_hash or "") is None
or _SHA256.fullmatch(candidate_keyring_hash or "") is None
or not branch
or not tag_name
or _REMOTE_NAME.fullmatch(remote) is None
):
raise RuntimeError("published catalog result has no complete remote identity")
return CatalogPublishResult(
generated_at=datetime.now(tz=UTC)
.replace(microsecond=0)
.isoformat()
.replace("+00:00", "Z"),
channel=channel,
status=status,
applied=applied,
candidate_dir=str(candidate_root),
candidate_catalog_path=str(candidate_catalog),
candidate_keyring_path=str(candidate_keyring),
web_root=str(resolved_web_root),
target_catalog_path=str(target_catalog),
target_keyring_path=str(target_keyring),
branch=branch,
tag_name=tag_name,
remote=remote,
validation_valid=validation_valid,
validation_error=validation_error,
validation_warnings=validation_warnings,
candidate_catalog_hash=candidate_catalog_hash,
candidate_keyring_hash=candidate_keyring_hash,
target_catalog_hash_before=target_catalog_hash_before,
target_keyring_hash_before=target_keyring_hash_before,
catalog_changed=catalog_changed,
keyring_changed=keyring_changed,
publication_commit_sha=publication_commit_sha,
publication_tag_object_sha=publication_tag_object_sha,
publication_tag_commit_sha=publication_tag_commit_sha,
steps=steps,
notes=notes,
)
def read_json(path: Path) -> object:
return read_bounded_json_source(path, label="catalog JSON")
def validate_catalog_payload(
payload: object,
*,
require_trusted: bool,
approved_channels: tuple[str, ...],
trusted_keys: dict[str, bytes],
) -> dict[str, object]:
"""Validate exactly the in-memory object that publication will consume."""
encoded = _public_json_bytes(payload)
descriptor, temporary_name = tempfile.mkstemp(
prefix="govoplan-catalog-validation-", suffix=".json"
)
temporary = Path(temporary_name)
try:
os.fchmod(descriptor, 0o600)
with os.fdopen(descriptor, "wb", closefd=True) as handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
return validate_module_package_catalog(
temporary,
require_trusted=require_trusted,
approved_channels=approved_channels,
trusted_keys=trusted_keys,
)
finally:
try:
os.close(descriptor)
except OSError:
pass
temporary.unlink(missing_ok=True)
def _public_json_bytes(payload: object) -> bytes:
encoded = (
json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=True) + "\n"
).encode("utf-8")
if len(encoded) > 16 * 1024 * 1024:
raise ValueError("published catalog object exceeds its size limit")
return encoded
def _atomic_write_public_json(
path: Path, payload: object, *, trusted_root: Path
) -> None:
"""Write through pinned directories from a trusted worktree root."""
encoded = _public_json_bytes(payload)
relative = _trusted_relative_path(path, trusted_root=trusted_root)
if len(relative.parts) < 2:
raise ValueError("published catalog target requires a parent directory")
parent_descriptor = _open_relative_directory(
trusted_root, relative.parts[:-1], create=True
)
target_name = relative.parts[-1]
try:
try:
observed = os.stat(
target_name, dir_fd=parent_descriptor, follow_symlinks=False
)
except FileNotFoundError:
observed = None
if observed is not None and not stat.S_ISREG(observed.st_mode):
raise ValueError("published catalog target must be a regular file")
temporary_name = f".{target_name}.{secrets.token_hex(16)}.tmp"
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0)
descriptor = os.open(temporary_name, flags, 0o600, dir_fd=parent_descriptor)
replaced = False
try:
with os.fdopen(descriptor, "wb", closefd=True) as handle:
handle.write(encoded)
handle.flush()
os.fsync(handle.fileno())
os.replace(
temporary_name,
target_name,
src_dir_fd=parent_descriptor,
dst_dir_fd=parent_descriptor,
)
replaced = True
published = os.open(
target_name,
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
dir_fd=parent_descriptor,
)
try:
if not stat.S_ISREG(os.fstat(published).st_mode):
raise ValueError("published catalog target must remain regular")
os.fchmod(published, 0o644)
finally:
os.close(published)
os.fsync(parent_descriptor)
finally:
if not replaced:
try:
os.close(descriptor)
except OSError:
pass
try:
os.unlink(temporary_name, dir_fd=parent_descriptor)
except FileNotFoundError:
pass
finally:
os.close(parent_descriptor)
def _trusted_relative_path(path: Path, *, trusted_root: Path) -> Path:
try:
relative = path.absolute().relative_to(trusted_root.absolute())
except ValueError as exc:
raise ValueError(
"published catalog target leaves the trusted worktree"
) from exc
if not relative.parts or any(part in {"", ".", ".."} for part in relative.parts):
raise ValueError("published catalog target is not canonical")
return relative
def _open_relative_directory(
trusted_root: Path, parts: tuple[str, ...], *, create: bool
) -> int:
root_flags = (
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
)
descriptor = os.open(trusted_root, root_flags)
try:
if not stat.S_ISDIR(os.fstat(descriptor).st_mode):
raise ValueError("publication worktree root must be a directory")
for part in parts:
if part in {"", ".", ".."} or "/" in part:
raise ValueError("published catalog path is not canonical")
try:
child = os.open(part, root_flags, dir_fd=descriptor)
except FileNotFoundError:
if not create:
raise
os.mkdir(part, mode=0o755, dir_fd=descriptor)
child = os.open(part, root_flags, dir_fd=descriptor)
os.close(descriptor)
descriptor = child
return descriptor
except BaseException:
os.close(descriptor)
raise
def publication_mutation_trust_issues(
*,
web_root: Path,
candidate_catalog: Path,
candidate_keyring: Path,
target_catalog: Path,
target_keyring: Path,
target_modules: Path,
) -> tuple[str, ...]:
"""Reject mutable trust inputs and unsafe website/Git ownership boundaries."""
issues: list[str] = []
for path, label in (
(web_root, "website root"),
(candidate_catalog.parent, "candidate catalog root"),
(candidate_keyring.parent, "candidate keyring root"),
):
issue = _operator_controlled_ancestor_issue(path, allow_sticky=True)
if issue:
issues.append(f"{label}: {issue}")
checks: list[tuple[Path, bool, bool, str]] = [
(web_root, True, True, "website root"),
(web_root / ".git", True, True, "website Git directory"),
(web_root / ".git" / "config", False, True, "website Git config"),
(candidate_catalog, False, True, "candidate catalog"),
(candidate_keyring, False, True, "candidate keyring"),
(target_keyring, False, True, "published trust keyring"),
(target_catalog, False, False, "published channel catalog"),
(web_root / ".git" / "HEAD", False, False, "website Git HEAD"),
(web_root / ".git" / "index", False, False, "website Git index"),
(web_root / ".git" / "objects", True, False, "website Git objects"),
(web_root / ".git" / "refs", True, False, "website Git refs"),
]
for target in (target_catalog.parent, target_keyring.parent, target_modules.parent):
try:
relative = target.absolute().relative_to(web_root.absolute())
except ValueError:
return ("publication target leaves the owned website root",)
current = web_root
for part in relative.parts:
current /= part
checks.append((current, True, False, "website publication directory"))
seen: set[Path] = set()
for path, directory, required, label in checks:
if path in seen:
continue
seen.add(path)
try:
observed = path.lstat()
except FileNotFoundError:
if required:
issues.append(f"{label} is missing")
continue
except OSError:
issues.append(f"{label} cannot be inspected safely")
continue
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
if stat.S_ISLNK(observed.st_mode) or not expected_type(observed.st_mode):
issues.append(
f"{label} must be a real {'directory' if directory else 'file'}"
)
elif observed.st_uid != os.geteuid():
issues.append(f"{label} is not owned by the current operator")
elif stat.S_IMODE(observed.st_mode) & 0o022:
issues.append(f"{label} is writable by another user")
return tuple(issues)
def publication_runtime_trust_issues() -> tuple[str, ...]:
"""Require the running meta release code/config to be operator-controlled."""
release_root = META_ROOT / "tools" / "release"
runtime_owners = {0, os.geteuid()}
package_roots: tuple[tuple[Path, str], ...] = (
(_loaded_package_root(govoplan_core, label="govoplan_core"), "GovOPlaN Core"),
(_loaded_package_root(cryptography, label="cryptography"), "cryptography"),
)
for path, label in (
(META_ROOT, "meta repository"),
(Path(sys.prefix), "Python environment"),
*package_roots,
):
ancestor_issue = _operator_controlled_ancestor_issue(path)
if ancestor_issue:
return (f"{label}: {ancestor_issue}",)
for executable, label in (
(Path(sys.executable), "Python executable"),
(Path("/usr/bin/git"), "Git executable"),
(Path("/usr/bin/ssh"), "SSH executable"),
):
executable_issue = _trusted_runtime_executable_issue(
executable,
permitted_owners=runtime_owners,
label=label,
)
if executable_issue:
return (executable_issue,)
required = (
(META_ROOT, True, "meta repository root"),
(META_ROOT / "repositories.json", False, "repository registry"),
(release_root, True, "release tooling root"),
)
issues: list[str] = []
for path, directory, label in required:
issue = _operator_control_issue(
path,
directory=directory,
label=label,
permitted_owners=runtime_owners,
)
if issue:
issues.append(issue)
if issues:
return tuple(issues)
pending = [release_root, *(path for path, _label in package_roots)]
inspected = 0
while pending:
parent = pending.pop()
try:
entries = tuple(os.scandir(parent))
except OSError:
return ("release tooling tree cannot be inspected safely",)
for entry in entries:
inspected += 1
if inspected > 4_096:
return ("release tooling tree exceeds its trust inspection bound",)
path = Path(entry.path)
try:
observed = path.lstat()
except OSError:
return ("release tooling tree changed during trust inspection",)
if stat.S_ISDIR(observed.st_mode):
pending.append(path)
directory = True
elif stat.S_ISREG(observed.st_mode):
directory = False
else:
return (
f"release tooling path is not a real file or directory: {path}",
)
issue = _operator_control_issue(
path,
directory=directory,
label="release tooling path",
observed=observed,
permitted_owners=runtime_owners,
)
if issue:
return (issue,)
return ()
def _loaded_package_root(module: object, *, label: str) -> Path:
source = getattr(module, "__file__", None)
if not isinstance(source, str) or not source:
raise RuntimeError(f"{label} package origin cannot be identified")
return Path(source).absolute().parent
def _trusted_runtime_executable_issue(
path: Path, *, permitted_owners: set[int], label: str = "Python executable"
) -> str | None:
executable = path.absolute()
ancestor_issue = _operator_controlled_ancestor_issue(executable.parent)
if ancestor_issue:
return f"{label}: {ancestor_issue}"
try:
link_metadata = executable.lstat()
except OSError:
return f"{label} cannot be inspected safely: {executable}"
if stat.S_ISLNK(link_metadata.st_mode):
if link_metadata.st_uid not in permitted_owners:
return f"{label} symlink has an untrusted owner: {executable}"
try:
target = executable.resolve(strict=True)
except OSError:
return f"{label} symlink cannot be resolved safely: {executable}"
else:
target = executable
ancestor_issue = _operator_controlled_ancestor_issue(target.parent)
if ancestor_issue:
return f"{label} target: {ancestor_issue}"
return _operator_control_issue(
target,
directory=False,
label=f"{label} target",
permitted_owners=permitted_owners,
)
def _operator_controlled_ancestor_issue(
path: Path, *, allow_sticky: bool = False
) -> str | None:
"""Reject a trust root that an ancestor directory can be replaced beneath."""
current = path.absolute()
permitted_owners = {0, os.geteuid()}
while True:
try:
observed = current.lstat()
except OSError:
return f"release trust-path ancestor cannot be inspected safely: {current}"
if stat.S_ISLNK(observed.st_mode) or not stat.S_ISDIR(observed.st_mode):
return f"release trust-path ancestor is not a real directory: {current}"
if observed.st_uid not in permitted_owners:
return f"release trust-path ancestor has an untrusted owner: {current}"
writable_by_others = stat.S_IMODE(observed.st_mode) & 0o022
sticky_boundary = allow_sticky and bool(observed.st_mode & stat.S_ISVTX)
if writable_by_others and not sticky_boundary:
return f"release trust-path ancestor is group/world writable: {current}"
if current.parent == current:
break
current = current.parent
return None
def _operator_control_issue(
path: Path,
*,
directory: bool,
label: str,
observed: os.stat_result | None = None,
permitted_owners: set[int] | None = None,
) -> str | None:
try:
metadata = observed or path.lstat()
except OSError:
return f"{label} cannot be inspected safely: {path}"
expected_type = stat.S_ISDIR if directory else stat.S_ISREG
if stat.S_ISLNK(metadata.st_mode) or not expected_type(metadata.st_mode):
return f"{label} must be a real {'directory' if directory else 'file'}: {path}"
owners = permitted_owners or {os.geteuid()}
if metadata.st_uid not in owners:
return f"{label} is not owned by the current operator: {path}"
if stat.S_IMODE(metadata.st_mode) & 0o022:
return f"{label} is group/world writable: {path}"
return None
def _remove_public_tree(path: Path, *, trusted_root: Path) -> None:
relative = _trusted_relative_path(path, trusted_root=trusted_root)
if len(relative.parts) < 2:
raise ValueError("published module target requires a pinned parent")
parent_descriptor = _open_relative_directory(
trusted_root, relative.parts[:-1], create=False
)
target_name = relative.parts[-1]
trash_name = f".{target_name}.{secrets.token_hex(16)}.trash"
try:
try:
observed = os.stat(
target_name, dir_fd=parent_descriptor, follow_symlinks=False
)
except FileNotFoundError:
return
if not stat.S_ISDIR(observed.st_mode):
raise ValueError("published module target must be a real directory")
os.rename(
target_name,
trash_name,
src_dir_fd=parent_descriptor,
dst_dir_fd=parent_descriptor,
)
trash_descriptor = os.open(
trash_name,
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0),
dir_fd=parent_descriptor,
)
try:
trash_device = os.fstat(trash_descriptor).st_dev
os.fchmod(trash_descriptor, 0o700)
finally:
os.close(trash_descriptor)
_remove_tree_at(
parent_descriptor,
trash_name,
expected_device=trash_device,
remaining=[_MAX_REMOVED_TREE_ENTRIES],
depth=0,
)
os.fsync(parent_descriptor)
finally:
os.close(parent_descriptor)
def _remove_tree_at(
parent_descriptor: int,
name: str,
*,
expected_device: int,
remaining: list[int],
depth: int,
) -> None:
"""Remove a renamed private tree without resolving a mutable parent path."""
directory_flags = (
os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | getattr(os, "O_NOFOLLOW", 0)
)
if depth > _MAX_REMOVED_TREE_DEPTH:
raise RuntimeError("published module tree exceeds its removal depth bound")
descriptor = os.open(name, directory_flags, dir_fd=parent_descriptor)
try:
if os.fstat(descriptor).st_dev != expected_device:
raise RuntimeError("published module tree crosses a mounted filesystem")
with os.scandir(descriptor) as entries:
for entry in entries:
remaining[0] -= 1
if remaining[0] < 0:
raise RuntimeError(
"published module tree exceeds its removal entry bound"
)
child_name = entry.name
observed = os.stat(child_name, dir_fd=descriptor, follow_symlinks=False)
if stat.S_ISDIR(observed.st_mode):
if observed.st_dev != expected_device:
raise RuntimeError(
"published module tree crosses a mounted filesystem"
)
_remove_tree_at(
descriptor,
child_name,
expected_device=expected_device,
remaining=remaining,
depth=depth + 1,
)
else:
os.unlink(child_name, dir_fd=descriptor)
finally:
os.close(descriptor)
os.rmdir(name, dir_fd=parent_descriptor)
def _expected_publication_blobs(
*,
web_root: Path,
target_catalog: Path,
candidate_payload: object,
target_keyring: Path,
keyring_payload: object,
module_root: Path,
directory_payloads: tuple[tuple[Path, dict[str, object]], ...],
) -> dict[str, bytes]:
expected = {
_trusted_relative_path(
target_catalog, trusted_root=web_root
).as_posix(): _public_json_bytes(candidate_payload),
_trusted_relative_path(
target_keyring, trusted_root=web_root
).as_posix(): _public_json_bytes(keyring_payload),
}
for relative_path, payload in directory_payloads:
target = module_root / relative_path
repository_path = _trusted_relative_path(
target, trusted_root=web_root
).as_posix()
if repository_path in expected:
raise ValueError("publication output paths are ambiguous")
expected[repository_path] = _public_json_bytes(payload)
return expected
def committed_publication_sha(web_root: Path) -> str:
commit_sha = git_text(web_root, "rev-parse", "--verify", "HEAD^{commit}")
if _GIT_OBJECT.fullmatch(commit_sha) is None:
raise RuntimeError("could not resolve the publication commit")
return commit_sha
def commit_publication_tree(
*,
web_root: Path,
frozen_head: str,
branch: str,
expected_blobs: dict[str, bytes],
module_root: Path,
message: str,
) -> str:
"""Build one exact commit from memory through a private index and no hooks."""
if _GIT_OBJECT.fullmatch(frozen_head) is None:
raise RuntimeError("publication parent identity is malformed")
module_prefix = _trusted_relative_path(
module_root, trusted_root=web_root
).as_posix()
expected_modules = {
path for path in expected_blobs if path.startswith(f"{module_prefix}/")
}
old_modules = _git_tree_paths(web_root, frozen_head, module_prefix)
with tempfile.TemporaryDirectory(
prefix="govoplan-publication-index-"
) as temporary_directory:
index_name = str(Path(temporary_directory) / "index")
environment = _sanitized_git_environment(private_index=index_name)
_git_checked(
web_root,
"read-tree",
frozen_head,
environment=environment,
)
for path, encoded in sorted(expected_blobs.items()):
object_id = (
_git_bytes(
web_root,
"hash-object",
"-w",
"--stdin",
input_bytes=encoded,
)
.decode("ascii")
.strip()
)
if _GIT_OBJECT.fullmatch(object_id) is None:
raise RuntimeError("Git returned an invalid publication blob identity")
_git_checked(
web_root,
"update-index",
"--add",
"--cacheinfo",
"100644",
object_id,
path,
environment=environment,
)
for path in sorted(old_modules - expected_modules):
_git_checked(
web_root,
"update-index",
"--force-remove",
"--",
path,
environment=environment,
)
tree_sha = _git_text_checked(web_root, "write-tree", environment=environment)
if _GIT_OBJECT.fullmatch(tree_sha) is None:
raise RuntimeError("Git returned an invalid publication tree identity")
_verify_treeish_publication(
web_root=web_root,
treeish=tree_sha,
expected_blobs=expected_blobs,
module_root=module_root,
)
expected_delta = _expected_catalog_delta_paths(
web_root=web_root,
frozen_head=frozen_head,
expected_blobs=expected_blobs,
old_modules=old_modules,
expected_modules=expected_modules,
)
observed_delta = _git_diff_paths(web_root, frozen_head, tree_sha)
if observed_delta != expected_delta:
raise RuntimeError(
"publication tree contains paths outside the computed catalog delta"
)
commit_sha = _git_text_checked(
web_root,
"-c",
"commit.gpgSign=false",
"commit-tree",
tree_sha,
"-p",
frozen_head,
input_bytes=(message + "\n").encode("utf-8"),
)
if _GIT_OBJECT.fullmatch(commit_sha) is None:
raise RuntimeError("Git returned an invalid publication commit identity")
commit_line = _git_text_checked(
web_root, "rev-list", "--parents", "-n", "1", commit_sha
)
if commit_line.split() != [commit_sha, frozen_head]:
raise RuntimeError("publication commit does not have the sole frozen parent")
commit_tree = _git_text_checked(web_root, "show", "-s", "--format=%T", commit_sha)
if commit_tree != tree_sha:
raise RuntimeError("publication commit tree differs from the verified tree")
symbolic_head = _git_text_checked(web_root, "symbolic-ref", "--quiet", "HEAD")
current_head = _git_text_checked(web_root, "rev-parse", "--verify", "HEAD^{commit}")
if symbolic_head != f"refs/heads/{branch}" or current_head != frozen_head:
raise RuntimeError("website HEAD changed after publication was frozen")
_git_checked(
web_root,
"-c",
"core.hooksPath=/dev/null",
"update-ref",
f"refs/heads/{branch}",
commit_sha,
frozen_head,
)
_git_checked(web_root, "read-tree", "--reset", commit_sha)
return commit_sha
def verify_committed_publication(
*,
web_root: Path,
commit_sha: str,
expected_blobs: dict[str, bytes],
module_root: Path,
) -> None:
"""Require the exact validated objects in one immutable commit tree."""
if _GIT_OBJECT.fullmatch(commit_sha) is None:
raise RuntimeError("publication commit identity is malformed")
_verify_treeish_publication(
web_root=web_root,
treeish=commit_sha,
expected_blobs=expected_blobs,
module_root=module_root,
)
def _verify_treeish_publication(
*,
web_root: Path,
treeish: str,
expected_blobs: dict[str, bytes],
module_root: Path,
) -> None:
module_prefix = _trusted_relative_path(
module_root, trusted_root=web_root
).as_posix()
expected_modules = {
path for path in expected_blobs if path.startswith(f"{module_prefix}/")
}
tree = _git_bytes(
web_root,
"ls-tree",
"-r",
"--name-only",
"-z",
treeish,
"--",
module_prefix,
)
observed_modules = {item.decode("utf-8") for item in tree.split(b"\0") if item}
if observed_modules != expected_modules:
raise RuntimeError(
"committed module directory differs from validated publication outputs"
)
for repository_path, expected in expected_blobs.items():
entry = _git_bytes(
web_root,
"ls-tree",
"-z",
treeish,
"--",
repository_path,
)
metadata, separator, encoded_path = entry.rstrip(b"\0").partition(b"\t")
fields = metadata.split()
if (
not separator
or encoded_path.decode("utf-8") != repository_path
or len(fields) != 3
or fields[0] != b"100644"
or fields[1] != b"blob"
or re.fullmatch(rb"[0-9a-f]{40,64}", fields[2]) is None
):
raise RuntimeError(
f"committed publication path is not one regular blob: {repository_path}"
)
observed = _git_bytes(web_root, "cat-file", "blob", fields[2].decode("ascii"))
if observed != expected:
raise RuntimeError(
f"committed publication blob differs from validated output: {repository_path}"
)
def _expected_catalog_delta_paths(
*,
web_root: Path,
frozen_head: str,
expected_blobs: dict[str, bytes],
old_modules: set[str],
expected_modules: set[str],
) -> set[str]:
changed = set(old_modules - expected_modules)
for path, expected in expected_blobs.items():
observed = _git_tree_blob(web_root, frozen_head, path)
if observed is None or observed != (b"100644", expected):
changed.add(path)
return changed
def _git_tree_paths(web_root: Path, treeish: str, prefix: str) -> set[str]:
encoded = _git_bytes(
web_root,
"ls-tree",
"-r",
"--name-only",
"-z",
treeish,
"--",
prefix,
)
return {item.decode("utf-8") for item in encoded.split(b"\0") if item}
def _git_tree_blob(
web_root: Path, treeish: str, repository_path: str
) -> tuple[bytes, bytes] | None:
entry = _git_bytes(web_root, "ls-tree", "-z", treeish, "--", repository_path)
if not entry:
return None
metadata, separator, encoded_path = entry.rstrip(b"\0").partition(b"\t")
fields = metadata.split()
if (
not separator
or encoded_path.decode("utf-8") != repository_path
or len(fields) != 3
or fields[1] != b"blob"
or re.fullmatch(rb"[0-9a-f]{40,64}", fields[2]) is None
):
return None
return fields[0], _git_bytes(
web_root, "cat-file", "blob", fields[2].decode("ascii")
)
def _git_diff_paths(web_root: Path, before: str, after: str) -> set[str]:
encoded = _git_bytes(
web_root,
"diff-tree",
"--no-renames",
"--no-ext-diff",
"--no-commit-id",
"--name-only",
"-r",
"-z",
before,
after,
)
return {item.decode("utf-8") for item in encoded.split(b"\0") if item}
def registered_website_remote() -> str:
"""Return the sole website endpoint registered for release publication."""
websites = tuple(
spec
for spec in load_repository_specs(include_website=True)
if spec.category == "website"
)
if len(websites) != 1:
raise RuntimeError("release configuration must register one website repository")
return _validated_remote_url(websites[0].remote)
def bind_publication_remote(
*, web_root: Path, remote: str, registered_remote: str
) -> FrozenPublicationRemote:
"""Bind one local remote name to the exact registered fetch/push URL."""
registered = _validated_remote_url(registered_remote)
fetch_urls = _configured_remote_urls(web_root, remote=remote, push=False)
push_urls = _configured_remote_urls(web_root, remote=remote, push=True)
if fetch_urls != (registered,) or push_urls != (registered,):
raise RuntimeError(
"configured website fetch and push URLs do not match the registered release remote"
)
digest = hashlib.sha256(
json.dumps(
{"fetch": fetch_urls, "push": push_urls},
sort_keys=True,
separators=(",", ":"),
ensure_ascii=True,
).encode("utf-8")
).hexdigest()
return FrozenPublicationRemote(url=registered, sha256=digest)
def verify_remote_branch_head(
*, web_root: Path, remote_url: str, branch: str, expected_commit: str
) -> None:
"""Prove the remote branch still identifies the frozen publication parent."""
if _GIT_OBJECT.fullmatch(expected_commit) is None:
raise RuntimeError("frozen website commit identity is malformed")
branch_ref = _validated_git_ref(
web_root, f"refs/heads/{branch}", label="website branch"
)
observed = _remote_ref_identities(
web_root=web_root,
remote_url=remote_url,
refs=(branch_ref,),
)
if observed != {branch_ref: expected_commit}:
raise RuntimeError(
"website HEAD is not identical to the remote publication branch"
)
def remote_publication_identity(
*, web_root: Path, remote_url: str, branch: str, tag_name: str
) -> dict[str, str]:
"""Read branch, annotated-tag object, and peeled commit from the remote."""
branch_ref = _validated_git_ref(
web_root, f"refs/heads/{branch}", label="website branch"
)
tag_ref = _validated_git_ref(
web_root, f"refs/tags/{tag_name}", label="website publication tag"
)
peeled_ref = f"{tag_ref}^{{}}"
observed = _remote_ref_identities(
web_root=web_root,
remote_url=remote_url,
refs=(branch_ref, tag_ref, peeled_ref),
)
if set(observed) != {branch_ref, tag_ref, peeled_ref}:
raise RuntimeError(
"remote publication does not expose one annotated tag identity"
)
return {
"publication_commit_sha": observed[branch_ref],
"publication_tag_object_sha": observed[tag_ref],
"publication_tag_commit_sha": observed[peeled_ref],
}
def verify_remote_publication(
*,
web_root: Path,
remote_url: str,
branch: str,
tag_name: str,
expected_commit: str,
expected_tag_object: str,
) -> dict[str, str]:
"""Independently prove the exact branch and annotated tag after a push."""
if (
_GIT_OBJECT.fullmatch(expected_commit) is None
or _GIT_OBJECT.fullmatch(expected_tag_object) is None
):
raise RuntimeError("expected publication identity is malformed")
identity = remote_publication_identity(
web_root=web_root,
remote_url=remote_url,
branch=branch,
tag_name=tag_name,
)
if identity != {
"publication_commit_sha": expected_commit,
"publication_tag_object_sha": expected_tag_object,
"publication_tag_commit_sha": expected_commit,
}:
raise RuntimeError(
"remote publication identity differs from the verified objects"
)
return identity
def _configured_remote_urls(
web_root: Path, *, remote: str, push: bool
) -> tuple[str, ...]:
arguments = ["remote", "get-url"]
if push:
arguments.append("--push")
arguments.extend(["--all", remote])
encoded = _git_bytes(
web_root,
*arguments,
max_output_bytes=_MAX_REMOTE_METADATA_BYTES,
)
try:
values = tuple(
_validated_remote_url(line)
for line in encoded.decode("utf-8", errors="strict").splitlines()
if line
)
except UnicodeError as exc:
raise RuntimeError("configured website remote is not valid UTF-8") from exc
if not values or len(values) > 16:
raise RuntimeError("configured website remote URL set is not bounded")
return values
def _validated_remote_url(value: str) -> str:
if not isinstance(value, str):
raise RuntimeError("website remote URL must be text")
try:
encoded = value.encode("utf-8", errors="strict")
except UnicodeError as exc:
raise RuntimeError("website remote URL is not valid UTF-8") from exc
if (
not value
or value.startswith("-")
or value != value.strip()
or len(encoded) > _MAX_REMOTE_METADATA_BYTES
or any(
character.isspace() or not character.isprintable() for character in value
)
):
raise RuntimeError("website remote URL is not a bounded printable endpoint")
return value
def _validated_git_ref(web_root: Path, value: str, *, label: str) -> str:
if len(value.encode("utf-8")) > 512:
raise RuntimeError(f"{label.capitalize()} is not a bounded Git ref")
_git_checked(
web_root,
"check-ref-format",
value,
max_output_bytes=_MAX_REMOTE_METADATA_BYTES,
)
return value
def _remote_ref_identities(
*, web_root: Path, remote_url: str, refs: tuple[str, ...]
) -> dict[str, str]:
endpoint = _validated_remote_url(remote_url)
encoded = _git_bytes(
web_root,
"ls-remote",
endpoint,
*refs,
max_output_bytes=_MAX_REMOTE_METADATA_BYTES,
isolate_config=True,
)
observed: dict[str, str] = {}
try:
lines = encoded.decode("utf-8", errors="strict").splitlines()
except UnicodeError as exc:
raise RuntimeError("remote Git identities are not valid UTF-8") from exc
if len(lines) > max(16, len(refs) * 2):
raise RuntimeError("remote Git identity response is not bounded")
allowed = set(refs)
for line in lines:
object_id, separator, ref = line.partition("\t")
if (
separator != "\t"
or ref not in allowed
or ref in observed
or _GIT_OBJECT.fullmatch(object_id) is None
):
raise RuntimeError("remote Git identity response is ambiguous")
observed[ref] = object_id
return observed
def website_dirty(path: Path) -> bool:
if not (path / ".git").exists():
return False
return bool(git_text(path, "status", "--porcelain"))
def default_tag_name(payload: object, *, channel: str) -> str | None:
if not isinstance(payload, dict):
return None
sequence = payload.get("sequence")
release = payload.get("release")
selected_units = (
release.get("selected_units") if isinstance(release, dict) else None
)
if (
isinstance(selected_units, list)
and len(selected_units) == 1
and isinstance(selected_units[0], dict)
):
repo = str(selected_units[0].get("repo") or "catalog")
version = str(selected_units[0].get("version") or "").removeprefix("v")
if version:
return f"catalog-{channel}-{repo}-v{version}"
return f"catalog-{channel}-{sequence}" if sequence is not None else None
def commit_message(payload: object, *, channel: str) -> str:
if isinstance(payload, dict):
release = payload.get("release")
selected_units = (
release.get("selected_units") if isinstance(release, dict) else None
)
if isinstance(selected_units, list) and selected_units:
units = ", ".join(
f"{item.get('repo')} v{str(item.get('version') or '').removeprefix('v')}"
for item in selected_units
if isinstance(item, dict)
)
if units:
return f"Publish {channel} catalog for {units}"
return f"Publish {channel} catalog"
def push_command(
web_root: Path, *, remote: str, branch: str | None, tag_name: str | None
) -> str:
if tag_name:
return shlex.join(
[
"git",
"-C",
str(web_root),
"push",
"--atomic",
remote,
f"HEAD:refs/heads/{branch or '<branch>'}",
f"refs/tags/{tag_name}",
]
)
return shlex.join(
[
"git",
"-C",
str(web_root),
"push",
remote,
f"HEAD:refs/heads/{branch or '<branch>'}",
]
)
def replace_status(step: CatalogPublishStep, status: str) -> CatalogPublishStep:
return CatalogPublishStep(
id=step.id,
title=step.title,
detail=step.detail,
command=step.command,
mutating=step.mutating,
status=status,
)
def _sanitized_git_environment(
*,
private_index: str | None = None,
isolate_config: bool = False,
base: dict[str, str] | None = None,
) -> dict[str, str]:
"""Remove caller-controlled Git redirection before invoking Git."""
source = base if base is not None else os.environ
environment = {
key: source[key]
for key in ("HOME", "LANG", "LC_ALL", "LC_CTYPE", "SSH_AUTH_SOCK")
if source.get(key)
}
environment.update(
{
"GIT_CONFIG_COUNT": "0",
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_NOSYSTEM": "1",
"GIT_NO_REPLACE_OBJECTS": "1",
"GIT_PAGER": "cat",
"GIT_SSH_COMMAND": "/usr/bin/ssh -o BatchMode=yes -o ConnectTimeout=8",
"GIT_TERMINAL_PROMPT": "0",
"PATH": "/usr/bin:/bin",
}
)
if isolate_config:
environment["GIT_CONFIG"] = os.devnull
if private_index is not None:
index_path = Path(private_index)
if not index_path.is_absolute():
raise RuntimeError("private publication index path must be absolute")
parent = index_path.parent.lstat()
if (
not stat.S_ISDIR(parent.st_mode)
or parent.st_uid != os.geteuid()
or stat.S_IMODE(parent.st_mode) & 0o077
):
raise RuntimeError("private publication index directory is not private")
environment["GIT_INDEX_FILE"] = str(index_path)
return environment
def _git_bytes(
path: Path,
*args: str,
input_bytes: bytes | None = None,
environment: dict[str, str] | None = None,
max_output_bytes: int = _MAX_GIT_OUTPUT_BYTES,
isolate_config: bool = False,
) -> bytes:
private_index = environment.get("GIT_INDEX_FILE") if environment else None
process_environment = _sanitized_git_environment(
private_index=private_index,
isolate_config=isolate_config,
base=environment,
)
result = subprocess.run(
[
"/usr/bin/git",
"-C",
str(path),
"-c",
"core.fsmonitor=false",
"-c",
"core.hooksPath=/dev/null",
*args,
],
check=False,
input=input_bytes,
env=process_environment,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=120,
)
if len(result.stdout) > max_output_bytes or len(result.stderr) > max_output_bytes:
raise RuntimeError("Git command output exceeded its safety bound")
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
return result.stdout
def _git_checked(
path: Path,
*args: str,
input_bytes: bytes | None = None,
environment: dict[str, str] | None = None,
max_output_bytes: int = _MAX_GIT_OUTPUT_BYTES,
isolate_config: bool = False,
) -> None:
_git_bytes(
path,
*args,
input_bytes=input_bytes,
environment=environment,
max_output_bytes=max_output_bytes,
isolate_config=isolate_config,
)
def _git_text_checked(
path: Path,
*args: str,
input_bytes: bytes | None = None,
environment: dict[str, str] | None = None,
max_output_bytes: int = _MAX_GIT_OUTPUT_BYTES,
isolate_config: bool = False,
) -> str:
try:
return (
_git_bytes(
path,
*args,
input_bytes=input_bytes,
environment=environment,
max_output_bytes=max_output_bytes,
isolate_config=isolate_config,
)
.decode("utf-8", errors="strict")
.strip()
)
except UnicodeError as exc:
raise RuntimeError("Git command output is not valid UTF-8") from exc
def git_text(path: Path, *args: str) -> str:
try:
return _git_text_checked(path, *args)
except (OSError, RuntimeError, subprocess.SubprocessError, UnicodeError):
return ""
def git_success(path: Path, *args: str) -> bool:
try:
_git_checked(path, *args)
except (OSError, RuntimeError, subprocess.SubprocessError, UnicodeError):
return False
return True
def run_checked(args: list[str], *, cwd: Path) -> None:
if not args or not Path(args[0]).is_absolute():
raise RuntimeError("publication build command must be an absolute path")
environment = _sanitized_git_environment()
environment["PATH"] = f"{Path(args[0]).parent}:/usr/bin:/bin"
result = subprocess.run(
args,
cwd=cwd,
env=environment,
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=600,
)
if (
len(result.stdout) > _MAX_GIT_OUTPUT_BYTES
or len(result.stderr) > _MAX_GIT_OUTPUT_BYTES
):
raise RuntimeError("publication command output exceeded its safety bound")
if result.returncode != 0:
raise subprocess.CalledProcessError(
result.returncode,
result.args,
output=result.stdout,
stderr=result.stderr,
)
def _trusted_npm_command(value: str) -> str:
candidate = Path(value)
if not candidate.is_absolute():
if candidate.name != value:
raise RuntimeError(
"npm executable must be an absolute path or the bare name npm"
)
located = shutil.which(value, path="/usr/bin:/bin")
if not located:
raise RuntimeError("npm executable is unavailable on the fixed system path")
candidate = Path(located)
candidate = Path(os.path.abspath(candidate))
permitted_owners = {0, os.geteuid()}
issue = _trusted_runtime_executable_issue(
candidate,
permitted_owners=permitted_owners,
label="npm executable",
)
if issue:
raise RuntimeError(issue)
node = candidate.parent / "node"
issue = _trusted_runtime_executable_issue(
node,
permitted_owners=permitted_owners,
label="Node.js executable",
)
if issue:
raise RuntimeError(issue)
return str(candidate)