Skip to content

ci: run pre-publish benchmark gate on every PR#1072

Open
carlos-alm wants to merge 12 commits intomainfrom
ci/regression-gate-on-pr
Open

ci: run pre-publish benchmark gate on every PR#1072
carlos-alm wants to merge 12 commits intomainfrom
ci/regression-gate-on-pr

Conversation

@carlos-alm
Copy link
Copy Markdown
Contributor

Summary

  • Ports the pre-publish-benchmark job from publish.yml into ci.yml so the regression gate runs on every PR instead of only at release time.
  • Reuses the existing native-host-build artifact (linux-x64), runs in parallel with the rest of CI, and is wired into ci-pipeline needs so a regression fails the PR.
  • Preserves CODEGRAPH_FAST_SKIP_DIAG=1 and the verbose vitest reporters so when something regresses, the logs already pinpoint the cause without re-running locally.

Why

Until now, the regression gate only ran inside publish.yml's pre-publish path (stable releases / workflow_dispatch). Regressions could land on main and were only caught at the next release. The recent diagnostics work (#1067, #1066 follow-ups) makes the gate's logs genuinely useful — moving it onto every PR puts that signal in front of the change that introduced the regression, while it's still cheap to fix.

How it differs from publish.yml's gate

  • Version label: --version dev instead of a semver. update-benchmark-report.ts treats dev as a rolling entry (replaces previous dev); the regression-guard's findLatestPair compares dev against the most recent non-skipped release.
  • Artifact source: downloads native-host-ubuntu-latest from CI's existing matrix instead of native-linux-x64 from publish's matrix — no new Rust build added.
  • No history-file artifact upload: that's only needed by the post-publish record-benchmarks job in benchmark.yml.
  • JSON results uploaded with if: always(): failed gates are debuggable without re-running the suite locally.

Test plan

  • CI green on this PR — confirms the new job builds, downloads the host artifact, runs all benchmark scripts (build/resolution/query/incremental), passes the resolution thresholds, tracer validation, and regression guard.
  • Verify the new job runs in parallel with lint/typecheck/test (only depends on native-host-build).
  • On a follow-up PR that intentionally regresses a metric (or just look at logs), confirm the gate fails with the diagnostic context (CODEGRAPH_FAST_SKIP_DIAG=1, verbose reporters, JSON artifact attached).

Mirrors the regression gate from publish.yml so PRs catch performance
regressions before merge instead of at release time. The gate measures
the PR-built native artifact as version "dev", appends to the benchmark
history files in-job, and fails if any metric regresses beyond threshold
vs the most recent release baseline.

Reuses the existing native-host-build artifact (linux-x64), runs in
parallel with the rest of CI, and preserves CODEGRAPH_FAST_SKIP_DIAG
so fast-skip rejection reasons surface in CI logs when issues happen.
JSON results upload on always() for post-failure debugging.
@claude
Copy link
Copy Markdown

claude Bot commented May 6, 2026

Claude finished @carlos-alm's task —— View job


Reviewing PR: ci: run pre-publish benchmark gate on every PR

Todo List:

  • Read and analyze the changed workflow file
  • Phase 1: Root cause analysis
  • Phase 2: Configuration change detection
  • Phase 3: Test integrity assessment
  • Phase 4: Code quality review
  • Phase 5: Backlog compliance evaluation
  • Provide final assessment and rating

Root Cause Analysis

Problem Being Solved: Performance regressions were only being caught at release time via the pre-publish benchmark gate in publish.yml, allowing regressions to land on main and only surface during the next release cycle when they're expensive to fix.

