Skip to content

Commit 6e52d0c

Browse files
localai-botmudler
andauthored
fix(ci): rebuild backends when shared build inputs change (#10975)
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`. 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>
1 parent 465d488 commit 6e52d0c

8 files changed

Lines changed: 529 additions & 135 deletions

File tree

.agents/adding-backends.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ The build matrix is data-only YAML at `.github/backend-matrix.yml` (not inside `
3434

3535
**Without an entry here no image is ever built or pushed, and the gallery entry in `backend/index.yaml` will point at a tag that does not exist.** The `dockerfile:` field must point at `./backend/Dockerfile.<lang>` matching the language bucket from step 1 (e.g. `Dockerfile.python`, `Dockerfile.golang`, `Dockerfile.rust`). The `tag-suffix` must match the `uri:` in the corresponding `backend/index.yaml` image entry exactly.
3636

37-
**`scripts/changed-backends.js` registration — REQUIRED for any new dockerfile suffix.** This is the single most common omission, because it has no effect on the PR that adds the backend (when no prior path filter could catch it anyway) — it only breaks the *next* PR that touches your backend's directory, which then gets zero CI jobs and looks broken for unrelated reasons. Edit `scripts/changed-backends.js:inferBackendPath` and add a branch BEFORE the more-generic suffixes:
37+
**Path-filter registration — REQUIRED for any new dockerfile suffix.** This is the single most common omission, because it has no effect on the PR that adds the backend (when no prior path filter could catch it anyway) — it only breaks the *next* PR that touches your backend's directory, which then gets zero CI jobs and looks broken for unrelated reasons. Edit `scripts/lib/backend-filter.mjs:inferBackendPath` and add a branch BEFORE the more-generic suffixes:
3838

3939
```js
4040
if (item.dockerfile.endsWith("<your-dockerfile-suffix>")) {
@@ -54,7 +54,9 @@ for (const e of m.include.filter(e => e.backend === '<your-backend>')) {
5454
}"
5555
```
5656

57-
A quick way to find the right insertion point: `grep -n 'item.dockerfile.endsWith' scripts/changed-backends.js`.
57+
A quick way to find the right insertion point: `grep -n 'item.dockerfile.endsWith' scripts/lib/backend-filter.mjs`.
58+
59+
If your backend consumes a *shared* build input that lives outside its own directory (a new script under `scripts/build/`, a new file copied into every image), add a rule to `SHARED_BUILD_INPUTS` in the same file — the per-backend prefix match cannot see those, and a miss ships your change to no image at all. See `scripts/lib/backend-filter_test.mjs` for the pattern; `make test-ci-scripts` runs it.
5860

5961
**`bump_deps.yaml` registration — REQUIRED for any backend pinning an upstream commit.** If your backend's Makefile has a `*_VERSION?=<sha>` pin to a third-party repo, the daily auto-bump bot at `.github/workflows/bump_deps.yaml` won't notice it unless you register the backend in its matrix. The bot runs `.github/bump_deps.sh` which `grep`s for `^$VAR?=` in the Makefile you list — so the pin MUST live in the Makefile (not in a separate shell script). The bump for ds4 (#9761) had to walk this back because the original landed the pin in `prepare.sh`, which the bot can't see. Pattern (for `antirez/ds4`):
6062

@@ -115,7 +117,7 @@ Wiring a backend into `includeDarwin:` is more than the matrix entry:
115117

116118
1. **`includeDarwin:` entry** — `tag-suffix: "-metal-darwin-arm64-<backend>"`, `build-type: "metal"`, `lang: "go"` for go+ggml backends; omit `build-type` for the bespoke C++ ones (llama-cpp / ds4 / privacy-filter). Match an existing entry of the same shape.
117119
2. **`backend/index.yaml`** — add `metal:` to the backend's `capabilities` map (main and `-development`) and concrete `metal-<backend>` / `metal-<backend>-development` image entries pointing at the `-metal-darwin-arm64-<backend>` images.
118-
3. **C/C++ backends only** — add an `inferBackendPathDarwin` case in `scripts/changed-backends.js` returning `backend/cpp/<backend>/` (the generic fallthrough assumes `backend/<lang>/`, which is wrong for a C++ source tree driven with `lang: go`), and give `run.sh` a Darwin branch that exports `DYLD_LIBRARY_PATH` instead of `LD_LIBRARY_PATH`. If the build is bespoke (single `grpc-server` + dylib bundling), model it on `scripts/build/ds4-darwin.sh` and add a `backends/<backend>-darwin` make target plus a gated step in `.github/workflows/backend_build_darwin.yml`.
120+
3. **C/C++ backends only** — add an `inferBackendPathDarwin` case in `scripts/lib/backend-filter.mjs` returning `backend/cpp/<backend>/` (the generic fallthrough assumes `backend/<lang>/`, which is wrong for a C++ source tree driven with `lang: go`), and give `run.sh` a Darwin branch that exports `DYLD_LIBRARY_PATH` instead of `LD_LIBRARY_PATH`. If the build is bespoke (single `grpc-server` + dylib bundling), model it on `scripts/build/ds4-darwin.sh` and add a `backends/<backend>-darwin` make target plus a gated step in `.github/workflows/backend_build_darwin.yml`.
119121
4. **C++ proto gotcha** — if the backend compiles the generated gRPC/protobuf in a separate CMake target (e.g. `hw_grpc_proto`), that target must link `protobuf::libprotobuf` + `gRPC::grpc++` so the Homebrew include dirs propagate; otherwise macOS fails with `google/protobuf/runtime_version.h not found` (Linux hides this because apt headers sit in `/usr/include`).
120122

121123
The CI path filter only builds a backend on a PR when a file under its directory changes, so a darwin-only YAML edit builds nothing — touch a file under `backend/<lang>/<backend>/` (a one-line comment is enough) in the same PR.
@@ -243,7 +245,7 @@ After adding a new backend, verify:
243245

244246
- [ ] Backend directory structure is complete with all necessary files
245247
- [ ] Build configurations added to `.github/backend-matrix.yml` for all desired platforms (per-arch entries with `platform-tag` for multi-arch; `builder-base-image` for llama-cpp / ik-llama-cpp / turboquant)
246-
- [ ] **OS coverage considered**: added to `includeDarwin:` (macOS/Apple Silicon) if the backend can build there — with the `backend/index.yaml` `metal:` capability + `metal-<backend>` image entries, a `run.sh` Darwin/DYLD branch and `inferBackendPathDarwin` case for C++ backends — or the PR explains why an OS is unsupported. Do not ship Linux-only by default.
248+
- [ ] **OS coverage considered**: added to `includeDarwin:` (macOS/Apple Silicon) if the backend can build there — with the `backend/index.yaml` `metal:` capability + `metal-<backend>` image entries, a `run.sh` Darwin/DYLD branch and `inferBackendPathDarwin` case (in `scripts/lib/backend-filter.mjs`) for C++ backends — or the PR explains why an OS is unsupported. Do not ship Linux-only by default.
247249
- [ ] Meta definition added to `backend/index.yaml` in the `## metas` section
248250
- [ ] Image entries added to `backend/index.yaml` for all build variants (latest + development)
249251
- [ ] Tag suffixes match between workflow file and index.yaml

.agents/ci-caching.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,24 @@ Both `backend.yml` (push) and `backend_pr.yml` (PR) generate their matrix dynami
114114
- **Tag pushes**: `FORCE_ALL=true` is set from the workflow side (`startsWith(github.ref, 'refs/tags/')`) — releases rebuild every backend regardless of diff.
115115
- **Schedule / `workflow_dispatch`**: no `event.before`, falls through to "run everything" automatically.
116116

117+
### Shared build inputs
118+
119+
The per-backend prefix match only sees files under a backend's own directory, so a change to shared build infrastructure would rebuild *nothing* — an empty matrix, every job green, and the change reaching no image. That silently un-shipped PR #10946 (a partial-cuDNN packaging fix in `scripts/build/package-gpu-libs.sh`), which merged 1h48m after the weekly cron and so sat unbuilt for a week.
120+
121+
`SHARED_BUILD_INPUTS` in `scripts/lib/backend-filter.mjs` closes that hole. Each rule maps a shared path to the narrowest set of matrix entries it can honestly invalidate, since a full matrix is 417 Linux + 56 Darwin builds:
122+
123+
| Changed path | Rebuilds |
124+
|---|---|
125+
| `backend/backend.proto` | everything (all languages compile or copy it) |
126+
| `backend/Dockerfile.<x>` | the Linux entries whose `dockerfile:` names it |
127+
| `backend/python/common/` | Python, Linux + Darwin |
128+
| `scripts/build/package-gpu-libs.sh` | Python, Linux only |
129+
| `scripts/build/<lang>-darwin.sh` | the Darwin entries that build target routes to |
130+
| `.github/workflows/backend_build[_darwin].yml` | everything on that OS |
131+
| anything else under `scripts/build/` (except `*_test.sh`) | everything — conservative default for unclassified packaging inputs |
132+
133+
Deliberately excluded: `backend/index.yaml` (gallery metadata, never enters an image), `.github/backend-matrix.yml` (adding a backend would rebuild all of them), `backend/Dockerfile.base-grpc-builder` (owned by `base-images.yml`), and the root `Makefile` (touched in ~11% of commits, and its backend-relevant edits arrive alongside the backend directory anyway). `make test-ci-scripts` pins all of this.
134+
117135
The Sunday 06:00 UTC cron on `backend.yml` exists specifically because path filtering can leave Python backends frozen on stale wheels. `DEPS_REFRESH` (below) only fires when the build actually runs, so an untouched Python backend would never re-resolve its unpinned deps. The weekly cron is the safety net.
118136

119137
## The `DEPS_REFRESH` cache-buster (Python backends)

.github/backend-matrix.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
# checklist in .agents/adding-backends.md (includeDarwin entry, the index.yaml
2424
# `metal:` capability + `metal-<backend>` image entries, a `run.sh` Darwin/DYLD
2525
# branch for C/C++ backends, and the inferBackendPathDarwin case in
26-
# scripts/changed-backends.js so the path filter actually builds it).
26+
# scripts/lib/backend-filter.mjs so the path filter actually builds it).
2727

2828
# Linux matrix (consumed by backend-jobs).
2929
include:

.github/workflows/lint.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,3 +57,12 @@ jobs:
5757
- uses: actions/checkout@v7
5858
- name: run packaging script tests
5959
run: make test-build-scripts
60+
61+
# The backend matrix path filter fails silently: a miss emits an empty
62+
# matrix, every job goes green, and the change reaches no image (#10946).
63+
# Its tests need only node, so they ride along with this job.
64+
- uses: actions/setup-node@v4
65+
with:
66+
node-version: '20'
67+
- name: run CI script tests
68+
run: make test-ci-scripts

Makefile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,13 @@ test-backend-cpp:
215215
test-build-scripts:
216216
@set -e; for t in scripts/build/*_test.sh; do echo "== $$t"; bash "$$t"; done
217217

218+
## Runs the unit tests for the CI helper scripts under scripts/lib/. Currently
219+
## the backend matrix path filter, whose failure mode is invisible in CI: it
220+
## emits an empty matrix, every job goes green, and the change ships to no
221+
## image at all (see PR #10946). Plain `node --test`, no dependencies.
222+
test-ci-scripts:
223+
@set -e; for t in scripts/lib/*_test.mjs; do echo "== $$t"; node --test "$$t"; done
224+
218225
## Runs the core suite ($(TEST_PATHS)) with statement-coverage instrumentation
219226
## and writes a merged profile to $(COVERAGE_PROFILE). Deliberately omits
220227
## --fail-fast so a single failure doesn't truncate the coverage number, and

scripts/changed-backends.js

Lines changed: 12 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@ import fs from "fs";
22
import * as yaml from "js-yaml";
33
import { Octokit } from "@octokit/core";
44

5+
import {
6+
getAllBackendPaths,
7+
filterMatrix,
8+
} from "./lib/backend-filter.mjs";
9+
510
// Matrix data lives in a small data-only YAML so both backend.yml (master push)
611
// and backend_pr.yml (pull_request) can use a dynamic `matrix: ${{ fromJson(...) }}`
712
// for the live job, while this script remains the single source of truth for
@@ -13,125 +18,7 @@ const includesDarwin = matrixYml.includeDarwin;
1318
const eventPath = process.env.GITHUB_EVENT_PATH;
1419
const event = JSON.parse(fs.readFileSync(eventPath, "utf8"));
1520

16-
// Infer backend path
17-
function inferBackendPath(item) {
18-
if (item.dockerfile.endsWith("python")) {
19-
return `backend/python/${item.backend}/`;
20-
}
21-
// parakeet-cpp is a Go backend (Dockerfile.golang) wrapping the parakeet.cpp
22-
// ggml port via purego. It lives in backend/go/parakeet-cpp/; this explicit
23-
// branch (placed before the generic golang one, which would also resolve it
24-
// correctly) documents the mapping and guards against a future
25-
// dockerfile-suffix change.
26-
if (item.backend === "parakeet-cpp") {
27-
return `backend/go/parakeet-cpp/`;
28-
}
29-
// ced is a Go backend (Dockerfile.golang) wrapping the ced.cpp ggml port via
30-
// purego, living in backend/go/ced/. Same explicit-branch rationale as
31-
// parakeet-cpp above: the generic golang fallthrough would also resolve it,
32-
// but this documents the mapping and guards a future dockerfile-suffix change.
33-
if (item.backend === "ced") {
34-
return `backend/go/ced/`;
35-
}
36-
// moss-transcribe-cpp is a Go backend (Dockerfile.golang) wrapping the
37-
// moss-transcribe.cpp ggml port via purego, living in
38-
// backend/go/moss-transcribe-cpp/. Same explicit-branch rationale as
39-
// parakeet-cpp / ced: the generic golang fallthrough would also resolve it,
40-
// but this documents the mapping and guards a future dockerfile-suffix change.
41-
if (item.backend === "moss-transcribe-cpp") {
42-
return `backend/go/moss-transcribe-cpp/`;
43-
}
44-
// moss-tts-cpp is a Go backend (Dockerfile.golang) wrapping the moss-tts.cpp
45-
// ggml port via purego, living in backend/go/moss-tts-cpp/. Same
46-
// explicit-branch rationale as parakeet-cpp / ced / moss-transcribe-cpp: the
47-
// generic golang fallthrough would also resolve it, but this documents the
48-
// mapping and guards a future dockerfile-suffix change.
49-
if (item.backend === "moss-tts-cpp") {
50-
return `backend/go/moss-tts-cpp/`;
51-
}
52-
if (item.dockerfile.endsWith("golang")) {
53-
return `backend/go/${item.backend}/`;
54-
}
55-
if (item.dockerfile.endsWith("rust")) {
56-
return `backend/rust/${item.backend}/`;
57-
}
58-
if (item.dockerfile.endsWith("ik-llama-cpp")) {
59-
return `backend/cpp/ik-llama-cpp/`;
60-
}
61-
if (item.dockerfile.endsWith("turboquant")) {
62-
// turboquant is a llama.cpp fork that reuses backend/cpp/llama-cpp sources
63-
// via a thin wrapper Makefile. Changes to either dir should retrigger it.
64-
return `backend/cpp/turboquant/`;
65-
}
66-
if (item.dockerfile.endsWith("bonsai")) {
67-
// bonsai is a llama.cpp fork that reuses backend/cpp/llama-cpp sources
68-
// via a thin wrapper Makefile. Changes to either dir should retrigger it.
69-
return `backend/cpp/bonsai/`;
70-
}
71-
if (item.dockerfile.endsWith("privacy-filter")) {
72-
return `backend/cpp/privacy-filter/`;
73-
}
74-
if (item.dockerfile.endsWith("ds4")) {
75-
return `backend/cpp/ds4/`;
76-
}
77-
if (item.dockerfile.endsWith("llama-cpp")) {
78-
return `backend/cpp/llama-cpp/`;
79-
}
80-
return null;
81-
}
82-
83-
function inferBackendPathDarwin(item) {
84-
// llama-cpp on Darwin builds from the C++ sources, not a backend/go/llama-cpp
85-
// tree (which doesn't exist). The Darwin job is matrix-driven with lang=go
86-
// for runner/toolchain selection, but the source path is C++.
87-
if (item.backend === "llama-cpp") {
88-
return `backend/cpp/llama-cpp/`;
89-
}
90-
// ds4 is C++ too (built via `make backends/ds4-darwin`); the matrix entry
91-
// carries lang=go for runner/toolchain selection, but the source is C++.
92-
if (item.backend === "ds4") {
93-
return `backend/cpp/ds4/`;
94-
}
95-
// privacy-filter is C++ too (built via `make backends/privacy-filter-darwin`);
96-
// same lang=go-for-runner convention, source under backend/cpp.
97-
if (item.backend === "privacy-filter") {
98-
return `backend/cpp/privacy-filter/`;
99-
}
100-
if (!item.lang) {
101-
return `backend/python/${item.backend}/`;
102-
}
103-
104-
return `backend/${item.lang}/${item.backend}/`;
105-
}
106-
107-
// Build a deduplicated map of backend name -> path prefix from all matrix entries
108-
function getAllBackendPaths() {
109-
const paths = new Map();
110-
for (const item of includes) {
111-
const p = inferBackendPath(item);
112-
if (p && !paths.has(item.backend)) {
113-
paths.set(item.backend, p);
114-
}
115-
}
116-
for (const item of includesDarwin) {
117-
const p = inferBackendPathDarwin(item);
118-
if (p && !paths.has(item.backend)) {
119-
paths.set(item.backend, p);
120-
}
121-
}
122-
return paths;
123-
}
124-
125-
const allBackendPaths = getAllBackendPaths();
126-
127-
function backendChanged(backend, pathPrefix, changedFiles) {
128-
if (changedFiles.some(file => file.startsWith(pathPrefix))) return true;
129-
130-
// Fork backends reuse backend/cpp/llama-cpp sources via thin wrappers;
131-
// changes to either directory must retrigger their pipelines.
132-
return (backend === "turboquant" || backend === "bonsai") &&
133-
changedFiles.some(file => file.startsWith("backend/cpp/llama-cpp/"));
134-
}
21+
const allBackendPaths = getAllBackendPaths(includes, includesDarwin);
13522

13623
const token = process.env.GITHUB_TOKEN;
13724
const octokit = new Octokit({ auth: token });
@@ -316,15 +203,10 @@ function emitFullMatrix() {
316203
function emitFilteredMatrix(changedFiles) {
317204
console.log("Changed files:", changedFiles);
318205

319-
const filtered = includes.filter(item => {
320-
const backendPath = inferBackendPath(item);
321-
if (!backendPath) return false;
322-
return backendChanged(item.backend, backendPath, changedFiles);
323-
});
324-
325-
const filteredDarwin = includesDarwin.filter(item => {
326-
const backendPath = inferBackendPathDarwin(item);
327-
return changedFiles.some(file => file.startsWith(backendPath));
206+
const { filtered, filteredDarwin, changedBackends } = filterMatrix({
207+
includes,
208+
includesDarwin,
209+
changedFiles,
328210
});
329211

330212
console.log("Filtered files:", filtered);
@@ -350,8 +232,8 @@ function emitFilteredMatrix(changedFiles) {
350232
emitSinglearchShards(singlearch);
351233

352234
// Per-backend boolean outputs
353-
for (const [backend, pathPrefix] of allBackendPaths) {
354-
const changed = backendChanged(backend, pathPrefix, changedFiles);
235+
for (const backend of allBackendPaths.keys()) {
236+
const changed = changedBackends.has(backend);
355237
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${backend}=${changed ? 'true' : 'false'}\n`);
356238
}
357239
}

0 commit comments

Comments
 (0)