diff --git a/.github/workflows/build-tree-sitter-prebuilds.yml b/.github/workflows/build-tree-sitter-prebuilds.yml index e8570205e..ef64a64b8 100644 --- a/.github/workflows/build-tree-sitter-prebuilds.yml +++ b/.github/workflows/build-tree-sitter-prebuilds.yml @@ -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; diff --git a/gitnexus/test/unit/prebuild-coverage.test.ts b/gitnexus/test/unit/prebuild-coverage.test.ts index e31b67889..d8b554c6a 100644 --- a/gitnexus/test/unit/prebuild-coverage.test.ts +++ b/gitnexus/test/unit/prebuild-coverage.test.ts @@ -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 }[] } }; + }; + 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 = {}) { + 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 = {}): 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'); + }, + ); +});