Why This Approach: Moving the gate to run on every PR means regressions are caught immediately when introduced, while the diagnostic context is still fresh and the fix is cheap. The recent diagnostics improvements (#1067, #1066) make the gate logs genuinely useful for debugging.

Risk Assessment: This is a defensive change that adds quality gates without removing existing safeguards. The approach mirrors the existing proven gate from publish.yml with minimal modifications.

Backlog Compliance

  • Zero-dep: ✓ — No new runtime dependencies added, builds on existing benchmark infrastructure
  • Foundation-aligned: ✓ — Aligns with P1 (always-current) by preventing regressions from reaching main, supports P5 (embeddable-first) by ensuring API stability
  • Problem-fit: 4 — Directly improves development workflow by catching performance regressions before they affect users/agents
  • Breaking: No — Purely additive CI enhancement, no changes to user-facing APIs or behavior
  • Tier: 1

Critical Concerns

None identified. This is a well-executed defensive improvement.

Strengths:

  1. Smart artifact reuse (lines 339-343): Reuses native-host-ubuntu-latest from existing CI matrix instead of building a separate artifact, avoiding duplication
  2. Proper parallelization (line 310, 444): Depends only on native-host-build, runs parallel with other checks, doesn't extend critical path
  3. Rolling version strategy (line 374): Uses --version dev as a rolling entry that replaces previous dev runs, compared against latest stable baseline
  4. Debuggable failures (lines 431-440): if: always() ensures JSON artifacts are uploaded even on failure, making regressions debuggable without local reproduction
  5. Preserved diagnostics (line 316): Maintains CODEGRAPH_FAST_SKIP_DIAG=1 for the incremental rebuild regression investigation
  6. Identical test coverage: All four benchmark suites (build/resolution/query/incremental) plus threshold gates and tracer validation preserved from publish.yml

Technical Implementation Quality:

  • Clean duplication strategy: Rather than complex abstraction, cleanly duplicates the proven logic with targeted modifications (version label, artifact source)
  • Consistent with existing patterns: Uses same timeout values, shell scripting patterns, and error handling as other CI jobs
  • Proper gate integration: Correctly wired into ci-pipeline needs array (line 444) so regressions fail the PR

Minor Considerations:

  • Resource usage: This adds ~5-10 minutes of compute time per PR, but given the value of catching regressions early, this is well-justified overhead
  • No history upload: Correctly omits the history file artifact upload since that's only needed for the post-publish recording job

Final Recommendation

  • Rating: ⭐⭐⭐⭐☆ (4/5)
  • Action: APPROVE
  • Reasoning: This is a high-quality defensive improvement that addresses a real pain point (late regression detection) with a proven, well-implemented solution. The approach is technically sound, preserves all safeguards, and follows existing patterns. The only reason it's not 5 stars is that it's "just" moving existing functionality rather than innovative new capability—but that's actually a strength for this type of quality gate.

@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 6, 2026

Greptile Summary

This PR ports the pre-publish-benchmark gate from publish.yml into ci.yml so benchmark regressions are caught on every PR rather than only at release time. It also updates regression-guard.test.ts to correctly handle the rolling dev version produced by the per-PR gate.

  • CI job (ci.yml): Adds pre-publish-benchmark that downloads the PR-built native addon, runs all four benchmark scripts with --version dev, and wires the result into ci-pipeline needs; conditioned on github.event_name == 'pull_request' so it doesn't double-run on push-to-main.
  • Regression guard (regression-guard.test.ts): Introduces isDevLatest in findLatestPair to bypass the semver gap check for dev, adds baselineVersion to assertNoRegressions so KNOWN_REGRESSIONS anchored to a release version still suppress false failures during PR runs, and mirrors the same fallback in the resolution precision/recall loop.
  • Known regressions updated: Adds 3.9.6:Full build and 3.9.6:1-file rebuild with root-cause documentation so the newly-gated PR runs don't fail on pre-existing tracked regressions.

Confidence Score: 4/5

Safe to merge for CI infrastructure; the import-resolution sub-benchmark still silently skips the dev build, leaving one measurement blind to PR-introduced regressions in that specific path.

The isDevLatest bypass, baselineVersion fallback in assertNoRegressions, and resolution isDev fallback are all correctly implemented. The resolveEntries filter at line 572 still excludes dev entries, so nativeBatchMs/jsFallbackMs regressions introduced by a PR would pass the gate silently.

tests/benchmarks/regression-guard.test.ts — the resolveEntries filter at line 572 excludes dev entries, leaving the import-resolution sub-benchmark without PR-level coverage.

Important Files Changed

Filename Overview
.github/workflows/ci.yml Adds pre-publish-benchmark job conditioned on pull_request events; correctly mirrors publish.yml patterns, properly wired into ci-pipeline needs with appropriate timeouts on all benchmark execution steps.
tests/benchmarks/regression-guard.test.ts Adds dev-version handling to findLatestPair and assertNoRegressions; the import-resolution sub-benchmark (resolveEntries) still explicitly excludes dev entries, meaning a PR-introduced regression in nativeBatchMs/jsFallbackMs would not be caught by this gate.

Reviews (10): Last reviewed commit: "Merge branch 'main' into ci/regression-g..." | Re-trigger Greptile

Comment thread .github/workflows/ci.yml
Comment on lines +371 to +407
- name: Run build benchmark
run: |
STRIP_FLAG=$(node -e "const [M]=process.versions.node.split('.').map(Number); console.log(M>=23?'--strip-types':'--experimental-strip-types')")
node $STRIP_FLAG --import ./scripts/ts-resolve-loader.js scripts/benchmark.ts --version dev --dist > benchmark-result.json

- name: Run resolution benchmark
run: |
STRIP_FLAG=$(node -e "const [M]=process.versions.node.split('.').map(Number); console.log(M>=23?'--strip-types':'--experimental-strip-types')")
node $STRIP_FLAG --import ./scripts/ts-resolve-loader.js scripts/resolution-benchmark.ts --version dev --dist > resolution-result.json

- name: Gate on resolution thresholds
timeout-minutes: 30
run: npx vitest run tests/benchmarks/resolution/resolution-benchmark.test.ts --reporter=verbose

- name: Run tracer validation (same-file edge recall)
timeout-minutes: 10
run: npx vitest run tests/benchmarks/resolution/tracer/tracer-validation.test.ts --reporter=verbose

- name: Merge resolution into build result
run: |
node -e "
const fs = require('fs');
const build = JSON.parse(fs.readFileSync('benchmark-result.json', 'utf8'));
const resolution = JSON.parse(fs.readFileSync('resolution-result.json', 'utf8'));
build.resolution = resolution;
fs.writeFileSync('benchmark-result.json', JSON.stringify(build, null, 2));
"

- name: Run query benchmark
run: |
STRIP_FLAG=$(node -e "const [M]=process.versions.node.split('.').map(Number); console.log(M>=23?'--strip-types':'--experimental-strip-types')")
node $STRIP_FLAG --import ./scripts/ts-resolve-loader.js scripts/query-benchmark.ts --version dev --dist > query-benchmark-result.json

- name: Run incremental benchmark
run: |
STRIP_FLAG=$(node -e "const [M]=process.versions.node.split('.').map(Number); console.log(M>=23?'--strip-types':'--experimental-strip-types')")
node $STRIP_FLAG --import ./scripts/ts-resolve-loader.js scripts/incremental-benchmark.ts --version dev --dist > incremental-benchmark-result.json
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Missing timeouts on benchmark execution steps

The four benchmark run steps (Run build benchmark, Run resolution benchmark, Run query benchmark, Run incremental benchmark) have no timeout-minutes, unlike Gate on resolution thresholds (30 min) and Run tracer validation (10 min). If any benchmark script hangs — for example due to an unresponsive subprocess or an infinite loop in new code — the job will occupy the runner for up to 6 hours before GitHub kills it, blocking the ci-pipeline gate for the entire duration. The equivalent steps in publish.yml share the same gap. Adding per-step timeouts (e.g., 15–20 min each) keeps a stuck job from becoming a multi-hour blocker.

Fix in Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9aca6db — added timeout-minutes: 20 to all four benchmark execution steps (build / resolution / query / incremental) so a hung script can't hold the runner for the GitHub default 6 hours. Same caveat carries over to publish.yml's equivalent steps; left untouched here per one-PR-one-concern (will follow up if needed).

carlos-alm and others added 3 commits May 6, 2026 00:05
…1072)

