# 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/ # 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-` (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 BUILDER — rootless BuildKit (operator decision on PR #81, 2026-10-29). # The act runner's job containers get NO Docker daemon (act_runner 0.2.11 forces docker_host "-" # for a tcp:// DinD, and gitea/act strips every bind when valid_volumes is empty — measured on # this PR's first run), and handing them the privileged DinD daemon would be root on the node for # anyone who can push a branch. So images are built by platform/buildkit/: a ROOTLESS buildkitd # (not privileged; uid 1000 in a user namespace), reached over mTLS at # buildkitd.buildkit.svc.cluster.local:1234 with a pinned `buildx` (remote driver). Its client # certificate reaches jobs as three Actions secrets — BUILDKIT_CA, BUILDKIT_CLIENT_CERT, # BUILDKIT_CLIENT_KEY — copied from cert-manager by infra/gitea/buildkit-client-secret.sh. # Pull requests get the client certificate (they must build) but never a registry credential. # # UNTIL platform/buildkit IS DEPLOYED (Flux applies it only after merge) THIS JOB FAILS, LOUDLY, # at its builder preflight. A skipped Gitea job reports `success`, so it is red on purpose. # # VERIFICATION is each Containerfile's `verify` stage, built before anything is pushed. # 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 - platform/buildkit/versions.env 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 - platform/buildkit/versions.env 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 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 }} # The BUILDER's client certificate (platform/buildkit). Not a registry credential: # a pull request can build, never push. BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }} BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }} BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }} 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 }} BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }} BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }} BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }} 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 }} BUILDKIT_CA: ${{ secrets.BUILDKIT_CA }} BUILDKIT_CLIENT_CERT: ${{ secrets.BUILDKIT_CLIENT_CERT }} BUILDKIT_CLIENT_KEY: ${{ secrets.BUILDKIT_CLIENT_KEY }} run: ./.gitea/workflows/scripts/images.sh