Skip to content

fix(ci): rebuild backends when shared build inputs change#10975

Merged
mudler merged 1 commit into
masterfrom
fix/ci-shared-build-paths
Jul 20, 2026
Merged

fix(ci): rebuild backends when shared build inputs change#10975
mudler merged 1 commit into
masterfrom
fix/ci-shared-build-paths

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

The incident

PR #10946 fixed a real bug: backend images shipped a partial 4-of-8 cuDNN library set that silently mixed versions with the venv's pip cuDNN, producing CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH at inference time on ~10 of 11 backends on an L4T fleet.

It merged 2026-07-19 07:48 UTC. The weekly full-matrix cron (0 6 * * 0) had fired at 06:00 the same day, 1h48m earlier. Every build since was path-filtered.

No backend image contains that fix, and none would until the 07-26 cron. CI was green throughout. The fix looked merged and shipped; it was neither. It was only caught because someone went looking for the rebuilt image to deploy it.

Cause

emitFilteredMatrix filtered with changedFiles.some(f => f.startsWith(backendPath)), where backendPath is the backend's own directory. A file only triggered a rebuild if it lived under that backend's tree.

scripts/build/package-gpu-libs.sh is COPY'd and executed by backend/Dockerfile.python for every Python backend image, but lives under scripts/ — so it matched nothing and produced an empty matrix.

The failure mode is invisible: an empty matrix is not an error.

The same hole applied to backend/Dockerfile.* itself. A change to backend/Dockerfile.llama-cpp triggered nothing, because the Dockerfile does not live under backend/cpp/llama-cpp/. That one was not in the original report and was found while deriving the path set.

Fix

A SHARED_BUILD_INPUTS table maps each shared path to the narrowest set of matrix entries it can honestly invalidate, plus a generic rule for backend/Dockerfile.<x> — exact rather than heuristic, since every matrix entry already names its dockerfile:.

A full matrix is 417 Linux + 56 Darwin builds, so granularity matters. Measured against the real matrix:

Changed path Rebuilds
backend/backend.proto everything (all four languages compile or copy it)
backend/Dockerfile.<x> the Linux entries whose dockerfile: names it
backend/python/common/ Python, Linux + Darwin
scripts/build/package-gpu-libs.sh Python, Linux only — 176 of 417, not a full matrix
scripts/build/<x>-darwin.sh the Darwin entries that build target routes to
.github/workflows/backend_build[_darwin].yml everything on that OS
anything else under scripts/build/ (except *_test.sh) everything

Each inclusion was verified against the Dockerfiles, the reusable workflows and the Makefile targets rather than assumed — e.g. package-gpu-libs.sh is Linux-only because python-darwin.sh never invokes it.

That last row is deliberate: a new shared script silently shipping to nothing is the exact failure being fixed here. Over-building is recoverable; under-building is invisible.

Deliberately excluded, to keep CI spend honest

  • backend/index.yaml — grepped: zero references in any Dockerfile or in backend_build.yml. Gallery metadata read at runtime; never enters an image.
  • .github/backend-matrix.yml — would fire all 417 builds on every new-backend PR, and those already trigger via their own directory. Residual gap: editing an existing entry's base-image will not rebuild it.
  • backend/Dockerfile.base-grpc-builder — owned by base-images.yml, which has its own path filter; backend builds consume the result by mutable tag.
  • Root Makefile — ~11% of recent commits; its backend-relevant edits arrive alongside the backend directory anyway.

Known remaining gap

Go backends genuinely link pkg/ (e.g. backend/go/ced/main.go imports pkg/grpc), so a core change can alter a Go backend binary without rebuilding it. Including pkg/ would fire a Go matrix on nearly every commit, so it is left for a follow-up rather than absorbed here.

Tests

The filtering logic moved to scripts/lib/backend-filter.mjs so it can be unit-tested without bun, js-yaml, or a GitHub API round-trip. 15 tests via node --test (zero dependencies), run from the existing build-scripts job in lint.yml.

Verified red-first against the pre-fix logic, extracted unchanged — 11 failed, 4 passed:

not ok 1 - a change to only package-gpu-libs.sh rebuilds every Python image
  error:  'expected a non-empty Linux matrix'
  actual: 0
ok   2 - docs- and README-only changes never produce a matrix
ok   3 - a backend's own directory still triggers only that backend
ok  14 - tests for the packaging scripts do not rebuild anything
ok  15 - turboquant still retriggers on llama-cpp source changes

The negative pins passed before the fix as well as after — which is what makes them meaningful, since they would have caught an over-broad fix that simply rebuilt everything.

Final: 15/15 pass. make test-build-scripts unchanged and still green (16 PASS lines, no regression). make lint: 0 issues.

Note for reviewers

.agents/adding-backends.md and .github/backend-matrix.yml both point contributors at scripts/changed-backends.js:inferBackendPath, which this change moves. Those docs describe backend registration as "the single most common omission", so a stale pointer there has real cost — hence the doc updates in this diff. Separable if you would rather keep it narrow.


🤖 Generated with Claude Code

The backend matrix path filter only matched files under a backend's own
directory, so a change to shared build infrastructure rebuilt nothing at
all: an empty matrix, every job green, and the change reaching no image.

PR #10946 fixed scripts/build/package-gpu-libs.sh shipping a partial
4-of-8 cuDNN library set, which mixed versions with the venv's pip cuDNN
and produced CUDNN_STATUS_SUBLIBRARY_VERSION_MISMATCH at inference time.
It merged 1h48m after the weekly full-matrix cron had already run, so no
backend image ever received the fix and nothing signalled that it had
been un-shipped.