The benchmark regression guard fails on the same root cause as the
existing 3.9.6 known regressions ('Build ms/file', 'No-op rebuild'):
PR #1016 expanded AST_TYPE_MAPS from 3 to 23 languages, causing
zero-AST-row files to return astNodes: undefined and trigger a
full-corpus re-parse. The regression shows up in INCREMENTAL-BENCHMARKS
under the 'Full build' label as well, which was missed when PR #1038
added the other 3.9.6 entries.

Both engines regressed: native 2148 -> 2986 (+39%), wasm 7563 -> 14036
(+86%). Fixed by PR #1038; reclears with v3.9.7+ benchmark data.

Refs #1036, #1037, #1038.
Address Greptile review feedback:

- Add 'if: github.event_name == "pull_request"' to skip the gate on
  push-to-main. The merged PR already passed the gate on this same
  diff, so re-running on the merge commit doubles CI minutes per
  landed change with no new signal. Mirrors publish.yml's
  'if: github.event_name != "push"' skip.

- Add per-step 'timeout-minutes: 20' to the four benchmark execution
  steps (build/resolution/query/incremental). Without explicit
  timeouts, a hung script would hold the runner for the GitHub default
  (6 hours), blocking the ci-pipeline gate for the entire duration.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

Addressed Greptile review feedback in 9aca6db and fda3c4f:

  • Event filter (if: github.event_name == 'pull_request') — added to scope the gate to PRs only, mirroring publish.yml's if: github.event_name != 'push' skip. Push-to-main no longer re-runs the full benchmark suite on the merge commit (the merging PR already gated on the same diff).
  • Per-step timeout-minutes — added 20-minute timeouts to the four benchmark execution steps (build / resolution / query / incremental) so a hung script can't hold the runner for the GitHub default 6 hours.

