qasim@wiki:~$

Rolling Out Blocking Gates

How to turn on quality enforcement against a legacy codebase without halting the business.

The problem with switching everything on at once

A pipeline pointed at a mature codebase for the first time produces numbers like these:

CheckFirst-run result
Lint (Biome)25,381 errors, 659 warnings across 490 files
Format (Prettier)346 files with violations
Tests (Jest)19 of 20 suites fail to run — module resolution misconfiguration
Dependency audit17 vulnerabilities (12 high, 3 moderate, 2 low)
TypeScript compileClean

Make all of that blocking on Monday morning and every build fails, no one can deploy, and by Tuesday afternoon someone with authority instructs you to turn the pipeline off. The gate loses, permanently, because it was introduced as an obstacle rather than a ratchet.

The goal is a gate that survives. A blocking gate that gets disabled after two days has negative value — it burns credibility and makes the next attempt harder. Sequencing is not bureaucracy here; it is what makes enforcement stick.

Three-phase rollout

PhaseDurationBehaviourPurpose
1 · Observe1–2 weeksAll new checks notify-only. Nothing blocks.Establish the baseline. Let the team see the numbers without pressure.
2 · Enforce new work2–4 weeksBlock on new findings. Existing debt tracked separately.Stop the problem growing while the backlog is worked.
3 · Full enforcementOngoingBlock on any violation.Standard is now the floor.

SonarQube's New Code conditions implement Phase 2 natively — that is exactly what they are for. Lint and format tools generally do not have that concept, which is why they move from Phase 1 straight to Phase 3 once the backlog is cleared.

Phase 1 — notify-only

Every new check lands in this form first:

stage('Lint and Format') {
  steps {
    script {
      // TEMPORARY — notify-only during rollout.
      // Revert to blocking:
      //   sh 'npm run lint:check'
      //   sh 'npm run format:check'
      def lintExit = sh(script: 'npm run lint:check',   returnStatus: true)
      def fmtExit  = sh(script: 'npm run format:check', returnStatus: true)

      if (lintExit != 0 || fmtExit != 0) {
        echo 'Lint/format issues found — UNSTABLE, continuing.'
        currentBuild.result = 'UNSTABLE'
      } else {
        echo 'Lint and format checks passed.'
      }
    }
  }
}
Always leave the blocking version in a comment. Six weeks later, whoever flips the switch — possibly not you — needs to know exactly what the enforcing form looked like. This turns a re-derivation into a two-line edit.

The safety property that makes this acceptable

Notify-only is not the same as ungated. Because the deploy stage is guarded on SUCCESS, an UNSTABLE build still cannot deploy:

stage('Deploy to Staging') {
  when { expression { currentBuild.currentResult == 'SUCCESS' } }
  // UNSTABLE ≠ SUCCESS → deploy is skipped
}

So during Phase 1 the pipeline runs end to end, collects full analysis, emails the committer — and still ships nothing that fails a check. That is the argument to make when a stakeholder asks whether notify-only means "no enforcement".

Phase 2 — enforce on new code

Delegated to SonarQube's gate. Conditions under New Code only evaluate lines added or changed in the New Code window:

MetricConditionEffect
Issues> 0No new bugs or smells may be introduced
Coverage< 80%New code must be tested
Duplicated Lines> 3%No new copy-paste
Security Hotspots Reviewed< 100%Every new hotspot triaged

Existing debt is invisible to these conditions, so the team is never blocked by code they did not write — while every new commit is held to standard. This is the ratchet: quality can only improve.

Phase 3 — full enforcement

stage('Lint and Format') {
  steps {
    // BLOCKING — any violation fails the build and prevents deployment.
    sh 'npm run lint:check'
    sh 'npm run format:check'
  }
}

Flip a check to Phase 3 when its backlog is at or near zero. Checks move independently — format may reach Phase 3 in a week (it is auto-fixable) while lint takes two months.

Sequencing by effort

Order the backlog by cost-to-fix, not by severity. Early wins build momentum and shrink the number on the dashboard fast.

