yams/scripts/ci/publish_repository.py
trvon 5eeb3b8239
fix(ci): drop --force from wrangler r2 object put
The #206 revert restored the pre-September upload code, which passed
--force, while the workflow still pins wrangler 4.69.0. That version's
'r2 object put' has no --force option (it overwrites without prompting)
and exits with 'Unknown argument: force', so the v0.20.3 release failed
its first R2 upload and no apt, yum or Arch repository was published.
Immutable objects are still checked with 'get' before any write.

Signed-off-by: trvon <me@trevon.dev>
2026-09-30 14:48:44 -06:00

390 lines
13 KiB
Python
Executable file

#!/usr/bin/env python3
"""Publish one validated package-repository channel to R2 in fail-closed order."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Protocol
RELEASE_CHANNELS = frozenset({"stable", "nightly", "weekly"})
PACKAGE_SUFFIXES = (".deb", ".rpm", ".pkg.tar.zst")
REPOSITORY_DIRS = ("aptrepo", "yumrepo", "archrepo")
MISSING_OBJECT_RE = re.compile(
r"(?:404|object[^\n]*not found|key[^\n]*does not exist|specified key does not exist)",
re.IGNORECASE,
)
class PublicationError(RuntimeError):
"""A publication precondition or storage operation failed."""
@dataclass(frozen=True)
class PublicationObject:
source: Path
key: str
immutable: bool
phase: str
content_type: str
@dataclass(frozen=True)
class ValidatedManifest:
release_channel: str
publication_channel: str
release_run_id: int
release_run_attempt: int
class ObjectStore(Protocol):
def get(self, key: str) -> bytes | None:
raise NotImplementedError
def put(self, key: str, source: Path, content_type: str) -> None:
raise NotImplementedError
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def content_type(key: str) -> str:
suffixes = (
(".json", "application/json"),
(".key", "application/pgp-keys"),
(".txt", "text/plain"),
(".xml", "application/xml"),
(".gz", "application/gzip"),
(".deb", "application/vnd.debian.binary-package"),
(".rpm", "application/x-rpm"),
(".pkg.tar.zst", "application/zstd"),
(".db", "application/gzip"),
(".files", "application/gzip"),
(".asc", "application/pgp-signature"),
)
for suffix, value in suffixes:
if key.endswith(suffix):
return value
return "application/octet-stream"
def validate_manifest(
manifest_path: Path,
expected_channel: str,
selected_run_id: int,
selected_run_attempt: int,
) -> ValidatedManifest:
if expected_channel not in {"stable", "experimental"}:
raise PublicationError("expected channel must be stable or experimental")
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise PublicationError(f"missing release manifest: {manifest_path}") from error
except json.JSONDecodeError as error:
raise PublicationError(f"invalid release manifest JSON: {error}") from error
if not isinstance(manifest, dict):
raise PublicationError("release manifest must be a JSON object")
release_channel = manifest.get("channel")
if release_channel not in RELEASE_CHANNELS:
raise PublicationError("release manifest has missing or invalid channel")
publication_channel = "stable" if release_channel == "stable" else "experimental"
if publication_channel != expected_channel:
raise PublicationError(
f"release manifest channel {release_channel!r} does not match "
f"requested {expected_channel!r} publication"
)
run_id = manifest.get("release_workflow_run_id")
if isinstance(run_id, bool) or not isinstance(run_id, int) or run_id <= 0:
raise PublicationError(
"release manifest has missing or invalid workflow run ID"
)
if run_id != selected_run_id:
raise PublicationError(
f"release manifest run ID {run_id} does not match selected run {selected_run_id}"
)
run_attempt = manifest.get("release_workflow_run_attempt")
if (
isinstance(run_attempt, bool)
or not isinstance(run_attempt, int)
or run_attempt <= 0
):
raise PublicationError(
"release manifest has missing or invalid workflow run attempt"
)
if run_attempt != selected_run_attempt:
raise PublicationError(
f"release manifest run attempt {run_attempt} does not match selected "
f"attempt {selected_run_attempt}"
)
source_sha = manifest.get("source_sha")
source_ref = manifest.get("source_ref")
if (
not isinstance(source_sha, str)
or re.fullmatch(r"[0-9a-f]{40}", source_sha) is None
):
raise PublicationError("release manifest has invalid source SHA")
if not isinstance(source_ref, str):
raise PublicationError("release manifest has missing source ref")
if (
publication_channel == "experimental"
and source_ref != "refs/heads/experimental"
):
raise PublicationError(
"experimental manifest must come from refs/heads/experimental"
)
if publication_channel == "stable" and not source_ref.startswith("refs/tags/"):
raise PublicationError("stable manifest must come from a tag ref")
return ValidatedManifest(release_channel, publication_channel, run_id, run_attempt)
def _safe_relative_files(root: Path) -> list[Path]:
files: list[Path] = []
if not root.is_dir():
return files
resolved_root = root.resolve()
for path in root.rglob("*"):
if path.is_symlink():
resolved = path.resolve()
if resolved_root not in resolved.parents or not resolved.is_file():
raise PublicationError(
f"refusing unsafe symlink in repository artifact: {path}"
)
if path.is_file():
files.append(path)
return sorted(files)
def build_publication_plan(
repo_root: Path,
manifest_path: Path,
expected_channel: str,
selected_run_id: int,
selected_run_attempt: int,
public_key_path: Path | None = None,
) -> list[PublicationObject]:
validated = validate_manifest(
manifest_path, expected_channel, selected_run_id, selected_run_attempt
)
prefix = "" if validated.publication_channel == "stable" else "experimental/"
objects: list[PublicationObject] = []
for directory in REPOSITORY_DIRS:
root = repo_root / directory
for path in _safe_relative_files(root):
relative = path.relative_to(root).as_posix()
key = f"{prefix}{directory}/{relative}"
is_package = key.endswith(PACKAGE_SUFFIXES)
objects.append(
PublicationObject(
source=path,
key=key,
immutable=is_package,
phase="payload" if is_package else "metadata",
content_type=content_type(key),
)
)
if not objects:
raise PublicationError("no package repository objects were found")
if public_key_path is not None:
if not public_key_path.is_file():
raise PublicationError(f"missing public signing key: {public_key_path}")
key = (
"gpg.key"
if validated.publication_channel == "stable"
else ("experimental/aptrepo/gpg.key")
)
objects.append(
PublicationObject(
source=public_key_path,
key=key,
immutable=False,
phase="metadata",
content_type=content_type(key),
)
)
latest_key = (
"latest.json"
if validated.publication_channel == "stable"
else ("experimental/latest.json")
)
objects.append(
PublicationObject(
source=manifest_path,
key=latest_key,
immutable=False,
phase="latest",
content_type="application/json",
)
)
phase_order = {"payload": 0, "metadata": 1, "latest": 2}
return sorted(objects, key=lambda item: (phase_order[item.phase], item.key))
def publish(plan: list[PublicationObject], store: ObjectStore) -> None:
latest_seen = False
for item in plan:
if latest_seen:
raise PublicationError(
"publication plan contains objects after latest.json"
)
if item.phase == "latest":
latest_seen = True
if item.immutable:
existing = store.get(item.key)
if existing is not None:
existing_digest = sha256_bytes(existing)
source_digest = sha256_file(item.source)
if existing_digest == source_digest:
print(f"immutable object already matches; skipping {item.key}")
continue
raise PublicationError(
f"immutable object conflict for {item.key}: "
f"existing sha256={existing_digest}, source sha256={source_digest}"
)
store.put(item.key, item.source, item.content_type)
if not latest_seen:
raise PublicationError("publication plan did not contain latest.json")
class WranglerObjectStore:
def __init__(self, wrangler: Path, bucket: str) -> None:
self.wrangler = wrangler
self.bucket = bucket
def _target(self, key: str) -> str:
return f"{self.bucket}/{key}"
def get(self, key: str) -> bytes | None:
with tempfile.NamedTemporaryFile(delete=False) as temporary:
output = Path(temporary.name)
try:
result = subprocess.run(
[
str(self.wrangler),
"r2",
"object",
"get",
self._target(key),
"--remote",
"--file",
str(output),
],
check=False,
capture_output=True,
text=True,
)
if result.returncode == 0:
return output.read_bytes()
diagnostic = f"{result.stdout}\n{result.stderr}".strip()
if MISSING_OBJECT_RE.search(diagnostic):
return None
raise PublicationError(
f"failed to check immutable object {key!r}; refusing overwrite: {diagnostic}"
)
finally:
output.unlink(missing_ok=True)
def put(self, key: str, source: Path, content_type: str) -> None:
print(f"uploading {key}", file=sys.stderr)
result = subprocess.run(
[
str(self.wrangler),
"r2",
"object",
"put",
self._target(key),
"--remote",
"--file",
str(source),
"--content-type",
content_type,
],
check=False,
)
if result.returncode != 0:
raise PublicationError(f"R2 upload failed for {key}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--repo-root", type=Path, required=True)
parser.add_argument("--manifest", type=Path, required=True)
parser.add_argument(
"--expected-channel", choices=("stable", "experimental"), required=True
)
parser.add_argument("--selected-run-id", type=int, required=True)
parser.add_argument("--selected-run-attempt", type=int, required=True)
parser.add_argument("--bucket", default="yams-repository")
parser.add_argument("--wrangler", type=Path, required=True)
parser.add_argument("--public-key", type=Path)
parser.add_argument("--plan-only", action="store_true")
return parser.parse_args()
def main() -> int:
args = parse_args()
try:
plan = build_publication_plan(
args.repo_root,
args.manifest,
args.expected_channel,
args.selected_run_id,
args.selected_run_attempt,
args.public_key,
)
if args.plan_only:
print(
json.dumps(
[
{
"key": item.key,
"immutable": item.immutable,
"phase": item.phase,
"content_type": item.content_type,
}
for item in plan
],
indent=2,
)
)
return 0
if not os.environ.get("CLOUDFLARE_API_TOKEN") or not os.environ.get(
"CLOUDFLARE_ACCOUNT_ID"
):
raise PublicationError("Cloudflare API token/account missing")
publish(plan, WranglerObjectStore(args.wrangler, args.bucket))
except (OSError, PublicationError) as error:
print(f"repository publication error: {error}", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
raise SystemExit(main())