Add a SHARED_BUILD_INPUTS table mapping each shared path to the narrowest
set of matrix entries it can honestly invalidate, plus a generic rule for
backend/Dockerfile.<x> (which each entry already names). A full matrix is
417 Linux + 56 Darwin builds, so package-gpu-libs.sh now rebuilds the 176
Python entries rather than everything. Unclassified files under
scripts/build/ fall back to a full rebuild deliberately: over-building is
recoverable, silently shipping nothing is not.

Extract the filtering logic to scripts/lib/backend-filter.mjs so it can be
unit-tested without bun, js-yaml or a GitHub API round-trip, and run those
tests from the existing lint workflow via `make test-ci-scripts`.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
@mudler
mudler merged commit 6e52d0c into master Jul 20, 2026
65 of 66 checks passed
@mudler
mudler deleted the fix/ci-shared-build-paths branch July 20, 2026 11:48
mudler added a commit that referenced this pull request Jul 20, 2026
… coverage-ratchet fixes (#10989)

* fix(http): make /readyz reflect startup readiness instead of always 200

/readyz was registered as a static handler returning 200 unconditionally,
so it carried no information: it was green whenever it could be reached at
all. Readiness could not distinguish "serving" from "still starting", and
any future change that started the HTTP listener earlier would silently
turn the probe into a lie.

Track startup completion on the Application (atomic flag, flipped at the
very end of New() on the success path only) and have the readiness handler
consult it per request, returning 503 with a small JSON body while startup
is in progress. A nil readiness source fails open so embedders keep the
historical behaviour.

/healthz is deliberately left readiness-independent. Liveness and readiness
answer different questions, and failing liveness during a long preload makes
an orchestrator restart the pod mid-download so the preload never finishes.

This matters because since #10949 the startup preload materializes
HuggingFace artifacts for managed backends: tens of GB for a large model
(31 GB observed on a live cluster). Both probes stay in quietPaths and stay
exempt from auth.

Note the listener is still started only after New() returns, so today the
not-ready state is not observable over HTTP. Moving the listener earlier is
a separate, deliberate decision and is not made here.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* chore(gitignore): anchor the mock-backend pattern so its source dir is traversable

The bare `mock-backend` pattern matched the *directory*
tests/e2e/mock-backend/, not just the binary built into it. Git will not
descend into an ignored directory even for tracked files, so
`git add tests/e2e/mock-backend/main.go` required -f. This was hit while
working on #10970.

Anchor it to the artifact's full path. The built binary stays ignored (it is
also covered by tests/e2e/mock-backend/.gitignore) while the source directory
becomes traversable again.

Verified with `git check-ignore -v`: a new source file under
tests/e2e/mock-backend/ is no longer ignored, and the binary produced by
`make build-mock-backend` still is.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

* chore(coverage): raise the coverage ratchet from 48.5% to 54.2%

The committed baseline had drifted well below reality: it still read 48.5%
while a full instrumented run measures 54.2%. A stale-low baseline makes the
gate meaningless — coverage could regress by more than 5 percentage points
and still pass.

Raising a ratchet is a deliberate act, not something to fold into an
unrelated fix, so it gets its own commit. The headroom was earned by tests
landed in #10946, #10947, #10948, #10949, #10956, #10967, #10968, #10970 and
#10975.

Measured with `make test-coverage` on this branch (the same instrumented run
`make test-coverage-baseline` uses: ginkgo over ./pkg and ./core plus the
in-process tests/e2e suite, --covermode=atomic, --coverpkg over core/... and
pkg/..., generated protobuf excluded). The run completed with exit 0 and zero
spec failures; the total was then written with the exact command the
test-coverage-baseline target uses:

  go tool cover -func=coverage/coverage.out \
    | awk '/^total:/{gsub(/%/,"",$NF); print $NF}' > coverage-baseline.txt

Verified afterwards with scripts/coverage-check.sh, which reports OK.

Note the measured figure includes the readiness specs added earlier on this
branch, so it is a demonstrated floor rather than an estimate.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
mudler added a commit that referenced this pull request Jul 20, 2026
…ry edits (#10988)

PR #10975 taught the backend matrix filter about shared build inputs, but
left two paths that still rebuild nothing.

Go backends link code from the main tree. `go list -deps ./backend/go/...`
resolves to exactly six pkg subtrees (audio, grpc incl. base/grpcerrors/proto,
httpclient, sound, store, utils), identical for GOOS/GOARCH in
{linux,darwin} x {amd64,arm64}. Editing any of them changes the shipped
binary, but they sit outside every backend directory so the prefix match
never saw them. Enumerating those six rather than taking all of pkg/ is the
point: all of pkg/ changes in ~8.6% of commits, these six in 2.0% — the same
order as the already accepted scripts/build/ rule (1.9%). Blast radius
199/417 Linux, 26/56 Darwin; the ~21 core-server-only pkg subtrees still
rebuild nothing, and neither do _test.go files.

.github/backend-matrix.yml was excluded wholesale because matching its path
would rebuild all 417 entries on every new-backend PR. That hid a real hole:
editing an existing entry's base-image, build-type or cuda version changes
the image it produces while touching no file the filter can see. Since the
change is within a structured file, compare it against the base revision and
rebuild only the entries whose fields actually differ — 1 entry for a
base-image edit, 0 for a comment or whitespace edit, and all 417 only when
the previous revision cannot be resolved. This also closes a third hole: a
new matrix entry for an existing backend (a new CUDA variant, say) touches
nothing under that backend's directory and previously rebuilt nothing.

changed-backends.js fetches the base revision via the contents API, and only
when the changed-file list actually names the matrix file, so the common path
costs no extra request.


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
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.

2 participants