PriorityItemEffortImpact
P1npm run format — auto-fix all formatting5 min346 violations → 0
P1npm audit fix (no --force)5 minResolves safely-fixable CVEs
P2Fix Jest module resolution config30 minUnblocks 19 suites, restores coverage data
P2Auto-fixable lint rules (import order, simplifiable expressions)1 hourLarge error-count reduction
P3Replace any with real typesMulti-dayBulk of remaining errors
P3Refactor high cognitive-complexity functionsDaysA handful of errors, high code-health value
P4Formal exceptions for dependencies with no upstream fix1 hourDocumented risk acceptance
Lead with the five-minute fixes. "We removed 346 violations before lunch" changes the conversation from "this tool is blocking us" to "this tool is helping us". Momentum matters more than starting with the technically most important item.

Scope discipline

When a stakeholder says "focus on code quality", it is worth mapping that instruction onto specific pipeline stages rather than assuming:

CheckCode quality?Enforce now?
SonarQube gateYes — this is literally its purposeBlocking
LintYes — encodes the team's own standardsBlocking
FormatYes — and auto-fixableBlocking
TestsYes in principle — but current failures are a config bug, not bad codeNotify until config fixed
Dependency auditNo — that is dependency managementNotify
Enforcing a check that fails for reasons outside the stated scope is how gates lose support. Blocking on a test suite that cannot run because of a missing resolver config punishes developers for something the instruction never covered. Say so explicitly, and enforce it later when the reason is genuinely code quality.

Documenting exceptions

Every non-blocking check is a temporary exception and should read like one — in the code, not only in someone's memory:

stage('Test') {
  steps {
    script {
      // TEMPORARY notify-only.
      // Reason:  19/20 Jest suites fail — moduleNameMapper missing for src/ path aliases.
      // Owner:   dev team
      // Revert:  when suites run; replace this block with  sh 'npm run test:cov'
      // Note:    coverage thresholds remain enforced by the SonarQube gate regardless.
      def testExit = sh(script: 'npm run test:cov', returnStatus: true)
      if (testExit != 0) { currentBuild.result = 'UNSTABLE' }
    }
  }
}

Four things every exception comment should carry: what is relaxed, why, who owns the fix, and what condition ends it. An exception without an end condition is a permanent change wearing a temporary label.

Surfacing exceptions in build notifications

${buildStatus == 'UNSTABLE' ? '''Build completed with warnings. Currently notify-only:
  - Dependency Audit: high-severity findings
  - Test: 19/20 suites failing (Jest resolver config)

Lint and Format ARE enforced — the build did not fail on those.
See the SonarQube dashboard for gate results.''' : ''}

This makes the relaxed set visible on every single build, to everyone on the notification list. Exceptions that are invisible become permanent by default.

Communicating the change

Technical readiness is not the hard part. The rollout announcement should cover:

Get the decision-maker's endorsement on the same thread. When builds start failing, the team will look for someone to appeal to. A visible "approved, please prioritise accordingly" from the person who asked for enforcement turns "DevOps blocked our builds" into "we are implementing an agreed standard." That is not politics; it is how a shared decision gets shared ownership.

Verifying the gate actually blocks

Do not assume — confirm, and keep the evidence.

#TestExpected
1Push a commit with a deliberate lint errorBuild fails at Lint; console shows deploy skipped
2Check the console for the skip messageStage "Deploy to Staging" skipped due to earlier failure(s)
3Confirm nothing changed on the targetgit log -1 on the deploy path shows the old commit
4Confirm the notification arrivedCommitter receives the FAILED email with a working console link
5Fix and re-pushBuild goes green, deploy runs, health check passes

Screenshot the skip message. It is the single most useful artefact when someone later asks whether the gate genuinely prevents deployment.

Rollout checklist

#Item
1Baseline numbers captured and shared
2Fix list prioritised by effort with owners assigned
3Enforcement date agreed and announced
4Decision-maker endorsement visible on the thread
5Blocking version preserved in comments for every notify-only stage
6Each exception documents what / why / owner / end condition
7Notification body lists the currently relaxed checks
8Deploy guard verified against a deliberately failing build
9Enforce on one job first; roll to the rest once stable