image: key agent layers on upstream version, not hourly timestamp

The old AGENT_INSTALL_CACHEBUST=<hourly-timestamp> build-arg re-ran
all three agent-installer layers (+ the sanity check) on every hourly
cron build. Because `curl install.sh | bash` isn't reproducible, each
rebuild emitted fresh layer blobs -> a new ~200 MiB-of-agent-layers
image every hour even when no agent version changed, so :latest
consumers re-downloaded those layers hourly for nothing. (Confirmed
on published images: consecutive hourly builds share layers 1-9 but
get fresh digests for every agent layer, incl. the no-fs-change
sanity layer.)

Replace it with a per-agent version cache key: the workflow resolves
each agent's current upstream version (codex -> openai/codex
releases/latest, opencode -> sst/opencode releases/latest, claude ->
npm @anthropic-ai/claude-code latest) and passes AGENT_VERSION_{CODEX,
OPENCODE,CLAUDE}. Each installer RUN references its arg so BuildKit
rebuilds that layer ONLY when the version string changes; an unchanged
hourly build is now a pure cache hit (identical layer digests ->
nothing to re-pull). Resolution failure fails the build rather than
shipping a stale key and skipping a real update.

Also correct the layer-order rationale. #12 ordered the installers
"smallest first"; the real published sizes are codex 95 / opencode 50
/ claude 68 MiB compressed, and codex is also the most frequently
released (multiple stable cuts/day). Since a lower-layer change
cascades up through every layer above it, the topmost agent layer
re-emits on essentially every change -> the biggest/most-frequent
agent (codex) belongs at the BOTTOM. The existing order
(codex -> opencode -> claude) is already optimal across the plausible
frequency range; only the (wrong) comment is fixed, no reorder.

Follow-up to #12.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gtxsKVWPrR8K1M1Te57MG
This commit is contained in:
Evgeny Boger 2026-06-18 09:57:47 +00:00
parent 5c7aea9460
commit 2943cd940a
2 changed files with 100 additions and 37 deletions

View file

