What a Gate Is Made Of: Coverage, Severity, and Pass Criteria as Machine Checks
"Should this merge?" answered by a reviewer is a preference; answered by a pipeline it is policy. A quality gate is simply a set of conditions, evaluated automatically, whose failure blocks the merge — and the craft is entirely in choosing conditions that are objective, cheap to measure, and actually correlated with the defects you care about. The pipeline from Integrating Tests into CI decides whether a failed suite turns red; the gate decides which reds are allowed to merge.
| Gate | Typical threshold | Measured by | Catches |
|---|---|---|---|
| required suites pass | 0 failures on unit + integration | CI exit code | regressions introduced by the diff |
| line coverage | ≥ 80% overall, or ≥ 85% on changed lines | istanbul / JaCoCo / Cobertura | untested new logic |
| branch coverage | ≥ 70% | same tools | untested conditionals — the more meaningful number |
| critical-defect count | 0 open blockers on the touched module | issue tracker query | shipping known-broken behavior |
| contract/schema gate | 0 breaking changes | Pact can-i-deploy, oasdiff |
damage to other services |
| flaky rate of the touched suite | below the agreed cap | retry counts from reports | merging tests nobody trusts |
Two design decisions cause most of the argument. Absolute versus changed-code coverage: an 80%-overall rule punishes a team inheriting a 40% legacy codebase with no way to comply — they either stop writing tests or start padding. The ratchet that works in practice is changed-code coverage (every new or modified line must be covered, measured by diffing coverage against the base branch) plus a non-decreasing overall number: quality can only hold or improve, and the debt shrinks as code is touched. Second, which number you gate on: branch coverage is the stricter, more honest metric — a line executing only on the happy path is "covered" while its error branch has never run — and line coverage alone routinely reports 85% on code with untested negations.
Enforcement is a command with an exit code, not a report someone opens:
npx jest --ci --coverage \
--coverageThreshold='{ "lines": 80, "branches": 70, "functions": 80 }'
# Jest: Coverage does not meet threshold -> exit 1 -> job red -> merge blocked
npx jest --changed --coverageThreshold='{ "lines": 100 }' # diff-aware gate for legacy repos
Notice that thresholds live in the repository (config file or workflow), so changing them is a reviewed pull request with a reason attached — a gate whose value lives in someone's dashboard settings is one admin click away from being turned off, and it will be. The threshold numbers themselves are negotiable and should be: set them at current + a step you can hold (79% → 82%, not 79% → 90%), because a gate the team cannot pass gets bypassed within a week, and a bypassed gate is worse than no gate — it teaches everyone that red means nothing.
Gates are not only for merges. The same mechanism scales up a ladder of decisions: a merge gate (fast suites, changed-code coverage, contract diff), a release gate (full regression green, zero critical defects open, no breaking contract changes, performance smoke within budget), and a break-glass gate for genuine emergencies — audited, attributed, and reported. Each is the same pattern, thresholds evaluated by a command with an exit code, applied where the cost of a wrong decision is higher. Teams that gate only merges discover the gap at release week, when a sequence of individually-fine merges combines into a release nobody validated end to end. Once the machine has an opinion, the remaining question is what humans may override, which is the second chapter.
Waivers: Keeping the Gate Strict by Making Exceptions Loud
A gate with no exception path gets edited the first time it blocks something important — and from that moment it is a suggestion. The design that survives contact with release pressure is the opposite: make the gate hard to change and the exception explicit, owned, and temporary.
# waivers.yaml — versioned, reviewed, diffed like any policy
waivers:
- id: W-014
gate: coverage-lines
scope: "src/legacy/export/**" # narrow: files, gate, or check — never "everything"
reason: "PDF export module scheduled for removal in Q3; no new logic added since 2025-11"
owner: "@maria" # a person, not a team channel
approved_by: "@tech-lead" # someone other than the author
expires: "2026-07-01" # the gate returns automatically
linked: "JIRA-4821"
Every field enforces a discipline that keeps waivers from becoming loopholes. Scope — a waiver names the gate and the narrow path it covers, so the rest of the repository stays blocked; a blanket waiver is a disabled gate. Owner — one accountable name means the expiry produces a conversation instead of silence. Expiry — the exception ends on a date, and the pipeline re-evaluates it, so old debt cannot accumulate invisibly. Approver ≠ author — self-approved waivers are how a blocked merge becomes an unblocked merge with extra steps. The evaluation logic is a few lines in the gate step:
# fail unless an unexpired waiver covers this scope
python scripts/check_waivers.py --gate coverage-lines --path "$CHANGED_PATHS" \
|| { echo "::error::coverage below threshold and no active waiver"; exit 1; }
The break-glass path for genuine emergencies deserves to exist and to be expensive: an admin-only label (emergency-merge) that records who, when, which gate, and why in an audit log, requires a same-day follow-up ticket, and reports weekly to the team. Teams that skip this path do not get a stricter gate — they get merges done by re-running the job until a flake passes, which is worse on both axes.
Automate the hygiene too, because a waiver that needs a human to notice its expiry never gets noticed: a scheduled job parses waivers.yaml, opens an issue against each owner whose entry has passed expires, and fails the build if an expired entry still claims coverage — posting the active list whenever the gate blocks. Pair it with a severity definition written down once, since zero critical failures is unenforceable until critical is a field your tooling emits (a test tagged blocker, a P0/P1 defect, a breaking contract change) rather than an opinion offered in review.
Watch the aggregate and you get a free health metric: if waivers spike, the threshold is wrong or the culture is. A steadily growing waiver list means the coverage number was set too high for the team's reality; a handful of long-lived, narrowly scoped waivers means the gate is doing its job on everything else. Publish the count — active waivers, expiring this week, expired-but-still-open — next to the gate results, because a policy nobody can see is a policy nobody follows. And those results belong where the author already is: on the pull request itself, which is the third chapter.
Gate Results in the Pull Request: Verdict and Numbers on One Page
A gate that fails with Job failed (exit code 1) has done its job technically and failed its job practically: the author now opens CI logs, scrolls past 400 lines of setup, and greps for the one sentence that matters. Publish the verdict and the numbers where the author is already looking — the pull request's Checks tab and conversation — so the fix starts immediately.
There are three delivery mechanisms, and a good setup uses all of them:
- A named check per gate.
coverage-gate,contract-gate,flaky-gateas separate required status checks — not one monolithiccijob — so the failing gate is legible at a glance and can be required individually. - Inline annotations. GitHub turns
::error file=src/checkout.ts,line=84::lines 84-91 uncoveredinto a red marker on the diff itself; the author sees the problem on the line they wrote. - A sticky summary comment. One comment, updated in place, carrying the numbers — so the conversation stays readable instead of accumulating 40 "gate failed" comments per PR.
- name: Publish gate summary
if: always()
run: |
{
echo "## Quality gates"
echo "| Gate | Threshold | Actual | Verdict |"
echo "|---|---|---|---|"
echo "| Coverage (changed lines) | 100% | 87.5% | ❌ |"
echo "| Branch coverage | 70% | 71.2% | ✅ |"
echo "| Contract (can-i-deploy) | 0 breaking | 0 | ✅ |"
echo "| Flaky rate, touched suite | < 2% | 5.1% (3 tests) | ❌ |"
} >> "$GITHUB_STEP_SUMMARY"
The table above encodes a reporting principle worth stating: show the number next to the threshold, not just pass/fail. "Coverage gate failed" produces an argument about whether 80% is fair; "79.4% vs 80%, one uncovered branch in applyDiscount" produces a commit. Name the offending test for a flaky-gate failure, name the breaking operation for a contract failure, and link to the report artifact for everything else — each gate's message should carry the next action, so the PR page is a complete triage surface.
Two policy details keep this honest over time. First, evaluate gates against the diff, not just the whole repo (changed-code coverage, new-code-only static analysis): a global score makes every PR about the team's history, and authors stop reading a verdict they cannot influence. Second, publish gate results always — on success too — because an absent check is ambiguous (did it pass, was it skipped, did it crash?), while a green table with numbers tells everyone the policy was evaluated. Latency belongs in the design as well: a gate that takes twelve minutes to announce "coverage failed" delivers its verdict after the author has already moved on. Run the cheap gates first and publish their annotations immediately, keep the whole check inside your stated feedback budget, and push any gate that needs the full environment into the slow lane — it still blocks, it just doesn't delay the fast signal. Where the platform supports it, make results sticky: one summary comment updated in place and annotations refreshed on each push, so a PR with three pushes shows the current verdict instead of twelve stale ones.
Done this way, the gate stops being an obstacle at the end of the merge and becomes the shared definition of done — the numbers on every PR, the same numbers, updated by the same machines. What happens when those numbers say a test is sometimes red is Parallelization and Flaky Test Management.
Quiz
1. A team with 41% line coverage must introduce a coverage gate. Which policy is most likely to hold?
2. Which waiver entry is acceptable under a strict-but-practical policy?
3. Why should gate results be published as a table with actual numbers rather than just a red X?
4. The waiver list has grown from 3 to 27 in two quarters. What does that most likely indicate?
Flashcards
Question
What is a quality gate, mechanically?
Click to reveal answer
Answer
Conditions evaluated automatically in the pipeline (required suites pass with 0 failures, coverage ≥ threshold, 0 breaking contract changes, 0 critical defects) whose failure blocks the merge via required status checks. The condition must have an exit code, not a dashboard entry.
Question
Absolute coverage threshold vs changed-code threshold — which for a legacy repo?
Click to reveal answer
Answer
Absolute (≥80% overall) is unreachable on a 40% codebase and gets bypassed. Changed-code (100% of new/modified lines covered, diffed against base) plus non-decreasing overall is the ratchet: every PR holds or improves quality, and the number climbs as legacy code is touched.
Question
Line coverage vs branch coverage — why teams gate on branches.
Click to reveal answer
Answer
Line coverage counts executable lines run; branch coverage counts if/else and early-return paths taken. A function called only on the happy path can be 100% line-covered with its error branch never executed — hence a typical pair like lines ≥ 80, branches ≥ 70.
Question
The six fields every waiver needs, and what each prevents.
Click to reveal answer
Answer
Gate (which rule), narrow scope (not `**` — prevents disabling everything), reason, owner (a person), approver ≠ author (prevents self-unblocking), expiry date (prevents indefinite debt), plus a linked ticket. Versioned in the repo so waivers are reviewed diffs, and re-evaluated automatically when they expire.
Question
Three mechanisms for publishing gate results on the pull request.
Click to reveal answer
Answer
1) One named check per gate (`coverage-gate`, `contract-gate`) so failures are legible and individually required. 2) Inline annotations (`::error file=...,line=84::`) marking the exact diff line. 3) A sticky summary comment (or step summary) with a threshold-vs-actual table, updated in place. Publish on success too — an absent check is ambiguous.
Revision Notes
Key Takeaways
- 1. A gate needs a threshold, an exit code, and a required check — a dashboard number blocks nothing
- 2. Changed-code coverage plus a non-decreasing overall number is the ratchet that legacy codebases can actually comply with
- 3. Branch coverage exposes untested conditionals that line coverage reports as green
- 4. Exceptions must be narrow, owned, approved by someone else, and dated — an expired waiver re-evaluates itself
- 5. Show threshold vs actual with the offending line or test named: pass/fail invites argument, numbers invite a commit
Interview Tips
- • "How do you decide what a quality gate should check?" — cheap, objective, correlated with real defects: required suites, changed-code coverage, contract compatibility, flaky rate; explain why you'd gate on branch over line coverage
- • "Your team inherited 40% coverage — what's your gate?" — changed-code 100% + non-decreasing overall, thresholds raised in steps; explicitly reject an unreachable absolute number as the reason gates get disabled
- • "How do you stop waivers from gutting the gate?" — versioned waiver file, narrow scope, named owner, independent approver, expiry, ticket; watch waiver counts as the policy's health metric
- • "How are gate results communicated to the author?" — named checks, inline annotations on the diff line, sticky summary with threshold-vs-actual; argue that the PR page must be a complete triage surface
Cheat Sheet
Quality Gates and Merge Policy
Gate = threshold + exit code + required check.
| Gate | Threshold example | Tool |
|---|---|---|
| required suites | 0 failures | CI exit code |
| changed-line coverage | 100% of diff | diff-aware coverage tool |
| absolute coverage | lines ≥ 80, branches ≥ 70 | istanbul / JaCoCo / Cobertura |
| contract | 0 breaking | pact-broker can-i-deploy, oasdiff breaking |
| critical defects | 0 open blockers on touched module | issue tracker query |
| flaky rate | < 2% of touched suite | retry counts from reports |
npx jest --ci --coverage --coverageThreshold='{ "lines": 80, "branches": 70 }'
Thresholds live in the repo → changing them is a reviewed PR. Set them at current + a step you can hold (79→82, not 79→90).
Waivers (waivers.yaml, versioned): id · gate · narrow scope · reason · owner · approved_by ≠ author · expires · linked ticket. Break-glass = admin label + audit log + same-day ticket + weekly report. Track counts: active / expiring / expired-open — growth means the threshold or the culture is off.
Publish in the PR: named check per gate · inline annotations on the diff line · sticky summary table with threshold vs actual:
| Gate | Threshold | Actual | Verdict |
|---|---|---|---|
| Coverage (changed) | 100% | 87.5% | ❌ |
Evaluate against the diff (new-code focus) so the verdict is something the author controls.