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]>
175 lines
10 KiB
YAML
175 lines
10 KiB
YAML
# Container images — built, verified and published by CI, never from a workstation.
|
|
#
|
|
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
# WHY THIS FILE EXISTS (operator rule, 2026-10-29)
|
|
# ═══════════════════════════════════════════════════════════════════════════════════════════════
|
|
# "Container image creation, hosting and lifecycle must be part of CI/CD and the codebase, never a
|
|
# one-off build/push from a workstation." Before this workflow, three images existed only as
|
|
# workstation builds and NOTHING had ever been pushed to the Gitea registry: the Home-Network org's
|
|
# package list was empty, platform/pm-agent/ pinned an all-zero digest and sat in ImagePullBackOff,
|
|
# and conformance/versions.env carried an empty digest. Both build scripts said "publishing is an
|
|
# operator action" — a step with no log, no recorded inputs and no verification anyone could read.
|
|
#
|
|
# WHAT IT DOES — the logic is in scripts/images.sh; this file only wires triggers and secrets.
|
|
# pull_request -> build + VERIFY only. No push, and no registry credential is referenced.
|
|
# push to main -> build + verify + PUBLISH to gitea.home.keithjackson.dev/home-network/<image>
|
|
# dispatch -> same as a push when run on main; verify-only on any other ref.
|
|
# Images: toolbox (infra/devcontainer), agent-runner (infra/agent, FROM toolbox) and
|
|
# conformance-runner (conformance/incluster). Only images whose inputs changed are built.
|
|
#
|
|
# THE DIGEST LIFECYCLE — this workflow NEVER writes to git.
|
|
# 1. publish pushes `sha-<commit>` (immutable) and `main` (moving), and prints the digest the
|
|
# REGISTRY returns in the job summary.
|
|
# 2. Renovate (renovate.yaml) follows the `main` tag of each image through the
|
|
# `# renovate-digest:` markers next to every digest reference, and opens the bump PR.
|
|
# 3. That PR goes through the gate like any other change, and Flux deploys it.
|
|
# The bump PR is Renovate's and not this job's because a PR opened with the Actions token does not
|
|
# trigger workflows, so it could never pass the REQUIRED gate check; Renovate's token is a real
|
|
# user's.
|
|
#
|
|
# THE PUSH CREDENTIAL — `REGISTRY_PUSH_TOKEN`, a repository Actions secret, referenced BY NAME.
|
|
# The automatic Actions token CANNOT be used. Gitea 1.24.3 source, verified:
|
|
# * services/auth/basic.go (Basic.Verify) turns a task token into the pseudo-user
|
|
# `gitea-actions` (ActionsUserID -2) and sets NO token scope;
|
|
# * routers/api/packages/container/container.go (Authenticate, the /v2/token exchange every
|
|
# docker client uses) then requires read:package / write:package / all scope and answers 401;
|
|
# * services/context/package.go (determineAccessMode) has `// TODO: ActionUser permission check`
|
|
# and no branch for that user, so even past login it would get at most READ on a public org.
|
|
# So: a personal access token with scope `write:package`, minted by an account that has package
|
|
# WRITE on the Home-Network org (org owner, or a team with the Packages unit at write). A dedicated
|
|
# bot account in a packages-only team is the least-privilege choice. The username sent with it is
|
|
# not checked by Gitea (basic.go resolves the owner from the token), so `vars.REGISTRY_PUSH_USER`
|
|
# is optional and only labels the login.
|
|
#
|
|
# THE RUNNER (apps/wave-1/gitea-actions.yaml): act_runner 0.2.11, one job at a time, Docker
|
|
# executor on a privileged `docker:27-dind` sidecar whose image store is an emptyDir on the node
|
|
# (no sizeLimit) and whose memory limit is 2Gi — shared by the job container AND every build
|
|
# container. The toolbox is ~1.4 GB uncompressed and agent-runner adds ~0.3 GB on top of it, so the
|
|
# cleanup step below is not optional: without it each run would leave ~3 GB on a node that had
|
|
# 7 GB free when this was written, and the next eviction would take the runner with it.
|
|
name: images
|
|
|
|
on:
|
|
pull_request:
|
|
branches: [main]
|
|
paths:
|
|
- infra/devcontainer/Containerfile
|
|
- infra/devcontainer/versions.env
|
|
- infra/devcontainer/build-image.sh
|
|
- infra/agent/**
|
|
- conformance/incluster/Containerfile
|
|
- conformance/incluster/conformance-clone
|
|
- conformance/incluster/build-image.sh
|
|
- .gitea/workflows/images.yaml
|
|
- .gitea/workflows/scripts/images.sh
|
|
push:
|
|
branches: [main]
|
|
paths:
|
|
- infra/devcontainer/Containerfile
|
|
- infra/devcontainer/versions.env
|
|
- infra/devcontainer/build-image.sh
|
|
- infra/agent/**
|
|
- conformance/incluster/Containerfile
|
|
- conformance/incluster/conformance-clone
|
|
- conformance/incluster/build-image.sh
|
|
- .gitea/workflows/images.yaml
|
|
- .gitea/workflows/scripts/images.sh
|
|
workflow_dispatch:
|
|
inputs:
|
|
images:
|
|
description: "Which images: 'all', or a space-separated subset of: toolbox agent-runner conformance-runner"
|
|
required: false
|
|
default: "all"
|
|
|
|
concurrency:
|
|
# Never cancel a publish half-way: a pushed `sha-` tag with no matching `main` move is
|
|
# recoverable (a rerun skips what is already published) but pointless to provoke.
|
|
group: images-${{ github.ref }}
|
|
cancel-in-progress: false
|
|
|
|
jobs:
|
|
images:
|
|
runs-on: ubuntu-latest
|
|
# Measured on the PR that introduced this file — see its decisions entry. Generous because a
|
|
# cold DinD store downloads the Fedora base and every pinned tool on each run.
|
|
timeout-minutes: 75
|
|
steps:
|
|
- name: Checkout
|
|
uses: actions/checkout@v4
|
|
with:
|
|
fetch-depth: 0 # the per-image change detection diffs against the base commit
|
|
|
|
# ── pull requests: build + verify, NO credential ───────────────────────────────────────
|
|
- name: Build and verify (pull request — nothing is pushed)
|
|
if: github.event_name == 'pull_request'
|
|
env:
|
|
MODE: verify
|
|
IMAGES: auto
|
|
PR_BASE_REF: ${{ github.base_ref }}
|
|
run: |
|
|
set -euo pipefail
|
|
BASE_SHA="$(git merge-base "origin/${PR_BASE_REF}" HEAD)"
|
|
export BASE_SHA
|
|
./.gitea/workflows/scripts/images.sh
|
|
|
|
# ── non-main dispatch: verify only ──────────────────────────────────────────────────────
|
|
- name: Build and verify (dispatch off main — nothing is pushed)
|
|
if: github.event_name == 'workflow_dispatch' && github.ref != 'refs/heads/main'
|
|
env:
|
|
MODE: verify
|
|
IMAGES: ${{ inputs.images == 'all' && 'toolbox agent-runner conformance-runner' || inputs.images }}
|
|
run: ./.gitea/workflows/scripts/images.sh
|
|
|
|
# ── main: publish ───────────────────────────────────────────────────────────────────────
|
|
- name: Preflight - the push token must exist, and its absence must be LOUD
|
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
|
env:
|
|
REGISTRY_PUSH_TOKEN: ${{ secrets.REGISTRY_PUSH_TOKEN }}
|
|
run: |
|
|
set -euo pipefail
|
|
if [ -z "${REGISTRY_PUSH_TOKEN:-}" ]; then
|
|
echo "::error::REGISTRY_PUSH_TOKEN is not set on this repository." >&2
|
|
cat >&2 <<'MSG'
|
|
|
|
Images cannot be published without a registry credential, and this step fails rather
|
|
than skipping ON PURPOSE: a skipped Gitea job reports `success`, so skipping here would
|
|
make "nothing was published" look exactly like "published". That is how this repo went
|
|
a month with images that existed only on one workstation.
|
|
|
|
WHY NOT THE BUILT-IN ACTIONS TOKEN: Gitea 1.24.3 gives it no package scope at the
|
|
registry's /v2/token exchange (routers/api/packages/container/container.go,
|
|
Authenticate) — see the header of .gitea/workflows/images.yaml.
|
|
|
|
TO FIX: as an account with package WRITE on the Home-Network org (ideally a dedicated
|
|
bot in a packages-only team), create an access token with scope `write:package` and
|
|
nothing else, then add it to this repository as the Actions secret
|
|
REGISTRY_PUSH_TOKEN. Optionally set the Actions variable REGISTRY_PUSH_USER to that
|
|
account's name. Do not commit the token anywhere. Then re-run this workflow
|
|
(workflow_dispatch on main, images: all).
|
|
MSG
|
|
exit 1
|
|
fi
|
|
echo "REGISTRY_PUSH_TOKEN is present (value not printed)."
|
|
|
|
- name: Build, verify and publish (main)
|
|
if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
|
env:
|
|
MODE: publish
|
|
IMAGES: ${{ github.event_name == 'workflow_dispatch' && (inputs.images == 'all' && 'toolbox agent-runner conformance-runner' || inputs.images) || 'auto' }}
|
|
BASE_SHA: ${{ github.event.before }}
|
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_PUSH_TOKEN }}
|
|
REGISTRY_USER: ${{ vars.REGISTRY_PUSH_USER }}
|
|
run: ./.gitea/workflows/scripts/images.sh
|
|
|
|
# ── always: give the DinD store back ────────────────────────────────────────────────────
|
|
# The runner's image store is a node emptyDir with no sizeLimit (see header). Everything this
|
|
# job built or pulled is removed; `prune -a` cannot remove the job's own image, because the
|
|
# job container is still running on it.
|
|
- name: Clean the DinD image store
|
|
if: always()
|
|
run: |
|
|
docker logout gitea.home.keithjackson.dev >/dev/null 2>&1 || true
|
|
docker image prune -af >/dev/null || true
|
|
docker builder prune -af >/dev/null || true
|
|
docker system df || true
|