Skip to content

Commit f430648

Browse files
committed
fix(ci): rebuild Go backends on linked pkg/ changes and on matrix entry edits
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. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
1 parent 0d9d07d commit f430648

3 files changed

Lines changed: 371 additions & 11 deletions

File tree

scripts/changed-backends.js

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,14 @@ import { Octokit } from "@octokit/core";
55
import {
66
getAllBackendPaths,
77
filterMatrix,
8+
BACKEND_MATRIX_FILE,
89
} from "./lib/backend-filter.mjs";
910

1011
// Matrix data lives in a small data-only YAML so both backend.yml (master push)
1112
// and backend_pr.yml (pull_request) can use a dynamic `matrix: ${{ fromJson(...) }}`
1213
// for the live job, while this script remains the single source of truth for
1314
// "what backends does the project know about".
14-
const matrixYml = yaml.load(fs.readFileSync(".github/backend-matrix.yml", "utf8"));
15+
const matrixYml = yaml.load(fs.readFileSync(BACKEND_MATRIX_FILE, "utf8"));
1516
const includes = matrixYml.include;
1617
const includesDarwin = matrixYml.includeDarwin;
1718

@@ -76,6 +77,45 @@ async function getChangedFilesForPush(event) {
7677
return res.data.files.map(f => f.filename);
7778
}
7879

80+
// The matrix file's contents at the base revision, so filterMatrix can rebuild
81+
// only the entries whose fields actually changed instead of all 417 (or, as
82+
// before, none of them). Returns null when the previous revision cannot be
83+
// resolved, which filterMatrix treats as "rebuild everything".
84+
//
85+
// Only called when the changed-file list actually names the matrix file, so the
86+
// common path costs no extra API request.
87+
async function getPreviousMatrix(event) {
88+
const ref = event.pull_request ? event.pull_request.base.sha : event.before;
89+
if (!ref || /^0+$/.test(ref)) return null;
90+
const owner = event.repository.owner.login;
91+
const repo = event.repository.name;
92+
try {
93+
const res = await octokit.request('GET /repos/{owner}/{repo}/contents/{path}', {
94+
owner,
95+
repo,
96+
path: BACKEND_MATRIX_FILE,
97+
ref,
98+
mediaType: { format: 'raw' },
99+
});
100+
// `format: raw` yields a string; fall back to the JSON representation in
101+
// case a proxy or a future Octokit version ignores the media type.
102+
const raw = typeof res.data === 'string'
103+
? res.data
104+
: Buffer.from(res.data.content, 'base64').toString('utf8');
105+
const previous = yaml.load(raw);
106+
return {
107+
include: previous.include || [],
108+
includeDarwin: previous.includeDarwin || [],
109+
};
110+
} catch (err) {
111+
console.log(
112+
`could not read ${BACKEND_MATRIX_FILE} at ${ref}, falling back to run-all:`,
113+
err.message
114+
);
115+
return null;
116+
}
117+
}
118+
79119
// Group matrix entries by tag-suffix and emit a merge-matrix entry per group.
80120
// Both multi-leg groups (per-arch fan-out) and singletons get one entry each:
81121
// the build job pushes by digest only with no tags applied, so every backend
@@ -200,13 +240,14 @@ function emitFullMatrix() {
200240
}
201241
}
202242

203-
function emitFilteredMatrix(changedFiles) {
243+
function emitFilteredMatrix(changedFiles, previousMatrix) {
204244
console.log("Changed files:", changedFiles);
205245

206246
const { filtered, filteredDarwin, changedBackends } = filterMatrix({
207247
includes,
208248
includesDarwin,
209249
changedFiles,
250+
previousMatrix,
210251
});
211252

212253
console.log("Filtered files:", filtered);
@@ -260,5 +301,10 @@ function emitFilteredMatrix(changedFiles) {
260301
emitFullMatrix();
261302
return;
262303
}
263-
emitFilteredMatrix(changedFiles);
304+
305+
const previousMatrix = changedFiles.includes(BACKEND_MATRIX_FILE)
306+
? await getPreviousMatrix(event)
307+
: null;
308+
309+
emitFilteredMatrix(changedFiles, previousMatrix);
264310
})();

scripts/lib/backend-filter.mjs

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,39 @@ const isDarwinGenericGo = item =>
137137

138138
const isLinuxPython = item => item.dockerfile.endsWith("python");
139139

140+
// Dockerfile.golang is the only Linux dockerfile that compiles Go (it runs
141+
// `make protogen-go && make -C backend/go/<backend> build`); every other one
142+
// builds C++, Rust or a Python venv and links no Go code at all.
143+
const isLinuxGo = item => item.dockerfile.endsWith("golang");
144+
140145
const never = () => false;
141146
const always = () => true;
142147

148+
// The pkg/ subtrees that end up inside a Go backend binary. Not a guess:
149+
//
150+
// go list -deps ./backend/go/... | grep LocalAI/pkg
151+
//
152+
// returns exactly pkg/audio, pkg/grpc (+ base, grpcerrors, proto), pkg/httpclient,
153+
// pkg/sound, pkg/store and pkg/utils — identical for GOOS/GOARCH in
154+
// {linux,darwin} x {amd64,arm64}, so one list covers both matrices. Notably
155+
// absent: pkg/model, pkg/downloader, pkg/functions and the other ~21 pkg
156+
// subtrees, which are core-server-only.
157+
//
158+
// Enumerating rather than taking all of pkg/ is the whole point. All of pkg/
159+
// changes in ~8.6% of commits and would fire a 199-entry Go matrix that often;
160+
// these six subtrees change in 2.0%, which is the same order as the already
161+
// accepted scripts/build/ rule (1.9%). If the enumeration ever drifts from the
162+
// `go list` output the tests pin both directions — a listed subtree must
163+
// trigger, an unlisted one must not.
164+
const GO_BACKEND_PKG_PREFIXES = [
165+
"pkg/audio/",
166+
"pkg/grpc/", // covers base/, grpcerrors/ and the generated proto/
167+
"pkg/httpclient/",
168+
"pkg/sound/",
169+
"pkg/store/",
170+
"pkg/utils/",
171+
];
172+
143173
// Shared build inputs: files that end up in, or decide the contents of, images
144174
// belonging to backends whose own directory they do not live under. The
145175
// per-backend prefix match in filterMatrix() structurally cannot see these, so
@@ -173,6 +203,21 @@ export const SHARED_BUILD_INPUTS = [
173203
linux: isLinuxPython,
174204
darwin: isDarwinPython,
175205
},
206+
{
207+
// Compiled into every Go backend binary (see GO_BACKEND_PKG_PREFIXES).
208+
// `_test.go` files never reach a binary, same carve-out as the
209+
// `*_test.sh` one on the scripts/build/ catch-all below.
210+
//
211+
// Darwin's bespoke builders (llama-cpp, ds4, privacy-filter) carry
212+
// lang=go for runner selection only — their sources are C++ and link no
213+
// Go, so isDarwinGenericGo is the correct predicate here, exactly as it
214+
// is for scripts/build/golang-darwin.sh.
215+
matches: file =>
216+
GO_BACKEND_PKG_PREFIXES.some(prefix => file.startsWith(prefix)) &&
217+
!file.endsWith("_test.go"),
218+
linux: isLinuxGo,
219+
darwin: isDarwinGenericGo,
220+
},
176221
{
177222
// The reusable build workflows own build-args, packaging and push for
178223
// their whole OS family, so a change there can alter what lands in every
@@ -247,6 +292,51 @@ function dockerfileChanged(item, changedFiles) {
247292
return df !== "" && changedFiles.includes(df);
248293
}
249294

295+
// .github/backend-matrix.yml is a shared build input like the ones above, but
296+
// it cannot be handled by a path rule: every entry lives in it, so matching the
297+
// path would rebuild all 417 Linux entries on every PR that adds a backend —
298+
// and new backends already trigger via their own directory. Excluding it
299+
// wholesale is what the rest of this file does, and that leaves a real hole:
300+
// editing an existing entry's base-image / build-type / cuda version changes
301+
// the image that entry produces while touching no file the prefix match can
302+
// see, so it rebuilt nothing.
303+
//
304+
// The fix needs the file's *previous* contents, which a changed-path list
305+
// cannot carry. changed-backends.js fetches the base revision via the GitHub
306+
// contents API and hands it in as `previousMatrix`; everything below stays
307+
// pure. When the file did not change, `previousMatrix` is never consulted.
308+
export const BACKEND_MATRIX_FILE = ".github/backend-matrix.yml";
309+
310+
// Identity of a matrix entry across revisions. tag-suffix names the image;
311+
// per-arch legs of the same image are distinguished by platform-tag. Verified
312+
// unique across all 417 Linux and 56 Darwin entries.
313+
export function matrixEntryKey(item) {
314+
return JSON.stringify([item["tag-suffix"] || "", item["platform-tag"] || ""]);
315+
}
316+
317+
// Full field set, order-independent, so a reordered or reindented YAML entry
318+
// compares equal and only a real value change counts.
319+
function entryFingerprint(item) {
320+
return JSON.stringify(
321+
Object.keys(item).sort().map(key => [key, item[key]])
322+
);
323+
}
324+
325+
// Keys of entries that are new or whose fields differ from the previous
326+
// revision. Removed entries produce nothing to build.
327+
function changedEntryKeys(current, previous) {
328+
const before = new Map();
329+
for (const item of previous) {
330+
before.set(matrixEntryKey(item), entryFingerprint(item));
331+
}
332+
const keys = new Set();
333+
for (const item of current) {
334+
const key = matrixEntryKey(item);
335+
if (before.get(key) !== entryFingerprint(item)) keys.add(key);
336+
}
337+
return keys;
338+
}
339+
250340
function matchedSharedRules(changedFiles) {
251341
const rules = [];
252342
for (const file of changedFiles) {
@@ -259,20 +349,46 @@ function matchedSharedRules(changedFiles) {
259349
// Filter both matrices against a changed-file list. Returns the surviving
260350
// entries plus the set of backend names considered changed, which drives the
261351
// per-backend boolean outputs consumed by test-extra.yml.
262-
export function filterMatrix({ includes, includesDarwin, changedFiles }) {
352+
export function filterMatrix({
353+
includes,
354+
includesDarwin,
355+
changedFiles,
356+
previousMatrix,
357+
}) {
263358
const sharedRules = matchedSharedRules(changedFiles);
264359

360+
const matrixFileChanged = changedFiles.includes(BACKEND_MATRIX_FILE);
361+
// The matrix file changed but we could not resolve what it used to say (API
362+
// failure, shallow history, first push). Claiming "no entry changed" would
363+
// reintroduce exactly the silent-empty-matrix failure this file exists to
364+
// prevent, so fall back to rebuilding everything — the same posture
365+
// changed-backends.js takes for a truncated or unavailable diff.
366+
const matrixDiffUnavailable = matrixFileChanged && !previousMatrix;
367+
368+
const changedLinuxKeys =
369+
matrixFileChanged && previousMatrix
370+
? changedEntryKeys(includes, previousMatrix.include || [])
371+
: null;
372+
const changedDarwinKeys =
373+
matrixFileChanged && previousMatrix
374+
? changedEntryKeys(includesDarwin, previousMatrix.includeDarwin || [])
375+
: null;
376+
265377
const filtered = includes.filter(item => {
266378
const backendPath = inferBackendPath(item);
267379
if (!backendPath) return false;
268380
if (backendChanged(item.backend, backendPath, changedFiles)) return true;
269381
if (dockerfileChanged(item, changedFiles)) return true;
382+
if (matrixDiffUnavailable) return true;
383+
if (changedLinuxKeys && changedLinuxKeys.has(matrixEntryKey(item))) return true;
270384
return sharedRules.some(rule => rule.linux(item));
271385
});
272386

273387
const filteredDarwin = includesDarwin.filter(item => {
274388
const backendPath = inferBackendPathDarwin(item);
275389
if (changedFiles.some(file => file.startsWith(backendPath))) return true;
390+
if (matrixDiffUnavailable) return true;
391+
if (changedDarwinKeys && changedDarwinKeys.has(matrixEntryKey(item))) return true;
276392
return sharedRules.some(rule => rule.darwin(item));
277393
});
278394

0 commit comments

Comments
 (0)