#!/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 | --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 <