fix(ci): stop the codegen gate from skipping itself on a large diff

`git diff --name-only | grep -qE` matched, grep exited, git died on SIGPIPE, and
`pipefail` turned the whole pipeline into a failure — so a push that edited the
schema alongside enough other files printed `changed=false` and skipped the
freshness check entirely. Read the list into a variable and match it in the
shell instead.

The two unresolvable-base cases could not see any of this: they asserted
`changed=true`, which the diff-failed fallback also prints, so deleting the
guard they were named for left them green. The script now names the branch it
took on stderr and they assert that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-27 14:21:37 +07:00
co-authored by Claude Opus 5
parent 9e217703f8
commit f42a76b1ea
2 changed files with 53 additions and 5 deletions
+24 -3
View File
@@ -21,16 +21,37 @@ else
fi
# When the range can't be resolved (new branch, force-push, tag), check anyway.
# stdout is consumed verbatim as a workflow output, so diagnostics go to stderr.
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "$base^{commit}" 2>/dev/null; then
echo "reason=unresolvable-base" >&2
echo "changed=true"
exit 0
fi
# The generated file is in the list too: a push that edits only it would otherwise skip
# the check, and the drift surfaces later on someone else's unrelated codegen push.
if git diff --name-only "$base" "$HEAD_SHA" | grep -qE '^(backend/pkg/graph/schema\.graphqls|frontend/graphql-schema\.graphql|frontend/graphql-codegen\.ts|frontend/pnpm-lock\.yaml|frontend/src/graphql/types\.ts)$'; then
INPUTS=(
backend/pkg/graph/schema.graphqls
frontend/graphql-schema.graphql
frontend/graphql-codegen.ts
frontend/pnpm-lock.yaml
frontend/src/graphql/types.ts
)
if ! files=$(git diff --name-only "$base" "$HEAD_SHA"); then
echo "reason=diff-failed" >&2
echo "changed=true"
else
echo "changed=false"
exit 0
fi
for input in "${INPUTS[@]}"; do
case $'\n'"$files"$'\n' in
*$'\n'"$input"$'\n'*)
echo "changed=true"
exit 0
;;
esac
done
echo "changed=false"
+29 -2
View File
@@ -1,4 +1,4 @@
import { execFileSync } from 'node:child_process';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -23,6 +23,9 @@ const commit = (path: string, body: string, message: string) => {
const run = (event: string, before: string, baseSha: string, headSha: string) =>
execFileSync(SCRIPT, [event, before, baseSha, headSha], { cwd: repo, encoding: 'utf8' }).trim();
const runReason = (event: string, before: string, baseSha: string, headSha: string) =>
spawnSync(SCRIPT, [event, before, baseSha, headSha], { cwd: repo, encoding: 'utf8' }).stderr.trim();
// The PR arrives as the merge commit GitHub builds, not as the branch head — the range the gate
// picks has to span the whole PR, not the newest push.
beforeAll(() => {
@@ -45,8 +48,16 @@ describe('codegen freshness gate — range selection', () => {
expect(run('pull_request', '', sha.base, sha.merge)).toBe('changed=true');
});
// The answer alone cannot tell this branch from the diff-failed fallback below, which prints
// the same `changed=true`; only the reason distinguishes them.
it('checks a force-push, where the previous head no longer resolves', () => {
expect(run('pull_request', '0'.repeat(40), sha.base, sha.merge)).toBe('changed=true');
expect(run('push', 'b'.repeat(40), '', sha.schema)).toBe('changed=true');
expect(runReason('push', 'b'.repeat(40), '', sha.schema)).toContain('reason=unresolvable-base');
});
it('checks the first push of a branch, where there is no previous head at all', () => {
expect(run('push', '0'.repeat(40), '', sha.schema)).toBe('changed=true');
expect(runReason('push', '0'.repeat(40), '', sha.schema)).toContain('reason=unresolvable-base');
});
it('keeps checking a push by its own range', () => {
@@ -56,4 +67,20 @@ describe('codegen freshness gate — range selection', () => {
it('still skips a PR that touches no codegen input', () => {
expect(run('pull_request', '', sha.schema, sha.unrelated)).toBe('changed=false');
});
it('checks a codegen input whose diff is larger than one pipe buffer', () => {
const pad = 'x'.repeat(180);
mkdirSync(join(repo, 'frontend', 'filler'), { recursive: true });
for (let i = 0; i < 1000; i += 1) {
writeFileSync(join(repo, 'frontend', 'filler', `${i}-${pad}.txt`), '');
}
const bulk = commit('backend/pkg/graph/schema.graphqls', 'type Query { a: Int, b: Int }\n', 'bulk');
const names = git('diff', '--name-only', sha.unrelated, bulk);
expect(names.split('\n')[0]).toBe('backend/pkg/graph/schema.graphqls');
expect(names.length).toBeGreaterThan(64 * 1024);
expect(run('push', sha.unrelated, '', bulk)).toBe('changed=true');
});
});