Also fixed the failing CI:

carlos-alm and others added 2 commits May 6, 2026 16:52
parseSemver('dev') returns null, so effectiveGap('dev', anyRelease) is
Infinity — the > MAX_VERSION_GAP check silently rejected every dev →
release pairing and findLatestPair fell through to compare the two most
recent real releases. The per-PR regression gate was running on static
historical data instead of the PR's own dev numbers.

Bypass the gap check when latestVersion === 'dev' so dev is always
compared against the most recent comparable release. Real releases
still respect MAX_VERSION_GAP to avoid stale baselines.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

Addressed Greptile's outstanding concern about effectiveGap("dev", anyRelease) returning Infinity in dbd48ae:

The bug was real — parseSemver("dev") returns null, so effectiveGap("dev", anyRelease) always exceeded MAX_VERSION_GAP, causing findLatestPair to silently fall through to compare the two most recent real releases instead of dev vs the latest release. That defeated the entire purpose of running the regression guard on every PR.

Fix: in findLatestPair, when latestVersion === 'dev', bypass the version-gap check. Dev is always the current PR build, so the most recent comparable release is the correct baseline regardless of feature-expansion distance. Real releases still respect MAX_VERSION_GAP to avoid stale baselines. All 17 regression-guard tests pass.

@carlos-alm
Copy link
Copy Markdown
Contributor Author

@greptileai

carlos-alm and others added 4 commits May 7, 2026 20:00
… is dev (#1072)

KNOWN_REGRESSIONS keys are anchored to the release where the regression
was first observed (e.g. '3.9.6:No-op rebuild'). When the per-PR gate
runs 'dev' as latest, lookups using 'dev:Foo' never match — defeating
the exemption mechanism for every documented regression.

Fall back to the baseline (previous) version's key when latest is 'dev',
so a single '3.9.6:Foo' entry covers both '3.9.6 vs 3.9.5' (release-time)
and 'dev vs 3.9.6' (per-PR) until the next release clears the regression
and the entry is pruned by the existing stale-entry test.
The native 1-file rebuild regressed from 78ms to ~116ms (build) and 54ms
to ~81ms (incremental) when #1069 made backfillNativeDroppedFiles run on
every successful orchestrator pass — including incrementals — to repair
file_hashes/nodes rows for unsupported-extension files. #1070 fixed the
orchestrator side, but the JS-side call stayed unconditional, wasting
~45ms per incremental on the codegraph corpus.

Fix is tracked in PR #1082, which gates the backfill call on
`isFullBuild || removedCount > 0`. Adding the known-regression entry
lets this PR's gate pass while #1082 ships through review; the existing
stale-entry test will warn once 3.9.7 lands and the entry stays past
its useful life.
@carlos-alm
Copy link
Copy Markdown
Contributor Author

Pushed two follow-up commits to fix the failing CI on this PR's own gate:

  • 3cf32b1 - fix(bench): look up KNOWN_REGRESSIONS by baseline version when latest is dev. The exemption keys are anchored to the release where the regression was first observed (e.g. 3.9.6:No-op rebuild); when the per-PR gate runs dev as latest, lookups using dev:Foo never matched, so every documented regression resurfaced. Added a baseline-version fallback so a single 3.9.6:Foo entry now covers both 3.9.6 vs 3.9.5 (release-time) and dev vs 3.9.6 (per-PR) until the next release clears the regression.
  • 46492cf - test(bench): mark 3.9.6 1-file rebuild as known regression. After the fallback fix, only 1-file rebuild remained failing (78 → 116ms / 54 → 81ms). Root cause is fix(native): persist file_hashes for dropped/symbol-less files #1069's unconditional backfill on every incremental, fix tracked in perf(native): skip backfill on clean incrementals #1082; added the entry with the same documentation pattern as the other 3.9.6:* exemptions.

Pre-publish benchmark gate is now passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant