Quality Gates That Don’t Suck: The Paved Road That Stops Tech Debt at the PR

You don’t need a bespoke platform to stop the slow bleed of tech debt. You need boring, automated gates that run on every PR and a paved-road config teams won’t fight.

Quality gates aren’t bureaucracy. They’re the bumpers that let teams bowl fast without throwing gutters into prod.
Back to all posts

The PR that sank a sprint

I watched a retail team ship a “tiny” change to their cart service—two files, one feature flag. It compiled, unit tests passed locally. A week later we were triaging a production incident: rounding bug, duplicated coupon logic, and a silent null coalescing default that masked the real error. Classic vibe-coded patch. No quality gates, no static analysis in CI, and no branch protection. Debt snuck in one PR at a time until the team’s velocity tanked.

If this sounds familiar, you don’t need an innovation lab. You need boring, paved-road quality gates that block merges when the basics aren’t met. The trick is making them strict enough to matter, simple enough to adopt, and automated enough that nobody negotiates with a checklist in Slack.

Quality gates, paved-road style

When I say “quality gate,” I mean: a small set of automated checks that must pass before merge, enforced by branch protection, with thresholds you’ll actually maintain. No bespoke dashboards. No ten-page policy docs.

Paved-road means you pick defaults that 80% of teams can live with:

  • CI runs on every PR. No green, no merge.
  • One shared workflow template. No copy/paste snowflakes.
  • One linter, one formatter, one type checker per language. Defaults over debate.
  • A quality gate (SonarCloud/Qube) with a few pass/fail rules, not a thousand informational warnings.
  • Dependency and SAST scans where the failures are actionable.
  • Terraform-managed branch protection so rules don’t drift.

This wins because the enforcement happens at the only place developers can’t ignore: the merge button.

The minimal gate that catches 80% of debt

Here’s the version we roll out first at GitPlumbers. It catches the most common forms of debt (format/lint drift, type/compile errors, low test signal, obvious vulns, and AI hallucination nonsense) without grinding PRs to a halt.

  • Build/compile or tsc --noEmit/dotnet build/mvn -q -DskipTests=false/pytest depending on stack
  • Lint/format: eslint, prettier --check, ruff for Python, .NET analyzers
  • Unit tests with a coverage floor (start at 60%, not 90%)
  • SAST: semgrep default rules or GitHub CodeQL if you have GHAS
  • Dependency scan: npm audit --omit=dev or pip-audit, plus container scan via trivy
  • SonarCloud/Qube quality gate: fail on new critical issues and new code coverage < threshold
  • Branch protection requiring these checks + signed commits (optional) + at least one review

Example GitHub Actions workflow we drop in as the paved road:

name: quality-gate
on:
  pull_request:
    types: [opened, synchronize, reopened]
  push:
    branches: [main]

jobs:
  gate:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      security-events: write
      pull-requests: write
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Lint & format
        run: |
          npx prettier --check .
          npx eslint . --max-warnings=0
      - name: Type check
        run: npx tsc --noEmit
      - name: Unit tests
        run: npx vitest run --coverage
      - name: Semgrep SAST
        uses: returntocorp/semgrep-action@v1
        with:
          config: p/ci
      - name: Dependency scan
        run: npm audit --audit-level=high || true # see notes below
      - name: Trivy FS/Config scan
        uses: aquasecurity/trivy-action@0.20.0
        with:
          scan-type: 'fs'
          ignore-unfixed: true
      - name: SonarCloud
        uses: SonarSource/sonarcloud-github-action@v2
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
        with:
          args: >
            -Dsonar.projectKey=acme_storefront
            -Dsonar.organization=acme
            -Dsonar.javascript.lcov.reportPaths=coverage/lcov.info

Two calls outs:

  • We start with npm audit warning-only to get signal without day-one pain, then flip to blocking once the backlog is burned down.
  • If you’re on GitLab/Bitbucket, same idea: one pipeline stage runs these, set them as required for merge.

Language paved-road configs that shrink debate:

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "strict": true,
    "noImplicitAny": true,
    "noUnusedLocals": true,
    "noUncheckedIndexedAccess": true,
    "skipLibCheck": true
  }
}
// .eslintrc.json
{
  "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"],
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint"],
  "rules": {"@typescript-eslint/no-floating-promises": "error"}
}
# pyproject.toml (Python paved road)
[tool.ruff]
select = ["E", "F", "I"]
line-length = 100

