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.
148 lines
6.8 KiB
YAML
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"
|