feat(images): build, verify and publish the three images in CI; Renovate carries the digests
gate / gate (pull_request) Failing after 1m56s
images / images (pull_request) Failing after 6s
gate / conformance (pull_request) Has been skipped

Operator rule 2026-10-29: image creation, hosting and lifecycle belong to CI/CD, never to a
workstation build/push. Nothing had ever been pushed to the Gitea registry.

- .gitea/workflows/images.yaml + scripts/images.sh: PRs build+verify (no credential); main
  builds, verifies and pushes toolbox, agent-runner (FROM toolbox) and conformance-runner as
  sha-<commit> + main, reading the digest back from the registry. Only changed images build.
- Each image's own build-image.sh does the build and in-image verification, now engine-neutral
  (DinD: no podman-only calls, probe over stdin not a bind mount). New toolbox build-image.sh.
- Digest references carry `# renovate-digest:` markers; renovate.json5 gains a custom.regex
  manager for them. The worker Job template is deliberately untracked (it would loop).
- renovate.yaml pins renovate-version 44.167.0: the action's default (39.264.0) rejects the
  current config's managerFilePatterns, so every run would have died at CONFIG_VALIDATION.
- Push credential: REGISTRY_PUSH_TOKEN secret; the Actions token has no package scope in
  Gitea 1.24.3 (container.go Authenticate). Retention: infra/gitea/registry-retention.sh.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
jakman85andClaude Opus 5.5 committed 2026-10-29 21:34:06 -05:00
1 parent 1301af9a4c
commit 0da34ddbc6
24 files changed
+1280 -210

No files matched your search

+69 -71
View File
@@ -1,26 +1,36 @@
#!/usr/bin/env bash
###############################################################################
# infra/agent/build-image.sh — build the pinned `agent-runner` image and print its digest.
# infra/agent/build-image.sh — build the pinned `agent-runner` image and prove it works.
#
# ./build-image.sh # build + verify + print the digest and push command
# ./build-image.sh # build + verify + print the image id
# ./build-image.sh --no-verify # build only (skip the in-image proof)
# ENGINE=docker ./build-image.sh # build with docker instead of podman
# AGENT_BASE_IMAGE=home-k3s-dev:local ./build-image.sh
# # build on a LOCAL toolbox instead of the pinned one
# # (`infra/devcontainer/dev.sh build` makes that tag)
# IMAGE_TAG=foo ./build-image.sh # override the local tag (CI uses this)
#
# THIS SCRIPT NEVER PUSHES. Publishing is an operator action: the registry credential is not
# an agent's to hold, and the pushed digest is what a fail-closed admission policy then
# trusts. So this builds locally, proves the toolchain and both entrypoints work INSIDE the
# image as the non-root uid with the read-only root filesystem the policy forces, and prints
# the exact push line for a human.
# THIS SCRIPT NEVER PUSHES — and nobody pushes this image from a workstation either.
# Publishing is done by CI: .gitea/workflows/images.yaml calls this script, and on a push to
# `main` it pushes what this script built AND verified to the Gitea registry, records the
# registry-returned digest in the job summary, and Renovate then opens the pull request that
# moves platform/pm-agent/ onto that digest. So this script's job is to build, and to prove
# the toolchain and both entrypoints work INSIDE the image as the non-root uid with the
# read-only root filesystem the admission policy forces — the same proof locally and in CI.
#
# VERSIONS ARE NOT DEFINED HERE. infra/agent/versions.env holds the four pins this image
# adds (base image, node, Claude Code CLI, model) and infra/devcontainer/versions.env holds
# the platform toolchain the base already carries. Nothing in this file invents a version.
# VERSIONS ARE NOT DEFINED HERE. infra/agent/versions.env holds the pins this image adds
# (base image, node, Claude Code CLI, model) and infra/devcontainer/versions.env holds the
# platform toolchain the base already carries. Nothing in this file invents a version.
#
# THE DIGEST CAVEAT — read it before pasting a digest into anything. The digest printed here
# is the LOCAL manifest digest. It is normally identical to the registry digest, but that is
# a property of the push rather than a guarantee, so the push command below uses
# `--digestfile`: the digest the REGISTRY returns is the authoritative one, and that is the
# one that goes into platform/pm-agent/configmap.yaml.
# THE BASE. AGENT_BASE_IMAGE in versions.env is the PUBLISHED toolbox, pinned by digest and
# kept current by Renovate. An AGENT_BASE_IMAGE in the environment overrides it — CI passes
# the toolbox it built in the same run, and a developer can pass `home-k3s-dev:local`.
#
# ENGINE PORTABILITY. CI runs this with ENGINE=docker against a Docker-in-Docker daemon, so
# nothing below may be podman-only (`image exists`, `--digestfile`, `{{.Digest}}`), and
# nothing may BIND-MOUNT a file from this checkout into a container: under DinD the daemon
# resolves bind sources on ITS filesystem, where this checkout does not exist, and silently
# mounts an empty directory instead. Files go in over stdin.
###############################################################################
set -euo pipefail
@@ -46,8 +56,11 @@ command -v "${ENGINE}" >/dev/null 2>&1 || die "'${ENGINE}' not on PATH (set ENGI
# --- pins: sourced, never invented -------------------------------------------
AGENT_ENV="${HERE}/versions.env"
[ -f "${AGENT_ENV}" ] || die "missing ${AGENT_ENV} — the pins live there, not here"
BASE_OVERRIDE="${AGENT_BASE_IMAGE:-}"
TAG_OVERRIDE="${IMAGE_TAG:-}"
# shellcheck disable=SC1090
source "${AGENT_ENV}"
[ -z "${BASE_OVERRIDE}" ] || AGENT_BASE_IMAGE="${BASE_OVERRIDE}"
: "${AGENT_BASE_IMAGE:?AGENT_BASE_IMAGE not set in ${AGENT_ENV}}"
: "${NODEJS_MAJOR:?NODEJS_MAJOR not set in ${AGENT_ENV}}"
: "${CLAUDE_CODE_VERSION:?CLAUDE_CODE_VERSION not set in ${AGENT_ENV}}"
@@ -55,16 +68,28 @@ source "${AGENT_ENV}"
: "${AGENT_MODEL:?AGENT_MODEL not set in ${AGENT_ENV}}"
: "${AGENT_IMAGE_REPO:?AGENT_IMAGE_REPO not set in ${AGENT_ENV}}"
# --- the base must already exist ---------------------------------------------
# Deliberately NOT built on demand. The toolbox build is its own reviewed artefact with its
# own pins, and silently building it here would mean this script could change the toolchain
# a worker gates with as a side effect of rebuilding the agent image.
if ! "${ENGINE}" image exists "${AGENT_BASE_IMAGE}" 2>/dev/null; then
die "base image '${AGENT_BASE_IMAGE}' is not present. Build the pinned toolbox first:
${REPO_ROOT}/infra/devcontainer/dev.sh build"
# --- the base must exist, locally or in the registry ---------------------------
# Deliberately NOT built on demand. The toolbox is its own reviewed artefact with its own
# pins, and silently building it here would mean this script could change the toolchain a
# worker gates with as a side effect of rebuilding the agent image.
case "${AGENT_BASE_IMAGE}" in
*@sha256:0000000000000000000000000000000000000000000000000000000000000000)
die "AGENT_BASE_IMAGE is still the all-zero placeholder in ${AGENT_ENV}: the toolbox has
not been published yet. Either let CI publish it (.gitea/workflows/images.yaml) and
merge Renovate's digest bump, or build on a local toolbox:
${REPO_ROOT}/infra/devcontainer/dev.sh build
AGENT_BASE_IMAGE=home-k3s-dev:local $0" ;;
esac
if ! "${ENGINE}" image inspect "${AGENT_BASE_IMAGE}" >/dev/null 2>&1; then
info "base ${AGENT_BASE_IMAGE} is not local; pulling it"
"${ENGINE}" pull "${AGENT_BASE_IMAGE}" >/dev/null \
|| die "base image '${AGENT_BASE_IMAGE}' is neither local nor pullable (registry login?).
To build on a local toolbox instead:
${REPO_ROOT}/infra/devcontainer/dev.sh build
AGENT_BASE_IMAGE=home-k3s-dev:local $0"
fi
IMAGE_TAG="claude${CLAUDE_CODE_VERSION}-node${NODEJS_MAJOR}"
IMAGE_TAG="${TAG_OVERRIDE:-claude${CLAUDE_CODE_VERSION}-node${NODEJS_MAJOR}}"
IMAGE_REF="${AGENT_IMAGE_REPO}:${IMAGE_TAG}"
log "Building ${IMAGE_REF}"
@@ -75,7 +100,11 @@ info " node ${NODEJS_MAJOR}"
info " claude ${CLAUDE_CODE_VERSION} (floor ${CLAUDE_CODE_MIN_VERSION})"
info " model ${AGENT_MODEL}"
"${ENGINE}" build \
# Provenance: CI passes the commit it is building, so a pulled image names its source tree.
LABEL_ARGS=()
[ -z "${BUILD_REVISION:-}" ] || LABEL_ARGS+=(--label "org.opencontainers.image.revision=${BUILD_REVISION}")
"${ENGINE}" build "${LABEL_ARGS[@]}" \
--file "${HERE}/Containerfile" \
--tag "${IMAGE_REF}" \
--build-arg "AGENT_BASE_IMAGE=${AGENT_BASE_IMAGE}" \
@@ -138,63 +167,32 @@ if [ "${VERIFY}" -eq 1 ]; then
# that will actually run in the cluster. The probe is mounted read-only rather than baked
# in: it is a build-time check, not something a worker Job has any business carrying.
log "Probing pm-worker's input validation inside the image"
"${ENGINE}" run --rm \
#
# The probe goes in over STDIN, not as a bind mount: under Docker-in-Docker (CI) a bind
# source is resolved on the daemon's filesystem, where this file does not exist, and the
# container would get an empty directory. PM_WORKER_PATH points it at the baked worker,
# since the probe's own default (a sibling of the probe file) has no meaning on stdin.
"${ENGINE}" run --rm -i \
--user 65532:65532 \
--read-only \
--tmpfs /workspace:rw,mode=1777 \
--tmpfs /tmp:rw,mode=1777 \
--network none \
--volume "${HERE}/probe-validation.sh:/probe-validation.sh:ro,Z" \
-e PM_WORKER_PATH=/usr/local/bin/pm-worker \
--entrypoint "" \
"${IMAGE_REF}" \
/bin/bash /probe-validation.sh
/bin/bash -s < "${HERE}/probe-validation.sh"
log "Image verified"
fi
# --- digest ------------------------------------------------------------------
DIGEST="$("${ENGINE}" image inspect --format '{{.Digest}}' "${IMAGE_REF}")"
# --- result ------------------------------------------------------------------
# No digest is printed: a manifest digest does not exist until a registry has the manifest,
# and the only digest anything may pin is the one the REGISTRY returns. CI captures that at
# push time and prints it in the job summary (.gitea/workflows/scripts/images.sh).
SIZE="$("${ENGINE}" image inspect --format '{{.Size}}' "${IMAGE_REF}")"
log "Built ${IMAGE_REF}"
info "local manifest digest ${DIGEST}"
info "size $(( SIZE / 1024 / 1024 )) MiB"
cat <<EOF
================================================================================
NOT PUSHED. Publishing is an operator action — this script holds no registry
credential and will never acquire one.
OPERATOR: run these, then put the digest from the digestfile into
platform/pm-agent/configmap.yaml (PM_WORKER_IMAGE) via a pull request:
${ENGINE} login gitea.home.keithjackson.dev
${ENGINE} push --digestfile /tmp/agent-runner.digest ${IMAGE_REF}
cat /tmp/agent-runner.digest
# -> sha256:.... THIS is the authoritative digest, not the local one above.
# platform/pm-agent/configmap.yaml
PM_WORKER_IMAGE: "${AGENT_IMAGE_REPO}@\$(cat /tmp/agent-runner.digest)"
WHY THE DIGEST AND NOT THE TAG: platform/kyverno/admission/pm-agent-job-shape.yaml is a
fail-closed Enforce policy. It DENIES any worker Job whose image is not
${AGENT_IMAGE_REPO}@sha256:<digest> — a tag reference is denied,
and so is any other repository path. The dispatcher also refuses to start if
PM_WORKER_IMAGE carries no digest, so the failure names its own cause.
THE SAME IMAGE RUNS THE DISPATCHER. platform/pm-agent/deployment.yaml pins it too, so a
new digest is a two-line change in one pull request: the Deployment's image and
PM_WORKER_IMAGE. They must be the same digest — the dispatcher and its workers share the
committed Job template and the env-name contract, and a mismatched pair would disagree
about it.
TIGHTEN THE BOUNDARY AFTERWARDS: the policy admits the wildcard
\`${AGENT_IMAGE_REPO}@sha256:*\`, so anyone who can push to that registry path can publish
a different digest. Replacing the wildcard with the LITERAL digest closes that gap; it is a
one-line change, reviewed in a pull request, which is exactly where a change to the
security boundary belongs.
================================================================================
EOF
info "image id $("${ENGINE}" image inspect --format '{{.Id}}' "${IMAGE_REF}")"
info "size $(( SIZE / 1024 / 1024 )) MiB"
info "Not pushed. CI publishes this image from main (.gitea/workflows/images.yaml); see"
info "platform/pm-agent/README.md for how a new digest reaches the cluster."
+3 -1
View File
@@ -22,7 +22,9 @@
###############################################################################
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# `:-$0` because build-image.sh feeds this script over STDIN (`bash -s`), where BASH_SOURCE is
# empty and `set -u` would abort on it; PM_WORKER_PATH is always set in that case.
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
WORKER="${PM_WORKER_PATH:-${HERE}/pm-worker}"
[ -r "$WORKER" ] || { echo "cannot read ${WORKER}" >&2; exit 2; }
+10 -4
View File
@@ -9,10 +9,16 @@
# truth and, the first time the two drifted, the worker's "green" would stop meaning the
# gate's green. So this file pins ONLY what the toolbox does not carry.
# The toolbox image the runner is layered on. Built by `infra/devcontainer/dev.sh build`
# (tag `home-k3s-dev:local`); infra/agent/build-image.sh asserts it exists rather than
# silently building a different base.
AGENT_BASE_IMAGE=home-k3s-dev:local
# The toolbox image the runner is layered on: the PUBLISHED toolbox, by digest. CI builds and
# publishes it (.gitea/workflows/images.yaml) and Renovate moves this line onto each new digest
# of its `main` tag via the marker below, in a pull request — nobody edits it by hand.
#
# ALL-ZERO UNTIL THE FIRST PUBLISH. The first run of the image pipeline on main sees this
# placeholder, builds and publishes the toolbox first, and builds agent-runner FROM that fresh
# digest. For a LOCAL build on a local toolbox, override it:
# infra/devcontainer/dev.sh build && AGENT_BASE_IMAGE=home-k3s-dev:local infra/agent/build-image.sh
# renovate-digest: image=gitea.home.keithjackson.dev/home-network/toolbox tag=main
AGENT_BASE_IMAGE=gitea.home.keithjackson.dev/home-network/toolbox@sha256:0000000000000000000000000000000000000000000000000000000000000000
# Node.js — the runtime for the Claude Code CLI, installed from the Fedora base's module
# stream. A major version, not a patch: the CLI supports the active LTS line and the
+6
View File
@@ -80,6 +80,12 @@ spec:
# Replaced by the dispatcher with the digest-pinned image. A tag here would be
# DENIED by admission; the dispatcher refuses a non-digest PM_WORKER_IMAGE at
# startup so the error names the real cause.
#
# DELIBERATELY ALL-ZERO, AND DELIBERATELY NOT TRACKED BY RENOVATE. This file is
# baked INTO agent-runner, so it cannot carry agent-runner's own digest: bumping it
# would change the image's inputs, CI would publish a new digest, Renovate would bump
# it again — forever. pm-dispatch overwrites this field from PM_WORKER_IMAGE
# (platform/pm-agent/configmap.yaml, which IS tracked) on every Job it creates.
image: gitea.home.keithjackson.dev/home-network/agent-runner@sha256:0000000000000000000000000000000000000000000000000000000000000000
imagePullPolicy: IfNotPresent
# The pinned entrypoint, with NO args. Args on a pinned command are