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]>
14 KiB
date, branch, title
| date | branch | title |
|---|---|---|
| 2026-10-29 | feat/image-pipeline | Images are built, verified and published by CI; Renovate carries the digests into git |
What changed
Operator rule (2026-10-29): container image creation, hosting and lifecycle belong to CI/CD and
the codebase, never to a one-off workstation build/push. Before this branch the Home-Network org's
package list was empty, platform/pm-agent/ pinned an all-zero digest (ImagePullBackOff), and
both build-image.sh scripts said "publishing is an operator action".
-
.gitea/workflows/images.yaml+.gitea/workflows/scripts/images.sh(the logic).- Pull request: build and verify only. No push, and the step references no secret at all.
- Push to
main, orworkflow_dispatchonmain: build, verify, then push togitea.home.keithjackson.dev/home-network/{toolbox,agent-runner,conformance-runner}. Tags aresha-<12 hex>(immutable: the driver refuses to overwrite one) andmain(moving). The digest is read back from the registry with a HEAD on the manifest, compared with what the client printed, and written to the job summary. - Only images whose inputs changed are built. A re-run on the same commit skips images that are already published.
- agent-runner builds FROM the toolbox built in the same run (on main, FROM its just-pushed
digest). Otherwise:
- in verify mode the toolbox is built anyway as its base, because a PR holds no credential;
- in publish mode it uses
AGENT_BASE_IMAGEfrominfra/agent/versions.env; - if that is still the zero placeholder, the toolbox is built and published first. This is how the first run on main bootstraps.
- An always-run step prunes the DinD image store.
-
One build recipe per image, reused by CI.
- New
infra/devcontainer/build-image.sh.dev.sh buildnow delegates to it. It has an in-image verification step that the toolbox never had: every tool is on PATH, and promtool, kubectl, chainsaw, kyverno and sops report their pins. infra/agent/build-image.shandconformance/incluster/build-image.shwere made engine-neutral, because CI runsENGINE=dockeragainst DinD:podman image existsbecameimage inspect, with a pull fallback;{{.Digest}}and--digestfileare gone. No local digest is printed, since only the registry's digest is meaningful;probe-validation.shis fed over stdin instead of a bind mount. Under DinD a bind source is resolved on the daemon's filesystem, which would silently mount an empty directory;probe-validation.shitself gained${BASH_SOURCE[0]:-$0}so it survivesbash -sunderset -u.
AGENT_BASE_IMAGEandIMAGE_TAGcan be overridden from the environment, andBUILD_REVISIONadds anorg.opencontainers.image.revisionlabel.- The "publishing is an operator action" text was replaced throughout.
- New
-
Digest references. Each now carries a
# renovate-digest: image=<repo> tag=mainmarker on the line above it:platform/pm-agent/deployment.yamlandconfigmap.yaml(PM_WORKER_IMAGE), which share one dependency, so they bump in one PR;infra/agent/versions.envAGENT_BASE_IMAGE, which washome-k3s-dev:localand is now the published toolbox by digest (zero placeholder until the first publish);conformance/versions.envCONFORMANCE_RUNNER_IMAGE_DIGEST, which went from""to the zero placeholder because Renovate can only replace a digest it can match.
The admission policies are untouched; the
<repo>@sha256:<digest>form is preserved. -
renovate.json5:- a new
custom.regexmanager for the markers; enabledManagersregex→custom.regex;- a packageRule that disables the
kubernetesmanager for these three repos. Otherwise it would also see the digest-only lines and look them up aslatest; - an
own-imagelabel; ignorePathsnarrowed fromconformance/**to its subdirectories.ignorePathsapplies to custom managers too, so the blanket glob hidconformance/versions.env.
- a new
-
renovate.yaml:renovate-version: 44.167.0pinned, plus aRENOVATE_HOST_RULESdocker rule for the Gitea registry, using the sameRENOVATE_TOKEN, which now also needsread:package. See below for why the version pin is a real fix and not tidiness. -
infra/gitea/registry-retention.sh: dry run by default,--applyto delete. This is an operator action. Per image it keeps:mainandlatest;- the 10 newest
sha-tags; - every tag whose digest is pinned anywhere in
origin/main; - every digest-named version.
-
infra/gitea/conformance-trigger-kubeconfig.sh: the "runner image is digest-pinned" precondition grepped the literal lineCONFORMANCE_RUNNER_IMAGE="${…_REPO}@${…_DIGEST}"for 64 hex digits, so it could never pass. It now sources the file, evaluates the value, and rejects the zero placeholder. -
conformance/tests/l0-static/alert-rules-unit-tested/check.sh: its "the toolbox build passes--build-arg PROMTOOL_VERSION" assertion now readsinfra/devcontainer/build-image.shinstead ofdev.sh. The gate caught this on the first run, because the recipe had moved. -
Docs:
platform/pm-agent/README.md,runbooks/pm-agent.md(two rows),docs/plans/pm-agent-issue-to-pr.md(follow-up 1),conformance/incluster/README.md,infra/devcontainer/README.md, and a new "How images are built" section in.gitea/workflows/README.md.
Decisions and pushback
-
The push credential is a PAT secret,
REGISTRY_PUSH_TOKEN; the Actions token cannot push. Checked against Gitea v1.24.3 source:services/auth/basic.goBasic.Verifyturns a task token into the pseudo-usergitea-actions(ID −2) and sets no token scope;routers/api/packages/container/container.goAuthenticate(the/v2/tokenexchange every docker client performs) requiresread:package/write:package/allscope, so it answers 401. The Actions token cannot even pull;services/context/package.godetermineAccessModehas// TODO: ActionUser permission checkand no branch for that user. Even past login it would get at most read on a public org;models/repo/repo_unit.goActionsConfighas no token-permissions setting in 1.24.3.
The workflow fails loudly when the secret is absent, copying the
RENOVATE_TOKENpreflight pattern. -
regexvscustom.regex: both work. Switched to the canonicalcustom.regex. In Renovate:lib/config/migrations/custom/enabled-managers-migration.tsrewritesregex→custom.regex;- that migration runs on every repository run (
lib/workers/repository/init/merge.ts→migrateAndValidate), not only in the validator; lib/modules/manager/index.tsgetEnabledManagersListaccepts either spelling;custom.regexexists since 37.51.1 (renovatebot/renovate#24079).
-
The open question hid a bigger bug: the Renovate config on main is invalid for the Renovate the workflow actually runs.
renovatebot/[email protected]defaultsrenovate-versionto39(action.yml,src/docker.ts), which is 39.264.0.- The validator used in #79 was
ghcr.io/renovatebot/renovatewith no tag, i.e.latest, 44.167.0. - Measured:
renovate:39.264.0 renovate-config-validatoron main'srenovate.json5givesERROR: Found errors in configurationwith five errors, allmanagerFilePatterns.fileMatchwas renamed in 40.2.0. - So once #80 fixes the filename, every run would still die at
CONFIG_VALIDATION. - Fixed by pinning
renovate-version: 44.167.0. This branch's config validates clean on it. The only--strictcomplaint is a pending migration of the pre-existing top-level"docker": {"enabled": true}block, which I left alone.
-
Unworkable as briefed: tracking the worker Job template's digest.
infra/agent/worker-job-template.yamlisCOPYed into agent-runner. A Renovate bump there changes agent-runner's inputs, CI publishes a new digest, Renovate bumps it again, and so on forever.pm-dispatchoverwrites that field fromPM_WORKER_IMAGEat runtime (pm-dispatch.py:649), so nothing is lost by leaving it all-zero. It is commented as deliberately untracked.- For the same loop reason,
conformance/versions.envis not an input of the conformance-runner build, and a pipeline-only change does not republish on main.
-
Changed from the brief: retention is a script, not a Gitea cleanup rule.
- Gitea 1.24.3 has no API for cleanup rules (none in
routers/api/v1/api.go; web session+CSRF forms only, inrouters/web/web.go). So a rule cannot be created, or drift-asserted, as code.ci/gitea-settings.shwas therefore not extended. A cheaper proxy (count versions per image) would needread:packageonSETTINGS_API_TOKEN, which it lacks. - A built-in rule is also unsafe here. Gitea stores a manifest as a file of the TAG
version it was pushed under (
routers/api/packages/container/manifest.gocreatePackageAndVersion), and resolves a by-digest pull from those files (models/packages/container/search.go). Deleting the tag a deployed digest was pushed under breaks the@sha256:pull, and the rule's "newest N" knows nothing about what git pins. The script keeps every digest git pins.
- Gitea 1.24.3 has no API for cleanup rules (none in
-
Auto-merge. Digest bumps of these images auto-merge on a green gate (the existing
digestrule). The exception is the conformance-runner bump:conformance/versions.envis a sensitive path, so it waits for an administrator's/approve-overrideevery time.
What I verified
- Local gate, pinned toolbox, from this worktree, committed HEAD. Command:
ENGINE=<shim> DEV_KUBECONFIG=/dev/null ./infra/devcontainer/dev.sh run bash -c './ci/gate.sh'. The shim adds the.gitmount. Result: see the PR body for the rc and stage list. They are recorded there rather than here because this entry is part of the tree being gated. - Local podman builds through the CI driver, run as
MODE=verify ENGINE=podman BASE_SHA=$(git merge-base origin/main HEAD) .gitea/workflows/scripts/images.sh. It returned rc 0 and built and verified all three images:- toolbox: every tool on PATH, and the five pins matched;
- agent-runner:
PM_SELFTEST=1 pm-dispatchOK, and the stdin-fedprobe-validation.shgave "20 refusal(s), 0 failure(s)"; - conformance-runner: all seven
conformance-clonerefusals.
- Other driver paths, run locally:
- agent-runner alone builds the toolbox as its base;
- an empty diff builds nothing;
- publish mode without a token dies before doing anything;
infra/agent/build-image.shwith the zero placeholder dies with build-locally instructions.
- Renovate, with the pinned 44.167.0 image, locally:
renovate-config-validatorexits 0;- a
--platform=localextractdry run finds all four marker references (custom regex manager,currentValue: main,currentDigest: sha256:000…); - a
lookupdry run shows thekubernetes-manager duplicates asskipReason: disabledand the marker deps taking the "unsupported/unversioned value main → getDigest(main)" path. That lookup currently 404s because nothing is published yet.
- Renovate 39.264.0 rejects main's current
renovate.json5. Validator output reproduced above. registry-retention.shdry run against the live Gitea (read-only): zero versions per image, nothing to delete. The token exchange and the package listing both answered.- Live, read-only measurements (kubectl and the Gitea API):
- the runner pod is on k3s-prod-1, which had 7 GB nodefs available;
- DinD holds only the 1.54 GB act image; buildx is v0.20.1; the daemon is 27.5.1 on overlay2 with cgroup v2;
- Gitea
/datais 9.7G, 1% used; - the Gateway's Envoy (Cilium) has
streamIdleTimeout: 300s,maxStreamDuration: 0s, and no routetimeouton the gitea route; - Gitea's
LIMIT_SIZE_CONTAINERis unset (default −1 = unlimited,modules/setting/packages.go).
What I did NOT verify
-
Nothing has been pushed to the registry, by me or by CI. The publish path has never run:
- the registry HEAD/digest read-back;
- the immutable-tag guard;
- "already published at this commit" idempotence;
- agent-runner
FROM <toolbox>@sha256:; docker loginwith a PAT.
The first merge to main is the first test, and it needs
REGISTRY_PUSH_TOKENto exist first. -
Large-layer push through the Gateway is UNVERIFIED. Envoy has no request-body cap and no stream duration cap here. But with no route
timeoutset, Envoy's default 15 s upstream timeout applies from the end of the request to the end of the response. That covers Gitea finalising a blob: copying the upload into package storage on Longhorn. A ~250 MB compressed layer should finish well inside 15 s, but I could not measure it without pushing. If the first publish shows 504s on blob PUTs, settimeouts.requeston the gitea HTTPRoute. -
DinD fit is estimated, not measured (until the PR run reports):
- memory: 2Gi is shared by the job container and every build container;
- disk: the toolbox, agent-runner, conformance-runner, Fedora base and build cache should
total roughly 3 GB on top of the 1.5 GB act image, against 7 GB free on the node, with
dind-storagehaving no sizeLimit.
-
That act_runner hands the job container a Docker socket is inferred from act_runner's DOCKER_HOST handling, not measured. The driver fails loudly if it does not.
-
Renovate has still never completed a run here. Three things block it:
- #80, the
configurationFilename; RENOVATE_TOKENgainingread:package;- this branch's version pin.
renovatebot/[email protected]running a 44.x image is untested. The action only passes the tag todocker run. - #80, the
-
Pods cannot pull these images yet regardless. The kubelet pull credential is PR #61 (
pm-agent-registrydockerconfigjson,read:package), still open. Until it merges, a correct digest still ends in ImagePullBackOff (401), as before. -
registry-retention.sh --applyhas never run, and its keep logic has only run against an empty registry. -
git diffchange detection on Giteapushevents relies ongithub.event.before. A force-push or a first push falls back to building everything, which is intended but not observed.