Compare commits

..
Author SHA1 Message Date
agent 71ba76943a fix: compose the manifest with imagetools, not docker manifest
A per-arch push came back as "is a manifest list". Since Buildx 0.10,
provenance and SBOM attestations are ON by default, so even a SINGLE-platform
build publishes an OCI index (image + attestation) rather than a plain
manifest — and `docker manifest create` refuses to nest an index inside an
index.

Two changes, either of which alone would fix it, kept together because they
fail in different directions:

  --provenance=false --sbom=false   keeps each per-arch push a plain manifest.
                                    Probed for support first, so an older
                                    docker that lacks the flags still works.
  imagetools create                 composes correctly whether the sources are
                                    plain manifests OR indexes, so attestations
                                    reappearing later cannot break it again.
                                    Falls back to docker manifest when buildx
                                    is unavailable, with the actionable hint in
                                    the failure message.

Verification now accepts either inspector's output and asserts both platforms
are present by name.
2026-08-20 14:28:18 +02:00
4 changed files with 43 additions and 176 deletions

No files matched your search

-137
View File
@@ -1,137 +0,0 @@
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.
- 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"
[ -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"
+7 -12
View File
@@ -6,20 +6,15 @@
# 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.
#
# 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).
# Build multi-arch (cluster is mixed amd64/arm64) and push to the Gitea registry:
#
# Then in flux-config set the image on BOTH deployment-zegit-runner and
# deployment-forgejo-runner, digest-pinned.
# docker buildx create --use --name zegit-ci 2>/dev/null || true
# docker login gitea.langenoja.se # username + a Gitea personal access token
# docker buildx build --platform linux/amd64,linux/arm64 \
# -t gitea.langenoja.se/zegit-zoo/zegit-forgejo-runner:12.11.1-ci --push .
#
# The image is NOT public. Pulls need a registry credential in
# 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.
# Then in flux-config set both containers' image: to the tag above and commit.
# Package visibility is PUBLIC, so the runner namespace pulls without a secret.
FROM code.forgejo.org/forgejo/runner:12.11.1
USER root
+7 -21
View File
@@ -61,6 +61,13 @@ can clear it directly:
A pin you added yourself (no marker) is never touched, and the script refuses to
run if an unmarked pin points somewhere other than the gateway.
If a per-arch push reports *"is a manifest list"*, that is BuildKit's provenance
attestation: since Buildx 0.10 it is on by default, so even a single-platform
build publishes an OCI **index** rather than a plain manifest, and
`docker manifest create` refuses to nest one. The script builds with
`--provenance=false --sbom=false` and composes with `docker buildx imagetools
create`, which handles either shape.
Requirements: a Gitea PAT with **`write:package`** (`read:package` is pull-only),
LAN reachability of the gateway, and qemu/binfmt registered for the arm64 build.
@@ -73,24 +80,3 @@ Two traps the script exists to prevent: pushing to the bare MagicDNS name
namespace**, since a registry host is only recognised when it contains a dot or
colon; and `docker push $_` pushes `.`, because `$_` is the last ARGUMENT of the
build command, not its tag.
### Automated builds
`.github/workflows/build-image.yml` builds and publishes to
**ghcr.io/zegit-zoo/zegit-forgejo-runner** on GitHub.
Run it from the Actions tab (**build image** → Run workflow) with the tag, e.g.
`12.11.1-ci4`. A push to `master` that touches the Dockerfile only VALIDATES
that both architectures still build — it publishes nothing, because guessing a
tag from a branch push would silently mint versions.
It builds on GitHub rather than our own Gitea Actions because the runners are
host mode (`ubuntu-latest:host`, no job containers) and drop ALL capabilities
with `allowPrivilegeEscalation: false`; rootless buildah needs user namespaces
for its RUN steps, so building there would mean weakening that posture. It
publishes to ghcr.io rather than the Gitea registry because
`gitea.langenoja.se` sits behind Cloudflare, whose body cap returns **413** on a
registry layer — and the cluster already pulls zegit's own images from ghcr.io
with an existing `github-registry-credentials` pull secret.
`push-image.sh` remains for building by hand when you need it.
+29 -6
View File
@@ -91,23 +91,46 @@ esac
docker login "$REGISTRY" || die "login failed — the PAT needs write:package, not read:package"
# --provenance=false --sbom=false: since Buildx 0.10 attestations are ON by
# default, so even a SINGLE-platform build publishes an OCI INDEX (image +
# attestation) instead of a plain manifest. `docker manifest create` then
# refuses it with "is a manifest list". Suppressing them keeps each arch a
# plain manifest. Harmless if the flags are unsupported — we probe first.
PROV_FLAGS=""
if docker build --help 2>/dev/null | grep -q -- '--provenance'; then
PROV_FLAGS="--provenance=false --sbom=false"
fi
for arch in amd64 arm64; do
echo "building linux/$arch"
docker build --platform "linux/$arch" -t "$REPO:$TAG-$arch" . \
|| die "build failed for $arch (arm64 needs qemu/binfmt registered)"
# shellcheck disable=SC2086 # PROV_FLAGS is deliberately word-split
docker build $PROV_FLAGS --platform "linux/$arch" -t "$REPO:$TAG-$arch" . \
|| die "build failed for $arch (arm64 needs qemu/binfmt: docker run --privileged --rm tonistiigi/binfmt --install arm64)"
# Explicit tag, never $_ — in zsh $_ is the last ARGUMENT, which here is '.'
docker push "$REPO:$TAG-$arch" || die "push failed for $arch"
done
# `docker buildx imagetools create` rather than `docker manifest create`: it
# composes correctly whether the sources are plain manifests OR indexes, so it
# does not fail with "is a manifest list" when attestations sneak back in. It
# is a client-side registry operation, so it honours the /etc/hosts pin too.
if docker buildx imagetools create --help >/dev/null 2>&1; then
docker buildx imagetools create -t "$REPO:$TAG" "$REPO:$TAG-amd64" "$REPO:$TAG-arm64" \
|| die "imagetools create failed"
else
docker manifest rm "$REPO:$TAG" >/dev/null 2>&1 || true
docker manifest create "$REPO:$TAG" --amend "$REPO:$TAG-amd64" --amend "$REPO:$TAG-arm64"
docker manifest create "$REPO:$TAG" --amend "$REPO:$TAG-amd64" --amend "$REPO:$TAG-arm64" \
|| die "manifest create failed — if it says \"is a manifest list\", rebuild with --provenance=false"
docker manifest push "$REPO:$TAG"
fi
# Verify by reading it back, rather than trusting the push's exit code.
echo "verifying the published manifest lists both architectures"
got=$(docker manifest inspect "$REPO:$TAG" | grep -o '"architecture": *"[a-z0-9]*"' | sort -u | wc -l)
[ "$got" -ge 2 ] || die "published manifest does not list two architectures"
docker manifest inspect "$REPO:$TAG" | grep -o '"architecture": *"[a-z0-9]*"' | sort -u
inspect=$(docker buildx imagetools inspect "$REPO:$TAG" 2>/dev/null || docker manifest inspect "$REPO:$TAG")
for want in amd64 arm64; do
echo "$inspect" | grep -q "$want" || die "published manifest is missing linux/$want"
done
echo "$inspect" | grep -iE 'platform|architecture' | sort -u | head -6
cat <<EOF