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 \
|
verify-venv/bin/python -c \
|
||||||
'from bot_bottle.release_manifest import load_manifest; print(load_manifest())'
|
'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
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: bot-bottle-release
|
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
|
bot-bottle start <agent> # prepares the selected agent image, then attaches
|
||||||
```
|
```
|
||||||
|
|
||||||
Packaged releases carry immutable orchestrator and gateway identities. Docker
|
Packaged releases carry immutable orchestrator, gateway, and first-party agent
|
||||||
and Apple Container pull the package-selected OCI digests; Firecracker pulls
|
image identities. Docker and Apple Container pull the package-selected OCI
|
||||||
the matching checksum-verified rootfs artifacts. A source checkout uses local
|
digests; Firecracker pulls the matching checksum-verified rootfs artifacts.
|
||||||
infrastructure builds for development. Set `BOT_BOTTLE_INFRA_BUILD=local` to
|
Each verified wheel and its identities are published as an immutable generic
|
||||||
make that development override explicit when diagnosing a packaged release;
|
package keyed by the full source commit; `bundle-index.json` is uploaded last
|
||||||
production never falls back to a build after an artifact pull fails.
|
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
|
## 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 pathlib import Path
|
||||||
|
|
||||||
from bot_bottle.release_bundle import build_bundle_index, canonical_bytes
|
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:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
parser.add_argument("--manifest", required=True, type=Path)
|
parser.add_argument("--manifest", required=True, type=Path)
|
||||||
parser.add_argument("--wheel", 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("--workflow-run", required=True)
|
||||||
parser.add_argument("--published-at", required=True)
|
parser.add_argument("--published-at", required=True)
|
||||||
parser.add_argument("--output", required=True, type=Path)
|
parser.add_argument("--output", required=True, type=Path)
|
||||||
|
parser.add_argument("--publish", action="store_true")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
manifest = json.loads(args.manifest.read_text(encoding="utf-8"))
|
||||||
index = build_bundle_index(
|
index = build_bundle_index(
|
||||||
manifest,
|
manifest,
|
||||||
wheel=args.wheel,
|
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,
|
workflow_run=args.workflow_run,
|
||||||
published_at=args.published_at,
|
published_at=args.published_at,
|
||||||
)
|
)
|
||||||
args.output.write_bytes(canonical_bytes(index))
|
args.output.write_bytes(canonical_bytes(index))
|
||||||
|
if args.publish:
|
||||||
|
publish_bundle(index, args.wheel)
|
||||||
return 0
|
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