Skip to content

ci: pull CI and base images from a GHCR mirror instead of Docker Hub - #801

Merged
ValentinKolb merged 5 commits into
mainfrom
ci/ghcr-image-mirror
Oct 10, 2026
Merged

ValentinKolb merged 5 commits into
mainfrom
ci/ghcr-image-mirror

Conversation

@ValentinKolb

@ValentinKolb ValentinKolb commented Oct 9, 2026 •

Copy link
Copy Markdown
Collaborator

Outcome

CI, the nightly and release workflows and the image builds no longer pull anything from Docker Hub. Every Docker Hub image they use is pinned by digest in .github/mirror-images.txt and pulled from the public mirror ghcr.io/k2b-dev/mirror/<name>:<tag>@sha256:…, which serves the same digest, so provenance and reproducibility stay intact.

Root cause

The merge-group runs of release PR #800 failed twice on Docker Hub's anonymous pull limit (429 toomanyrequests):

Both times images, grids (1-3), integration and packed failed: the image builds (oven/bun base images, the docker/dockerfile frontend, moby/buildkit for setup-buildx, BuildKit's default SBOM generator docker/buildkit-syft-scanner) and the service containers and docker run steps. A release pulls these images roughly 27 x 2 times, so retries cannot fix it.

How it works

  • .github/mirror-images.txt lists every Docker Hub image as <name>:<tag>@sha256:<digest> (12 entries).
  • .github/mirror-images.sh copies each listed digest the mirror does not serve yet, manifest list unchanged (docker buildx imagetools create), and verifies that every digest is pullable without credentials (anonymous GHCR token + manifest HEAD). Registry requests are retried, a failed image does not stop the others, and the script is idempotent and resumable.
  • .github/workflows/mirror-images.yml runs the script; ci.yml calls it as a new mirror job that every job pulling an image needs, so a PR that adds or bumps an image mirrors it first. The same workflow runs weekly, on demand and on main pushes that change the list.
  • Service containers, setup-buildx's BuildKit image, the SBOM generator and the # syntax= / FROM lines of every Dockerfile name the mirror reference at the listed digest.
  • Steps and test fixtures that start an image by its Docker Hub name (docker run nats:2.14.3-alpine, the Files Filegate suites, the Assistant artifact suite's Postgres, the packed runtime check) get it from .github/pull-images.sh, which pulls the mirrored digest and tags it locally with the Docker Hub name; it refuses an unlisted Docker Hub image.
  • New repository rule scripts/checks/mirror-images.ts (+ tests) fails when a workflow or Dockerfile would pull a Docker Hub image directly (including the default SBOM generator and docker:// steps), when a mirror reference carries a different digest than the list, and when a list entry is unused.

Bootstrap order (one time)

  1. This PR's own mirror job copies all 12 digests into 9 new packages with GITHUB_TOKEN (packages: write). If Docker Hub answers 429 partway, re-running only the mirror job continues where it stopped.
  2. Every package must be anonymously pullable. If the job reports "cannot be pulled without credentials", a maintainer makes the package public once (Package settings > Change visibility > Public), then re-runs the mirror job:

Status: done. The first mirror run of this PR (run 38001105385) copied all 12 digests without a Docker Hub 429, and GHCR created the packages publicly readable, so no visibility change was needed. All 12 digests answer an anonymous token request and manifest HEAD with 200.

After that, a run only verifies the mirror and never touches Docker Hub.

Docs

  • contributing/testing.md: new section "Where CI images come from" (how images are consumed, the mirror job, the check, how to add or bump an image, fork PRs, trying it against a local registry).
  • contributing/release-process.md: release image builds take base images, frontend, BuildKit and SBOM generator from the mirror.
  • operations/build-and-deploy.md: building the monorepo Dockerfiles from source needs ghcr.io, no Docker Hub or GitHub login.

Verification

  • bun run check: 24/24 rules pass, including the new mirror-images rule.
  • bun test scripts/checks/mirror-images.test.ts: 6 pass.
  • git diff --check clean; bash -n on both scripts.
  • Before this PR, none of the 9 mirror packages existed (anonymous token request answered 403 for each); after the PR's mirror job, all 12 digests are anonymously pullable (token 200, manifest HEAD 200).
  • Reviewed by Claude and Codex; findings fixed in the second commit.

Residual risks

  • CI and releases now depend on ghcr.io instead of Docker Hub for these images; a GHCR outage blocks them the same way.
  • A package that is ever switched back to private, or a deleted version, fails the mirror job and the weekly run loudly rather than silently falling back to Docker Hub.
  • A fork PR that adds or bumps an image cannot write packages: its mirror job fails until a maintainer runs Mirror images on a branch of this repository with the same list.
  • Dependabot does not bump these images; bumps go through the list (documented).
  • The mirror-images check cannot see which job runs a test file that starts its own container; such an image must be added to the pull steps in ci.yml and nightly.yml by hand (documented).
  • The copy itself still talks to Docker Hub anonymously, so the bootstrap and future bumps can hit 429; the job is resumable for that reason.

The merge-group run of release PR #800 failed because Docker Hub rate-limited
anonymous pulls (429 toomanyrequests): the image builds (oven/bun base, the
docker/dockerfile frontend, moby/buildkit for setup-buildx) and the service
containers and `docker run` steps of packed, grids and integration. A release
pulls these images about 27 x 2 times, so retries cannot fix it.

Every Docker Hub image that CI, the release workflows or the image builds use
is now pinned by digest in .github/mirror-images.txt and copied, manifest list
unchanged, to the public ghcr.io/k2b-dev/mirror by .github/mirror-images.sh.
Service containers, BuildKit and the Dockerfiles name the mirror at the same
digest, so provenance and reproducibility stay intact. Steps and test fixtures
that start an image by its Docker Hub name get it from pull-images.sh, which
pulls the mirrored digest and tags it locally with that name.

CI's new `mirror` job runs the copy before every job that pulls an image, so a
pull request that adds or bumps an image mirrors it first; otherwise it only
verifies that each digest is present and anonymously pullable. The same
workflow runs weekly and on demand. The `mirror-images` check fails when a
workflow or Dockerfile would pull from Docker Hub, when a mirror reference
does not carry the listed digest, and when a list entry is unused.
The Assistant artifact suite starts `postgres:17-alpine` by name. It found
that tag only because the integration service container used the same
name; with the service on the mirror reference it would pull from Docker
Hub again. The integration pull steps of ci.yml and nightly.yml now pull
it, and the suite's rsql image, through pull-images.sh.

BuildKit's default SBOM generator, docker/buildkit-syft-scanner, comes
from Docker Hub, and every release image build pulls it. The builds now
name the mirrored generator. The mirror-images check flags an SBOM
attestation without a mirrored generator and `docker://` steps that
point to Docker Hub.

mirror-images.sh decided presence by the mirror's tag, which another
branch can move, so it copied from Docker Hub again for digests the
mirror already served. It now asks GHCR for the pinned digest without
credentials, which is how consumers pull. It takes the copied digest
from the push, since GHCR can answer a fresh tag with stale data. It
retries registry requests, keeps going after a failed image, and copies
single manifests unchanged. A failed login fails only a run that has to
copy.
@ValentinKolb
ValentinKolb enabled auto-merge October 9, 2026 22:47
@ValentinKolb
ValentinKolb added this pull request to the merge queue Oct 9, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Oct 9, 2026
main added .github/poppler.sh, which pulls backplane/pdf from Docker Hub by digest; pull-images.sh refuses unlisted Docker Hub images. The image is now listed in .github/mirror-images.txt and poppler.sh names its mirror reference. The mirror-images check also reads shell scripts, so that reference counts as a use and must carry the listed digest.
@ValentinKolb
ValentinKolb added this pull request to the merge queue Oct 10, 2026
Matches the date that the queued Assistant skills change sets on build-and-deploy.md, so both merge without a conflict.
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 10, 2026
@ValentinKolb
ValentinKolb added this pull request to the merge queue Oct 10, 2026
Merged via the queue into main with commit be600c6 Oct 10, 2026
22 checks passed
@ValentinKolb
ValentinKolb deleted the ci/ghcr-image-mirror branch October 10, 2026 08:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant