Failing a Pull Request on a Health Score Regression
A scheduled crawl tells you the site regressed last week. A pull-request gate tells you which change did it, before it ships — which is a much more useful thing to know and a much harder thing to measure, because a sampled crawl of a preview environment carries real variance. This page is part of Integrating Custom Crawlers with CI/CD Pipelines and covers building that gate so it fires on regressions and not on noise.
Environment isolation and dependency declaration
set -euo pipefail
export PR_SAMPLE_FILE="audit/pr_sample_urls.txt" # pinned, in version control
export PR_BASE_ARTIFACT="s3://audit/base/${GITHUB_BASE_REF}/latest.parquet"
export PR_NOISE_FLOOR=1.5 # measured, in composite score points
export PR_BLOCK_DELTA=5.0 # fail the check past this
export PR_OVERRIDE_LABEL="audit-override"
export PR_MAX_MINUTES=8
PR_NOISE_FLOOR has to be measured rather than assumed. Run the same commit through the gate five times against the same preview environment, take the spread of the composite score, and set the floor above it. A threshold below the noise floor produces a gate that fires on changes touching nothing but copy, and a gate that does that is muted within a fortnight.
Implementation
# .github/workflows/audit-gate.yml
name: Health score gate
on:
pull_request:
paths: ['src/**', 'templates/**', 'public/**', 'next.config.js']
concurrency:
group: audit-gate-${{ github.head_ref }}
cancel-in-progress: true # only the newest push matters
jobs:
gate:
runs-on: ubuntu-24.04
timeout-minutes: 12
steps:
- uses: actions/checkout@v4
- name: Wait for the preview deployment
run: ./audit/wait_for_preview.sh "${{ github.event.pull_request.head.sha }}"
- name: Crawl the pinned sample
run: |
python3 -m audit.crawl --urls "$PR_SAMPLE_FILE" --base-url "$PREVIEW_URL" --output pr.parquet --timeout-minutes "$PR_MAX_MINUTES"
- name: Compare against the base branch
id: compare
run: python3 -m audit.gate.compare --head pr.parquet --base "$PR_BASE_ARTIFACT" --json > delta.json
- name: Comment the delta
uses: actions/github-script@v7
with:
script: |
const d = require('./delta.json');
const body = `**Health score:** ${d.head.toFixed(1)} `
+ `(${d.delta >= 0 ? '+' : ''}${d.delta.toFixed(1)} vs base)\n\n`
+ d.rows.map(r => `- \`${r.template}\`: ${r.delta.toFixed(1)}`).join('\n');
await github.rest.issues.createComment({
issue_number: context.issue.number, owner: context.repo.owner,
repo: context.repo.repo, body });
- name: Fail on a real regression
run: |
DELTA=$(jq -r '.delta' delta.json)
OVERRIDE='${{ contains(github.event.pull_request.labels.*.name, 'audit-override') }}'
python3 - "$DELTA" "$OVERRIDE" <<'PY'
import os, sys
delta, override = float(sys.argv[1]), sys.argv[2] == "true"
floor = float(os.environ["PR_NOISE_FLOOR"])
block = float(os.environ["PR_BLOCK_DELTA"])
if delta > -floor:
print(f"PASS: delta {delta:+.1f} is inside the noise floor")
elif delta > -block:
print(f"WARN: delta {delta:+.1f} exceeds noise but is under the block threshold")
elif override:
print(f"OVERRIDE: delta {delta:+.1f} accepted via label")
else:
sys.exit(f"FAIL: delta {delta:+.1f} exceeds the block threshold of -{block}")
PY
The parts that decide whether anyone trusts this:
concurrencywithcancel-in-progressso a series of pushes does not queue five crawls against one preview environment, which would both waste the window and make the results inconsistent.- The sample file is pinned in version control. Changing it is a deliberate rebaseline that shows up as a diff, not something that drifts between runs.
- The comment is posted before the pass/fail step. An author whose check fails should already have the per-template breakdown in front of them, rather than having to open a job log to find out which template moved.
- The override is a label, so it is recorded on the pull request and visible to a reviewer. An override with no trace is indistinguishable from a gate nobody ran.
Verification and smoke test
set -euo pipefail
# 1. Measure the noise floor before trusting any threshold.
for i in 1 2 3 4 5; do
python3 -m audit.crawl --urls "$PR_SAMPLE_FILE" --base-url "$PREVIEW_URL" --output "noise_$i.parquet" >/dev/null
done
python3 -c "
import pyarrow.parquet as pq, statistics as st
scores = [pq.read_table(f'noise_{i}.parquet').column('composite').to_pylist()[0]
for i in range(1, 6)]
spread = max(scores) - min(scores)
print(f'spread over 5 identical runs: {spread:.2f} points')
assert spread < float('${PR_NOISE_FLOOR}'), 'noise floor is set too low'
print('PASS: noise floor is above the observed spread')"
# 2. A deliberately regressed branch must fail the gate.
python3 -m audit.gate.compare --head fixtures/regressed.parquet --base fixtures/base.parquet --json | jq -e '.delta < -5' >/dev/null && echo "PASS: a known regression is detected"
Expected output is two PASS lines, with an observed spread comfortably below the configured floor. Re-run the first check whenever the sample or the preview environment changes — noise is a property of the harness, and the harness moves.
Failure modes
The gate is slower than the review
A crawl that takes twenty minutes is a crawl nobody waits for. Cut the sample rather than the depth: one or two URLs per template is enough to catch a template-level regression, and template-level regressions are what a pull request can actually cause.
The base artifact is stale
The gate compares against whatever was last published for the base branch, which after a quiet fortnight may predate several merges. Refresh the base artifact on every merge to the base branch, and fail the gate when the artifact is older than a configured maximum rather than comparing against it silently.
Every pull request shows a small regression
The preview environment is systematically slower than the base environment — usually cold caches, or a preview build without production optimisations. Compare like with like by crawling the base branch's own preview rather than a production artifact, and treat the resulting delta as the measurement. This is the same environment-parity problem covered in normalizing performance data across device types, applied to environments rather than devices.
FAQ
Why measure a noise floor instead of picking a threshold?
Because a sampled crawl of a preview environment has real run-to-run variance, and a threshold below it produces a gate that fires on pull requests touching nothing but copy. That is fatal, because the gate is only useful while people read its output — a check that fires on noise is muted or bypassed within a fortnight, and a muted gate blocks nothing at all. Running the same commit five times and setting the floor above the observed spread makes the first failure a real one.
Should the pull-request crawl feed the trend series?
No. It is a sample of one or two URLs per template, selected for speed rather than representativeness of the whole estate, and it runs against a preview environment rather than production. Feeding it into the baseline would bias the trend toward whichever templates happen to be in the sample and toward preview-environment performance. The scheduled full crawl is the trend series; the pull-request crawl answers a different question about a specific change.
Should there be a way to override the gate?
Yes, and it should be recorded. Without an override, the only way past a false positive is to bypass the check entirely — which removes the signal permanently rather than for one pull request, and teaches everyone that the gate is an obstacle rather than information. An explicit label that appears on the pull request and lands in the audit trail keeps the escape hatch visible and countable, so a rising override rate becomes its own finding.
Related
- Integrating Custom Crawlers with CI/CD Pipelines — the parent guide covering runner images, dependency pinning and artifact capture
- GitHub Actions vs GitLab CI for Crawler Scheduling — the scheduling primitives behind the concurrency guard this gate relies on
- Tracking Metric Trends Across Release Cycles — the release-tagged series the scheduled crawl feeds and this gate does not