Skip to content

Commit 75473e5

Browse files
authored
Stop the manifest merge from booting BuildKit it never uses (#1175)
The merge job runs only `docker buildx imagetools create`, a registry-only operation needing no builder, but setup-buildx-action's default container driver booted moby/buildkit from Docker Hub — whose timeouts killed three dev builds and stranded :dev. Pin driver: docker on both merge jobs; build jobs keep the container driver and get a fail-soft pre-pull retry. Red/green test as Wall 6 in test_workflow_safety_invariants.py.
1 parent 4f014c0 commit 75473e5

3 files changed

Lines changed: 141 additions & 2 deletions

File tree

.github/workflows/docker-image-build-dev.yml

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,24 @@ jobs:
122122
password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
123123

124124
# --- 3. Build & Push (Digest Only) ---
125+
# Unlike the merge job, this one genuinely needs BuildKit, so it can't
126+
# drop the container driver. Pre-pull with backoff instead: the Docker
127+
# Hub timeouts that red these builds are transient, and
128+
# setup-buildx-action boots from the local image when one is present.
129+
# Fail-soft — if the pull never lands, or the action's default image tag
130+
# moves in a future buildx, fall through and let it pull as before.
131+
- name: Pre-pull BuildKit image (Docker Hub flake guard)
132+
run: |
133+
set -u
134+
for attempt in 1 2 3; do
135+
if docker pull moby/buildkit:buildx-stable-1; then
136+
exit 0
137+
fi
138+
echo "buildkit pull attempt ${attempt} failed; retrying in $((attempt * 15))s"
139+
sleep "$((attempt * 15))"
140+
done
141+
echo "::warning::Could not pre-pull moby/buildkit after 3 attempts; letting setup-buildx-action try."
142+
125143
- name: Set up Docker Buildx
126144
uses: docker/setup-buildx-action@v4
127145
with:
@@ -243,6 +261,24 @@ jobs:
243261
password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
244262

245263
# --- 3. Build & Push (Digest Only) ---
264+
# Unlike the merge job, this one genuinely needs BuildKit, so it can't
265+
# drop the container driver. Pre-pull with backoff instead: the Docker
266+
# Hub timeouts that red these builds are transient, and
267+
# setup-buildx-action boots from the local image when one is present.
268+
# Fail-soft — if the pull never lands, or the action's default image tag
269+
# moves in a future buildx, fall through and let it pull as before.
270+
- name: Pre-pull BuildKit image (Docker Hub flake guard)
271+
run: |
272+
set -u
273+
for attempt in 1 2 3; do
274+
if docker pull moby/buildkit:buildx-stable-1; then
275+
exit 0
276+
fi
277+
echo "buildkit pull attempt ${attempt} failed; retrying in $((attempt * 15))s"
278+
sleep "$((attempt * 15))"
279+
done
280+
echo "::warning::Could not pre-pull moby/buildkit after 3 attempts; letting setup-buildx-action try."
281+
246282
- name: Set up Docker Buildx
247283
uses: docker/setup-buildx-action@v4
248284
with:
@@ -388,8 +424,17 @@ jobs:
388424
pattern: digests-*
389425
merge-multiple: true
390426

391-
- name: Set up Docker Buildx
427+
# `imagetools create` below is a registry-only operation — it reads the
428+
# per-arch manifests and writes a manifest list. It needs no BuildKit.
429+
# The default docker-container driver would boot moby/buildkit pulled
430+
# from Docker Hub, whose egress from GitHub runners intermittently times
431+
# out; on 2026-07-27 that killed three consecutive dev builds at "booting
432+
# buildkit" *after* both arch builds had already pushed, stranding :dev.
433+
# `driver: docker` reuses the local dockerd, so this job touches only GHCR.
434+
- name: Set up Docker Buildx (local driver — no Docker Hub pull)
392435
uses: docker/setup-buildx-action@v4
436+
with:
437+
driver: docker
393438

394439
- name: Create manifest list and push
395440
working-directory: /tmp/digests

.github/workflows/docker-image-build-release.yml

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,21 @@ jobs:
9595
# pbs-cache mirror; falls back to GITHUB_TOKEN for push-only contexts.
9696
password: ${{ secrets.GH_PAT || secrets.GITHUB_TOKEN }}
9797

98+
# See the dev workflow for the full note. This job builds, so it needs
99+
# BuildKit; pre-pull with backoff to survive a transient Docker Hub
100+
# timeout. Fail-soft — falls through to the action's own pull.
101+
- name: Pre-pull BuildKit image (Docker Hub flake guard)
102+
run: |
103+
set -u
104+
for attempt in 1 2 3; do
105+
if docker pull moby/buildkit:buildx-stable-1; then
106+
exit 0
107+
fi
108+
echo "buildkit pull attempt ${attempt} failed; retrying in $((attempt * 15))s"
109+
sleep "$((attempt * 15))"
110+
done
111+
echo "::warning::Could not pre-pull moby/buildkit after 3 attempts; letting setup-buildx-action try."
112+
98113
- name: Set up Docker Buildx
99114
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
100115

@@ -191,8 +206,16 @@ jobs:
191206
pattern: digests-*
192207
merge-multiple: true
193208

194-
- name: Set up Docker Buildx
209+
# Registry-only manifest merge — see the dev workflow for the full note.
210+
# The default container driver boots moby/buildkit from Docker Hub, which
211+
# intermittently times out from GitHub runners. Here that is worse than on
212+
# dev: the tag is already published, so a failure leaves
213+
# `docker pull ...:vX.Y.Z` 404ing for every user (the v4.0.169 failure
214+
# mode). `driver: docker` keeps this job on GHCR only.
215+
- name: Set up Docker Buildx (local driver — no Docker Hub pull)
195216
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
217+
with:
218+
driver: docker
196219

197220
- name: Compute tag list
198221
id: tags

tests/unit/test_workflow_safety_invariants.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,77 @@ def test_integration_tests_job_authenticates_to_ghcr():
495495
)
496496

