feat(release): publish immutable commit bundles
prd-number-check / require-numbered-prds (pull_request) Successful in 10s
tracker-policy-pr / check-pr (pull_request) Successful in 20s
lint / lint (push) Successful in 1m1s
test / unit (pull_request) Successful in 56s
test / integration-docker (pull_request) Successful in 1m0s
test / coverage (pull_request) Failing after 23s
test / image-input-builds (pull_request) Successful in 2m31s

This commit is contained in:
2026-07-27 17:09:13 +00:00
parent 9f65ab016d
commit 249eaff53d
5 changed files with 279 additions and 10 deletions
+17 -2
View File
@@ -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
+9 -6
View File
@@ -194,12 +194,15 @@ BOT_BOTTLE_BACKEND=firecracker bot-bottle start <agent>
bot-bottle start <agent> # 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
+148
View File
@@ -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",
]
+7 -2
View File
@@ -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
+98
View File
@@ -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)