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
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user