Skip to content

ci: guard against pinned base-image digests disappearing upstream #3305

Description

@myasnikovdaniil

Problem

Dockerfile base images are pinned to immutable digests for reproducibility and for OpenSSF Scorecard Pinned-Dependencies (.github/renovate.json sets pinDigests: true for the dockerfile and github-actions managers). Some registries retire the superseded manifest as soon as a tag moves, so a pinned digest can stop resolving with no warning and no diff, and the failure lands inside whatever unrelated PR happens to rebuild that package next.

This has already happened twice, on the same image, six days apart:

Neither fix added a check, so the exposure is unchanged for every other pin in the tree.

Why a guard rather than a mirror

The original version of this issue proposed mirroring volatile bases into a project-controlled registry and pinning the mirrored digest. That is more machinery than the measured failure rate supports: two incidents in six days on one image, zero in the 23 days since #3400 removed the last rolling-tag pin on a retiring registry. A mirror means a registry to keep, a sync job to maintain, and a rewrite of every FROM. What #3400 did instead, move to a registry that retains, cost one line and solved the real case.

So the durable part of this issue is detection, not indirection. Prefer a retaining registry when picking a base, and have CI tell us when a pin is gone instead of letting a PR author discover it.

What is exposed today

38 Dockerfile files plus 1 Containerfile under packages/*/*/images/ pin 17 unique base refs. Verified with crane manifest on 2026-08-13: all 17 resolve. Nothing is broken right now.

By registry:

Registry Unique pinned refs Retirement behaviour
quay.io 2 Retires on tag move. Confirmed: quay.io/centos/centos:stream9@sha256:0c9db821… is still MANIFEST_UNKNOWN today while the tag heads sha256:04bad0b0…
docker.io non-library 3 Publisher-controlled, retains today
gcr.io 1 ref across 7 sites Retains today: the pinned distroless/static:nonroot@sha256:d29e660c… is behind head sha256:f7f8f729… and still resolves
docker.io/library 11 Retains. Three of our own pins are already behind their tag head and still resolve: golang:1.26 (pinned 079e5980…, head 705e964a…), debian:bookworm (30482e87… / 813017f3…), node:22-alpine (16e22a55… / c610fcdf…)
registry.k8s.io 0 none

The two quay.io pins:

  • quay.io/poseidon/matchbox:v0.11.0@sha256:06bcdae8…, at packages/core/talos/images/matchbox/Dockerfile:1
  • quay.io/cilium/cilium:v1.19.5@sha256:20fbbc14…, at packages/system/cilium/images/cilium/Dockerfile:1

Both sit on immutable-style release tags rather than a rolling tag like stream9, so upstream would have to re-push a released version for them to break. That is lower likelihood, not a different failure mode.

Also in scope, from packages/*/*/images/*.tag (13 of the 18 files hold first-party refs the build stamps, 5 hold third-party ones, carrying 3 unique refs):

  • docker.io/alpine/k8s:1.33.4@sha256:b0523f0a… in kubernetes-nodes/images/kubectl.tag, kubernetes/images/kubectl.tag, monitoring/images/kubectl.tag
  • docker.io/library/busybox:1.37.0@sha256:1487d0af… in kubernetes/images/busybox.tag
  • ghcr.io/seaweedfs/seaweedfs-cosi-driver:v0.3.1 in extra/seaweedfs/images/seaweedfs-cosi-driver.tag, not digest-pinned, so Renovate's pinDigests rule cannot reach it (it covers the dockerfile and github-actions managers only) and a digest liveness check has nothing to test

Unpinned bases, which cannot 404 but can change content with no diff, listed for completeness rather than as work:

  • gcr.io/distroless/static:nonroot-${TARGETARCH}, the ARG BASEIMAGE default at packages/apps/kubernetes/images/cluster-autoscaler/Dockerfile:3. packages/apps/kubernetes/Makefile passes no --build-arg BASEIMAGE, so this default is what builds.
  • quay.io/metallb/controller:${VERSION} and :speaker, at packages/system/metallb/images/metallb/Dockerfile:5,7, with VERSION from the vendored chart's appVersion.
  • kubeovn/kube-ovn-base:$BASE_TAG and golang:1.26-bookworm, at packages/system/kubeovn/images/kubeovn/Dockerfile:5,19,39.

Proposed guard

What it checks

A script, hack/check-base-digests.sh, POSIX sh, that:

  1. Enumerates every digest-pinned third-party base ref reachable from the build: FROM …@sha256:… and ARG …=…@sha256:… lines in packages/*/*/images/**/Dockerfile and **/Containerfile, plus refs in packages/*/*/images/*.tag whose repository is not ghcr.io/cozystack/cozystack/*. Deduplicates by ref: today that is 19 digest-pinned refs to resolve (17 Dockerfile bases plus alpine/k8s and busybox from the .tag files), spread across many more occurrence sites, plus one tag-only ref to flag as unpinned.
  2. Resolves each ref anonymously with a manifest HEAD (crane manifest or skopeo inspect --raw, whichever the lane already has, and nightly.yaml already installs skopeo).
  3. Prints one line per ref with its verdict and its file:line, and exits non-zero when any ref is definitively gone.

Fail-open discipline, stated because getting it wrong here is worse than not having the guard. A MANIFEST_UNKNOWN or a 404 is a failure. A timeout, a 5xx, a rate limit or an auth error is not a failure, but it must be reported as ::warning:: and the summary must say how many refs went unchecked. The guard must never print a clean result when a lookup did not complete, because a check that fails open silently reads exactly like a check that passed.

Where it runs

Two places, both existing lanes. No new workflow file: this repo prefers extending a lane and asks for a measured failure frequency first, and two incidents in six days does not justify standing up a workflow of its own.

  1. Scheduled detection, a base-digests job in .github/workflows/nightly.yaml. That workflow already runs cron: '0 2 * * *' and already installs skopeo in its mirror job. The new job needs no secrets (every base is anonymously pullable), no build, and runs on ubuntu-latest in seconds. It gates nothing, it fails so the nightly goes red and names the ref and its file:line in a ::error:: annotation. Detection window drops from "whenever something next rebuilds that package" to under 24 hours.

  2. PR-time signal, invoke the same script from the existing checks job in .github/workflows/pull-requests.yaml, conditioned on the PR having touched a Dockerfile, Containerfile or *.tag. This catches a bad digest at review time, including one a Renovate digest PR is proposing. Because checks is a required status, this invocation fails only on a definitive MANIFEST_UNKNOWN/404 and treats every transport error as a pass with a warning.

An offline unit test, hack/check-base-digests.bats under make unit-tests, covers the enumerator against fixtures: that it finds FROM and ARG forms, that it skips first-party ghcr.io/cozystack/cozystack/* refs, that it deduplicates, and that a tag-only ref is reported as unpinned rather than silently dropped. Follow the harness constraints documented in hack/image-pin-consistency.bats (hack/cozytest.sh is not real bats: no run, no $status, no skip, repo-root-relative paths, and env -u REGISTRY where the ambient CI REGISTRY would skew a tree scan). The network resolve stays out of unit-tests so no PR-gating unit test acquires a network dependency.

What it does not do

  • No auto-repin. Adopting a base nobody reviewed is how a supply chain gets a surprise.
  • No mirror registry. Revisit if the guard fires more than once a quarter.
  • No new workflow file.

Also worth deciding

packages/extra/seaweedfs/images/seaweedfs-cosi-driver.tag holds ghcr.io/seaweedfs/seaweedfs-cosi-driver:v0.3.1 with no digest, so it is outside Renovate's pinDigests rule and outside anything a digest check can verify. Either pin it, or record it here as a deliberate exception. The original version of this issue asked for exactly that ("document any deliberate exceptions where a rolling tag must remain unpinned") and this is the only case in the tree.

Definition of done

  • hack/check-base-digests.sh exists, is covered offline by hack/check-base-digests.bats under make unit-tests, and reports unchecked refs rather than swallowing them.
  • It runs daily as a job in .github/workflows/nightly.yaml and on Dockerfile-touching PRs from the existing checks job.
  • The seaweedfs cosi-driver ref is either digest-pinned or recorded here as an exception.
  • No new workflow file, no mirror registry.

Activity

  1. myasnikovdaniil commented on Jul 21, 2026

    @myasnikovdaniil
    ContributorAuthor

    Investigated this. Summary of findings, then a concrete proposal — the second half is the part that actually addresses the durable problem, and it is not what the issue originally proposed.

    Findings

    1. Quay retirement is instantaneous, not a delayed GC. The superseded manifest stops resolving the moment the tag moves. From Quay's tag-history API for centos/centos:stream9:

    sha256:3714c89f…  start 2026-07-21T02:04:01Z  (active)
    sha256:a93e3316…  start 2026-07-15T03:20:27Z  end 2026-07-21T02:04:01Z  → MANIFEST_UNKNOWN
    sha256:0c9db821…  start 2026-07-07T00:49:47Z  end 2026-07-15T03:20:27Z  → MANIFEST_UNKNOWN
    

    Measured gap between the tag move on July 15 and the first red build: 5h40m.

    This is not CentOS-specific. The same probe against quay.io/fedora/fedora:latest (4/4 historical digests gone) and quay.io/centos/centos:stream10 (3/3 gone) shows the same behaviour, so it should be treated as a property of the registry, not a one-off.

    2. It has already recurred. #3303 (Jul 15) and #3386 (Jul 21) are byte-identical fixes six days apart. stream9 rolls every 6–8 days. The digest merged in #3386 is currently the live tag, so it is next in line to be retired.

    3. The blast radius is one reference. Of the digest-pinned bases in the repo, everything on docker.io/library and gcr.io/distroless provably retains superseded manifests — verified by taking four superseded digests out of this repo's own git history (golang:1.26, golang:1.26-alpine, alpine:3.24, alpine:3.22) and confirming they all still resolve after their tags moved. The only pin on a non-retaining registry was centos:stream9. The two remaining Quay pins (poseidon/matchbox:v0.11.0, cilium/cilium:v1.19.5) are immutable version tags that do not roll.

    4. A faster Renovate schedule cannot fix this. Three independent reasons: the exposure window opens at T+0 so no cron frequency avoids it; stream9 rolls Tue/Wed while the digest group is scheduled before 6am on monday, i.e. anti-phased; and with do-not-merge/hold plus automerge: false, recovery latency is human review, not cron frequency. Both repins in practice landed faster (9h and 6h43m) than any schedule change would have delivered.

    5. release-1.4 and release-1.5 were never affected because they still carry the unpinned FROM quay.io/centos/centos:stream9. Closing the backports #3309/#3310 was correct.

    Immediate fix

    #3400 moves the runtime base to docker.io/library/almalinux:9, keeping the digest pin. Verified equivalent by running both bases with the Dockerfile's exact install line: all eight mount binaries at identical paths, e2fsprogs/xfsprogs at identical NVRs, same four-architecture index, and the driver is built CGO_ENABLED=0 so the base libc is not linked.

    Explicitly not proposed: unpinning, or mirroring bases into a project-controlled registry. Unpinning would trade a supply-chain property for an availability one on a privileged CSI node image. Mirroring is real infrastructure — script, workflow, schedule, retention carve-out, ongoing ownership — to preserve access to a single base that has a one-line alternative.

    What #3400 does not solve

    It lowers the probability of a dead pin but does nothing about impact or recovery latency, which is the durable half of this issue:

    Any dead digest still blocks the entire platform build. The root build target runs 29 packages serially, and packages/apps/kubernetes is 4th. One unresolvable base aborts the remaining 25 packages and leaves the shared mode=max build cache unwritten, so subsequent PRs are slower as well. This is true for any package, not just this one.

    Recovery is gated by design. do-not-merge/hold and automerge: false are deliberate policy and should stay, but they mean a repin cannot land quickly even once the problem is known.

    Suggested follow-ups, in order of leverage:

    1. Do not let one package abort the whole serial build on main. Collect failures and continue, so a dead base costs one package rather than the platform. Release tag builds should keep failing hard — a partial platform must not ship.
    2. A documented emergency override (an explicit build-arg, opt-in, never automatic) so a maintainer can unblock builds within minutes while the proper repin goes through normal review. An automatic digest-to-tag fallback should be rejected — it silently defeats the pin exactly when the pin matters.
    3. A scheduled resolve check scoped to pins on non-retaining registries — after fix(kubernetes): move kubevirt-csi-driver base off quay.io/centos to almalinux #3400 that is two references, so it is cheap. The value is lead time, not detection: it should open the repin PR before anyone needs to build, rather than reporting a breakage that is already loud.
    4. A registry allowlist check on changed FROM lines, so a future base on a registry that retires manifests fails CI at review time instead of six days later.

    Happy to split these into separate PRs if the direction looks right. Item 1 is the one that actually converts "the platform is blocked" into "one package is blocked", so I would start there.

  2. changed the title [-]ci: prevent retired base-image digests from blocking builds[/-] [+]ci: guard against pinned base-image digests disappearing upstream[/+] on Aug 14, 2026
  3. myasnikovdaniil commented on Aug 14, 2026

    @myasnikovdaniil
    ContributorAuthor

    Rewrote this as a guard specification. The two merged fixes addressed one incident rather than the class: #3386 re-pinned a single digest and #3400 moved that one image to a registry that retains manifests. Neither added a check, so every other pin still fails the same way, a MANIFEST_UNKNOWN inside an unrelated PR's build.

    The incident also recurred, which the original body did not capture: #3303 re-pinned on 2026-07-15, the newly pinned digest was deleted too, and #3386 re-pinned again on 2026-07-21 before #3400 moved off quay.io the same day. Two blocking incidents in six days, both fixed by hand.

    Current exposure, verified with crane manifest on 2026-08-13: 17 unique digest-pinned bases across 41 Dockerfiles, all resolving. Two sit on quay.io (poseidon/matchbox:v0.11.0, cilium/cilium:v1.19.5), both on immutable-style release tags rather than a rolling one, which is lower likelihood than the stream9 case but the same failure mode. The retirement asymmetry is measured: the retired centos:stream9 digest is still MANIFEST_UNKNOWN today, while three of our own docker.io/library pins are already behind their tag head and still resolve.

    Scoped the proposal down from the original mirror-and-repin plan to a liveness guard in two existing lanes, a base-digests job in nightly.yaml for daily detection and the same script from the checks job on Dockerfile-touching PRs, because two incidents in six days and zero in the 23 days since #3400 does not justify standing up a mirror registry and a sync job. Keeping this open: the guard does not exist yet, and one third-party ref (seaweedfs-cosi-driver.tag) is not digest-pinned at all, so it sits outside both Renovate's pinDigests rule and any digest check.

  4. added
    kind/featureCategorizes issue or PR as related to a new feature
    area/ciIssues or PRs related to CI workflows, GitHub Actions, automation
    area/buildIssues or PRs related to image build infrastructure, multi-arch support
    triage/acceptedIndicates an issue is ready to be actively worked on
    priority/backlogGeneral backlog priority. Lower than priority/important-longterm
    and removed
    triage/needs-triageIndicates an issue needs triage by a maintainer
    on Sep 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area/buildIssues or PRs related to image build infrastructure, multi-arch supportarea/ciIssues or PRs related to CI workflows, GitHub Actions, automationkind/featureCategorizes issue or PR as related to a new featurepriority/backlogGeneral backlog priority. Lower than priority/important-longtermtriage/acceptedIndicates an issue is ready to be actively worked on

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions