wirenboard-agent-vm/.github/workflows/build-image.yml
Evgeny Boger aba431cfc8 image: clamp layer mtimes (rewrite-timestamp) + pin sbom off + assert single manifest
Code-review follow-up to the deterministic-manifest change. SOURCE_DATE_EPOCH
only clamps the image CONFIG (created + history); layer file mtimes still
defaulted to build wall-clock, so the manifest digest was reproducible only
while the gha layer cache held. On a cache miss (eviction / size cap /
base-layer invalidation) the apt/curl RUN steps re-ran and emitted layers
with fresh mtimes → new diff_ids → new manifest digest for an unchanged
commit, re-introducing the ~700 MiB consumer re-pull.

- outputs: add `rewrite-timestamp=true` so buildkit clamps every in-layer
  file mtime to SOURCE_DATE_EPOCH (the commit time). Layer digests — and
  thus the manifest digest — are now reproducible independent of cache
  state; the digest moves only on a real content change.
- sbom: false — pin it (defaults off today) so a future buildx default flip
  can't re-wrap the single-platform build in an attestation index whose
  build-time digest would churn :latest hourly. Complements provenance:false.
- "Show pushed digest" now asserts the pushed `:sha-…` is a single image
  manifest, not an index — fails the build if an attestation ever leaks back
  in and silently restores the churn.

Follow-up to #15 / part of #17.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016gtxsKVWPrR8K1M1Te57MG
2026-06-19 15:48:27 +00:00

308 lines
16 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

