Quality Gates That Don’t Suck: Paved-Road Automation That Stops Technical Debt at the Pull Request
Stop arguing in PRs and start enforcing the basics automatically. The paved-road approach to linting, typing, tests, and security that prevents debt without grinding delivery to a halt.
Automation should catch the boring stuff so reviewers can argue about architecture, not semicolons.Back to all posts
The PR Queue That Turned Into a Dumpster Fire
I’ve watched this movie at three companies in the last five years: AI-assisted coding helped velocity—until the bills came due. We had eslint warnings silenced, @ts-ignore littering the codebase, flaky test suites that were “green enough,” and a backlog of Dependabot PRs nobody wanted to merge. Release trains slowed. Incidents ticked up. The same hot files kept paging us at 2 a.m.
We didn’t need another platform. We needed paved-road quality gates that stopped the worst debt from landing in main. Not bespoke. Not a six-month SonarQube rollout. Just opinionated defaults that run on every PR and block merges when the basics aren’t met.
Here’s the approach that actually works, the configs we ship, and where the cost/benefit bends in your favor.
What “Quality Gates” Actually Mean (When They Work)
A quality gate is a set of automated checks that must pass before code merges. When tuned well, they reduce:
- Rework rate (PRs reopened, commits after merge to fix basic issues)
- Escaped defects (bugs/security vulns found post-merge)
- PR review thrash (humans debating nits the linter could catch)
- MTTR by keeping the trunk healthier
The baseline paved-road gate stack:
- Style/Lint:
eslintorgolangci-lintwith--max-warnings=0 - Type checks:
tsc --noEmitfor TS,mypyfor Python - Unit tests + coverage: enforce a per-PR patch coverage threshold
- SAST:
semgrepwith a minimal, high-signal ruleset - Dependency scanning: Dependabot or Snyk, auto-merge low-risk patches
- Secrets scanning:
gitleaksor native platform scanning - Branch protection: required checks + CODEOWNERS on touchy areas
Everything else is optional. Start here. Add only when signal-to-noise is strong.
A Paved-Road GitHub Actions Setup You Can Copy
Below is a compact CI that blocks merges on lint, type, tests, coverage, SAST, and secrets. It uses stock Actions and open-source tools. No dashboards, no bespoke scripts.
name: ci
on:
pull_request:
branches: [ main ]
permissions:
contents: read
pull-requests: write
security-events: write
jobs:
node-ci:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- name: Lint (no warnings)
run: npx eslint . --max-warnings=0
- name: Type check
run: npx tsc --noEmit
- name: Unit tests with coverage
run: npx vitest --run --coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
flags: frontend
verbose: true
sast:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Semgrep scan (fail on new issues)
uses: returntocorp/semgrep-action@v1
with:
config: p/ci # curated, low-noise rules
generateSarif: true
baselineRef: main
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: semgrep.sarif
secrets:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Gitleaks
uses: gitleaks/gitleaks-action@v2
env:
GITLEAKS_LICENSE: ${{ secrets.GITLEAKS_LICENSE }} # optional for proAdd branch protection and required checks so none of this is “advisory.”
# Example: require status checks on main
# Requires GitHub admin token with repo:admin scope
REPO="org/repo"
REQUIRED_CHECKS='["node-ci", "sast", "secrets"]'
gh api \
-X PUT \
-H "Accept: application/vnd.github+json" \
repos/$REPO/branches/main/protection \
-f required_status_checks.strict=true \
-f required_status_checks.contexts:="$REQUIRED_CHECKS" \
-f enforce_admins=true \
-f required_pull_request_reviews.required_approving_review_count=1And make sure owners watch the right files:
# .github/CODEOWNERS
apps/payments/** @payments-team
infra/** @platform-team
**/*.sql @data-teamFor Go, swap eslint/tsc for golangci-lint and go test:
go-ci:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: 1.22
- uses: golangci/golangci-lint-action@v6
with:
version: v1.60
args: --timeout=5m --out-format=github-actions
- run: go test ./... -coverprofile=coverage.out -covermode=atomic
- uses: codecov/codecov-action@v4
with:
files: coverage.out
flags: backendRatcheting: Better Every PR Without a Painful Rewrite
Your codebase already has issues. Don’t block on fixing the past. Instead, ratchet quality so it only fails when a PR makes things worse.
- Patch coverage gate: enforce coverage on lines touched by the PR, not the whole repo. Codecov handles this with “patch” status checks.
- Baseline SAST: Semgrep’s
baselineRef: mainfails only on new findings or changed lines. - Lint/type zeros:
--max-warnings=0only hurts if warnings already exist. If they do, add a temporaryeslint . > lint.txtto count warnings, fix the top offenders, then ratchet to zero within a week.
Codecov example to require 80% overall, 90% patch coverage:
# codecov.yml at repo root
coverage:
status:
project:
default:
target: 80%
patch:
default:
target: 90%
threshold: 1% # small wiggle roomFor Jest/Vitest, bake sane thresholds so the local run matches CI:
// package.json
{
"vitest": {
"coverage": {
"reporter": ["text", "lcov"],
"lines": 0.8,
"functions": 0.8,
"branches": 0.7
}
}
}This lets teams improve incrementally without blocking urgent work—exactly how you keep adoption high.
Before/After: What Actually Changes
At a fintech we helped last year, they had 14 services, GitHub Actions on paper, and still merged broken code weekly. After a two-week rollout of paved-road gates:
- Before: 22% of PRs required follow-up fixes within 48 hours; average PR lead time 2.6 days
- After: 8% follow-ups; PR lead time 2.3 days (initial +8% bump week one, then down as reviewers trusted automation)
- Before: 4 paging incidents/quarter tied to bad merges; After: 1 per quarter
- Before: 120 open Dependabot PRs; After: <10 with an auto-merge policy on patch updates
We didn’t deploy SonarQube. We didn’t hire a “DevEx guild.” We just enforced the basics where it mattered: at the gate.
Cost/Benefit: Sonar vs Semgrep, Heavy vs Light, and When to Say No
Where we’ve seen teams burn months:
- A Big Platform First: rolling out SonarQube/Cloud with custom rules and dashboards before enforcing lint/type/test. It’s upside-down. Start with cheap signal, then graduate.
- Bespoke scripts: homegrown linters and diff coverage tools that only two people can maintain. When they leave, the gates rot.
Trade-offs in plain English:
- Semgrep vs SonarQube: Semgrep’s
p/cirules are fast and low-noise. Sonar adds deeper analysis and code smells, but also false positives and platform overhead. Start with Semgrep; add Sonar only if your false negative pain is real and you have an owner. - Codecov vs roll-your-own diff coverage: Codecov’s patch gating is a one-line win. DIY scripts drift. Use Codecov or GitHub’s native coverage summary if/when it matures.
- Dependabot vs Snyk: Dependabot is free and fine for most OSS deps. Snyk adds license policies and transitive risk scoring—worth it if you have a regulated environment and someone to tune policies.
- Secrets scanning:
gitleakscatches 90% with near-zero config. GitHub Advanced Security adds push protection and SARIF integration—worth it at scale.
Say no to:
- Org-wide “block everything” on day one. Roll out by repo or service.
- Quality gates you can’t run locally. If
npm testandnpx eslintdon’t tell the same truth as CI, you’re going to get revolt. - Gates that create toil for platform teams. No ticket queues to retrigger jobs, please.
Rollout Plan: Two Weeks, Real Impact
Here’s the exact sequence we run at GitPlumbers:
- Baseline and pick the paved road
- Decide on
eslint, type checker, test runner, Semgrepp/ci, Codecov,gitleaks, and Dependabot. - Add
CODEOWNERSfor high-risk paths.
- Decide on
- Wire CI and protection
- Add the workflow above, Codecov token, and branch protection with required checks.
- Enable Dependabot daily for app and Docker updates.
- Ratchet, not rewrite
- Turn on Codecov patch gating and Semgrep baselining.
- Set lint to warnings allowed for week one, then move to
--max-warnings=0.
- Prove it with data
- Track PR lead time, rework rate, escaped defects, and build-green rate.
- Share a weekly dashboard in Slack. Celebrate fewer “nit” comments in reviews.
- Expand carefully
- Add language-specific gates for other stacks (e.g.,
mypy,pytest --cov-fail-under,terraform validatewithtfsec). - Only after stability, consider heavier platforms (Sonar, Snyk) if the signal justifies the spend.
- Add language-specific gates for other stacks (e.g.,
Optional infra extras that stay paved-road:
# Terraform on PRs (keeps IaC debt out)
jobs:
terraform:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
- run: terraform fmt -check
- run: terraform init -backend=false
- run: terraform validate
- name: tfsec
uses: aquasecurity/tfsec-action@v1Lessons From the Trenches
- If a gate is flaky, it’s worse than useless. Fix or remove within 48 hours.
- Don’t outsource judgment to tools. Use CODEOWNERS to route risky diffs to the people who know the domain.
- For AI-generated code, tune the gates slightly tighter: forbid
@ts-ignoreand require patch coverage >= 95% in high-risk dirs. We’ve used a simpleeslintrule and avitestper-project override to good effect. - Avoid footguns: pin Action versions, cache wisely, and keep jobs under 10 minutes. Long CI turns quality gates into resentment generators.
- Make the local dev loop match CI. Ship
npm scriptsthat mirror the gates so devs get fast feedback.
// package.json scripts
{
"scripts": {
"lint": "eslint . --max-warnings=0",
"typecheck": "tsc --noEmit",
"test": "vitest --run --coverage"
}
}You’ll know it’s working when PR reviews shift from “missing semicolons” to “is this the right boundary in payments?” That’s the whole point.
The Quiet Win: Fewer Debates, Faster Merges, Safer Releases
I’ve seen big-budget platform teams deliver less impact than a week of paved-road gates. The trick is resisting over-engineering. Start with the boring basics, enforce them ruthlessly, and ratchet quality forward one PR at a time.
If you’re staring at a repo full of AI-vibe code, flaky tests, and a nervous on-call rotation, we’ve done this cleanup before. GitPlumbers can drop in, wire this up, and leave you with a paved road your teams actually like to drive on.
Key takeaways
- Automate the boring basics: lint, type checks, tests with coverage, SAST, dependency and secret scanning.
- Use paved-road defaults (GitHub Actions, Semgrep, Codecov, Dependabot, Gitleaks) before introducing bespoke platforms.
- Ratcheting beats rewrites: baseline current issues and only block regressions on new/changed code.
- Branch protection with required checks is your enforcement lever—don’t rely on human policing.
- Measure impact with PR lead time, rework rate, escaped defects, and build-green rate, not vanity metrics.
Implementation checklist
- Enable branch protection with required checks for main branches.
- Add lint, type, unit test with coverage, SAST, dependency, and secret scan jobs to CI.
- Adopt ratcheting: patch coverage gate + baseline scan that only fails on new issues.
- Use CODEOWNERS to enforce domain reviews on risky areas.
- Start with paved-road defaults; add heavier tools only when the signal-to-noise justifies it.
- Track PR lead time, rework rate, and escaped defects to validate ROI.
- Roll out in phases by repo/service; don’t flip the org-wide switch on day one.
Questions we hear from teams
- Will gates slow us down?
- In week one, yes—expect a small bump in PR lead time (~5–10%). By week two, review time drops because nits vanish and tests stabilize. Teams we’ve worked with see net neutral or improved lead time within two sprints and fewer post-merge fixes.
- What if our repo already has thousands of lint errors?
- Don’t boil the ocean. Start with CI reporting, then flip to `--max-warnings=0` after you fix the top offenders. Or scope the linter to changed files (`lint-staged`) during rollout. Ratcheting keeps velocity while you pay the debt down.
- Do we need SonarQube?
- Not to start. Semgrep + coverage + strict lint/type checks catch the majority of issues with less noise. Add Sonar when you have a dedicated owner and the false negatives you’re seeing justify the spend and maintenance.
- How do we handle AI-generated code safely?
- Tighten the gates where AI contributes: forbid `@ts-ignore`, require higher patch coverage (90–95%) in those directories, and enable Semgrep rules targeting insecure patterns common in AI-generated code (e.g., unsafe HTTP clients, weak crypto).
- What metrics prove ROI?
- Track PR lead time, rework rate (PRs with follow-up fixes within 48 hours), escaped defects per release, build-green rate, and incident count linked to bad merges. If those don’t improve within two sprints, adjust the gates.
Ready to modernize your codebase?
Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.