@ -59,6 +59,39 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve agent versions
id: ver
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
# Each agent's installer fetches "latest" at build time. We
# resolve that same "latest" version here and feed it to the
# Dockerfile as a per-agent cache key (AGENT_VERSION_*), so an
# installer layer is rebuilt only when its agent actually
# released — not on every hourly cron run. Sources match what
# each installer pulls:
# - codex: github.com/openai/codex releases/latest
# (install.sh is .../releases/latest/download/)
# - opencode: github.com/sst/opencode releases/latest
# (opencode.ai/install installs that release)
# - claude: npm @anthropic-ai/claude-code `latest`
# (claude.ai/install.sh installs that version)
# Fail the build if any lookup fails rather than silently
# shipping a stale key (which would skip a real agent update).
codex=$(gh api repos/openai/codex/releases/latest --jq .tag_name)
opencode=$(gh api repos/sst/opencode/releases/latest --jq .tag_name)
claude=$(curl -fsSL https://registry.npmjs.org/@anthropic-ai/claude-code/latest | jq -r .version)
for v in "$codex" "$opencode" "$claude"; do
[ -n "$v" ] && [ "$v" != "null" ] || { echo "::error::failed to resolve an agent version (codex=$codex opencode=$opencode claude=$claude)"; exit 1; }
done
{
echo "codex=$codex"
echo "opencode=$opencode"
echo "claude=$claude"
} >> "$GITHUB_OUTPUT"
echo "resolved agent versions: codex=$codex opencode=$opencode claude=$claude"
- name: Build and push
id: build
uses: docker/build-push-action@v7
@ -79,17 +112,20 @@ jobs:
# microsandbox's `tar_ingest.rs:427` already accepts the
# `application/vnd.oci.image.layer.v1.tar+zstd` media type.
outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=true
# Cache-bust the three agent-installer RUN layers each run
# by passing the per-build timestamp as a build-arg the
# Dockerfile references right before those RUNs. Without
# this, `cache-from: type=gha` matches the invariant
# `RUN curl … install.sh | bash` instruction strings and
# the hourly cron silently reused the same baked-in
# agent binaries instead of fetching new releases (the
# whole point of the schedule). The heavy apt/chromium/
# docker/node layers above the ARG stay cached.
# Per-agent version cache keys. `Resolve agent versions`
# above looks up each agent's current upstream version; the
# Dockerfile references these in the matching installer RUN,
# so a layer is rebuilt ONLY when that agent actually
# released — not on every hourly cron run. This keeps an
# unchanged hourly build a pure cache hit (identical
# agent-layer digests → `:latest` consumers re-pull nothing)
# instead of re-emitting ~200 MiB of fresh-but-identical
# agent layers every hour. The heavy apt/chromium/docker/
# node layers below the args stay cached regardless.
build-args: |
AGENT_INSTALL_CACHEBUST=${{ steps.ts.outputs.tag }}
AGENT_VERSION_CODEX=${{ steps.ver.outputs.codex }}
AGENT_VERSION_OPENCODE=${{ steps.ver.outputs.opencode }}
AGENT_VERSION_CLAUDE=${{ steps.ver.outputs.claude }}
# `cache-from`/`cache-to` keep layer rebuilds fast when only
# the agent-version layers at the top of the Dockerfile
# change (the common hourly case).

View file

@ -15,13 +15,15 @@
# deliberately deferred too.
#
# Layer policy: the file is ordered so the LAST RUN steps are the
# smallest and the most frequently invalidated. The hourly cron
# bumps the three agent installers (and the sanity check), and the
# `AGENT_INSTALL_CACHEBUST` ARG ensures Docker rebuilds those
# layers. Everything earlier — Debian base, chromium, docker
# engine, Python lint pins, codestyle clone — stays cached across
# hourly rebuilds, so users pulling `:latest` after a cron run
# only re-download ~tens of MB instead of the full image.
# agent installers (the most frequently updated content). Each is
# keyed on its agent's upstream version via a per-agent
# `AGENT_VERSION_*` ARG (see below), so the hourly cron rebuilds an
# installer layer ONLY when that agent actually released — an
# unchanged build is a pure cache hit. Everything earlier — Debian
# base, chromium, docker engine, Python lint pins, codestyle clone —
# stays cached across rebuilds, so users pulling `:latest` re-download
# only the layer(s) for whichever agent actually changed (and nothing
# at all when none did).
#
# Published to ghcr.io/wirenboard/agent-vm-template:latest by the
# hourly CI workflow (.github/workflows/build-image.yml). Source-
@ -487,18 +489,23 @@ RUN git clone --depth=1 --branch "${CODESTYLE_REF}" \
|| { msg="==> wirenboard/codestyle clone FAILED (ref=${CODESTYLE_REF}) — re-clone in-VM with: git clone https://github.com/wirenboard/codestyle.git /opt/wirenboard/codestyle"; \
if [ -n "${AGENT_INSTALL_SOFT_FAIL:-}" ]; then echo "$msg (soft-fail mode)"; else echo "$msg" >&2; exit 1; fi; }
# Cache-bust for the three agent installers below. The workflow
# passes the hourly timestamp tag as the value so each scheduled
# build invalidates exactly these RUN layers (and the sanity check
# that follows) while leaving every earlier layer — Debian base,
# chromium, docker engine, WB build deps, cross toolchains, Python
# lint pins, codestyle clone — cached. Without this the GHA layer
# cache reuses the `curl … install.sh | bash` layers indefinitely,
# so hourly cron runs never picked up new claude-code/codex/
# opencode releases. Local `images/build.sh` builds leave the
# default empty value and get the normal Docker layer-cache
# behaviour.
ARG AGENT_INSTALL_CACHEBUST=
# Per-agent cache keys (the three `ARG AGENT_VERSION_*` below, one
# per installer RUN). The workflow resolves each agent's current
# UPSTREAM version and passes it in, so an installer layer is rebuilt
# only when that agent actually released — not on every hourly cron
# run.
#
# This replaces the old single `AGENT_INSTALL_CACHEBUST=<timestamp>`
# arg, which changed every hour and re-emitted all three
# `curl … install.sh | bash` layers (plus the sanity check) on every
# build. Because those installs aren't reproducible, each rebuild
# produced fresh layer blobs → a brand-new ~200 MiB-of-agent-layers
# image every hour even when no agent version had changed, so
# `:latest` consumers re-downloaded those layers hourly for nothing.
# Keying on the real version makes an unchanged hourly build a pure
# cache hit (identical layer digests → nothing to re-pull). Local
# `images/build.sh` builds leave the defaults empty and get normal
# Docker layer-cache behaviour.
# Shared helper for the three agent installer RUNs below. Downloads
# the upstream `install.sh`, runs it under the requested shell, and
@ -546,11 +553,25 @@ RUN printf '%s\n' \
&& chmod 0755 /usr/local/bin/agent-vm-install \
&& test -s /usr/local/bin/agent-vm-install
# The three agents each get their own RUN so a single agent
# release only invalidates one layer (~30 MB per agent). Order is
# rough size order (smallest first) so the steady-state worst-
# case download — a fresh release for Claude Code (the biggest)
# — only re-pulls the topmost layer.
# The three agents each get their own RUN + their own version arg so
# a single agent's release rebuilds only that layer (and, by Docker's
# layer cascade, the layers stacked ABOVE it).
#
# Layer order is deliberate and is NOT by size. A change to any layer
# forces every layer above it to rebuild, so the TOPMOST agent layer
# is re-emitted on essentially every build that changes anything,
# while the BOTTOM agent layer is re-emitted only when it itself
# bumps. The agent that is both largest and most frequently released
# therefore belongs at the BOTTOM, not the top:
# - codex ~95 MiB, multiple stable cuts/day → bottom (first)
# - opencode ~50 MiB, several per week → middle
# - claude ~68 MiB, ~daily → top (last)
# This minimizes expected re-pushed bytes per changed build across
# the plausible release-frequency range; putting the big codex layer
# on top (so it re-pushes on every change) is the worst case. Each
# RUN references its version arg (`: "${AGENT_VERSION_*}"`) so
# BuildKit ties the layer's cache key to the version string and
# rebuilds it ONLY when that string changes.
#
# Agent installers HARD-FAIL by default — if an upstream installer
# is broken or removed, the hourly cron should NOT silently ship an
@ -559,20 +580,26 @@ RUN printf '%s\n' \
# sets it) turns the three RUNs into warnings instead.
# Codex CLI official installer.
RUN agent-vm-install codex sh \
ARG AGENT_VERSION_CODEX=
RUN : "codex ${AGENT_VERSION_CODEX}" \
&& agent-vm-install codex sh \
https://github.com/openai/codex/releases/latest/download/install.sh
# OpenCode official installer. Installs into ~/.opencode/bin; PATH already
# includes it. Symlink into /usr/local/bin so non-login shells find it too.
# The symlink is best-effort — under soft-fail mode the install may have
# been skipped, in which case there's nothing to link.
RUN agent-vm-install opencode bash https://opencode.ai/install \
ARG AGENT_VERSION_OPENCODE=
RUN : "opencode ${AGENT_VERSION_OPENCODE}" \
&& agent-vm-install opencode bash https://opencode.ai/install \
&& { [ -x /root/.opencode/bin/opencode ] \
&& ln -sf /root/.opencode/bin/opencode /usr/local/bin/opencode \
|| true; }
# Claude Code official installer.
RUN agent-vm-install claude bash https://claude.ai/install.sh
ARG AGENT_VERSION_CLAUDE=
RUN : "claude ${AGENT_VERSION_CLAUDE}" \
&& agent-vm-install claude bash https://claude.ai/install.sh
# Sanity check at build time. Every command is invoked directly
# (`tool --version >/dev/null`) so a present-but-broken binary —