186 lines
11 KiB
YAML
186 lines
11 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 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
|