name: build image (gitea) # Publishes the runner toolchain image from the SELF-HOSTED Gitea runners, via # the in-cluster rootless buildkitd — the same path zegit's own images already # use (zegit .github/workflows/image-gitea.yml). # # Why this exists alongside build-image.yml: that workflow is GitHub-only, so # the image the Gitea runners are BUILT FROM could only be produced by GitHub. # When GitHub Actions became unavailable on 2026-08-29 that circularity bit — # the fix for a missing tool in this very image (openssl/jq, whose absence # blocked v0.0.134 and v0.0.135) could not be published on the forge the project # is moving to. This removes the dependency. # # build-image.yml's header says the runners cannot build images because rootless # buildah needs user namespaces the pod does not have. That is true of buildah # IN THE JOB, and irrelevant here: buildkitd is its own hardened workload in # buildkit-system, and this job only speaks to it over the network with # buildctl. Nothing is built inside the runner container. on: workflow_dispatch: inputs: tag: description: 'Image tag, e.g. 12.11.1-ci6 (upstream runner version + ci revision)' required: true type: string push: branches: [master] paths: - 'Dockerfile' - '.github/workflows/build-image-gitea.yml' env: BUILDKIT_HOST: tcp://buildkitd.buildkit-system.svc.cluster.local:1234 BUILDKIT_VERSION: v0.32.2 REGISTRY: gitea.langenoja.se IMAGE_PATH: zegit-zoo/zegit-forgejo-runner jobs: build: # Gitea-only: buildkitd is an in-cluster service that GitHub runners cannot # reach. build-image.yml still covers the GitHub/ghcr.io side. if: ${{ github.server_url != 'https://github.com' }} runs-on: ubuntu-latest timeout-minutes: 45 steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: persist-credentials: false # Workspace-local because the runner is non-root and cannot write to # /usr/local/bin. The smoke test uses the FULL PATH deliberately: # $GITHUB_PATH only takes effect for SUBSEQUENT steps, so a bare # `buildctl --version` here would be command-not-found. - name: Install buildctl run: | set -euo pipefail case "$(uname -m)" in x86_64) arch=amd64;; aarch64) arch=arm64;; *) echo "::error::unsupported arch $(uname -m)"; exit 1;; esac mkdir -p "$GITHUB_WORKSPACE/.bin" curl -fsSL "https://github.com/moby/buildkit/releases/download/${BUILDKIT_VERSION}/buildkit-${BUILDKIT_VERSION}.linux-${arch}.tar.gz" \ | tar -xz -C "$GITHUB_WORKSPACE/.bin" --strip-components=1 bin/buildctl echo "$GITHUB_WORKSPACE/.bin" >> "$GITHUB_PATH" "$GITHUB_WORKSPACE/.bin/buildctl" --version - name: buildkitd reachable run: buildctl debug workers # A master push VALIDATES that 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: | set -euo pipefail if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then echo "push=true" >> "$GITHUB_OUTPUT" echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT" else echo "push=false" >> "$GITHUB_OUTPUT" echo "tag=ci-validation" >> "$GITHUB_OUTPUT" fi # Refuse a publish that cannot complete, rather than reporting success # having pushed nothing. A validation run needs no credentials. # The Gitea Actions token cannot write packages; this needs an explicit # package-write PAT (read:package is pull-only). - name: Registry login if: ${{ steps.meta.outputs.push == 'true' }} env: TOKEN: ${{ secrets.ZEGIT_CI_PACKAGE_TOKEN }} run: | set -euo pipefail if [ -z "${TOKEN:-}" ]; then echo "::error::ZEGIT_CI_PACKAGE_TOKEN is not visible to this run. It is the org-scoped package-write token every zegit-zoo image workflow uses (docs: flux-config docs/rotation/forge-secrets.yaml); this repository must be able to read the org secret, and the name must match." exit 1 fi mkdir -p "$HOME/.docker" printf '{"auths":{"%s":{"auth":"%s"}}}\n' \ "$REGISTRY" "$(printf 'zegit-zoo:%s' "$TOKEN" | base64 -w0)" \ > "$HOME/.docker/config.json" - name: Build multi-arch run: | set -euo pipefail REF="${REGISTRY}/${IMAGE_PATH}:${{ steps.meta.outputs.tag }}" # No --output on a validation run: buildctl v0.32 has no "cacheonly" # exporter (run 16, 2026-09-08: 'exporter "cacheonly" could not be # found'), so every push-triggered validation since this workflow # was added had failed — silently, since nothing depended on it. A # build without an exporter still builds every stage on both arches. if [ "${{ steps.meta.outputs.push }}" = "true" ]; then buildctl build --progress=plain \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --opt platform=linux/amd64,linux/arm64 \ --output "type=image,name=${REF},push=true" else buildctl build --progress=plain \ --frontend dockerfile.v0 \ --local context=. \ --local dockerfile=. \ --opt platform=linux/amd64,linux/arm64 fi # The image is a TOOLCHAIN, so "it built" is not the property that matters # — "it still contains what the gates shell out to" is. build-image.yml # asserts that with `docker run`, which does not exist on these runners; # buildkit can do it directly by building a stage FROM the published image # whose RUN fails when a tool is absent. That is strictly better: buildkit # runs it natively per platform, so a package that resolves on amd64 but # not on arm64 cannot reach half the nodes unnoticed. # # openssl and jq are on this list because their absence blocked v0.0.134 # and v0.0.135: the release tag gate shells out to both to verify the # Control Plane's signed quorum verdict, and a missing tool was reported # as "verdict not signed" and "signature FAILED verification" — a tooling # gap narrated as a security verdict. Extend this list whenever a gate # grows a shell dependency; it only covers what someone remembered to add. - name: Assert the published image carries the toolchain the gates need if: ${{ steps.meta.outputs.push == 'true' }} run: | set -euo pipefail REF="${REGISTRY}/${IMAGE_PATH}:${{ steps.meta.outputs.tag }}" work=$(mktemp -d) { echo "FROM ${REF}" echo 'RUN set -eu; \' echo ' python3 -c "import yaml, sys; print(\"python3+yaml\", sys.version.split()[0])"; \' echo ' for t in gcc node bash make curl tar openssl jq chromium-browser psql; do \' echo ' command -v "$t" >/dev/null || { echo "MISSING: $t" >&2; exit 1; }; \' echo ' echo "ok: $t"; \' echo ' done; \' echo ' test -x "$(git --exec-path)/git-http-backend" || { echo "MISSING: git-http-backend" >&2; exit 1; }; \' echo ' echo "ok: git-http-backend"' } > "$work/Dockerfile" cat "$work/Dockerfile" buildctl build --progress=plain \ --frontend dockerfile.v0 \ --local context="$work" \ --local dockerfile="$work" \ --opt platform=linux/amd64,linux/arm64 echo "✓ toolchain intact on linux/amd64 and linux/arm64" - name: Summary run: | if [ "${{ steps.meta.outputs.push }}" = "true" ]; then echo "published ${REGISTRY}/${IMAGE_PATH}:${{ steps.meta.outputs.tag }} (amd64+arm64), toolchain asserted on both" else echo "validation only — the Dockerfile builds on both arches; nothing published" fi