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"