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: gateway_image:
description: Digest-pinned gateway OCI reference description: Digest-pinned gateway OCI reference
required: true 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: firecracker_orchestrator_version:
description: Published orchestrator rootfs package version description: Published orchestrator rootfs package version
required: true required: true
@@ -42,6 +51,9 @@ jobs:
--source-commit "$GITHUB_SHA" \ --source-commit "$GITHUB_SHA" \
--orchestrator-image '${{ inputs.orchestrator_image }}' \ --orchestrator-image '${{ inputs.orchestrator_image }}' \
--gateway-image '${{ inputs.gateway_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-version '${{ inputs.firecracker_orchestrator_version }}' \
--firecracker-orchestrator-sha256 '${{ inputs.firecracker_orchestrator_sha256 }}' \ --firecracker-orchestrator-sha256 '${{ inputs.firecracker_orchestrator_sha256 }}' \
--firecracker-gateway-version '${{ inputs.firecracker_gateway_version }}' \ --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}") info(f"using cached agent image {plan.image!r}")
return BottleImages(agent=plan.image) 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.build_image(plan.image, str(resources.build_root()), dockerfile=plan.dockerfile_path)
docker_mod.verify_agent_image( docker_mod.verify_agent_image(
plan.image, runtime_for(plan.agent_provider_template).smoke_test, 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 contextlib import ExitStack, contextmanager
from typing import Callable, Generator from typing import Callable, Generator
from ...agent_provider import runtime_for
from ...bottle_state import ( from ...bottle_state import (
egress_state_dir, egress_state_dir,
git_gate_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}") info(f"using cached agent image {plan.image!r}")
return plan.image 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 return plan.image
+7 -1
View File
@@ -9,7 +9,7 @@ backend-specific final resolution.
from __future__ import annotations from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Protocol from typing import TYPE_CHECKING, Protocol
from ..agent_provider import AgentProvisionPlan, build_agent_provision_plan, get_provider 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 ..env import ResolvedEnv, resolve_env
from ..git_gate import GitGate, GitGatePlan from ..git_gate import GitGate, GitGatePlan
from ..manifest import Manifest from ..manifest import Manifest
from ..release_manifest import oci_image
from ..supervisor.plan import SupervisePlan from ..supervisor.plan import SupervisePlan
from ..workspace import workspace_plan from ..workspace import workspace_plan
from .resolve_common import ( from .resolve_common import (
@@ -112,6 +113,11 @@ class BottlePreparationPlanner:
color=spec.color, color=spec.color,
provider_settings=provider_config.settings, 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) provision = merge_provision_env_vars(provision)
return PreparedBottle( return PreparedBottle(
manifest=manifest, manifest=manifest,
+4
View File
@@ -51,6 +51,10 @@ def build_bundle_index(
"oci": { "oci": {
"orchestrator": manifest.orchestrator_image, "orchestrator": manifest.orchestrator_image,
"gateway": manifest.gateway_image, "gateway": manifest.gateway_image,
**{
f"agent_{role}": reference
for role, reference in manifest.agent_images.items()
},
}, },
"firecracker": { "firecracker": {
"orchestrator": { "orchestrator": {
+7 -2
View File
@@ -32,6 +32,7 @@ class ReleaseManifest:
source_commit: str source_commit: str
orchestrator_image: str orchestrator_image: str
gateway_image: str gateway_image: str
agent_images: dict[str, str]
firecracker_orchestrator: FirecrackerArtifact firecracker_orchestrator: FirecrackerArtifact
firecracker_gateway: FirecrackerArtifact firecracker_gateway: FirecrackerArtifact
@@ -66,7 +67,7 @@ def parse_manifest(data: Any) -> ReleaseManifest:
if not isinstance(oci, dict): if not isinstance(oci, dict):
raise ReleaseManifestError("release manifest oci must be an object") raise ReleaseManifestError("release manifest oci must be an object")
images: dict[str, str] = {} images: dict[str, str] = {}
for role in ("orchestrator", "gateway"): for role in ("orchestrator", "gateway", "agent_claude", "agent_codex", "agent_pi"):
ref = oci.get(role) ref = oci.get(role)
if not isinstance(ref, str) or _OCI_RE.fullmatch(ref) is None: if not isinstance(ref, str) or _OCI_RE.fullmatch(ref) is None:
raise ReleaseManifestError( raise ReleaseManifestError(
@@ -79,6 +80,9 @@ def parse_manifest(data: Any) -> ReleaseManifest:
source_commit=commit, source_commit=commit,
orchestrator_image=images["orchestrator"], orchestrator_image=images["orchestrator"],
gateway_image=images["gateway"], gateway_image=images["gateway"],
agent_images={
role: images[f"agent_{role}"] for role in ("claude", "codex", "pi")
},
firecracker_orchestrator=_artifact( firecracker_orchestrator=_artifact(
firecracker.get("orchestrator"), "orchestrator"), firecracker.get("orchestrator"), "orchestrator"),
firecracker_gateway=_artifact(firecracker.get("gateway"), "gateway"), 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 assert manifest is not None
return ( return (
manifest.orchestrator_image if role == "orchestrator" 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, False,
) )
+10 -3
View File
@@ -49,6 +49,8 @@ supply-chain boundary between release production and runtime.
immutable orchestrator and gateway identities. immutable orchestrator and gateway identities.
- Docker and Apple Container acquire and run the manifest's digest-pinned OCI - Docker and Apple Container acquire and run the manifest's digest-pinned OCI
images without invoking `docker build` or `container build`. 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 - Firecracker acquires the manifest's versioned, checksum-verified
orchestrator and gateway rootfs artifacts rather than deriving versions at orchestrator and gateway rootfs artifacts rather than deriving versions at
launch. launch.
@@ -75,7 +77,7 @@ supply-chain boundary between release production and runtime.
## Non-goals ## 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. through the backend-specific agent-image paths.
- Installing Docker, Apple Container, Firecracker, or other host prerequisites. - Installing Docker, Apple Container, Firecracker, or other host prerequisites.
- Replacing Gitea's OCI or generic-package registries. - Replacing Gitea's OCI or generic-package registries.
@@ -101,7 +103,10 @@ shipped as package data:
"source_commit": "<40 lowercase hex>", "source_commit": "<40 lowercase hex>",
"oci": { "oci": {
"orchestrator": "gitea.dideric.is/didericis/bot-bottle-orchestrator@sha256:<64 hex>", "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": { "firecracker": {
"orchestrator": { "orchestrator": {
@@ -131,7 +136,8 @@ it refuses mutable or incomplete release inputs.
The release job operates on one tested commit: 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. 2. Push them and resolve their registry manifest digests.
3. Build Firecracker's orchestrator and gateway rootfs artifacts from the same 3. Build Firecracker's orchestrator and gateway rootfs artifacts from the same
source and pinned OCI bases. source and pinned OCI bases.
@@ -158,6 +164,7 @@ immutable external bundle index under that SHA. The index contains:
- schema version and source SHA; - schema version and source SHA;
- wheel URL and SHA-256; - wheel URL and SHA-256;
- orchestrator and gateway OCI digest references; - orchestrator and gateway OCI digest references;
- Claude, Codex, and Pi agent OCI digest references;
- Firecracker package versions and compressed-file SHA-256 values; - Firecracker package versions and compressed-file SHA-256 values;
- producing workflow/run identity and publication timestamp; and - producing workflow/run identity and publication timestamp; and
- qualification records, if any, naming the test workflow and release tag. - 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("--source-commit", required=True)
result.add_argument("--orchestrator-image", required=True) result.add_argument("--orchestrator-image", required=True)
result.add_argument("--gateway-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"): for role in ("orchestrator", "gateway"):
result.add_argument(f"--firecracker-{role}-version", required=True) result.add_argument(f"--firecracker-{role}-version", required=True)
result.add_argument(f"--firecracker-{role}-sha256", required=True) result.add_argument(f"--firecracker-{role}-sha256", required=True)
@@ -29,6 +31,10 @@ def main(argv: list[str] | None = None) -> int:
"oci": { "oci": {
"orchestrator": args.orchestrator_image, "orchestrator": args.orchestrator_image,
"gateway": args.gateway_image, "gateway": args.gateway_image,
**{
f"agent_{provider}": getattr(args, f"agent_{provider}_image")
for provider in ("claude", "codex", "pi")
},
}, },
"firecracker": { "firecracker": {
role: { role: {
@@ -17,6 +17,9 @@ class TestGenerateReleaseManifest(unittest.TestCase):
"--orchestrator-image", "--orchestrator-image",
"registry/orchestrator@sha256:" + "a" * 64, "registry/orchestrator@sha256:" + "a" * 64,
"--gateway-image", "registry/gateway@sha256:" + "b" * 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-version", "orch-v1",
"--firecracker-orchestrator-sha256", "c" * 64, "--firecracker-orchestrator-sha256", "c" * 64,
"--firecracker-gateway-version", "gateway-v1", "--firecracker-gateway-version", "gateway-v1",
+15
View File
@@ -18,6 +18,9 @@ def manifest() -> dict[str, object]:
"oci": { "oci": {
"orchestrator": "registry/orchestrator@sha256:" + "a" * 64, "orchestrator": "registry/orchestrator@sha256:" + "a" * 64,
"gateway": "registry/gateway@sha256:" + "b" * 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": { "firecracker": {
"orchestrator": {"version": "orch-v1", "sha256": "c" * 64}, "orchestrator": {"version": "orch-v1", "sha256": "c" * 64},
@@ -31,6 +34,7 @@ class TestParseManifest(unittest.TestCase):
parsed = parse_manifest(manifest()) parsed = parse_manifest(manifest())
self.assertTrue(parsed.orchestrator_image.endswith("a" * 64)) self.assertTrue(parsed.orchestrator_image.endswith("a" * 64))
self.assertEqual("orch-v1", parsed.firecracker_orchestrator.version) 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: def test_rejects_mutable_oci_reference(self) -> None:
data = manifest() data = manifest()
@@ -100,6 +104,17 @@ class TestImageSelection(unittest.TestCase):
self.assertEqual(parsed.orchestrator_image, ref) self.assertEqual(parsed.orchestrator_image, ref)
self.assertFalse(local) 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: def test_mutable_override_fails_outside_local_mode(self) -> None:
parsed = parse_manifest(manifest()) parsed = parse_manifest(manifest())
with patch( with patch(