Files
zegit-forgejo-runner/.github/workflows/build-image.yml
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

148 lines
6.8 KiB
YAML

name: build image
# Publishes the runner toolchain image to ghcr.io.
#
# Why GitHub rather than our own Gitea Actions: the runners are host mode
# (`ubuntu-latest:host`, no job containers) and their container drops ALL
# capabilities with allowPrivilegeEscalation:false. Rootless buildah needs
# newuidmap/user namespaces for its RUN steps, so building images there would
# mean weakening the posture that hardening established. GitHub runners have
# docker + buildx + QEMU already.
#
# Why ghcr.io rather than the Gitea registry: gitea.langenoja.se sits behind
# Cloudflare, whose request-body cap returns 413 on a registry layer. The
# cluster already pulls zegit's own images from ghcr.io and the runner
# deployments already carry a github-registry-credentials pull secret, so this
# reuses an established, trusted path instead of inventing one.
on:
workflow_dispatch:
inputs:
tag:
description: 'Image tag, e.g. 12.11.1-ci4 (upstream runner version + ci revision)'
required: true
type: string
push:
branches: [master]
paths:
- 'Dockerfile'
- '.github/workflows/build-image.yml'
permissions:
contents: read
packages: write
env:
IMAGE: ghcr.io/${{ github.repository }}
jobs:
build:
# GitHub-only: the Gitea mirror has no ghcr.io and no GITHUB_TOKEN.
if: ${{ github.server_url == 'https://github.com' }}
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
# arm64 is not optional: forgejo-runner runs on arm64 nodes and
# zegit-runner on amd64, neither deployment sets a nodeSelector, and both
# use this one tag.
- uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# A master push VALIDATES the Dockerfile still builds on both arches but
# publishes nothing — only an explicit dispatch names a tag and pushes.
# Guessing a tag from a branch push would silently mint versions.
- id: meta
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "push=true" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE}:${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
else
echo "push=false" >> "$GITHUB_OUTPUT"
echo "tags=${IMAGE}:ci-validation" >> "$GITHUB_OUTPUT"
fi
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ steps.meta.outputs.push }}
tags: ${{ steps.meta.outputs.tags }}
provenance: false
sbom: false
# Read the published index back rather than trusting the push's exit
# code, and fail if either architecture is absent — a single-arch image
# under this tag would CrashLoop on half the nodes.
- name: Verify both architectures were published
if: ${{ steps.meta.outputs.push == 'true' }}
run: |
set -euo pipefail
out=$(docker buildx imagetools inspect "${IMAGE}:${{ inputs.tag }}")
echo "$out"
for want in amd64 arm64; do
echo "$out" | grep -q "linux/$want" || { echo "::error::published image is missing linux/$want"; exit 1; }
done
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"