ci(backend): refuse to move mutable image tags backwards#11025
Open
localai-bot wants to merge 1 commit into
Open
ci(backend): refuse to move mutable image tags backwards#11025localai-bot wants to merge 1 commit into
localai-bot wants to merge 1 commit into
Conversation
Backend images publish an immutable `sha-<commit>-<suffix>` tag alongside mutable `master-*`, `latest-*` and `v<version>-*` tags. Nothing checked whether an incoming build was newer than what a mutable tag already pointed at, so last writer won. That is not theoretical. Backend CI queues run hours deep (measured 259.7 min average queue wait against 18.6 min average execution, peak concurrency 19) and master pushes get a concurrency group keyed by github.sha, so no run supersedes another and completion order does not track commit order. On 19 Jul 2026: 10:41 UTC build of 626ae4d (contains the cuDNN packaging fix from #10946) advanced master-nvidia-l4t-cuda-13-arm64-longcat-video 15:40 UTC build of 1021194, a commit from 06:45 UTC that predates the fix, overwrote the same tag Every consumer pulling `master-*` after that got a pre-fix image, which bundles a partial 4-of-8 cuDNN library set at 9.24.0 against a venv shipping a complete set at 9.20.0.48 and fails at inference with CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH. The fix was built correctly and on time, then un-shipped by a straggler, silently, for two days. Add an ordering guard. `scripts/tag-guard.mjs` reads the tag list docker/metadata-action produced, resolves what each mutable tag currently points at via its `org.opencontainers.image.revision` label, asks the GitHub compare API how the incoming commit relates to it, and emits only the tags this build may publish. Immutable `sha-*` tags always publish: pinning one is how this incident was worked around and that must keep working. Wired into both push paths that write mutable tags: - backend_merge.yml, quay and Docker Hub `imagetools create` steps. This is the multi-arch manifest merge and also the tagging step for single-arch backends, since backend_build.yml pushes by digest only. - backend_build_darwin.yml publish job. Darwin images are `crane push`ed from a raw OCI tarball and carried no labels at all, so the job now stamps the revision with `crane mutate` after pushing; until each darwin tag has gone through that once the guard fails open with a warning. Blocked tags are logged, annotated with `::warning::` and listed in the job summary. The whole failure mode being fixed here is silence, so a quiet skip would be the same bug wearing a different hat. Unknown or unresolvable revisions and registry/API errors fail open with a warning rather than blocking: a guard that failed closed on an API blip would itself stop shipping merged fixes. Release `v*` tags go through the guard too. A fresh version tag has never been published so it takes the "tag does not exist yet" path and always publishes; the guard costs nothing there and does protect `latest-*` if an old release build is ever re-run. Logic lives in scripts/lib/tag-guard.mjs with unit tests in scripts/lib/tag-guard_test.mjs, run by `make test-ci-scripts` from lint.yml. Registry and GitHub lookups take an injected fetch so the decision table is tested without network access. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The defect
Backend images publish an immutable per-commit tag (
sha-<short>-<suffix>) alongside mutable rolling tags (master-<suffix>,latest-<suffix>,v<version>-<suffix>). Nothing checked whether an incoming build was newer than what a mutable tag already pointed at. Last writer won.Measured from the live registry:
A build of a commit from 06:45 finished roughly nine hours later and moved the mutable tag backwards in time. Every consumer pulling
master-*from then on got an image without the cuDNN fix: the pre-fix image bundles a partial 4-of-8 cuDNN library set at 9.24.0 while the venv ships a complete set at 9.20.0.48, givingCUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCHat inference. The fix was built properly and on time, then un-shipped by a straggler, silently, for two days.Why now: backend CI queues run 4+ hours deep (259.7 min average queue wait vs 18.6 min average execution, peak concurrency 19), and master pushes get a concurrency group keyed by
github.sha, so no run supersedes another. Completion order does not track commit order, and the deeper the queue the likelier a straggler clobbers a newer tag.The defect is still visible in the registry today:
quay.io/go-skynet/local-ai-backends:master-cpu-faster-whispercurrently carriesorg.opencontainers.image.revision=10211948b5470d332a93c841a9c8fe2b9a737148.Why this should merge before or with #11024
#11024 makes GPU backends visible to the upgrade checker for the first time, which will let
AutoUpgradeBackendsfan upgrades out to worker nodes at scale. With this tag defect unfixed, that machinery would auto-distribute regressed images across the fleet, unattended. This PR should land before or together with #11024.The fix
An ordering guard: refuse to advance a mutable tag unless the incoming build's commit descends from the commit the tag currently points at.
scripts/lib/tag-guard.mjsholds the decision logic and the registry/GitHub lookups (injectedfetch, so it is testable offline).scripts/tag-guard.mjsis the CI entrypoint: reads thedocker/metadata-actiontag list, prints the tags this build may publish, logs every decision.scripts/lib/tag-guard_test.mjscovers the decision table, run bymake test-ci-scriptsfromlint.yml.Push paths found and covered
backend_build.ymlpushes by canonical digest only and applies no tags, so it needed no change. Every mutable tag write happens in one of:backend_merge.yml, quayimagetools createbackend_merge.yml, Docker Hubimagetools createbackend_build_darwin.ymlpublish job,crane pushloopMechanism chosen, and why
Of the two candidate shapes, this is closest to (2) but reuses stamping that already exists rather than adding it.
sha-tags.docker/metadata-actionalready emitsorg.opencontainers.image.revision, andbackend_build.ymlalready passeslabels: ${{ steps.meta.outputs.labels }}tobuild-push-action, so Linux images have carried the source commit all along. Verified against the live registry. The alternative, mapping a digest back to itssha-tag, would mean enumerating a repository with hundreds of thousands of tags on every merge job.git merge-base --is-ancestor. The merge and darwin publish jobs use shallow sparse checkouts.merge-basewould need full history fetched into each of ~200 merge jobs per push to answer one ancestry question.GET /repos/{repo}/compare/{base}...{head}answers it in one request and needs no git context at all. Both repositories are public, so the registry side works on anonymous pull tokens and the guard needs no credentials of its own.crane pushed from a raw OCI tarball and carried no labels at all (verified:master-metal-darwin-arm64-mlxhasLabels: null). The publish job now stamps the revision withcrane mutateafter pushing. Until each darwin tag has been republished once through this step, the guard has nothing to compare and fails open with a warning, which is exactly today's behaviour.Behaviour
sha-<commit>-*immutable tagahead/identicalbehinddiverged/ no ancestor relationshipImmutable tags always pushing is load-bearing: pinning
sha-626ae4d-...is exactly how this incident was worked around, and the block message tells you which sha to pin.Blocked tags are logged to stderr, raised as
::warning::annotations and listed in the job summary. The failure mode being fixed here is silence, so a quiet skip would be the same bug in a different costume.Fail-open on unknown/unresolvable/error is deliberate: a guard that failed closed on a registry or GitHub blip would itself stop shipping merged fixes, which is the bug, only louder. Every fail-open path is annotated.
Release
v*tags go through the guard too. Tag builds are inherently ordered and a fresh version tag has never been published, so it takes the "tag does not exist yet" path and always publishes: the guard costs nothing there. It is kept in place becauselatest-*is emitted by the same builds, and re-running an old release build would otherwise movelatest-*backwards.Validation
Honest summary: the decision logic is tested and the guard has been exercised end to end against the live registry and the live GitHub API, but no real CI build has run this and it cannot be run locally.
Verified:
make test-ci-scriptspasses, 18/18 new tests, exit 0. Includes the incident case (an ancestor commit must not move the tag) and the "immutable tags always survive" invariant.scripts/tag-guard.mjsagainstquay.io/go-skynet/local-ai-backendsandlocalai/localai-backendsand the real GitHub compare API, using real commits from this repository:078614c, an ancestor of the commitmaster-cpu-faster-whispercurrently carries): withheld the mutable tag on both registries, keptsha-078614c-*, exit 0.3584e07 descends from 1021194, advancing the tag, plus a never-publishedv9.9.9-*tag correctly taking the "does not exist yet" path.docker, confirming the-targument array, the ci-cache digest glob,REGISTRY_PREFIXfiltering, and thatfirst_tagresolves to the immutable tag for the cosign digest lookup.mapfile < <(...), which would have swallowed the exit status.shellcheckclean on all four rewritten run blocks, exit 0. YAML parses.crane mutate --label/-tconfirmed against crane 0.21.7, the samelatestrelease the darwin job installs.backend_pr.ymlalso calls both reusable workflows; its push-side steps are gated ongithub.event_name != 'pull_request'and the darwin publish job is gated at job level, so PR builds are unaffected.Not verified: no real backend CI run has exercised this. Specifically unproven until a build lands:
nodeavailability on the runner image (relied on as preinstalled, nosetup-nodestep added), the darwincrane mutateround trip against a real pushed tarball, and the interaction of a withheld tag with the existingInspect manifeststep.Scope note: the root LocalAI image workflows (
image_merge.yml) have the same shape and are not covered here. Worth a follow-up.