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
agent b2d4e02c6f feat: add push-image.sh for publishing the runner image
Publishing this image by hand hit four obstacles in a row, each of which
recurs on every rebuild:

  413 Payload Too Large      gitea.langenoja.se is behind Cloudflare, which
                             caps request bodies below a registry layer
  Docker Hub auth refusal    the bare MagicDNS name was parsed as a Docker Hub
                             NAMESPACE — a registry host is only recognised
                             when it contains a dot or a colon
  insufficient_scope         the PAT needs write:package, not read:package
  wrong-arch pulls           forgejo-runner runs on arm64 and zegit-runner on
                             amd64, neither pins a nodeSelector, and both use
                             this one tag

The image reference must keep the hostname gitea.langenoja.se, because that is
what the cluster pulls. So the script keeps the NAME and changes only the
ROUTE: it pins that hostname to the Istio gateway on the LAN for the duration
of the push. The gateway serves a *.langenoja.se wildcard cert, so TLS
validates normally — no insecure-registries needed.

The pin carries a marker comment, which makes three cases distinguishable: a
leftover from a run that died before its trap fired (reuse, then remove), a
pin the operator added deliberately (never touch), and one pointing somewhere
other than the gateway (refuse to run). Without the marker a leftover pin was
indistinguishable from a deliberate one, so it survived every later run and
had to be reverted by hand. `--unpin` clears a leftover without building, and
the trap covers INT and TERM as well as EXIT.

It also verifies the published manifest really lists two architectures rather
than trusting the push's exit code.
2026-08-20 14:22:56 +02:00
agent afed70586a fix: add git-daemon so git-http-backend exists in the image
Alpine splits git-http-backend OUT of the `git` package: git alone ships
git-remote-http/https but not the CGI backend. The runner therefore had git
2.52.0 and no git-http-backend, so zegit's internal/forgeprobe smart-HTTP tests
failed at request time with "fork/exec git-http-backend: no such file or
directory" and reported three FAILURES for a missing tool.

That turned Gitea CI red on every zegit master commit while GitHub stayed
green, for hours, with nobody looking — GitHub is the release oracle, which is
exactly why a red mirror is invisible.

Verified against the real base image, not just alpine:3 — on
code.forgejo.org/forgejo/runner:12.11.1 (Alpine 3.23), `apk add git git-daemon`
puts git-http-backend at /usr/libexec/git-core/git-http-backend, and
`git --exec-path` resolves to that directory, which is the lookup the test
harness performs.
2026-08-20 13:37:22 +02:00
3 changed files with 194 additions and 0 deletions

No files matched your search

+8
View File
@@ -21,6 +21,13 @@ USER root
# nodejs -> JS actions runtime (actions/checkout, actions/setup-go, ...)
# git -> checkout
# git-daemon -> provides git-http-backend, which Alpine splits OUT of the `git`
# package (git alone ships git-remote-http/https but NOT the CGI
# backend). Without it, internal/forgeprobe's smart-HTTP tests died
# with "fork/exec git-http-backend: no such file or directory" and
# reported three FAILURES for a missing tool — which turned Gitea CI
# red on every zegit master commit while GitHub stayed green, for
# hours, unnoticed.
# bash -> `shell: bash` steps and many action entrypoints
# coreutils-> GNU userland (alpine busybox tools trip up some scripts)
# curl,tar -> setup-go / gitleaks download + extract their own arch-specific binaries
@@ -32,6 +39,7 @@ USER root
RUN apk add --no-cache \
nodejs \
git \
git-daemon \
bash \
coreutils \
curl \
+43
View File
@@ -37,3 +37,46 @@ reconciles). Do this only *after* the image exists, or the pod ImagePullBackOffs
`<base-runner-version>-ci` (e.g. `12.11.1-ci`). Must satisfy the cluster's
Gatekeeper `enforce-semantic-image-tags` constraint — never `:latest`.
## Publishing a new image
./push-image.sh 12.11.1-ci4
The registry hostname in the image reference must stay `gitea.langenoja.se`,
because that is what the cluster pulls. The script therefore keeps the NAME and
changes only the ROUTE: it pins that hostname to the Istio gateway on the LAN
(`10.40.0.10`) for the duration of the push, then removes the pin.
That matters because `gitea.langenoja.se` resolves to Cloudflare, which caps
request bodies below a registry layer and returns **413 Payload Too Large**. The
gateway serves a `*.langenoja.se` wildcard cert, so TLS still validates normally
— no `insecure-registries`, no `--tls-verify=false`.
If a run is killed before it can clean up, the pin is left behind. It carries a
marker comment, so the next run recognises it as its own and removes it — or you
can clear it directly:
./push-image.sh --unpin
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.
Multi-arch is not optional. `forgejo-runner` runs on arm64 nodes and
`zegit-runner` on amd64, neither deployment sets a `nodeSelector`, and both use
this single tag.
Two traps the script exists to prevent: pushing to the bare MagicDNS name
(`gitea-system-gitea-tailscale-http`) makes Docker treat it as a **Docker Hub
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.
Executable
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# push-image.sh — build and publish the runner toolchain image, multi-arch.
#
# Why this script exists: doing it by hand hit four obstacles in a row.
#
# 1. 413 Payload Too Large. gitea.langenoja.se is behind Cloudflare, which
# caps request bodies well below a registry layer. Fixed by pinning the
# hostname to the Istio gateway on the LAN (below) so the push never
# traverses Cloudflare.
# 2. Docker only treats a name as a registry HOST if it contains a dot or a
# colon. Pushing to the bare MagicDNS name `gitea-system-gitea-tailscale-http`
# was parsed as a Docker Hub NAMESPACE, so Docker Hub refused the auth.
# 3. The Gitea PAT needs `write:package`. `read:package` is pull-only.
# 4. Multi-arch is required: forgejo-runner runs on arm64 nodes and
# zegit-runner on amd64, neither deployment sets a nodeSelector, and both
# use this one tag.
#
# The hostname MUST stay gitea.langenoja.se in the image reference, because that
# is what the cluster pulls. So we keep the name and change only the ROUTE, via
# an /etc/hosts pin that is removed again on exit.
#
# The Istio gateway serves a *.langenoja.se wildcard cert, so TLS validates
# normally — no insecure-registries, no --tls-verify=false.
set -euo pipefail
TAG="${1:-}"
GATEWAY_IP="${GATEWAY_IP:-10.40.0.10}"
REGISTRY="gitea.langenoja.se"
REPO="$REGISTRY/zegit-zoo/zegit-forgejo-runner"
MARKER="# added by push-image.sh"
HOSTS_LINE="$GATEWAY_IP $REGISTRY $MARKER"
die() { echo "error: $*" >&2; exit 1; }
if [ "$TAG" = "--unpin" ]; then
MARKER="# added by push-image.sh"
if grep -qF "$MARKER" /etc/hosts 2>/dev/null; then
sudo sed -i "\|${MARKER}\$|d" /etc/hosts; echo "removed the leftover pin"
else
echo "no pin of ours in /etc/hosts; nothing to do"
fi
exit 0
fi
[ -n "$TAG" ] || die "usage: $0 <tag> | --unpin (e.g. $0 12.11.1-ci4)"
command -v docker >/dev/null || die "docker not on PATH"
# Three cases, and they must not be confused:
# OURS — a line carrying our marker. A LEFTOVER from a run that died
# before its trap fired. Reuse it, and DO remove it on exit.
# THEIRS — a line without our marker. Deliberate; never touch it, and
# refuse if it points somewhere other than the gateway.
# ABSENT — add ours.
# The earlier version could not tell OURS from THEIRS, so a leftover pin was
# treated as deliberate and survived every subsequent run. That is the bug that
# made an operator revert /etc/hosts by hand.
PIN_OWNED=0
if grep -qF "$MARKER" /etc/hosts 2>/dev/null; then
PIN_OWNED=1
echo "found a leftover pin from an interrupted run; reusing and will clean it up"
elif grep -qE "^[^#]*[[:space:]]$REGISTRY([[:space:]]|\$)" /etc/hosts 2>/dev/null; then
existing=$(grep -E "^[^#]*[[:space:]]$REGISTRY([[:space:]]|\$)" /etc/hosts | head -1)
[ "$(echo "$existing" | awk '{print $1}')" = "$GATEWAY_IP" ] \
|| die "/etc/hosts already pins $REGISTRY elsewhere (not ours, not the gateway): $existing"
echo "an unmarked pin to the gateway already exists; leaving it exactly as found"
fi
# Removes ONLY lines carrying our marker, so a deliberate pin is never touched.
unpin() {
if grep -qF "$MARKER" /etc/hosts 2>/dev/null; then
sudo sed -i "\|${MARKER}\$|d" /etc/hosts
echo "removed the /etc/hosts pin"
fi
}
cleanup() { [ "$PIN_OWNED" = "1" ] && unpin; }
# INT and TERM too: EXIT alone leaves the pin behind on Ctrl-C in some shells.
trap cleanup EXIT INT TERM
if [ "$PIN_OWNED" = "0" ] && ! grep -qE "^[^#]*[[:space:]]$REGISTRY([[:space:]]|\$)" /etc/hosts 2>/dev/null; then
echo "pinning $REGISTRY -> $GATEWAY_IP (bypasses Cloudflare; removed on exit)"
echo "$HOSTS_LINE" | sudo tee -a /etc/hosts >/dev/null
PIN_OWNED=1
fi
# Prove the route before building anything: 401 means the registry answered and
# wants auth, which is success here. A timeout means the pin or the LAN is wrong.
code=$(curl -sS -o /dev/null -w '%{http_code}' --max-time 20 "https://$REGISTRY/v2/" || true)
case "$code" in
401|200) echo "registry reachable via the pin (HTTP $code)" ;;
*) die "registry not reachable via $GATEWAY_IP (HTTP ${code:-timeout}). Is this host on the LAN?" ;;
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"
# 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" \
|| 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"
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
published $REPO:$TAG
Next, in flux-config, bump BOTH deployments that use this image:
applications/forgejo-runner/base/deployment-zegit-runner.yaml
applications/forgejo-runner/base/deployment-forgejo-runner.yaml
(markus-runner uses markus/gitea-runner and is unaffected.)
EOF