feat(release): pin first-party agent images
lint / lint (push) Successful in 1m15s
test / image-input-builds (pull_request) Successful in 1m12s
test / integration-docker (pull_request) Failing after 2m47s
tracker-policy-pr / check-pr (pull_request) Failing after 11m49s
test / unit (pull_request) Failing after 12m1s
prd-number-check / require-numbered-prds (pull_request) Failing after 12m6s
test / coverage (pull_request) Has been skipped

This commit is contained in:
2026-07-27 17:02:54 +00:00
parent 46b7e06b37
commit 9f65ab016d
10 changed files with 82 additions and 7 deletions
+12
View File
@@ -13,6 +13,15 @@ on:
gateway_image:
description: Digest-pinned gateway OCI reference
required: true
agent_claude_image:
description: Digest-pinned Claude agent OCI reference
required: true
agent_codex_image:
description: Digest-pinned Codex agent OCI reference
required: true
agent_pi_image:
description: Digest-pinned Pi agent OCI reference
required: true
firecracker_orchestrator_version:
description: Published orchestrator rootfs package version
required: true
@@ -42,6 +51,9 @@ jobs:
--source-commit "$GITHUB_SHA" \
--orchestrator-image '${{ inputs.orchestrator_image }}' \
--gateway-image '${{ inputs.gateway_image }}' \
--agent-claude-image '${{ inputs.agent_claude_image }}' \
--agent-codex-image '${{ inputs.agent_codex_image }}' \
--agent-pi-image '${{ inputs.agent_pi_image }}' \
--firecracker-orchestrator-version '${{ inputs.firecracker_orchestrator_version }}' \
--firecracker-orchestrator-sha256 '${{ inputs.firecracker_orchestrator_sha256 }}' \
--firecracker-gateway-version '${{ inputs.firecracker_gateway_version }}' \
+9
View File
@@ -84,6 +84,15 @@ def build_or_load_images(plan: DockerBottlePlan) -> BottleImages:
)
info(f"using cached agent image {plan.image!r}")
return BottleImages(agent=plan.image)
if "@sha256:" in plan.image:
pulled = docker_mod.run_docker(["docker", "pull", plan.image])
if pulled.returncode != 0:
die(f"pulling packaged agent image {plan.image!r} failed: "
f"{pulled.stderr.strip()}")
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
return BottleImages(agent=plan.image)
docker_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
+9 -1
View File
@@ -38,6 +38,7 @@ import subprocess
from contextlib import ExitStack, contextmanager
from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...bottle_state import (
egress_state_dir,
git_gate_state_dir,
@@ -93,7 +94,14 @@ def _agent_image(plan: MacosContainerBottlePlan) -> str:
)
info(f"using cached agent image {plan.image!r}")
return plan.image
container_mod.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
if "@sha256:" in plan.image:
container_mod.pull_image(plan.image)
container_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test,
)
return plan.image
container_mod.build_image(
plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
return plan.image
+7 -1
View File
@@ -9,7 +9,7 @@ backend-specific final resolution.
from __future__ import annotations
import os
from dataclasses import dataclass
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Protocol
from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider
@@ -17,6 +17,7 @@ from ..egress import EgressPlan
from ..env import ResolvedEnv, resolve_env
from ..git_gate import GitGate, GitGatePlan
from ..manifest import Manifest
from ..release_manifest import oci_image
from ..supervisor.plan import SupervisePlan
from ..workspace import workspace_plan
from .resolve_common import (
@@ -112,6 +113,11 @@ class BottlePreparationPlanner:
color=spec.color,
provider_settings=provider_config.settings,
)
if not provider_config.dockerfile:
image, local = oci_image(
f"agent_{provider_config.template}", provision.image)
if not local:
provision = replace(provision, image=image)
provision = merge_provision_env_vars(provision)
return PreparedBottle(
manifest=manifest,
+4
View File
@@ -51,6 +51,10 @@ def build_bundle_index(
"oci": {
"orchestrator": manifest.orchestrator_image,
"gateway": manifest.gateway_image,
**{
f"agent_{role}": reference
for role, reference in manifest.agent_images.items()
},
},
"firecracker": {
"orchestrator": {
+7 -2
View File
@@ -32,6 +32,7 @@ class ReleaseManifest:
source_commit: str
orchestrator_image: str
gateway_image: str
agent_images: dict[str, str]
firecracker_orchestrator: FirecrackerArtifact
firecracker_gateway: FirecrackerArtifact
@@ -66,7 +67,7 @@ def parse_manifest(data: Any) -> ReleaseManifest:
if not isinstance(oci, dict):
raise ReleaseManifestError("release manifest oci must be an object")
images: dict[str, str] = {}
for role in ("orchestrator", "gateway"):
for role in ("orchestrator", "gateway", "agent_claude", "agent_codex", "agent_pi"):
ref = oci.get(role)
if not isinstance(ref, str) or _OCI_RE.fullmatch(ref) is None:
raise ReleaseManifestError(
@@ -79,6 +80,9 @@ def parse_manifest(data: Any) -> ReleaseManifest:
source_commit=commit,
orchestrator_image=images["orchestrator"],
gateway_image=images["gateway"],
agent_images={
role: images[f"agent_{role}"] for role in ("claude", "codex", "pi")
},
firecracker_orchestrator=_artifact(
firecracker.get("orchestrator"), "orchestrator"),
firecracker_gateway=_artifact(firecracker.get("gateway"), "gateway"),
@@ -132,7 +136,8 @@ def oci_image(role: str, local_default: str) -> tuple[str, bool]:
assert manifest is not None
return (
manifest.orchestrator_image if role == "orchestrator"
else manifest.gateway_image,
else manifest.gateway_image if role == "gateway"
else manifest.agent_images[role.removeprefix("agent_")],
False,
)
+10 -3
View File
@@ -49,6 +49,8 @@ supply-chain boundary between release production and runtime.
immutable orchestrator and gateway identities.
- Docker and Apple Container acquire and run the manifest's digest-pinned OCI
images without invoking `docker build` or `container build`.
- The shipped Claude, Codex, and Pi agent images are built, smoke-tested,
published, and selected by digest as part of the same commit bundle.
- Firecracker acquires the manifest's versioned, checksum-verified
orchestrator and gateway rootfs artifacts rather than deriving versions at
launch.
@@ -75,7 +77,7 @@ supply-chain boundary between release production and runtime.
## Non-goals
- Changing agent/provider image builds. User Dockerfiles continue to build
- Prebuilding user-supplied Dockerfiles. Custom agent images continue to build
through the backend-specific agent-image paths.
- Installing Docker, Apple Container, Firecracker, or other host prerequisites.
- Replacing Gitea's OCI or generic-package registries.
@@ -101,7 +103,10 @@ shipped as package data:
"source_commit": "<40 lowercase hex>",
"oci": {
"orchestrator": "gitea.dideric.is/didericis/bot-bottle-orchestrator@sha256:<64 hex>",
"gateway": "gitea.dideric.is/didericis/bot-bottle-gateway@sha256:<64 hex>"
"gateway": "gitea.dideric.is/didericis/bot-bottle-gateway@sha256:<64 hex>",
"agent_claude": "gitea.dideric.is/didericis/bot-bottle-claude@sha256:<64 hex>",
"agent_codex": "gitea.dideric.is/didericis/bot-bottle-codex@sha256:<64 hex>",
"agent_pi": "gitea.dideric.is/didericis/bot-bottle-pi@sha256:<64 hex>"
},
"firecracker": {
"orchestrator": {
@@ -131,7 +136,8 @@ it refuses mutable or incomplete release inputs.
The release job operates on one tested commit:
1. Build the multi-architecture orchestrator and gateway OCI images.
1. Build and smoke-test the multi-architecture orchestrator, gateway, Claude,
Codex, and Pi OCI images.
2. Push them and resolve their registry manifest digests.
3. Build Firecracker's orchestrator and gateway rootfs artifacts from the same
source and pinned OCI bases.
@@ -158,6 +164,7 @@ immutable external bundle index under that SHA. The index contains:
- schema version and source SHA;
- wheel URL and SHA-256;
- orchestrator and gateway OCI digest references;
- Claude, Codex, and Pi agent OCI digest references;
- Firecracker package versions and compressed-file SHA-256 values;
- producing workflow/run identity and publication timestamp; and
- qualification records, if any, naming the test workflow and release tag.
+6
View File
@@ -14,6 +14,8 @@ def parser() -> argparse.ArgumentParser:
result.add_argument("--source-commit", required=True)
result.add_argument("--orchestrator-image", required=True)
result.add_argument("--gateway-image", required=True)
for provider in ("claude", "codex", "pi"):
result.add_argument(f"--agent-{provider}-image", required=True)
for role in ("orchestrator", "gateway"):
result.add_argument(f"--firecracker-{role}-version", required=True)
result.add_argument(f"--firecracker-{role}-sha256", required=True)
@@ -29,6 +31,10 @@ def main(argv: list[str] | None = None) -> int:
"oci": {
"orchestrator": args.orchestrator_image,
"gateway": args.gateway_image,
**{
f"agent_{provider}": getattr(args, f"agent_{provider}_image")
for provider in ("claude", "codex", "pi")
},
},
"firecracker": {
role: {
@@ -17,6 +17,9 @@ class TestGenerateReleaseManifest(unittest.TestCase):
"--orchestrator-image",
"registry/orchestrator@sha256:" + "a" * 64,
"--gateway-image", "registry/gateway@sha256:" + "b" * 64,
"--agent-claude-image", "registry/claude@sha256:" + "e" * 64,
"--agent-codex-image", "registry/codex@sha256:" + "f" * 64,
"--agent-pi-image", "registry/pi@sha256:" + "0" * 64,
"--firecracker-orchestrator-version", "orch-v1",
"--firecracker-orchestrator-sha256", "c" * 64,
"--firecracker-gateway-version", "gateway-v1",
+15
View File
@@ -18,6 +18,9 @@ def manifest() -> dict[str, object]:
"oci": {
"orchestrator": "registry/orchestrator@sha256:" + "a" * 64,
"gateway": "registry/gateway@sha256:" + "b" * 64,
"agent_claude": "registry/agent-claude@sha256:" + "e" * 64,
"agent_codex": "registry/agent-codex@sha256:" + "f" * 64,
"agent_pi": "registry/agent-pi@sha256:" + "0" * 64,
},
"firecracker": {
"orchestrator": {"version": "orch-v1", "sha256": "c" * 64},
@@ -31,6 +34,7 @@ class TestParseManifest(unittest.TestCase):
parsed = parse_manifest(manifest())
self.assertTrue(parsed.orchestrator_image.endswith("a" * 64))
self.assertEqual("orch-v1", parsed.firecracker_orchestrator.version)
self.assertTrue(parsed.agent_images["codex"].endswith("f" * 64))
def test_rejects_mutable_oci_reference(self) -> None:
data = manifest()
@@ -100,6 +104,17 @@ class TestImageSelection(unittest.TestCase):
self.assertEqual(parsed.orchestrator_image, ref)
self.assertFalse(local)
def test_packaged_install_selects_agent_reference(self) -> None:
parsed = parse_manifest(manifest())
with patch(
"bot_bottle.release_manifest.packaged_manifest", return_value=parsed,
), patch(
"bot_bottle.release_manifest.local_build_requested", return_value=False,
), patch.dict("os.environ", {}, clear=True):
ref, local = oci_image("agent_codex", "local:latest")
self.assertEqual(parsed.agent_images["codex"], ref)
self.assertFalse(local)
def test_mutable_override_fails_outside_local_mode(self) -> None:
parsed = parse_manifest(manifest())
with patch(