497497

498+
# ─── Wall 6: manifest-merge jobs don't boot BuildKit ───────────────────
499+
#
500+
# `docker buildx imagetools create` is a registry-only operation: it
501+
# reads the per-arch manifests and writes a manifest list. It needs no
502+
# builder. But `docker/setup-buildx-action` defaults to the
503+
# docker-container driver, which boots moby/buildkit pulled from Docker
504+
# Hub — so a job that only merges manifests took a hard dependency on a
505+
# third-party registry it never otherwise touches.
506+
#
507+
# Docker Hub egress from GitHub-hosted runners intermittently times out.
508+
# On 2026-07-27 three consecutive dev builds died at "booting buildkit"
509+
# with `Get "https://registry-1.docker.io/v2/": context deadline
510+
# exceeded` — after both arch builds had already succeeded and pushed.
511+
# The :dev tag went stale and the household canary stopped receiving
512+
# merges. The same shape sits in the release workflow's merge job, where
513+
# it is worse: the tag publishes, the manifest never lands, and
514+
# `docker pull ...:vX.Y.Z` 404s for everyone (the v4.0.169 failure mode).
515+
#
516+
# So: a job that merges manifests and does not itself build an image
517+
# must not set up a container-driver buildx.
518+
519+
520+
def _job_text(job: dict) -> str:
521+
"""Flatten every step's `run` + `uses` into one searchable string."""
522+
parts = []
523+
for step in _steps(job):
524+
parts.append(str(step.get("run", "")))
525+
parts.append(str(step.get("uses", "")))
526+
return "\n".join(parts)
527+
528+
529+
def test_manifest_merge_jobs_do_not_boot_container_buildkit():
530+
"""Registry-only manifest merges must not pull moby/buildkit.
531+
532+
Applies to any job that runs `imagetools create` but never builds an
533+
image. Such a job may either omit setup-buildx-action entirely (the
534+
buildx CLI plugin is preinstalled on GitHub runners) or pin
535+
`driver: docker`, which reuses the local dockerd. What it may not do
536+
is take the default container driver.
537+
"""
538+
offenders = []
539+
for path in all_workflows():
540+
wf = _load(path)
541+
for job_name, job in (wf.get("jobs") or {}).items():
542+
if not isinstance(job, dict):
543+
continue
544+
text = _job_text(job)
545+
if "imagetools create" not in text:
546+
continue
547+
# A job that genuinely builds an image needs a real builder.
548+
builds = "docker/build-push-action" in text or re.search(
549+
r"docker\s+buildx\s+build\b", text
550+
)
551+
if builds:
552+
continue
553+
for step in _steps(job):
554+
if not str(step.get("uses", "")).startswith(
555+
"docker/setup-buildx-action"
556+
):
557+
continue
558+
driver = (step.get("with") or {}).get("driver")
559+
if driver != "docker":
560+
offenders.append(f"{path.name}:{job_name}")
561+
assert not offenders, (
562+
"Manifest-merge job(s) boot a container-driver BuildKit for a "
563+
"registry-only `imagetools create`, taking a needless Docker Hub "
564+
"dependency that has already broken the dev channel: "
565+
f"{offenders}. Drop the setup-buildx step or pin `driver: docker`."
566+
)
567+
568+
498569
def test_all_workflows_have_minimum_permissions_block():
499570
"""Every workflow that does any mutating action (commenting,
500571
labeling, merging) must have an explicit top-level OR job-level

0 commit comments

Comments
 (0)