feat(images): build, verify and publish the three images in CI; Renovate carries the digests
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:
1 parent
27da5555a4
commit
e539109830
24 files changed
+1274
-210
No files matched your search
Executable
+291
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env bash
|
||||
###############################################################################
|
||||
# .gitea/workflows/scripts/images.sh — the image pipeline's driver (images.yaml calls this).
|
||||
#
|
||||
# Builds, verifies and (on main) PUBLISHES the three images this repo ships:
|
||||
#
|
||||
# toolbox infra/devcontainer/ the pinned dev/gate toolchain
|
||||
# agent-runner infra/agent/ FROM toolbox; the PM dispatcher + worker
|
||||
# conformance-runner conformance/incluster/ the in-cluster L2/L3 Chainsaw runner
|
||||
#
|
||||
# Each image is built by ITS OWN build-image.sh — the same script a developer runs locally —
|
||||
# so the in-image verification (non-root uid, read-only rootfs, tool presence, pm-dispatch
|
||||
# selftest, conformance-clone refusals, ...) is written once and runs in both places. This
|
||||
# file only decides WHAT to build, in which order, FROM which base, and where it goes.
|
||||
#
|
||||
# MODES (env MODE):
|
||||
# verify build + verify, never push, holds NO credential. Pull requests.
|
||||
# publish build + verify + push to ${REGISTRY}/${NAMESPACE}/<image> with tags
|
||||
# `sha-<12 hex>` (immutable: never re-pushed) and `main` (moving; Renovate follows
|
||||
# it). The digest recorded is the one the REGISTRY returns, read back after the push.
|
||||
#
|
||||
# WHAT GETS BUILT (env IMAGES): `auto` (default) diffs BASE_SHA..HEAD_SHA against each image's
|
||||
# inputs; otherwise a space-separated list. A change to the pipeline itself (images.yaml, this
|
||||
# file) builds everything in verify mode, and nothing extra in publish mode: re-publishing
|
||||
# unchanged inputs would only mint new digests and churn Renovate PRs.
|
||||
#
|
||||
# THE agent-runner BASE — the one ordering rule, and the reason this is not three jobs:
|
||||
# * toolbox built in this run -> agent-runner builds FROM it (in publish mode, FROM the
|
||||
# digest the registry just returned for it).
|
||||
# * verify mode, toolbox not changed -> the toolbox is built anyway as a base. A pull request
|
||||
# holds no registry credential, so it cannot pull the published one.
|
||||
# * publish mode, toolbox not changed -> AGENT_BASE_IMAGE from infra/agent/versions.env,
|
||||
# i.e. the published toolbox digest that Renovate keeps current. If that is still the
|
||||
# all-zero placeholder the toolbox has never been published, so it is built and published
|
||||
# first (this is how the very first run bootstraps).
|
||||
#
|
||||
# LOOP-FREEDOM. Renovate turns each publish into a digest-bump PR. Those bumps land in
|
||||
# platform/pm-agent/ (no image input), conformance/versions.env (no image input) and
|
||||
# infra/agent/versions.env (agent-runner input -> ONE more agent-runner build, whose own bump
|
||||
# lands in platform/pm-agent/ and stops). The worker Job template is deliberately NOT tracked:
|
||||
# it is baked INTO agent-runner, so bumping it would rebuild agent-runner, mint a new digest,
|
||||
# bump it again, forever. pm-dispatch overwrites that field at runtime anyway.
|
||||
###############################################################################
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
cd "${REPO_ROOT}"
|
||||
|
||||
MODE="${MODE:-verify}"
|
||||
IMAGES="${IMAGES:-auto}"
|
||||
ENGINE="${ENGINE:-docker}"
|
||||
REGISTRY="${REGISTRY:-gitea.home.keithjackson.dev}"
|
||||
NAMESPACE="${NAMESPACE:-home-network}"
|
||||
HEAD_SHA="${HEAD_SHA:-$(git rev-parse HEAD)}"
|
||||
BASE_SHA="${BASE_SHA:-}"
|
||||
SUMMARY="${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
ZERO_DIGEST="sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
ALL_IMAGES=(toolbox agent-runner conformance-runner)
|
||||
SHA_TAG="sha-${HEAD_SHA:0:12}"
|
||||
MOVING_TAG="main"
|
||||
|
||||
log() { printf '\n\033[1m==> %s\033[0m\n' "$*"; }
|
||||
info() { printf ' %s\n' "$*"; }
|
||||
die() { printf '\033[31mERROR: %s\033[0m\n' "$*" >&2; printf -- '- **ERROR**: %s\n' "$*" >>"${SUMMARY}"; exit 1; }
|
||||
|
||||
case "${MODE}" in verify|publish) ;; *) die "MODE must be verify or publish, got '${MODE}'" ;; esac
|
||||
|
||||
# --- inputs per image: a change under any of these rebuilds that image ---------------------
|
||||
# conformance-runner takes its chainsaw/kubectl/fedora pins from infra/devcontainer/versions.env.
|
||||
# conformance/versions.env is NOT an input (it only RECORDS the published digest), and must not
|
||||
# become one: Renovate writes it after every publish, and an input there would loop.
|
||||
inputs() {
|
||||
case "$1" in
|
||||
toolbox) echo "infra/devcontainer/Containerfile infra/devcontainer/versions.env infra/devcontainer/build-image.sh" ;;
|
||||
agent-runner) echo "infra/agent/" ;;
|
||||
conformance-runner) echo "conformance/incluster/Containerfile conformance/incluster/conformance-clone conformance/incluster/build-image.sh infra/devcontainer/versions.env" ;;
|
||||
esac
|
||||
}
|
||||
PIPELINE_FILES=".gitea/workflows/images.yaml .gitea/workflows/scripts/images.sh"
|
||||
|
||||
declare -A WANT=() BUILT=() DIGEST=() SECS=() NOTE=()
|
||||
|
||||
changed_any() { # <paths...> -> 0 if BASE_SHA..HEAD_SHA touches any
|
||||
# shellcheck disable=SC2086
|
||||
[ -n "$(git diff --name-only "${BASE_SHA}" "${HEAD_SHA}" -- "$@")" ]
|
||||
}
|
||||
|
||||
select_images() {
|
||||
if [ "${IMAGES}" != "auto" ]; then
|
||||
for i in ${IMAGES}; do
|
||||
case " ${ALL_IMAGES[*]} " in *" ${i} "*) WANT[$i]=1; NOTE[$i]="requested" ;; *) die "unknown image '${i}'" ;; esac
|
||||
done
|
||||
return
|
||||
fi
|
||||
if [ -z "${BASE_SHA}" ] || [[ "${BASE_SHA}" =~ ^0+$ ]] || ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
info "no usable diff base ('${BASE_SHA}'): building everything"
|
||||
for i in "${ALL_IMAGES[@]}"; do WANT[$i]=1; NOTE[$i]="no diff base"; done
|
||||
return
|
||||
fi
|
||||
info "diff base ${BASE_SHA} .. ${HEAD_SHA}"
|
||||
local i
|
||||
for i in "${ALL_IMAGES[@]}"; do
|
||||
# shellcheck disable=SC2046
|
||||
if changed_any $(inputs "$i"); then WANT[$i]=1; NOTE[$i]="inputs changed"; fi
|
||||
done
|
||||
# shellcheck disable=SC2086
|
||||
if [ "${MODE}" = verify ] && changed_any ${PIPELINE_FILES}; then
|
||||
for i in "${ALL_IMAGES[@]}"; do [ -n "${WANT[$i]:-}" ] || { WANT[$i]=1; NOTE[$i]="pipeline changed"; }; done
|
||||
fi
|
||||
}
|
||||
|
||||
# --- registry helpers (publish mode only) ----------------------------------------------------
|
||||
REG_TOKEN=""
|
||||
registry_token() {
|
||||
[ -n "${REG_TOKEN}" ] && return 0
|
||||
REG_TOKEN="$(curl -fsS --config <(printf 'user = "%s:%s"\n' "${REGISTRY_USER}" "${REGISTRY_TOKEN}") \
|
||||
"https://${REGISTRY}/v2/token?service=container_registry&scope=*" | jq -r '.token // empty')" \
|
||||
|| die "could not obtain a registry bearer token from https://${REGISTRY}/v2/token"
|
||||
[ -n "${REG_TOKEN}" ] || die "registry returned no bearer token"
|
||||
}
|
||||
# Prints the manifest digest the REGISTRY serves for <image>:<tag>; nothing if it is absent (404).
|
||||
# Any other answer (401, 5xx, no digest header) is fatal: "cannot tell" must never be read as
|
||||
# "not published", or the immutable-tag guard would be bypassed by an auth hiccup.
|
||||
registry_digest() {
|
||||
registry_token
|
||||
local hdrs code digest
|
||||
hdrs="$(curl -sS -I --config <(printf 'header = "Authorization: Bearer %s"\n' "${REG_TOKEN}") \
|
||||
-H 'Accept: application/vnd.oci.image.manifest.v1+json' \
|
||||
-H 'Accept: application/vnd.docker.distribution.manifest.v2+json' \
|
||||
-H 'Accept: application/vnd.oci.image.index.v1+json' \
|
||||
-H 'Accept: application/vnd.docker.distribution.manifest.list.v2+json' \
|
||||
"https://${REGISTRY}/v2/${NAMESPACE}/$1/manifests/$2" | tr -d '\r')" \
|
||||
|| die "registry unreachable while reading $1:$2"
|
||||
code="$(awk 'NR==1{print $2}' <<<"${hdrs}")"
|
||||
digest="$(awk -F': ' 'tolower($1)=="docker-content-digest"{print $2}' <<<"${hdrs}" | tail -1)"
|
||||
case "${code}" in
|
||||
200) [[ "${digest}" =~ ^sha256:[0-9a-f]{64}$ ]] || die "registry answered 200 for $1:$2 with no usable digest"
|
||||
echo "${digest}" ;;
|
||||
404) ;;
|
||||
*) die "registry answered HTTP ${code} for $1:$2 — cannot tell whether it exists" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
local_ref() { echo "${REGISTRY}/${NAMESPACE}/$1:ci-${HEAD_SHA:0:12}"; }
|
||||
|
||||
build_one() { # <image> [agent base]
|
||||
local img="$1" base="${2:-}" t0 tag
|
||||
tag="ci-${HEAD_SHA:0:12}"
|
||||
t0=$(date +%s)
|
||||
log "BUILD + VERIFY ${img}"
|
||||
case "${img}" in
|
||||
toolbox)
|
||||
ENGINE="${ENGINE}" IMAGE="$(local_ref toolbox)" BUILD_REVISION="${HEAD_SHA}" \
|
||||
./infra/devcontainer/build-image.sh ;;
|
||||
agent-runner)
|
||||
ENGINE="${ENGINE}" IMAGE_TAG="${tag}" AGENT_BASE_IMAGE="${base}" BUILD_REVISION="${HEAD_SHA}" \
|
||||
./infra/agent/build-image.sh ;;
|
||||
conformance-runner)
|
||||
ENGINE="${ENGINE}" IMAGE_TAG="${tag}" BUILD_REVISION="${HEAD_SHA}" \
|
||||
./conformance/incluster/build-image.sh ;;
|
||||
esac
|
||||
BUILT[$img]=1
|
||||
SECS[$img]=$(( $(date +%s) - t0 ))
|
||||
info "${img}: built and verified in ${SECS[$img]}s"
|
||||
}
|
||||
|
||||
push_one() { # <image> -> sets DIGEST[image] to the registry-returned digest
|
||||
local img="$1" repo src existing pushed got t0
|
||||
repo="${REGISTRY}/${NAMESPACE}/${img}"
|
||||
src="$(local_ref "${img}")"
|
||||
t0=$(date +%s)
|
||||
log "PUSH ${img} -> ${repo}:{${SHA_TAG},${MOVING_TAG}}"
|
||||
existing="$(registry_digest "${img}" "${SHA_TAG}")"
|
||||
if [ -n "${existing}" ]; then
|
||||
# Immutable tag: never overwrite. A rebuild is not byte-reproducible, so re-pushing would
|
||||
# silently change what `sha-<commit>` means after something may already have pinned it.
|
||||
die "${repo}:${SHA_TAG} already exists (${existing}). Tags here are immutable; this run must not
|
||||
replace it. (publish skips images that already exist at this commit BEFORE building them,
|
||||
so reaching this line means the tag appeared mid-run.)"
|
||||
fi
|
||||
"${ENGINE}" tag "${src}" "${repo}:${SHA_TAG}"
|
||||
"${ENGINE}" tag "${src}" "${repo}:${MOVING_TAG}"
|
||||
pushed="$("${ENGINE}" push "${repo}:${SHA_TAG}" | tee /dev/stderr | grep -oE 'digest: sha256:[0-9a-f]{64}' | tail -1 | cut -d' ' -f2)"
|
||||
"${ENGINE}" push "${repo}:${MOVING_TAG}" >/dev/null
|
||||
# The AUTHORITATIVE digest is what the registry serves, read back — not what the client
|
||||
# printed. They should agree; if they do not, nothing downstream may trust either.
|
||||
got="$(registry_digest "${img}" "${SHA_TAG}")"
|
||||
[ -n "${got}" ] || die "pushed ${repo}:${SHA_TAG} but the registry does not serve it"
|
||||
[ -z "${pushed}" ] || [ "${pushed}" = "${got}" ] \
|
||||
|| die "${img}: client reported ${pushed} but the registry serves ${got}"
|
||||
[ "$(registry_digest "${img}" "${MOVING_TAG}")" = "${got}" ] \
|
||||
|| die "${img}: ${MOVING_TAG} does not point at the digest just pushed (${got})"
|
||||
DIGEST[$img]="${got}"
|
||||
SECS[$img]=$(( ${SECS[$img]:-0} + $(date +%s) - t0 ))
|
||||
info "${img}: registry digest ${got}"
|
||||
}
|
||||
|
||||
# --- main ---------------------------------------------------------------------------------
|
||||
log "image pipeline: MODE=${MODE} IMAGES=${IMAGES} HEAD=${HEAD_SHA}"
|
||||
command -v "${ENGINE}" >/dev/null || die "${ENGINE} is not on PATH in the job container"
|
||||
"${ENGINE}" info >/dev/null 2>&1 \
|
||||
|| die "the ${ENGINE} daemon is not reachable from the job container — the runner did not
|
||||
hand it a Docker socket (act_runner container.docker_host). Nothing can be built."
|
||||
"${ENGINE}" version 2>/dev/null | sed -n '1,12p' || true
|
||||
"${ENGINE}" system df || true
|
||||
|
||||
if [ "${MODE}" = publish ]; then
|
||||
: "${REGISTRY_TOKEN:?REGISTRY_TOKEN is required in publish mode}"
|
||||
REGISTRY_USER="${REGISTRY_USER:-ci-image-publisher}"
|
||||
printf '%s' "${REGISTRY_TOKEN}" | "${ENGINE}" login "${REGISTRY}" -u "${REGISTRY_USER}" --password-stdin >/dev/null \
|
||||
|| die "docker login ${REGISTRY} failed — check REGISTRY_PUSH_TOKEN (scope write:package, owner has package write on the org)"
|
||||
fi
|
||||
|
||||
select_images
|
||||
|
||||
# Publish idempotence: an image already published at THIS commit is not rebuilt. This is what
|
||||
# makes a re-run after a half-finished publish safe — and it is the only way a rerun can
|
||||
# finish, because the immutable sha tag must never be pushed twice.
|
||||
if [ "${MODE}" = publish ]; then
|
||||
for i in "${ALL_IMAGES[@]}"; do
|
||||
[ -n "${WANT[$i]:-}" ] || continue
|
||||
d="$(registry_digest "${i}" "${SHA_TAG}")"
|
||||
if [ -n "${d}" ]; then
|
||||
info "${i}: already published at ${SHA_TAG} (${d}); not rebuilding"
|
||||
unset "WANT[$i]"; DIGEST[$i]="${d}"; NOTE[$i]="already published at this commit"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Decide the agent-runner base (see header).
|
||||
AGENT_BASE=""
|
||||
if [ -n "${WANT[agent-runner]:-}" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
PINNED_BASE="$(. ./infra/agent/versions.env; echo "${AGENT_BASE_IMAGE}")"
|
||||
if [ "${MODE}" = verify ] && [ -z "${WANT[toolbox]:-}" ]; then
|
||||
WANT[toolbox]=1; NOTE[toolbox]="built as agent-runner's base (verify only)"
|
||||
elif [ "${MODE}" = publish ] && [ -z "${WANT[toolbox]:-}" ] && [ -z "${DIGEST[toolbox]:-}" ]; then
|
||||
case "${PINNED_BASE}" in
|
||||
*@"${ZERO_DIGEST}") WANT[toolbox]=1; NOTE[toolbox]="never published; required as agent-runner's base" ;;
|
||||
*@sha256:*) AGENT_BASE="${PINNED_BASE}" ;;
|
||||
*) die "infra/agent/versions.env AGENT_BASE_IMAGE='${PINNED_BASE}' is not digest-pinned; CI will not build on a floating base" ;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
|
||||
yn() { if [ -n "$1" ]; then echo "$2"; else echo "$3"; fi; }
|
||||
for i in "${ALL_IMAGES[@]}"; do info "plan: ${i}: $(yn "${WANT[$i]:-}" BUILD skip) (${NOTE[$i]:-inputs unchanged})"; done
|
||||
|
||||
if [ -n "${WANT[toolbox]:-}" ]; then
|
||||
build_one toolbox
|
||||
if [ "${MODE}" = publish ]; then push_one toolbox; fi
|
||||
fi
|
||||
if [ -n "${WANT[agent-runner]:-}" ]; then
|
||||
if [ -z "${AGENT_BASE}" ]; then
|
||||
if [ "${MODE}" = publish ]; then
|
||||
AGENT_BASE="${REGISTRY}/${NAMESPACE}/toolbox@${DIGEST[toolbox]:?no toolbox digest to build agent-runner from}"
|
||||
else
|
||||
AGENT_BASE="$(local_ref toolbox)"
|
||||
fi
|
||||
fi
|
||||
info "agent-runner base: ${AGENT_BASE}"
|
||||
build_one agent-runner "${AGENT_BASE}"
|
||||
if [ "${MODE}" = publish ]; then push_one agent-runner; fi
|
||||
fi
|
||||
if [ -n "${WANT[conformance-runner]:-}" ]; then
|
||||
build_one conformance-runner
|
||||
if [ "${MODE}" = publish ]; then push_one conformance-runner; fi
|
||||
fi
|
||||
|
||||
# --- summary ------------------------------------------------------------------------------
|
||||
{
|
||||
echo "## Images (${MODE})"
|
||||
echo
|
||||
echo "commit \`${HEAD_SHA}\`"
|
||||
echo
|
||||
echo "| image | built + verified | seconds | published digest (registry-returned) | note |"
|
||||
echo "|---|---|---|---|---|"
|
||||
for i in "${ALL_IMAGES[@]}"; do
|
||||
d="${DIGEST[$i]:-}"
|
||||
echo "| \`${i}\` | $(yn "${BUILT[$i]:-}" yes no) | ${SECS[$i]:--} | $(yn "${d}" "\`${REGISTRY}/${NAMESPACE}/${i}@${d}\`" -) | ${NOTE[$i]:-inputs unchanged} |"
|
||||
done
|
||||
echo
|
||||
if [ "${MODE}" = publish ]; then
|
||||
echo "Tags pushed: \`${SHA_TAG}\` (immutable) and \`${MOVING_TAG}\` (moving)."
|
||||
echo "Nothing here writes to git. Renovate opens the digest-bump PR on its next run."
|
||||
else
|
||||
echo "Verify mode: nothing was pushed and no registry credential was present."
|
||||
fi
|
||||
} >>"${SUMMARY}"
|
||||
log "done"
|
||||
Reference in new issue
Block a user