name: build-image
# Build the agent-vm OCI image and push to GHCR on:
# - hourly schedule (picks up new claude-code/codex/opencode releases),
# - any push that touches images/ (Dockerfile or related),
# - manual dispatch (force a rebuild).
#
# Tags pushed:
# - `latest` — moving tag, used by agent-vm by default.
# - `YYYY-MM-DDTHH` — immutable, for users who need to pin.
# - `<short-sha>` — also immutable, for traceability to source.
#
# The binary release pipeline (release-npm.yml) runs on a separate
# cadence; image-API-version contract (images/Dockerfile + defaults.rs)
# keeps them compatible.
on:
schedule:
- cron: '0 * * * *' # every hour, on the hour, UTC
push:
branches:
- main
paths:
- 'images/**'
- '.github/workflows/build-image.yml'
workflow_dispatch:
permissions:
contents: read
packages: write # for ghcr.io push
concurrency:
# One image build at a time. Newer runs cancel queued older ones —
# but never interrupt a build in progress (cancel-in-progress: false).
group: build-image
cancel-in-progress: false
env:
IMAGE: ghcr.io/${{ github.repository_owner }}/agent-vm-template
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Compute timestamp tag + source epoch
id: ts
run: |
# Immutable hourly pin (:YYYY-MM-DDTHH).
echo "tag=$(date -u +%Y-%m-%dT%H)" >> "$GITHUB_OUTPUT"
# SOURCE_DATE_EPOCH = HEAD commit time. Fed to buildkit (see
# the build step), it clamps the image config's `created`
# field and every history timestamp to a value that is
# CONSTANT across the hourly cron builds of a given commit.
# So two builds with the same commit + same agent versions +
# same layers produce a byte-identical config → an identical
# linux/amd64 manifest digest. microsandbox keys its
# fsmeta/VMDK materialization on THAT digest, so an unchanged
# `:latest` becomes a true no-op pull (0 bytes) instead of
# re-downloading the whole image. A new commit, or any changed
# agent layer, moves the digest as it should. Without this the
# config carried a fresh build timestamp every hour, so every
# cron `:latest` had a new manifest digest and consumers
# re-pulled ~700 MiB of byte-identical layers hourly.
echo "epoch=$(git log -1 --pretty=%ct)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Log in to GHCR
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Resolve agent versions
id: ver
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
fail() { echo "::error::$1"; exit 1; }
# Resolve each agent's current upstream version 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. Each source is the
# EXACT one the matching installer reads, so the key tracks
# what actually gets installed:
# - codex: openai/codex releases/latest tag — install.sh's
# own resolver hits the same endpoint.
# - opencode: anomalyco/opencode releases/latest tag — the
# repo opencode.ai/install downloads from (it was
# renamed from sst/opencode; sst/* only resolves
# today via GitHub's rename redirect, so pin the
# real repo).
# - claude: downloads.claude.ai native channel `/latest`
# (a plain version string) — what claude.ai/
# install.sh installs. NOT npm: the npm dist-tags
# (@anthropic-ai/claude-code) move on a separate
# cadence (e.g. stable != latest), so keying on
# npm would miss native releases / rebuild for
# npm-only bumps.
# Any lookup failure fails the build (vs shipping a stale key
# that would skip a real agent update).
codex=$(gh api repos/openai/codex/releases/latest --jq .tag_name) \
|| fail "codex version lookup failed (openai/codex releases/latest)"
opencode=$(gh api repos/anomalyco/opencode/releases/latest --jq .tag_name) \
|| fail "opencode version lookup failed (anomalyco/opencode releases/latest)"
claude=$(curl -fsSL https://downloads.claude.ai/claude-code-releases/latest) \
|| fail "claude version lookup failed (downloads.claude.ai/claude-code-releases/latest)"
# Guard against an HTTP-200-but-garbage response (empty body,
# jq 'null', or an HTML error page) silently becoming a key.
[ -n "$codex" ] && [ "$codex" != null ] || fail "codex version empty/null"
[ -n "$opencode" ] && [ "$opencode" != null ] || fail "opencode version empty/null"
case "$claude" in [0-9]*.[0-9]*.[0-9]*) : ;; *) fail "claude version implausible: '$claude'" ;; esac
{
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
env:
# Clamps buildkit's image-config `created` + history
# timestamps to the commit time (see "Compute timestamp tag +
# source epoch") so an unchanged build yields an identical
# manifest digest. docker/build-push-action forwards
# SOURCE_DATE_EPOCH from the environment to buildkit.
SOURCE_DATE_EPOCH: ${{ steps.ts.outputs.epoch }}
uses: docker/build-push-action@v7
with:
context: images
file: images/Dockerfile
# `outputs: type=registry,push=true` replaces a bare
# `push: true` so we can carry compression directives on the
# same step. The benchmark in crates/agent-vm/examples/
# erofs_bench.rs showed gzip → zstd cuts the per-layer
# OCI→EROFS conversion cost by ~24× (143s → 5s on 856 MB
# of /usr/lib). `force-compression=true` re-emits even
# already-compressed base-image layers as zstd; without it
# we'd only re-compress layers we add and the base layers
# would stay gzip, halving the win. `compression-level=3`
# is zstd's default — the bench shows diminishing returns
# past that for binary-heavy content.
# microsandbox's `tar_ingest.rs:427` already accepts the
# `application/vnd.oci.image.layer.v1.tar+zstd` media type.
#
# `rewrite-timestamp=true` clamps the mtime of every file
# INSIDE each layer tar to SOURCE_DATE_EPOCH (the commit time).
# SOURCE_DATE_EPOCH alone only fixes the image config; layer
# file mtimes default to build wall-clock, so a `cache-from`
# MISS (gha eviction / size cap / a base-layer invalidation)
# would re-run the apt/curl RUN steps and emit layers with
# fresh mtimes → new diff_ids → new manifest digest for an
# unchanged commit, re-introducing the ~700 MiB consumer
# re-pull. Clamping mtimes makes the layer digests — and thus
# the manifest digest — reproducible independent of cache
# state, so the digest only moves on a real content change.
outputs: type=registry,push=true,compression=zstd,compression-level=3,force-compression=true,rewrite-timestamp=true
# 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_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).
cache-from: type=gha
cache-to: type=gha,mode=max
# No provenance/SBOM attestation. Either one makes buildx wrap
# even a single-platform build in an OCI index whose extra
# attestation manifest embeds the build time, so the index
# digest changes every build even when the image content is
# identical — which would keep `:latest`'s digest churning
# hourly (and nag the update banner) despite a stable
# linux/amd64 manifest. We don't consume either anywhere.
# Both are pinned off (provenance defaults to mode=min on push;
# sbom defaults off but is pinned so a future default flip
# can't silently re-introduce the index). The "Show pushed
# digest" step asserts the push stayed a single manifest.
provenance: false
sbom: false
# Moving tags (`:latest`, `:YYYY-MM-DDTHH`) are gated to
# the integration branches (`main`, `rewrite-microsandbox`)
# so workflow_dispatch on a feature branch can verify the
# build without clobbering production. The immutable
# `:sha-…` tag is always pushed and is what we inspect
# for branch-level verification.
# rewrite-microsandbox is included because it's the de-facto
# trunk for the microsandbox-rewrite work; release-npm
# already documents that workflow_dispatch happens from
# there too (release-npm.yml:96).
tags: |
${{ (github.ref_name == 'main' || github.ref_name == 'rewrite-microsandbox') && format('{0}:latest', env.IMAGE) || '' }}
${{ (github.ref_name == 'main' || github.ref_name == 'rewrite-microsandbox') && format('{0}:{1}', env.IMAGE, steps.ts.outputs.tag) || '' }}
${{ env.IMAGE }}:sha-${{ github.sha }}
# Tag with the short SHA too so we can trace any image
# back to the exact Dockerfile commit.
# NOTE: no `org.opencontainers.image.created` label. It was
# set to the per-hour timestamp and lived in the image config,
# so it changed the manifest digest every cron build even with
# identical content (the root cause of the hourly re-pull). The
# config still carries a `created` field, but clamped to the
# commit time via SOURCE_DATE_EPOCH, so it's stable per commit.
# `revision` (the commit sha) is stable across a commit's
# hourly builds, so it doesn't reintroduce churn.
labels: |
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
- name: Show pushed digest + assert single manifest
run: |
echo "Pushed ${{ env.IMAGE }}:${{ steps.ts.outputs.tag }}"
echo " digest: ${{ steps.build.outputs.digest }}"
# Guard the digest-stability invariant: the push must be a
# single image manifest, NOT a multi-arch index. An index
# (from a re-enabled provenance/sbom attestation, or a buildx
# default flip) carries a build-time-varying attestation digest
# and would silently restore the hourly `:latest` churn this
# workflow exists to prevent. The immutable `:sha-…` tag is
# always pushed (on every branch), so inspect that.
ref="${IMAGE}:sha-${{ github.sha }}"
if mt=$(docker buildx imagetools inspect --raw "$ref" 2>/dev/null | jq -r '.mediaType'); then
echo " pushed mediaType: $mt"
case "$mt" in
*manifest.list*|*image.index*)
echo "::error::pushed an index ($mt) — an attestation leaked back in; :latest digest will churn hourly. Ensure provenance:false + sbom:false."
exit 1 ;;
esac
else
echo "::warning::could not inspect $ref to assert single-manifest; skipping the check"
fi
retain:
# Delete date-tagged image versions older than 14 days so the
# registry doesn't grow unbounded. `latest` and `sha-...` tags
# are immune via the ignore-versions regex.
#
# Why a custom script and not actions/delete-package-versions's
# `min-versions-to-keep`: that knob only fires once you HAVE at
# least N versions, so a paused/disabled cron can leave stale
# versions outliving the 14-day window unbounded. The age-based
# approach prunes correctly regardless of build cadence.
needs: build
runs-on: ubuntu-latest
steps:
- name: Prune image versions older than 14 days
uses: actions/github-script@v9
with:
script: |
const owner = context.repo.owner;
// GHCR package name — `agent-vm-template`, not the repo's
// short name. The repo was renamed in ca6a2a3 but this
// string was left stale, so every `retain` run since then
// has failed with HttpError 404 and pruned nothing.
const pkg = 'agent-vm-template';
const horizonMs = 14 * 24 * 60 * 60 * 1000;
const now = Date.now();
// Patterns we never delete, regardless of age.
const keepTagRe = /^(latest|sha-[0-9a-f]+)$/i;
// List all versions (paginated).
const versions = await github.paginate(
github.rest.packages.getAllPackageVersionsForPackageOwnedByOrg,
{
package_type: 'container',
package_name: pkg,
org: owner,
per_page: 100,
}
);
core.info(`found ${versions.length} versions of ${owner}/${pkg}`);
let deleted = 0;
for (const v of versions) {
const tags = (v.metadata?.container?.tags) ?? [];
// Skip anything carrying a protected tag.
if (tags.some((t) => keepTagRe.test(t))) continue;
// Untagged manifest lists / blob orphans can be GC'd too.
const age = now - new Date(v.created_at).getTime();
if (age <= horizonMs) continue;
core.info(`deleting ${pkg}@${v.id} tags=${JSON.stringify(tags)} age_days=${(age/86400000).toFixed(1)}`);
try {
await github.rest.packages.deletePackageVersionForOrg({
package_type: 'container',
package_name: pkg,
org: owner,
package_version_id: v.id,
});
deleted++;
} catch (e) {
core.warning(`delete ${v.id} failed: ${e.message}`);
}
}
core.info(`deleted ${deleted} version(s) older than 14 days`);