From cf8f09612fdec083e8683ddc1ad9e9c69f975370 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Wed, 11 Nov 2026 17:01:09 -0800 Subject: [PATCH] fix(ci): mirror index copy works on Gitea (pinned buildx, digest assertion via raw bytes) [skip ci] Run 5156 failed copying python:3.12-slim / golang:1.25 into Gitea: `PUT .../manifests/-staging: 404`, leaving a stray single-manifest -staging version. Reproduced on real Gitea with scratch packages: buildx <= 0.20.x (runner distro buildx is 0.15.1) pushes the children of a copied index to the TAG path; Gitea only records manifests PUT by digest, so the index PUT is rejected (MANIFEST_BLOB_UNKNOWN -> 404). buildx >= 0.24 pushes children by digest and works; Gitea then preserves the index digest, so the digest-equality assertion stays. - workflow: install a sha256-verified pinned buildx (v0.28.0) instead of setup-buildx-action (imagetools needs no builder) and pass it as $BUILDX - mirror-index-copy.sh: BUILDX/MIN_BUILDX guard refuses buildx < 0.24 before any registry traffic; digest computed as sha256 of the raw index bytes (buildx 0.28 ignores `inspect --format` here) - test: attestation-children index and old-buildx guard cases Merge with [skip ci] in the merge title: any push to main triggers the HMB image build. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_0185ukXjXyosSkunDsdWz3mb --- .gitea/scripts/mirror-index-copy.sh | 36 ++++++++++++++++++++++-- .gitea/scripts/test-mirror-index-copy.sh | 20 +++++++++++++ .gitea/workflows/mirror-base-image.yaml | 25 +++++++++++++--- 3 files changed, 74 insertions(+), 7 deletions(-) diff --git a/.gitea/scripts/mirror-index-copy.sh b/.gitea/scripts/mirror-index-copy.sh index 095bbcb..5331a06 100755 --- a/.gitea/scripts/mirror-index-copy.sh +++ b/.gitea/scripts/mirror-index-copy.sh @@ -18,18 +18,45 @@ ## at 4 cannot be rolled back by this script (the tag already moved) but it is ## loud and the same digest was already proven at step 2. ## +## Gitea compatibility (measured against repo.anhonesthost.net, 2026-11-11): +## * Gitea's registry accepts whole indexes (OCI index, Docker manifest list, +## attestation children, 14-16 children) and preserves the index digest +## byte-for-byte, so the digest-equality assertion below holds there too. +## * It rejects an index PUT whose child manifests are not already stored as +## by-DIGEST versions (404 MANIFEST_BLOB_UNKNOWN). buildx <= 0.20.x pushes the +## children of a copied index to the TAG path (works on registry:2, not on +## Gitea): the copy dies with `PUT .../manifests/: 404` and leaves a +## stray single-manifest version named . buildx >= 0.24 pushes children by +## digest and works. The distro buildx on the runner is 0.15.1, so the +## workflow installs a pinned newer one and this script refuses to run on an +## older one (MIN_BUILDX) instead of leaving a stray version behind. +## ## Env: REQUIRED_PLATFORMS (default "linux/amd64 linux/arm64"). -## IMAGETOOLS (default "docker buildx imagetools"), for tests/skopeo-free CI. +## BUILDX (default "docker buildx"): the buildx command; may be the path of +## a pinned docker-buildx binary. +## IMAGETOOLS (default "$BUILDX imagetools"). +## MIN_BUILDX (default 0.24.0; empty disables the version guard). ## (buildx talks plain HTTP to a localhost registry, so the test needs no flags.) set -euo pipefail SRC="${1:?usage: mirror-index-copy.sh SRC@sha256:DIGEST DST_REPO:TAG}" DST="${2:?usage: mirror-index-copy.sh SRC@sha256:DIGEST DST_REPO:TAG}" REQUIRED_PLATFORMS="${REQUIRED_PLATFORMS:-linux/amd64 linux/arm64}" -read -r -a IMGTOOLS <<<"${IMAGETOOLS:-docker buildx imagetools}" +BUILDX="${BUILDX:-docker buildx}" +MIN_BUILDX="${MIN_BUILDX-0.24.0}" +read -r -a BX <<<"$BUILDX" +read -r -a IMGTOOLS <<<"${IMAGETOOLS:-${BUILDX} imagetools}" fail() { echo "::error::mirror-index-copy: $*" >&2; exit 1; } +# Version guard: old buildx corrupts the copy on Gitea (see header). +if [ -n "$MIN_BUILDX" ]; then + have_bx=$("${BX[@]}" version 2>/dev/null | awk 'NR==1{print $2}'); have_bx="${have_bx#v}" + [[ "$have_bx" =~ ^[0-9]+\.[0-9]+\.[0-9]+ ]] || fail "cannot determine buildx version from '${BUILDX} version'" + [ "$(printf '%s\n%s\n' "$MIN_BUILDX" "${have_bx%%[-+]*}" | sort -V | head -1)" = "$MIN_BUILDX" ] \ + || fail "buildx ${have_bx} is too old (need >= ${MIN_BUILDX}): older buildx pushes index children by tag, which Gitea rejects" +fi + # Validate shapes (registry host may carry :port, e.g. localhost:5055). [[ "$SRC" =~ ^[a-z0-9][a-z0-9._:/-]*@sha256:[0-9a-f]{64}$ ]] || fail "bad source reference '${SRC}'" [[ "$DST" =~ ^[a-z0-9][a-z0-9._:/-]*:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail "bad destination '${DST}'" @@ -47,7 +74,10 @@ assert_ref() { for want in $REQUIRED_PLATFORMS; do case " $have " in *" $want "*) ;; *) fail "${ref} is missing ${want} (has: ${have})" ;; esac done - got=$("${IMGTOOLS[@]}" inspect "$ref" --format '{{.Manifest.Digest}}') || fail "cannot read digest of ${ref}" + # Digest = sha256 of the exact index bytes the registry serves (the same + # thing a client pins by). Not `--format {{.Manifest.Digest}}`: buildx 0.28 + # ignores --format here and prints the human summary instead. + got="sha256:$(printf '%s' "$raw" | sha256sum | cut -d' ' -f1)" [ "$got" = "$src_digest" ] || fail "${ref} digest ${got} != upstream ${src_digest}" echo "ok: ${ref} platforms=[${have}] digest=${got}" } diff --git a/.gitea/scripts/test-mirror-index-copy.sh b/.gitea/scripts/test-mirror-index-copy.sh index 8096310..fd6b533 100755 --- a/.gitea/scripts/test-mirror-index-copy.sh +++ b/.gitea/scripts/test-mirror-index-copy.sh @@ -48,6 +48,26 @@ if "$here/mirror-index-copy.sh" "${REG}/${run}/amd64@${SINGLE}" "${REG}/${run}/d "$here/mirror-index-copy.sh" "${REG}/x:tag" "${REG}/${run}/d:1" >/dev/null 2>&1 && bad "tag-only source accepted" || ok "non-digest source refused" "$here/mirror-index-copy.sh" "${REG}/x@sha256:$(printf 'a%.0s' {1..64})" 'bad dst' >/dev/null 2>&1 && bad "bad dst accepted" || ok "bad destination refused" +# 6. index WITH buildx attestation children (unknown/unknown), like Docker Hub +# official images: copy passes, digest preserved, attestations not counted as platforms +printf 'FROM scratch\nLABEL t=att-%s\n' "$run" >"$work/Dockerfile" +if docker buildx build -q --platform linux/amd64,linux/arm64 --provenance=mode=min --sbom=false \ + --output "type=image,name=${REG}/${run}/att:v,push=true" "$work" >/dev/null 2>&1; then + ATT=$(digest "${REG}/${run}/att:v") + if "$here/mirror-index-copy.sh" "${REG}/${run}/att@${ATT}" "${REG}/${run}/attdst:1" >"$work/o6" 2>&1 \ + && [ "$(digest "${REG}/${run}/attdst:1")" = "$ATT" ]; then ok "index with attestation children copied, digest preserved ($ATT)"; else bad "attestation-index copy"; cat "$work/o6"; fi +else bad "could not build attestation index"; fi + +# 7. buildx version guard: a too-old buildx is refused BEFORE any registry traffic +# (old buildx leaves a stray single-manifest tag on Gitea) +printf '#!/bin/sh\necho "github.com/docker/buildx v0.15.1 deadbeef"\n' >"$work/old-buildx"; chmod +x "$work/old-buildx" +if BUILDX="$work/old-buildx" "$here/mirror-index-copy.sh" "${REG}/${run}/multi@${MULTI}" "${REG}/${run}/olddst:1" >"$work/o7" 2>&1; then bad "old buildx accepted"; else + grep -q 'too old' "$work/o7" && ok "old buildx refused: $(grep -m1 '::error::' "$work/o7")" || { bad "old-buildx failed for the wrong reason"; cat "$work/o7"; } + docker buildx imagetools inspect "${REG}/${run}/olddst:1-staging" >/dev/null 2>&1 && bad "old buildx touched the registry" || ok "no staging tag created by refused run" +fi +MIN_BUILDX= BUILDX="$work/old-buildx" IMAGETOOLS="docker buildx imagetools" "$here/mirror-index-copy.sh" "${REG}/${run}/multi@${MULTI}" "${REG}/${run}/olddst2:1" >/dev/null 2>&1 \ + && ok "MIN_BUILDX= disables the guard" || bad "guard not disabled by empty MIN_BUILDX" + # 5. optional: real upstream digest if [ -n "${MIRROR_TEST_UPSTREAM:-}" ]; then u="${MIRROR_TEST_UPSTREAM}" diff --git a/.gitea/workflows/mirror-base-image.yaml b/.gitea/workflows/mirror-base-image.yaml index a8796e3..e8c0ea7 100644 --- a/.gitea/workflows/mirror-base-image.yaml +++ b/.gitea/workflows/mirror-base-image.yaml @@ -55,9 +55,26 @@ jobs: echo "base-image: ${HUB_TAG} -> ${ref} (digest from registry-1.docker.io)" echo "ref=${ref}" >> "$GITHUB_OUTPUT" - # imagetools needs buildx; same setup as the build workflows. - - name: Set up Docker Buildx - uses: https://github.com/docker/setup-buildx-action@v3 + # imagetools needs a NEW buildx. The runner's distro buildx is 0.15.1, which + # pushes the children of a copied index to the TAG path: Gitea answers the + # index PUT with 404 and leaves a stray single-manifest -staging + # version (run 5156). buildx >= 0.24 pushes children by digest and works + # (measured on repo.anhonesthost.net). Pin one, verified by sha256 + # (linux-amd64 binary of the v0.28.0 release); mirror-index-copy.sh also + # refuses to run on a buildx older than 0.24. + - name: Install pinned buildx + env: + BUILDX_VERSION: v0.28.0 + BUILDX_SHA256: 696bc104bac3bb708eff1af3f8bbc09fda0fd88f5757c1f9b404a35117889224 + run: | + set -euo pipefail + dir="${RUNNER_TEMP:-/tmp}/pinned-buildx"; mkdir -p "$dir" + curl -fsSL --retry 3 --max-time 120 -o "$dir/docker-buildx" \ + "https://github.com/docker/buildx/releases/download/${BUILDX_VERSION}/buildx-${BUILDX_VERSION}.linux-amd64" + echo "${BUILDX_SHA256} $dir/docker-buildx" | sha256sum -c - + chmod +x "$dir/docker-buildx" + "$dir/docker-buildx" version + echo "BUILDX=$dir/docker-buildx" >> "$GITHUB_ENV" # The registry token is written ONLY by this trusted x86 runner step # (unchanged from before; no new secret exposure). @@ -69,7 +86,7 @@ jobs: password: ${{ secrets.CI_TOKEN }} # Registry-to-registry copy of the WHOLE upstream index (all platforms, - # incl. linux/arm64). The old pull/tag/push re-published only the runner's + # incl. linux/arm64; $BUILDX = the pinned buildx from the step above). The old pull/tag/push re-published only the runner's # amd64 manifest. mirror-index-copy.sh stages to -staging, asserts # amd64+arm64 present and index digest == the Docker-Hub-reported digest, # and only then moves the real tag, so a bad copy never replaces a good -- 2.52.0