diff --git a/.gitea/workflows/package-release.yml b/.gitea/workflows/package-release.yml index 29aade80..58b7b85a 100644 --- a/.gitea/workflows/package-release.yml +++ b/.gitea/workflows/package-release.yml @@ -73,8 +73,23 @@ jobs: verify-venv/bin/python -c \ 'from bot_bottle.release_manifest import load_manifest; print(load_manifest())' - - name: Upload verified release wheel + - name: Publish immutable commit bundle + env: + BOT_BOTTLE_RELEASE_TOKEN: ${{ secrets.BOT_BOTTLE_RELEASE_TOKEN }} + run: | + wheel="$(find dist -maxdepth 1 -name '*.whl' -type f)" + python3 scripts/generate_release_bundle.py \ + --manifest release-manifest.json \ + --wheel "$wheel" \ + --workflow-run "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --published-at "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --output bundle-index.json \ + --publish + + - name: Upload verified release bundle receipt uses: actions/upload-artifact@v3 with: name: bot-bottle-release - path: dist/*.whl + path: | + dist/*.whl + bundle-index.json diff --git a/README.md b/README.md index 6d427852..899ed7f1 100644 --- a/README.md +++ b/README.md @@ -194,12 +194,15 @@ BOT_BOTTLE_BACKEND=firecracker bot-bottle start bot-bottle start # prepares the selected agent image, then attaches ``` -Packaged releases carry immutable orchestrator and gateway identities. Docker -and Apple Container pull the package-selected OCI digests; Firecracker pulls -the matching checksum-verified rootfs artifacts. A source checkout uses local -infrastructure builds for development. Set `BOT_BOTTLE_INFRA_BUILD=local` to -make that development override explicit when diagnosing a packaged release; -production never falls back to a build after an artifact pull fails. +Packaged releases carry immutable orchestrator, gateway, and first-party agent +image identities. Docker and Apple Container pull the package-selected OCI +digests; Firecracker pulls the matching checksum-verified rootfs artifacts. +Each verified wheel and its identities are published as an immutable generic +package keyed by the full source commit; `bundle-index.json` is uploaded last +and is the completeness marker. A source checkout uses local builds for +development. Set `BOT_BOTTLE_INFRA_BUILD=local` to make that development +override explicit when diagnosing a packaged release; production never falls +back to a build after an artifact pull fails. ## Manifest diff --git a/bot_bottle/release_publish.py b/bot_bottle/release_publish.py new file mode 100644 index 00000000..ffd0978b --- /dev/null +++ b/bot_bottle/release_publish.py @@ -0,0 +1,148 @@ +"""Durable publication of immutable, commit-addressed release bundles.""" + +from __future__ import annotations + +import hashlib +import os +import urllib.error +import urllib.request +from pathlib import Path + +from .release_bundle import canonical_bytes, parse_bundle_index, sha256_file +from .release_manifest import ReleaseManifestError + +HTTP_TIMEOUT_SECONDS = 60.0 +INDEX_NAME = "bundle-index.json" +PACKAGE_NAME = "bot-bottle-builds" + + +def config() -> tuple[str, str, str]: + """Return generic-package base URL, owner, and write token.""" + base = os.environ.get( + "BOT_BOTTLE_RELEASE_BASE", "https://gitea.dideric.is").rstrip("/") + owner = os.environ.get("BOT_BOTTLE_RELEASE_OWNER", "didericis").strip() + token = os.environ.get("BOT_BOTTLE_RELEASE_TOKEN", "") + return base, owner, token + + +def bundle_url(source_commit: str, filename: str) -> str: + base, owner, _ = config() + return ( + f"{base}/api/packages/{owner}/generic/{PACKAGE_NAME}/" + f"{source_commit}/{filename}" + ) + + +def request(url: str, *, method: str = "GET", data: object | None = None, + length: int | None = None) -> urllib.request.Request: + result = urllib.request.Request(url, data=data, method=method) # type: ignore[arg-type] + _, _, token = config() + if token: + result.add_header("Authorization", f"token {token}") + if length is not None: + result.add_header("Content-Length", str(length)) + result.add_header("Content-Type", "application/octet-stream") + return result + + +def remote_bytes(url: str) -> bytes | None: + try: + with urllib.request.urlopen( + request(url), timeout=HTTP_TIMEOUT_SECONDS, + ) as response: + return response.read() + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise ReleaseManifestError( + f"checking release bundle failed (HTTP {exc.code}): {url}") from exc + except urllib.error.URLError as exc: + raise ReleaseManifestError( + f"release registry unreachable: {url} ({exc.reason})") from exc + + +def remote_sha256(url: str) -> str | None: + try: + with urllib.request.urlopen( + request(url), timeout=HTTP_TIMEOUT_SECONDS, + ) as response: + digest = hashlib.sha256() + for chunk in iter(lambda: response.read(1 << 20), b""): + digest.update(chunk) + return digest.hexdigest() + except urllib.error.HTTPError as exc: + if exc.code == 404: + return None + raise ReleaseManifestError( + f"checking release artifact failed (HTTP {exc.code}): {url}") from exc + except urllib.error.URLError as exc: + raise ReleaseManifestError( + f"release registry unreachable: {url} ({exc.reason})") from exc + + +def upload(url: str, source: bytes | Path) -> None: + handle = None + if isinstance(source, Path): + handle = source.open("rb") + data: object = handle + length = source.stat().st_size + else: + data = source + length = len(source) + try: + with urllib.request.urlopen( + request(url, method="PUT", data=data, length=length), + timeout=HTTP_TIMEOUT_SECONDS, + ): + pass + except (urllib.error.HTTPError, urllib.error.URLError) as exc: + raise ReleaseManifestError( + f"publishing release artifact failed: {url} ({exc})") from exc + finally: + if handle is not None: + handle.close() + + +def publish_bundle(index: dict[str, object], wheel: Path) -> str: + """Publish wheel first and index last, or verify an existing exact bundle.""" + parse_bundle_index(index) + source_commit = str(index["source_commit"]) + wheel_data = index["wheel"] + assert isinstance(wheel_data, dict) + filename = str(wheel_data["filename"]) + expected_sha = str(wheel_data["sha256"]) + if wheel.name != filename or sha256_file(wheel) != expected_sha: + raise ReleaseManifestError( + "release bundle wheel does not match its index") + + wheel_url = bundle_url(source_commit, filename) + index_url = bundle_url(source_commit, INDEX_NAME) + if wheel_data["url"] != wheel_url: + raise ReleaseManifestError( + "release bundle wheel URL does not match its commit coordinate") + + wanted_index = canonical_bytes(index) + existing_index = remote_bytes(index_url) + existing_wheel_sha = remote_sha256(wheel_url) + if existing_index is not None: + if existing_index != wanted_index or existing_wheel_sha != expected_sha: + raise ReleaseManifestError( + f"immutable release bundle {source_commit} already differs") + return index_url + if existing_wheel_sha is not None: + raise ReleaseManifestError( + f"partial release bundle {source_commit} exists without its index; " + "administrative cleanup is required") + + upload(wheel_url, wheel) + upload(index_url, wanted_index) + return index_url + + +__all__ = [ + "INDEX_NAME", + "PACKAGE_NAME", + "bundle_url", + "config", + "publish_bundle", +] diff --git a/scripts/generate_release_bundle.py b/scripts/generate_release_bundle.py index 13379249..a401a817 100644 --- a/scripts/generate_release_bundle.py +++ b/scripts/generate_release_bundle.py @@ -7,26 +7,31 @@ import json from pathlib import Path from bot_bottle.release_bundle import build_bundle_index, canonical_bytes +from bot_bottle.release_publish import bundle_url, publish_bundle def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser() parser.add_argument("--manifest", required=True, type=Path) parser.add_argument("--wheel", required=True, type=Path) - parser.add_argument("--wheel-url", required=True) + parser.add_argument("--wheel-url") parser.add_argument("--workflow-run", required=True) parser.add_argument("--published-at", required=True) parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--publish", action="store_true") args = parser.parse_args(argv) manifest = json.loads(args.manifest.read_text(encoding="utf-8")) index = build_bundle_index( manifest, wheel=args.wheel, - wheel_url=args.wheel_url, + wheel_url=args.wheel_url or bundle_url( + manifest["source_commit"], args.wheel.name), workflow_run=args.workflow_run, published_at=args.published_at, ) args.output.write_bytes(canonical_bytes(index)) + if args.publish: + publish_bundle(index, args.wheel) return 0 diff --git a/tests/unit/test_release_publish.py b/tests/unit/test_release_publish.py new file mode 100644 index 00000000..ece53bcb --- /dev/null +++ b/tests/unit/test_release_publish.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from bot_bottle.release_bundle import build_bundle_index, canonical_bytes +from bot_bottle.release_manifest import ReleaseManifestError +from bot_bottle.release_publish import bundle_url, publish_bundle +from tests.unit.test_release_manifest import manifest + + +class TestReleasePublish(unittest.TestCase): + def setUp(self) -> None: + env = { + "BOT_BOTTLE_RELEASE_BASE": "https://packages.example", + "BOT_BOTTLE_RELEASE_OWNER": "owner", + "BOT_BOTTLE_RELEASE_TOKEN": "token", + } + self.env = patch.dict("os.environ", env, clear=True) + self.env.start() + self.addCleanup(self.env.stop) + + def bundle(self, root: Path) -> tuple[dict[str, object], Path]: + wheel = root / "bot_bottle-0.1.0-py3-none-any.whl" + wheel.write_bytes(b"wheel") + index = build_bundle_index( + manifest(), + wheel=wheel, + wheel_url=bundle_url("1" * 40, wheel.name), + workflow_run="actions/123", + published_at="2026-07-27T00:00:00Z", + ) + return index, wheel + + @staticmethod + def wheel_sha(index: dict[str, object]) -> str: + wheel = index["wheel"] + assert isinstance(wheel, dict) + digest = wheel["sha256"] + assert isinstance(digest, str) + return digest + + def test_publishes_wheel_then_index(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + index, wheel = self.bundle(Path(tmp)) + with patch( + "bot_bottle.release_publish.remote_bytes", return_value=None, + ), patch( + "bot_bottle.release_publish.remote_sha256", return_value=None, + ), patch("bot_bottle.release_publish.upload") as upload: + result = publish_bundle(index, wheel) + self.assertTrue(result.endswith("/bundle-index.json")) + self.assertEqual(2, upload.call_count) + self.assertIs(wheel, upload.call_args_list[0].args[1]) + self.assertEqual(canonical_bytes(index), upload.call_args_list[1].args[1]) + + def test_exact_existing_bundle_is_idempotent(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + index, wheel = self.bundle(Path(tmp)) + with patch( + "bot_bottle.release_publish.remote_bytes", + return_value=canonical_bytes(index), + ), patch( + "bot_bottle.release_publish.remote_sha256", + return_value=self.wheel_sha(index), + ), patch("bot_bottle.release_publish.upload") as upload: + publish_bundle(index, wheel) + upload.assert_not_called() + + def test_rejects_partial_publication(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + index, wheel = self.bundle(Path(tmp)) + with patch( + "bot_bottle.release_publish.remote_bytes", return_value=None, + ), patch( + "bot_bottle.release_publish.remote_sha256", + return_value=self.wheel_sha(index), + ): + with self.assertRaisesRegex( + ReleaseManifestError, "administrative cleanup", + ): + publish_bundle(index, wheel) + + def test_rejects_existing_bundle_with_different_index(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + index, wheel = self.bundle(Path(tmp)) + with patch( + "bot_bottle.release_publish.remote_bytes", return_value=b"other", + ), patch( + "bot_bottle.release_publish.remote_sha256", + return_value=self.wheel_sha(index), + ): + with self.assertRaisesRegex( + ReleaseManifestError, "already differs", + ): + publish_bundle(index, wheel)