fix(ci): pick the codegen-freshness range from the PR base, not the last push

On pull_request, `github.event.before` is the PR's previous head, so the second
push to a PR diffs a range that no longer contains the commit that edited the
schema: the detector emits changed=false, the freshness step is skipped by its
`if:`, and lint-and-test goes green on a stale src/graphql/types.ts. Every
later push that does not itself touch one of the five inputs repeats it, forks
included — which is the case the trigger was added for. Nothing else covers the
gap: tsc reads the committed file as truth, eslint ignores it, and prettier
passes on codegen's own output.

Diff from pull_request.base.sha on PR events and keep `before` for push. The
range selection moves out of the YAML into .github/scripts so it can be driven
over real git fixtures: on the three-commit fixture the old range yields
changed=false and the new one changed=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-25 22:46:19 +07:00
co-authored by Claude Opus 4.8
parent 24e0529379
commit b58a40dd72
3 changed files with 100 additions and 14 deletions
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env bash
# Decide whether a run has to re-check that frontend/src/graphql/types.ts is fresh.
# Echoes `changed=true|false`. Split out of ci.yml so the range selection is testable
# (frontend/e2e/ci-codegen-gate.unit.test.ts drives it over real git fixtures).
#
# Usage: codegen-inputs-changed.sh <event_name> <before_sha> <base_sha> <head_sha>
set -uo pipefail
EVENT="${1:-}"
BEFORE="${2:-}"
BASE_SHA="${3:-}"
HEAD_SHA="${4:-}"
# On pull_request, `before` is the PR's PREVIOUS HEAD, so a follow-up push diffs a range
# that no longer contains the commit that edited the schema — the gate would skip itself
# on every push after the first. The PR's base is the range that always spans the change.
if [ "$EVENT" = "pull_request" ]; then
base="$BASE_SHA"
else
base="$BEFORE"
fi
# When the range can't be resolved (new branch, force-push, tag), check anyway.
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "$base^{commit}" 2>/dev/null; then
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
echo "changed=true"
else
echo "changed=false"
fi
+5 -14
View File
@@ -80,23 +80,14 @@ jobs:
# Only when a codegen input moved. The lockfile is included on purpose: a codegen
# version bump can change the output, and skipping it there would let types.ts go
# stale and fail the next schema push for something its author did not do.
# When the range can't be resolved (new branch, force-push, tag), check anyway.
- name: Frontend - Detect GraphQL codegen input changes
id: codegen-inputs
run: |
base='${{ github.event.before }}'
if [ -z "$base" ] || [ "$base" = "0000000000000000000000000000000000000000" ] \
|| ! git cat-file -e "$base^{commit}" 2>/dev/null; then
echo "changed=true" >> "$GITHUB_OUTPUT"
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" "${{ github.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
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
.github/scripts/codegen-inputs-changed.sh \
'${{ github.event_name }}' \
'${{ github.event.before }}' \
'${{ github.event.pull_request.base.sha }}' \
'${{ github.sha }}' >> "$GITHUB_OUTPUT"
# The app ships the operations compiled into src/graphql/types.ts; regenerate and
# diff so it can't drift from graphql-schema.graphql (the codegen input the stand's
+59
View File
@@ -0,0 +1,59 @@
import { execFileSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
const SCRIPT = join(__dirname, '..', '..', '.github', 'scripts', 'codegen-inputs-changed.sh');
let repo = '';
const sha: Record<string, string> = {};
const git = (...args: string[]) => execFileSync('git', ['-C', repo, ...args], { encoding: 'utf8' }).trim();
const commit = (path: string, body: string, message: string) => {
mkdirSync(join(repo, path.slice(0, path.lastIndexOf('/'))), { recursive: true });
writeFileSync(join(repo, path), body);
git('add', '-A');
git('-c', 'user.email=e2e@example.com', '-c', 'user.name=e2e', 'commit', '-m', message);
return git('rev-parse', 'HEAD');
};
const run = (event: string, before: string, baseSha: string, headSha: string) =>
execFileSync(SCRIPT, [event, before, baseSha, headSha], { cwd: repo, encoding: 'utf8' }).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(() => {
repo = mkdtempSync(join(tmpdir(), 'codegen-gate-'));
git('init', '-q', '-b', 'main');
sha.base = commit('README.md', 'base\n', 'base');
sha.schema = commit('backend/pkg/graph/schema.graphqls', 'type Query { a: Int }\n', 'edit the schema');
sha.unrelated = commit('README.md', 'base\nmore\n', 'unrelated follow-up');
sha.merge = git('commit-tree', `${sha.unrelated}^{tree}`, '-p', sha.base, '-p', sha.unrelated, '-m', 'merge');
});
afterAll(() => rmSync(repo, { force: true, recursive: true }));
describe('codegen freshness gate — range selection', () => {
it('checks a follow-up push whose newest commit did not touch a codegen input', () => {
expect(run('pull_request', sha.schema, sha.base, sha.merge)).toBe('changed=true');
});
it('checks a freshly opened PR, where there is no previous head', () => {
expect(run('pull_request', '', sha.base, sha.merge)).toBe('changed=true');
});
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');
});
it('keeps checking a push by its own range', () => {
expect(run('push', sha.base, '', sha.schema)).toBe('changed=true');
});
it('still skips a PR that touches no codegen input', () => {
expect(run('pull_request', '', sha.schema, sha.unrelated)).toBe('changed=false');
});
});