From 459dbd5288e787470c2272983df1bb1f85fd8658 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 09:53:02 +0000 Subject: [PATCH 1/2] Add a `pre-pull-docker-image` composite action Docker Hub intermittently returned 5xx errors that aborted `docker buildx` before it could resolve a build's base image, failing CI jobs before they ran any useful work; consumers had started hand-rolling pre-pull-with-retry steps inline in their workflows. Added a reusable composite action that pulls the image named by a Dockerfile's first `FROM` line into the runner's local image store, retrying transient failures with capped exponential backoff. A subsequent build in the same job using the default `docker` buildx driver then resolves the `FROM` image from the local store, keeping flaky registries off the build's critical path. The action is non-fatal: if every attempt fails it warns and exits successfully so the build can retry the pull itself. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UPXVU5ytyWJxSL4YChKhm6 --- pre-pull-docker-image/action.yml | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 pre-pull-docker-image/action.yml diff --git a/pre-pull-docker-image/action.yml b/pre-pull-docker-image/action.yml new file mode 100644 index 0000000..c8b9ed2 --- /dev/null +++ b/pre-pull-docker-image/action.yml @@ -0,0 +1,93 @@ +# A composite action that pulls a Docker image into the runner's local +# image store, retrying transient registry failures (e.g. 5xx errors +# from Docker Hub or GHCR) with capped exponential backoff. Running it +# before a `docker build`/`docker buildx` step (with the default +# `docker` buildx driver, which resolves `FROM` images from the local +# store when they are already present) keeps a flaky registry off the +# build's critical path. +# +# The action is deliberately non-fatal: if every attempt fails it warns +# and exits successfully, leaving the build to try the pull itself, so +# adding it to a job can only help and never hurt. + +name: "Pre-pull Docker Image" +description: + "Pull the Docker image named by a Dockerfile's first `FROM` line + with retries, so that a later build in the same job finds it in the + local image store." + +inputs: + dockerfile: + description: + "Path to a Dockerfile; the image to pull is taken from its first + `FROM` line (the base of its first stage)." + required: false + default: "Dockerfile" + max-attempts: + description: "Maximum number of pull attempts." + required: false + default: "5" + initial-delay-seconds: + description: + "Delay before the second attempt; doubles after every further + failed attempt." + required: false + default: "5" + +runs: + using: "composite" + steps: + - name: Pull the image, retrying transient failures + shell: bash + # Bind the inputs to environment variables so the script body + # never interpolates workflow expressions directly. + env: + DOCKERFILE: ${{ inputs.dockerfile }} + MAX_ATTEMPTS: ${{ inputs.max-attempts }} + INITIAL_DELAY_SECONDS: ${{ inputs.initial-delay-seconds }} + run: | + set -u + if [ ! -f "${DOCKERFILE}" ]; then + echo "warning: '${DOCKERFILE}' not found; skipping pre-pull" >&2 + exit 0 + fi + # Take the image from the first `FROM` line, skipping options + # like `--platform=...`. + from_line="$(grep -iE '^[[:space:]]*FROM[[:space:]]' \ + "${DOCKERFILE}" | head -n1)" + image="$(echo "${from_line}" \ + | awk '{ for (i = 2; i <= NF; i++) + if ($i !~ /^--/) { print $i; exit } }')" + if [ -z "${image}" ]; then + echo "warning: could not determine an image to pull;" \ + "skipping pre-pull" >&2 + exit 0 + fi + case "${image}" in + *'$'*) + # A reference like `FROM base-${VARIANT}` needs build args + # to resolve, which this action doesn't have. + echo "warning: image reference '${image}' contains an" \ + "unresolved variable; skipping pre-pull" >&2 + exit 0 + ;; + esac + echo "Pre-pulling image: ${image}" + attempt=1 + delay="${INITIAL_DELAY_SECONDS}" + while true; do + if docker pull "${image}"; then + break + fi + if [ "${attempt}" -ge "${MAX_ATTEMPTS}" ]; then + echo "warning: pull of ${image} failed after" \ + "${MAX_ATTEMPTS} attempts; continuing so the build can" \ + "retry the pull itself" >&2 + break + fi + echo "pull attempt ${attempt}/${MAX_ATTEMPTS} failed;" \ + "retrying in ${delay}s" >&2 + sleep "${delay}" + attempt=$((attempt + 1)) + delay=$((delay * 2)) + done From ca8724a6a149f7503fef24420a97c7e575645ad6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 12:19:36 +0000 Subject: [PATCH 2/2] fixup! Add a `pre-pull-docker-image` composite action --- pre-pull-docker-image/action.yml | 40 ++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/pre-pull-docker-image/action.yml b/pre-pull-docker-image/action.yml index c8b9ed2..6332b54 100644 --- a/pre-pull-docker-image/action.yml +++ b/pre-pull-docker-image/action.yml @@ -30,7 +30,7 @@ inputs: initial-delay-seconds: description: "Delay before the second attempt; doubles after every further - failed attempt." + failed attempt, capped at 60 seconds." required: false default: "5" @@ -47,6 +47,26 @@ runs: INITIAL_DELAY_SECONDS: ${{ inputs.initial-delay-seconds }} run: | set -u + # The numeric inputs arrive as unvalidated strings; a + # non-numeric value would make the loop's comparisons error and + # never hit the max-attempts break, so misconfigured values + # fall back to the defaults with a warning instead. + max_attempts="${MAX_ATTEMPTS}" + case "${max_attempts}" in + '' | *[!0-9]* | 0) + echo "warning: invalid max-attempts '${max_attempts}';" \ + "using 5" >&2 + max_attempts=5 + ;; + esac + delay="${INITIAL_DELAY_SECONDS}" + case "${delay}" in + '' | *[!0-9]*) + echo "warning: invalid initial-delay-seconds '${delay}';" \ + "using 5" >&2 + delay=5 + ;; + esac if [ ! -f "${DOCKERFILE}" ]; then echo "warning: '${DOCKERFILE}' not found; skipping pre-pull" >&2 exit 0 @@ -64,6 +84,12 @@ runs: exit 0 fi case "${image}" in + scratch) + # `scratch` is Docker's reserved empty image; there is + # nothing to pull. + echo "base image is 'scratch'; nothing to pre-pull" + exit 0 + ;; *'$'*) # A reference like `FROM base-${VARIANT}` needs build args # to resolve, which this action doesn't have. @@ -74,20 +100,24 @@ runs: esac echo "Pre-pulling image: ${image}" attempt=1 - delay="${INITIAL_DELAY_SECONDS}" while true; do if docker pull "${image}"; then break fi - if [ "${attempt}" -ge "${MAX_ATTEMPTS}" ]; then + if [ "${attempt}" -ge "${max_attempts}" ]; then echo "warning: pull of ${image} failed after" \ - "${MAX_ATTEMPTS} attempts; continuing so the build can" \ + "${max_attempts} attempts; continuing so the build can" \ "retry the pull itself" >&2 break fi - echo "pull attempt ${attempt}/${MAX_ATTEMPTS} failed;" \ + echo "pull attempt ${attempt}/${max_attempts} failed;" \ "retrying in ${delay}s" >&2 sleep "${delay}" attempt=$((attempt + 1)) + # Double the delay, capping it so a large `max-attempts` + # cannot produce very long sleeps. delay=$((delay * 2)) + if [ "${delay}" -gt 60 ]; then + delay=60 + fi done