Compare commits

...
Author SHA1 Message Date
agent bc1d6890b6 fix: bake openssl and jq into the runner image, and assert them
The release tag gate parses the Control Plane quorum verdict and verifies its
Ed25519 DSSE signature by shelling out to jq and openssl. Neither is in this
image, and both call sites read cmd ... || { echo <security message>; exit 1; },
so command-not-found took the failure branch: the gate reported a valid, signed
approval first as "not a signed DSSE envelope" and then as "signature FAILED
verification". That blocked v0.0.134 and v0.0.135 on 2026-08-31 and pointed the
investigation at the Tag Request rather than at this image — the same shape as
the git-daemon gap already documented above the package list.

Also adds both to the toolchain assertion. That check already existed and still
missed them, because it only covers what someone thought to list; the comment
now says so, and says to extend it whenever a gate grows a shell dependency.

Verified by building this image and running the gate script inside it against
the live Control Plane: openssl 3.5.8, jq 1.8.1, and the v0.0.135 verdict
verifies and binds to its commit. The unpatched image fails the same script.
2026-08-31 12:56:44 +02:00
markus bf26dc4e15 Merge pull request 'ci: assert the published image still contains the toolchain the gates need' (#5) from claude/ci/assert-image-contents into master
build image / build (push) Skipped
Reviewed-on: #5
2026-08-20 14:22:27 +00:00
markus d0b0bbf239 Merge pull request 'fix: restore gcc, musl-dev, python3 and py3-yaml to the image' (#4) from claude/fix/restore-toolchain into master
build image / build (push) Skipped
Reviewed-on: #4
2026-08-20 14:03:55 +00:00
agent afc1701f84 ci: assert the published image still contains the toolchain the gates need
This image is a TOOLCHAIN, so "it built" is not the property worth checking —
"it still contains what the repo's gates shell out to" is.

It exists because the hand-built image had drifted from this Dockerfile: the
deployed ci3 carried gcc, musl-dev, python3 and py3-yaml, the Dockerfile did
not, and rebuilding from the Dockerfile silently dropped them. Gitea CI then
failed at "credentials-coverage: need python3" without reaching the tests at
all. Nothing in the pipeline objected, because every check asked whether the
image built, not what was in it.

Asserts python3 with a working yaml import, gcc, node, bash, make, curl, tar
and git-http-backend, on BOTH architectures — a package that resolves on amd64
but not arm64 would otherwise reach half the nodes unnoticed.

Verified the check can actually FAIL, which is the only property that makes it
worth having: against the bare forgejo/runner:12.11.1 it reports python3+yaml,
gcc, node and git-http-backend all MISSING; with the packages installed it
reports 3.12.14 and ok for the rest.
2026-08-20 16:03:11 +02:00
agent 5427ccbad2 fix: restore gcc, musl-dev, python3 and py3-yaml to the image
REGRESSION, mine. The committed Dockerfile had drifted from the image actually
in service: the running ci3 image contained these four packages, the Dockerfile
did not, and I rebuilt ci4 from the Dockerfile. The rollout therefore removed
tools zegit's CI depends on.

Proof the deployed ci3 had them: its Gitea runs got PAST `make credentials-check`
— which shells out to python3 — and failed later at cover-check. The ci4 run
fails earlier, at `credentials-coverage: need python3`.

What each is for, so the next rebuild does not drop them again:
  python3, py3-yaml  the repo's own gates are Python — credentials-coverage.sh,
                     validate-admission.sh and feature-lint.sh all shell out to
                     it, and credentials-coverage parses manifests with yaml
  gcc, musl-dev      cgo, which `go test -race` in the Makefile test target
                     requires: a C compiler plus libc headers (musl on Alpine)

Verified on the real base image rather than assumed — on
code.forgejo.org/forgejo/runner:12.11.1, `apk add git git-daemon gcc musl-dev
python3 py3-yaml` yields Python 3.12.14, a working `import yaml`, gcc 15.2.0,
and git-http-backend on the git exec path.

The deeper lesson is that a hand-built image can drift from its Dockerfile
silently, and nothing notices until someone rebuilds. Publishing via CI (#2)
is what stops that recurring, since every future image comes from the
committed file.
2026-08-20 15:53:23 +02:00
markus 137dde8122 Merge pull request 'docs: correct the registry notes in the Dockerfile header' (#3) from claude/docs/correct-registry-notes into master
build image / build (push) Skipped
Reviewed-on: #3
2026-08-20 13:48:05 +00:00
agent 6b6743113f docs: correct the registry notes in the Dockerfile header
Two things were wrong, and the second actively misled.

The publish instructions described pushing to the Gitea registry by hand.
Publishing now runs on GitHub Actions to ghcr.io, because Cloudflare's
request-body cap returns 413 on a registry layer and our own runners cannot
build images (host mode, all capabilities dropped). The example tag had also
been mangled to "12.11.1-ci".

"Package visibility is PUBLIC, so the runner namespace pulls without a secret"
was false. The Gitea package is private — flux-config's kustomization says so
in as many words — and the runner Deployments have always carried a pull
secret. Reading that line led me to conclude the ghcr package would need no
credential; it does, and forgejo-runner-system now holds
github-registry-credentials for exactly that.

Verified with `docker build --check`: no warnings.
2026-08-20 15:42:26 +02:00
markus f2b9661be2 Merge pull request 'ci: build and publish the image on GitHub to ghcr.io' (#2) from claude/ci/build-on-github into master
build image / build (push) Skipped
Reviewed-on: #2
2026-08-20 12:46:03 +00:00
2 changed files with 92 additions and 8 deletions

No files matched your search

+54
View File
@@ -91,3 +91,57 @@ jobs:
echo "$out" | grep -q "linux/$want" || { echo "::error::published image is missing linux/$want"; exit 1; } echo "$out" | grep -q "linux/$want" || { echo "::error::published image is missing linux/$want"; exit 1; }
done done
echo "✓ ${IMAGE}:${{ inputs.tag }} carries linux/amd64 and linux/arm64" echo "✓ ${IMAGE}:${{ inputs.tag }} carries linux/amd64 and linux/arm64"
# The image is a TOOLCHAIN, so "it built" is not the property that matters
# — "it still contains what the gates shell out to" is. This exists because
# a hand-built image had drifted from this Dockerfile: the deployed ci3
# carried gcc, musl-dev, python3 and py3-yaml, the Dockerfile did not, and
# rebuilding from the Dockerfile silently dropped them. CI runs then failed
# at "credentials-coverage: need python3" instead of running the tests.
#
# Asserted on BOTH architectures, because a package that resolves on amd64
# and not on arm64 would otherwise reach half the nodes unnoticed.
#
# openssl and jq were added after this check already existed and still
# missed them: the release tag gate shells out to both to verify the
# Control Plane's signed quorum verdict, and their absence surfaced as
# "verdict not signed" / "signature FAILED verification" rather than as a
# missing tool, blocking v0.0.134 and v0.0.135. The list only covers what
# someone remembered to add, so add to it whenever a gate grows a new
# shell dependency.
- name: Verify the image still contains the toolchain the gates need
if: ${{ steps.meta.outputs.push == 'true' }}
run: |
set -euo pipefail
REF="${IMAGE}:${{ inputs.tag }}"
fail=0
for plat in linux/amd64 linux/arm64; do
echo "── $plat"
out=$(docker run --rm --platform "$plat" --entrypoint sh "$REF" -c '
set -u
py=$(python3 -c "import yaml,sys;print(sys.version.split()[0])" 2>/dev/null) \
&& echo "python3+yaml=$py" || echo "python3+yaml=MISSING"
command -v gcc >/dev/null && echo "gcc=ok" || echo "gcc=MISSING"
command -v node >/dev/null && echo "node=ok" || echo "node=MISSING"
command -v bash >/dev/null && echo "bash=ok" || echo "bash=MISSING"
command -v make >/dev/null && echo "make=ok" || echo "make=MISSING"
command -v curl >/dev/null && echo "curl=ok" || echo "curl=MISSING"
command -v tar >/dev/null && echo "tar=ok" || echo "tar=MISSING"
command -v openssl >/dev/null && echo "openssl=ok" || echo "openssl=MISSING"
command -v jq >/dev/null && echo "jq=ok" || echo "jq=MISSING"
[ -x "$(git --exec-path)/git-http-backend" ] \
&& echo "git-http-backend=ok" || echo "git-http-backend=MISSING"
' 2>&1) || { echo "::error::could not run $REF on $plat"; fail=1; continue; }
echo "$out"
if echo "$out" | grep -q MISSING; then
echo "$out" | grep MISSING | while read -r l; do
echo "::error::$plat is missing a required tool: $l"
done
fail=1
fi
done
[ "$fail" = 0 ] || {
echo "::error::The published image lost a tool the repo's gates depend on. Restore it in the Dockerfile — do not weaken this check."
exit 1
}
echo "✓ toolchain intact on both architectures"
+38 -8
View File
@@ -6,15 +6,20 @@
# The runner pod is non-root with all caps dropped, so it cannot apt/apk-add at # The runner pod is non-root with all caps dropped, so it cannot apt/apk-add at
# runtime -- the tools must be baked in here. # runtime -- the tools must be baked in here.
# #
# Build multi-arch (cluster is mixed amd64/arm64) and push to the Gitea registry: # PUBLISHING IS AUTOMATED. Run the "build image" workflow on GitHub with a tag
# (e.g. 12.11.1-ci4); it builds both architectures and pushes to
# ghcr.io/zegit-zoo/zegit-forgejo-runner. ./push-image.sh remains for building
# by hand. README.md explains why the registry moved off Gitea (Cloudflare's
# request-body cap returns 413 on a registry layer) and why the build cannot run
# on our own runners (host mode, all capabilities dropped).
# #
# docker buildx create --use --name zegit-ci 2>/dev/null || true # Then in flux-config set the image on BOTH deployment-zegit-runner and
# docker login gitea.langenoja.se # username + a Gitea personal access token # deployment-forgejo-runner, digest-pinned.
# docker buildx build --platform linux/amd64,linux/arm64 \
# -t gitea.langenoja.se/zegit-zoo/zegit-forgejo-runner:12.11.1-ci --push .
# #
# Then in flux-config set both containers' image: to the tag above and commit. # The image is NOT public. Pulls need a registry credential in
# Package visibility is PUBLIC, so the runner namespace pulls without a secret. # forgejo-runner-system -- github-registry-credentials for ghcr.io. An earlier
# version of this comment claimed the package was public and needed no secret;
# that was wrong for the Gitea registry too, which is private.
FROM code.forgejo.org/forgejo/runner:12.11.1 FROM code.forgejo.org/forgejo/runner:12.11.1
USER root USER root
@@ -33,6 +38,25 @@ USER root
# curl,tar -> setup-go / gitleaks download + extract their own arch-specific binaries # curl,tar -> setup-go / gitleaks download + extract their own arch-specific binaries
# make -> zegit Makefile targets # make -> zegit Makefile targets
# ca-certificates -> trusted roots for HTTPS to gitea/github/goproxy # ca-certificates -> trusted roots for HTTPS to gitea/github/goproxy
# openssl -> the release tag gate verifies the Control Plane's SIGNED quorum
# verdict (Ed25519 DSSE) with `openssl pkeyutl -verify -rawin`.
# Absent, that command failed "command not found" and the gate's
# `|| { echo ...; exit 1; }` reported it as "Quorum verdict
# signature FAILED verification" -- a TOOLING gap narrated as a
# SECURITY verdict, pointing the investigation at a perfectly
# valid signed approval instead of at this image. It blocked
# v0.0.134 and v0.0.135 on 2026-08-31.
# jq -> the same story one step earlier: the gate parsed that envelope
# with `jq -e`, and a missing jq was reported as "not a signed
# DSSE envelope". jq is the reflex tool for JSON in shell steps,
# so its absence keeps resurfacing as a misleading failure in
# whichever step reaches for it next.
# gcc,musl-dev -> C toolchain for cgo; `go test -race` (the Makefile test
# target) requires cgo, which needs a C compiler + libc headers
# (musl on Alpine)
# python3,py3-yaml -> the repo's own gates are Python: credentials-coverage.sh,
# validate-admission.sh and feature-lint.sh all shell out to
# python3, and credentials-coverage parses manifests with yaml
# #
# Go is intentionally NOT baked in: actions/setup-go fetches the pinned version # Go is intentionally NOT baked in: actions/setup-go fetches the pinned version
# per-run (node + tar are present), so the image needs no rebuild on Go bumps. # per-run (node + tar are present), so the image needs no rebuild on Go bumps.
@@ -45,7 +69,13 @@ RUN apk add --no-cache \
curl \ curl \
tar \ tar \
make \ make \
ca-certificates ca-certificates \
gcc \
musl-dev \
python3 \
py3-yaml \
openssl \
jq
# Match the runner pod's runAsUser (1000); keep the image non-root by default. # Match the runner pod's runAsUser (1000); keep the image non-root by default.
USER 1000 USER 1000