[tool.mypy]
python_version = "3.11"
warn_unused_ignores = true
disallow_untyped_defs = true
strict_optional = true

And yes, wire Sonar’s quality gate to fail on new code regressions:

# sonar-project.properties
sonar.projectKey=acme_storefront
sonar.organization=acme
sonar.qualitygate.wait=true
sonar.coverage.exclusions=**/*.spec.ts
sonar.newCode.referenceBranch=main

Before/after: what changed when we turned the gate on

We did this at a fintech (payments, PCI scope, React+Node+Python services, ArgoCD to K8s). Before gates:

  • PR lead time: 2.3 days median
  • Escaped defects: ~8/month across 14 services
  • MTTR: 5.1 hours (too many “unknown error” null paths)
  • Coverage: 31% median, with huge variance
  • Production deploy rollbacks: 3 in the prior quarter

Six weeks after gates went live:

  • PR lead time: 2.5 days (slightly up the first two weeks, then flat)
  • Escaped defects: 3/month (62% reduction)
  • MTTR: 3.2 hours (better observability + fewer logic bugs)
  • Coverage: 62% median on new code; legacy untouched allowed to live unfrozen
  • Rollbacks: 0 that quarter; one canary abort caught by tests + SAST

Cost: ~45 minutes per repo to adopt the paved-road workflow, ~1 engineer-week to burn down eslint + ruff errors across 10 repos, and another week to fix high vulns. We didn’t add headcount. The win came from stopping new debt at the door.

The biggest surprise? AI-generated patches (“just let Claude fix it”) went from silent landmines to obvious failures under tsc, mypy, and Sonar’s new-code rules. Vibe coding didn’t disappear—it just couldn’t merge.

Rollout plan: 30/60/90 days

You can ship this without a platform reorg.

  1. First 30 days

    • Pick the paved road: choose one CI provider, one SAST, one dependency scanner, SonarCloud or SonarQube, and language defaults (TS strict, ESLint, ruff/mypy).
    • Land the shared workflow in a template repo and as a reusable workflow.
    • Turn on branch protection for main with required checks.
    • Configure quality gate: fail on new critical issues and new-code coverage < 60%.
  2. Days 31–60

    • Roll to top 5 repos by change volume.
    • Make audit-only checks visible in PR (dependency scan) while teams fix backlog.
    • Publish a “break glass” protocol: label gate-bypass, requires approval from a named CODEOWNER, auto-expires in 48h.
    • Track metrics in a simple dashboard (GitHub insights + a Grafana board pulling PR durations via API; export to Prometheus if you must).
  3. Days 61–90

    • Expand to the rest of your services.
    • Flip dependency scans to blocking on High/Critical.
    • Add language coverage (Java/.NET) with their paved-road equivalents.
    • Review flakey rules; delete anything with chronic false positives.

Lock it in with Terraform so it doesn’t drift:

# terraform - manage branch protection and required checks
resource "github_branch_protection" "main" {
  repository_id  = github_repository.repo.node_id
  pattern        = "main"
  required_status_checks {
    strict   = true
    contexts = [
      "quality-gate",
      "SonarCloud Code Analysis"
    ]
  }
  required_pull_request_reviews {
    required_approving_review_count = 1
    require_code_owner_reviews      = true
  }
  enforce_admins = true
}

What to enforce vs what to observe

Enforce (block merges):

  • Build/compile/type checks: if it doesn’t typecheck or compile, don’t merge.
  • Lint with --max-warnings=0 for the paved-road ruleset only.
  • Unit tests on PR with coverage floor on new code (start 60%, ratchet quarterly if healthy).
  • SAST: fail on new High/Critical.
  • Dependency vulns: fail on High/Critical after backlog is cleared.
  • Sonar quality gate: fail on new code with coverage < threshold or new critical issues.

Observe (comment on PR, don’t block—at least initially):

  • Cyclomatic complexity, long functions, and duplication. Alert, don’t block. Refactors take time.
  • Performance micro-benchmarks unless regressions are clear and material.
  • Flaky test detector (comment + quarantine job; separate from merge gate).

A note on coverage: gate the delta, not the world. Legacy code with 12% coverage shouldn’t block a two-line fix. Use “new code” settings in Sonar and coverage tools.

Operating the gate long-term

Gates are not “set and forget.” They’re a product you own.

  • Make exceptions easy but expensive. A gate-bypass label that opens a 48h window, logs to a channel, and pings a CODEOWNER is fine. Anything longer means the gate isn’t credible.
  • Review rule health quarterly. If a rule causes >2% PR failures and >30% of those are false positives, fix or remove it.
  • Keep the paved road paved. Update the reusable workflow/version pins centrally. Don’t accept one-offs unless they’ll be everybody’s problem next quarter.
  • Map to business outcomes. Track escaped defects, cycle time, and rollback rate. If leaders don’t see the graph bend, they’ll see the gate as theater.
  • Connect to deployment: in GitOps shops (ArgoCD), consider requiring a passing quality-gate check when updating the app manifest in main. Don’t let drift bypass safety.

If you’ve got service meshes and fancy infra (Istio, Envoy), resist the urge to make quality gates about runtime features. Focus the gate on code and tests. Use runtime SLOs in Prometheus to validate outcomes, not as merge criteria.

What actually works (and what I’ve seen fail)

Works:

  • One-pager docs with copy/paste configs. Devs do the right thing when the path is shortest.
  • Starting soft on dependency scans, shifting to hard once the backlog is handled.
  • Ratcheting thresholds on “new code” only. Debt stops growing while legacy stabilizes.
  • Central enforcement via branch protection managed in Terraform. No drift.
  • Posting clear failure reasons on PR with links to docs and autofix commands (npm run lint:fix).

Fails:

  • “Everything is critical” SAST. Teams will cargo-cult // nosemgrep everywhere.
  • Coverage wars. Setting 85% from day one just drives gaming and mocking everything.
  • Dashboard-only gates (no branch protection). If the button is clickable, someone will click it Friday at 5pm.
  • Bespoke pipelines per team. Your platform team will be a ticket queue in six months.

GitPlumbers gets called when vibe-coded AI patches, copy/pasted microservices, and “temporary” forks accumulate. Quality gates are the cheapest intervention that sticks because they change default behavior without heroics.

Related Resources

Key takeaways

  • Quality gates prevent debt by blocking merges, not by nagging in Slack.
  • Favor paved-road defaults: one workflow, one set of rules, zero custom snowflakes.
  • Start minimal: compile/types check, lint, unit tests with coverage floor, SAST, and dependency scan.
  • Use branch protection as the enforcement lever; automate it with Terraform.
  • Measure outcomes (defects per KLOC, PR lead time) and adjust gates quarterly, not weekly.
  • Allow time-boxed bypasses with audit trails—gates should guide, not handcuff.

Implementation checklist

  • Turn on branch protection with required checks for your main branch(es).
  • Ship a single CI workflow that runs lint, typecheck/compile, tests+coverage, SAST, and dependency scan.
  • Set sane, low-friction thresholds (e.g., 60% coverage floor, zero critical vulns).
  • Adopt paved-road configs for JS/TS and Python (tsconfig, ESLint, ruff/mypy).
  • Wire a quality gate in SonarCloud/Qube and require it to pass.
  • Automate exception/bypass with labels and time-boxed approval.
  • Track before/after metrics: escaped defects, PR lead time, rework rate.
  • Review gates quarterly; remove rules nobody pays attention to.

Questions we hear from teams

Won’t quality gates slow my team down?
For the first 1–2 weeks, yes, a little. After that, PR lead time levels off because developers stop shipping work that fails later. Our clients typically see PR lead time flat and escaped defects drop 40–60% within one quarter.
Do I need SonarQube/SonarCloud, or can I just use linters and tests?
You can start without Sonar, but Sonar’s new-code gates and simple pass/fail rules cut through debate. If budget’s tight, begin with ESLint/ruff/mypy + coverage on new code, then add Sonar later.
How do we handle legacy code with low coverage?
Gate the delta. Use “new code” settings in Sonar and coverage tools. Don’t make today’s changes responsible for 2017’s test gaps. Ratchet thresholds quarterly.
What about AI-generated code and hallucinations?
Gates catch most of it: strict type checks, linters, and SAST flag suspicious patterns. We also recommend requiring tests for AI-generated patches and disabling auto-merge for those PRs.
Can we automate enforcement of required checks across hundreds of repos?
Yes. Manage branch protection and required checks with Terraform’s GitHub provider (or GitLab’s API) and apply via a repo module. No manual flipping of toggles.

Ready to modernize your codebase?

Let GitPlumbers help you transform AI-generated chaos into clean, scalable applications.

Talk to GitPlumbers about a paved-road quality gate rollout See how we rescue AI-generated code gone wrong

Related resources