Rolling Out Blocking Gates
How to turn on quality enforcement against a legacy codebase without halting the business.
Part 6 of 7
The problem with switching everything on at once
A pipeline pointed at a mature codebase for the first time produces numbers like these:
| Check | First-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 audit | 17 vulnerabilities (12 high, 3 moderate, 2 low) |
| TypeScript compile | Clean |
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.
Three-phase rollout
| Phase | Duration | Behaviour | Purpose |
|---|---|---|---|
| 1 · Observe | 1–2 weeks | All new checks notify-only. Nothing blocks. | Establish the baseline. Let the team see the numbers without pressure. |
| 2 · Enforce new work | 2–4 weeks | Block on new findings. Existing debt tracked separately. | Stop the problem growing while the backlog is worked. |
| 3 · Full enforcement | Ongoing | Block 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.'
}
}
}
}
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:
| Metric | Condition | Effect |
|---|---|---|
| Issues | > 0 | No 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.
| Priority | Item | Effort | Impact |
|---|---|---|---|
| P1 | npm run format — auto-fix all formatting | 5 min | 346 violations → 0 |
| P1 | npm audit fix (no --force) | 5 min | Resolves safely-fixable CVEs |
| P2 | Fix Jest module resolution config | 30 min | Unblocks 19 suites, restores coverage data |
| P2 | Auto-fixable lint rules (import order, simplifiable expressions) | 1 hour | Large error-count reduction |
| P3 | Replace any with real types | Multi-day | Bulk of remaining errors |
| P3 | Refactor high cognitive-complexity functions | Days | A handful of errors, high code-health value |
| P4 | Formal exceptions for dependencies with no upstream fix | 1 hour | Documented risk acceptance |
Scope discipline
When a stakeholder says "focus on code quality", it is worth mapping that instruction onto specific pipeline stages rather than assuming:
| Check | Code quality? | Enforce now? |
|---|---|---|
| SonarQube gate | Yes — this is literally its purpose | Blocking |
| Lint | Yes — encodes the team's own standards | Blocking |
| Format | Yes — and auto-fixable | Blocking |
| Tests | Yes in principle — but current failures are a config bug, not bad code | Notify until config fixed |
| Dependency audit | No — that is dependency management | Notify |
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:
- What changes and exactly when — a specific date, not "soon"
- Which checks block, which stay informational — with the reason for each
- What will happen on the first build — "it will fail at lint" said in advance is a plan; discovered after the fact it is an outage
- The prioritised fix list — with effort estimates, so the work looks finite
- A preparation window — a couple of days so the team can land the five-minute fixes first
Verifying the gate actually blocks
Do not assume — confirm, and keep the evidence.
| # | Test | Expected |
|---|---|---|
| 1 | Push a commit with a deliberate lint error | Build fails at Lint; console shows deploy skipped |
| 2 | Check the console for the skip message | Stage "Deploy to Staging" skipped due to earlier failure(s) |
| 3 | Confirm nothing changed on the target | git log -1 on the deploy path shows the old commit |
| 4 | Confirm the notification arrived | Committer receives the FAILED email with a working console link |
| 5 | Fix and re-push | Build 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 |
|---|---|
| 1 | Baseline numbers captured and shared |
| 2 | Fix list prioritised by effort with owners assigned |
| 3 | Enforcement date agreed and announced |
| 4 | Decision-maker endorsement visible on the thread |
| 5 | Blocking version preserved in comments for every notify-only stage |
| 6 | Each exception documents what / why / owner / end condition |
| 7 | Notification body lists the currently relaxed checks |
| 8 | Deploy guard verified against a deliberately failing build |
| 9 | Enforce on one job first; roll to the rest once stable |