fix(ci): stop native prebuild rebuild loops

PR path filters and source checks see the cumulative diff, so generated binaries kept rebuilding the original source change. Skip output-only synchronize events using their exact before/head range, failing closed when Git cannot compare it. Exercise the workflow against real commit histories, including multi-commit source pushes and merge-ref drift.
This commit is contained in:
Gergo Magyar 2026-09-05 07:40:42 +00:00
parent 296440886e
commit b422e15874
2 changed files with 214 additions and 13 deletions

View file

@ -38,8 +38,9 @@ name: Build tree-sitter prebuilds
# OR an edit to the grammar's build-affecting source (parser.c / grammar.js /
# binding.gyp / scanner / bindings). The `guard` job is the real gate (it
# diffs BOTH the recorded version AND the source files vs the PR base); the
# `paths:` filter below keeps ordinary code PRs at ZERO matrix time and
# excludes the prebuilds the job commits back, so it never retriggers itself.
# `paths:` filter below keeps ordinary code PRs at ZERO matrix time. PR
# filters see the cumulative diff, so the guard separately skips updates
# containing only the prebuilds the job commits back.
# Net effect: an ordinary code PR triggers nothing; touching one grammar's source
# costs exactly one matrix run for that grammar. Delivery of the rebuilt binaries:
# - same-repo PR -> committed straight onto the PR's own branch (in the SAME PR);
@ -85,8 +86,9 @@ on:
# Any build-affecting change under a vendored grammar triggers a rebuild —
# not just a version bump — so editing the vendored source (parser.c,
# grammar.js, binding.gyp, scanner, bindings) re-cuts the prebuilds too.
# The prebuilds we commit back are EXCLUDED (negated last) so the bot's own
# in-PR commit can never retrigger this workflow (no build->commit->build loop).
# Excludes PRs containing only prebuilds. A source PR still matches after a
# bot commit because PR filters use the cumulative diff; `guard` stops the
# build->commit->build loop using the synchronize event's before/head diff.
- 'gitnexus/vendor/tree-sitter-*/**'
- '!gitnexus/vendor/tree-sitter-*/prebuilds/**'
# Self-test: re-run the guard if a future grammar pin is reintroduced in
@ -128,6 +130,9 @@ jobs:
id: decide
env:
EVENT: ${{ github.event_name }}
ACTION: ${{ github.event.action }}
BEFORE_SHA: ${{ github.event.before }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
# Untrusted dispatch inputs — read via env only, validated in JS.
INPUT_GRAMMARS: ${{ inputs.grammars }}
INPUT_REF: ${{ inputs.ref }}
@ -136,7 +141,7 @@ jobs:
run: |
set -euo pipefail
node --input-type=module - <<'NODE'
import { execSync } from 'node:child_process';
import { execFileSync, execSync } from 'node:child_process';
import fs from 'node:fs';
import { appendFileSync } from 'node:fs';
@ -197,6 +202,30 @@ jobs:
const event = process.env.EVENT;
const force = process.env.FORCE === 'true';
// PR path filters and the source/version checks below see the entire
// PR, so excluding prebuilds there does NOT prevent a rebuild loop.
// Check the whole push (not HEAD^ or the author's identity): a push
// containing source edits followed by a binary commit must still build.
if (event === 'pull_request' && process.env.ACTION === 'synchronize') {
const before = process.env.BEFORE_SHA;
const head = process.env.HEAD_SHA;
for (const sha of [before, head]) {
if (!sha || !/^[0-9a-fA-F]{40}$/.test(sha)) {
throw new Error('synchronize requires valid before/head SHAs; refusing an unbounded rebuild');
}
}
// Fail closed if either commit is unavailable. Never fall back to
// the cumulative PR diff, which would re-enable the loop.
const changed = execFileSync('git', [
'diff', '--name-only', '--no-renames', '-z', before, head, '--',
], { encoding: 'utf8' }).split('\0').filter(Boolean);
if (changed.every((p) => /^gitnexus\/vendor\/tree-sitter-[^/]+\/prebuilds\//.test(p))) {
appendFileSync(process.env.GITHUB_OUTPUT, 'any=false\nmatrix={"include":[]}\n');
console.log('::notice::Push changes only prebuild outputs (or no files) — skipping native matrix.');
process.exit(0);
}
}
// Select which grammar shortnames are in play.
let selected;
if (event === 'workflow_dispatch') {
@ -253,9 +282,9 @@ jobs:
} else {
// pull_request: build when the recorded version changed OR any
// build-affecting source file under the vendored grammar changed vs
// the PR base. The prebuilds/ subtree is excluded from the diff so
// the bot's own in-PR commit (which adds ONLY prebuilds) never reads
// as a source change — this is the other half of the no-loop guard.
// the PR base. Exclude generated outputs from build inputs; the
// synchronize check above prevents rebuilding the original source
// change after every generated-prebuild commit.
const base = recordedVersion(baseRoot, name);
const versionChanged = !!head && head !== base;
let sourceChanged = false;

View file

@ -1,7 +1,19 @@
import { describe, it, expect } from 'vitest';
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import {
existsSync,
mkdtempSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { load } from 'js-yaml';
/**
* Regression guard: every tree-sitter grammar GitNexus ships must provide a
@ -184,9 +196,9 @@ describe('prebuild workflow validate snippets cover every REGISTRY grammar', ()
path.join(GITNEXUS_ROOT, '..', '.github/workflows/build-tree-sitter-prebuilds.yml'),
'utf8',
);
const registry = [
...workflow.matchAll(/^\s{12}(\w+):\s+\{\s+name:\s+'tree-sitter-/gm),
].map((m) => m[1]);
const registry = [...workflow.matchAll(/^\s{12}(\w+):\s+\{\s+name:\s+'tree-sitter-/gm)].map(
(m) => m[1],
);
const snippets = [...workflow.matchAll(/^\s{14}(\w+):\s+"/gm)].map((m) => m[1]);
it('REGISTRY and snippets are both non-empty (the regex still matches the workflow)', () => {
@ -200,3 +212,163 @@ describe('prebuild workflow validate snippets cover every REGISTRY grammar', ()
);
});
});
describe('prebuild workflow rebuild loop guard', () => {
const workflow = load(
readFileSync(
path.join(GITNEXUS_ROOT, '..', '.github/workflows/build-tree-sitter-prebuilds.yml'),
'utf8',
),
) as {
jobs: { guard: { steps: { id?: string; run?: string; env?: Record<string, string> }[] } };
};
const decide = workflow.jobs.guard.steps.find((step) => step.id === 'decide');
if (!decide?.run) throw new Error('Missing prebuild workflow Decide script');
// Execute the actual workflow script against real commits, including the
// cumulative PR diff that remains after generated binaries are committed.
const script = decide.run.match(/node --input-type=module - <<'NODE'\n([\s\S]*?)\nNODE/)?.[1];
if (!script) throw new Error('Missing prebuild workflow Decide Node heredoc');
let root: string;
let base: string;
let source: string;
const vendor = 'gitnexus/vendor/tree-sitter-zig';
function git(...args: string[]): string {
return execFileSync('git', args, { cwd: root, encoding: 'utf8' }).trim();
}
function write(rel: string, contents: string): void {
const dest = path.join(root, rel);
mkdirSync(path.dirname(dest), { recursive: true });
writeFileSync(dest, contents);
}
function commit(message: string): string {
git('add', 'gitnexus');
git('-c', 'commit.gpgsign=false', 'commit', '-qm', message);
return git('rev-parse', 'HEAD');
}
function runGuard(env: Record<string, string> = {}) {
const runnerTemp = mkdtempSync(path.join(root, 'runner-'));
const output = path.join(runnerTemp, 'output');
const result = spawnSync(process.execPath, ['--input-type=module', '-'], {
cwd: root,
input: script,
encoding: 'utf8',
env: {
...process.env,
EVENT: 'pull_request',
ACTION: 'synchronize',
BASE_SHA: base,
BEFORE_SHA: source,
HEAD_SHA: git('rev-parse', 'HEAD'),
INPUT_GRAMMARS: '',
INPUT_REF: '',
FORCE: 'false',
RUNNER_TEMP: runnerTemp,
GITHUB_OUTPUT: output,
...env,
},
});
return { ...result, output: existsSync(output) ? readFileSync(output, 'utf8') : '' };
}
function expectBuild(env: Record<string, string> = {}): void {
const result = runGuard(env);
expect(result.status, result.stderr).toBe(0);
expect(result.output).toContain('any=true\n');
const matrix = JSON.parse(result.output.split('matrix=')[1]);
expect(matrix.include).toHaveLength(6);
expect(matrix.include.every((entry: { grammar: string }) => entry.grammar === 'zig')).toBe(
true,
);
}
beforeEach(() => {
root = mkdtempSync(path.join(tmpdir(), 'gitnexus-prebuild-guard-'));
git('init', '-q');
git('config', 'user.name', 'Prebuild test');
git('config', 'user.email', 'prebuild-test@example.invalid');
mkdirSync(path.join(root, 'hooks'));
git('config', 'core.hooksPath', path.join(root, 'hooks'));
write('gitnexus/package.json', '{}');
base = commit('base without vendored zig');
write(`${vendor}/package.json`, '{"version":"1.1.2"}');
write(`${vendor}/src/parser.c`, 'original source');
source = commit('vendor zig source');
write(`${vendor}/prebuilds/win32-x64/tree-sitter-zig.node`, 'binary build 1');
write(`${vendor}/prebuilds/SHA256SUMS`, 'checksum 1');
commit('generated prebuilds');
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
it('wires the event action and exact push endpoints into the guard', () => {
expect(decide.env).toMatchObject({
ACTION: '${{ github.event.action }}',
BEFORE_SHA: '${{ github.event.before }}',
HEAD_SHA: '${{ github.event.pull_request.head.sha }}',
});
});
it('stops repeated binary-only updates while the PR still contains new source', () => {
let before = source;
for (let build = 2; build <= 4; build++) {
const result = runGuard({ BEFORE_SHA: before });
expect(result.status, result.stderr).toBe(0);
expect(result.output).toBe('any=false\nmatrix={"include":[]}\n');
before = git('rev-parse', 'HEAD');
write(`${vendor}/prebuilds/win32-x64/tree-sitter-zig.node`, `binary build ${build}`);
write(`${vendor}/prebuilds/SHA256SUMS`, `checksum ${build}`);
commit('generated prebuilds');
}
});
it('builds a newly opened PR and supports manual recuts', () => {
expectBuild({ ACTION: 'opened', BEFORE_SHA: '' });
expectBuild({ EVENT: 'workflow_dispatch', ACTION: '', BEFORE_SHA: '', INPUT_GRAMMARS: 'zig' });
});
it('builds source changes even when the last commit in the push only updates binaries', () => {
expectBuild({ BEFORE_SHA: base });
const before = git('rev-parse', 'HEAD');
write(`${vendor}/src/parser.c`, 'updated source without a version bump');
commit('edit parser');
write(`${vendor}/prebuilds/SHA256SUMS`, 'checksum 2');
commit('generated prebuilds');
expectBuild({ BEFORE_SHA: before });
});
it('still builds after unrelated updates that may have cancelled an earlier build', () => {
const before = git('rev-parse', 'HEAD');
write('gitnexus/package.json', '{"description":"updated"}');
commit('update package');
expectBuild({ BEFORE_SHA: before });
});
it('uses event endpoints even when the checkout contains additional base-branch changes', () => {
const head = git('rev-parse', 'HEAD');
write(`${vendor}/src/parser.c`, 'source from an advanced PR merge ref');
commit('simulate merge ref changes');
const result = runGuard({ HEAD_SHA: head });
expect(result.status, result.stderr).toBe(0);
expect(result.output).toBe('any=false\nmatrix={"include":[]}\n');
});
it('does not hide source removal when a source file moves into prebuilds', () => {
const before = git('rev-parse', 'HEAD');
git('mv', `${vendor}/src/parser.c`, `${vendor}/prebuilds/parser.c`);
commit('move source');
expectBuild({ BEFORE_SHA: before });
});
it.each(['', 'not-a-sha', '0'.repeat(40)])(
'fails closed for an unavailable push endpoint: %s',
(before) => {
const result = runGuard({ BEFORE_SHA: before });
expect(result.status).not.toBe(0);
expect(result.output).not.toContain('any=true');
},
);
});