mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-06 02:49:56 +00:00
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:
parent
296440886e
commit
b422e15874
2 changed files with 214 additions and 13 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue