Jenkinsfile Anatomy
A declarative pipeline built stage by stage, with the reasoning behind each block.
Part 4 of 7
Stage order and why it matters
Cheap checks first, expensive checks last, deploy only at the end. A build that will fail on a lint error should fail in 3 seconds, not after an 11-minute SonarQube scan.
Skeleton
pipeline {
agent any
tools { nodejs 'Node.js 24.11.x' }
triggers { pollSCM('H/7 0-2,5-23 * * 1-6') }
options {
timestamps()
disableConcurrentBuilds()
buildDiscarder(logRotator(numToKeepStr: '30'))
}
environment {
SONAR_PROJECT_KEY = 'simplex_kfc_api_server_stg'
DEPLOY_HOST = '10.10.1.223'
DEPLOY_USER = 'ubuntu'
SERVICE_USER = 'kfc'
NVM_DIR = '/home/kfc/.nvm'
DEPLOY_PATH = '/home/kfc/kfc-pk/kfc-api-server'
PM2_APP_NAME = 'kfc-server'
DEPLOY_BRANCH = 'release/uat'
HEALTH_PORT = '4016'
}
stages { /* … */ }
post { /* … */ }
}
environment. This is what makes the pipeline reusable across a dozen clients: to onboard a new one, copy the file and change nine strings. Nothing below the environment block changes. That property is worth protecting — resist the urge to hard-code a path inside a stage.Stage 1 — Node version check
Guards against the build node and the deploy target running different Node versions. Catching that mismatch at second 1 is far better than discovering it after deploy when the app won't boot.
stage('Node Version Check') {
steps {
sh '''
if [ ! -f .nvmrc ]; then
echo "WARNING: .nvmrc not found in repo root."
echo "Skipping Node version enforcement for this build."
exit 0
fi
REQUIRED=$(cat .nvmrc)
ACTUAL=$(node -v | sed 's/^v//')
if [ "$ACTUAL" != "$REQUIRED" ]; then
echo "Node version mismatch: required $REQUIRED, got $ACTUAL"
exit 1
fi
'''
}
}
.nvmrc format matters. Write 20.20.2, not v20.20.2. The check strips the v from node -v output but compares against the file verbatim — a v prefix in the file makes the comparison fail on every single build.The graceful fallback when .nvmrc is absent is deliberate: a missing file is a repo hygiene issue for the dev team to fix, not a reason to block their pipeline on day one. The warning stays in the log every build so it doesn't get forgotten.
Stage 2 — Install
stage('Install') {
steps { sh 'npm ci' }
}
npm ci | npm install | |
|---|---|---|
| Lockfile | Required; installs exactly what it specifies | May rewrite it |
| node_modules | Deleted and rebuilt | Incrementally updated |
| Reproducible | Yes | No |
npm ci is the correct CI choice — it fails loudly when package.json and package-lock.json disagree, rather than silently resolving to different versions than the developer tested.
npm ci fails with ERESOLVE, that is a genuine peer-dependency conflict in the project. Resist adding --legacy-peer-deps unilaterally to make the red go away — it masks a real inconsistency and makes CI behave differently from local development. Ask the team which they intend: match their local install command, or fix the conflicting version range.Stage 3 — Dependency audit (notify-only)
The reference implementation of the notify-only pattern used throughout the series.
stage('Dependency Audit') {
steps {
script {
// returnStatus: true captures the exit code instead of failing the build
def auditExit = sh(script: 'npm audit --audit-level=high', returnStatus: true)
def committerEmail = sh(script: "git log -1 --pretty=format:'%ae'", returnStdout: true).trim()
def committerName = sh(script: "git log -1 --pretty=format:'%an'", returnStdout: true).trim()
def commitMsg = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
if (auditExit != 0) {
def auditText = sh(script: 'npm audit --audit-level=high || true', returnStdout: true).trim()
def notifyTo = committerEmail?.trim() ? committerEmail : 'devops@example.com'
try {
emailext(
to: notifyTo,
subject: "[STG] npm audit high-severity — ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: """Hi ${committerName ?: 'team'},
Commit: "${commitMsg}"
${auditText}
Console: ${env.BUILD_URL}console
Notify-only — the build is NOT blocked."""
)
} catch (Exception e) {
echo "WARNING: audit email failed (${e.message})"
}
currentBuild.result = 'UNSTABLE'
} else {
echo 'Dependency audit passed — no high-severity issues.'
}
}
}
}
The notify-only pattern
| Element | Effect |
|---|---|
returnStatus: true | Captures exit code; the step never throws |
currentBuild.result = 'UNSTABLE' | Marks the build yellow — visible, but not a failure |
emailext in try/catch | Notifies the committer; mail problems can't fail the build |
Deploy guard reads SUCCESS | UNSTABLE still blocks the deploy — the safety property is preserved |
SUCCESS, an UNSTABLE build cannot ship. You get visibility without shipping risk.Stage 4 — Lint and format (blocking)
stage('Lint and Format') {
steps {
// BLOCKING — any violation fails the build and prevents deployment
sh 'npm run lint:check'
sh 'npm run format:check'
}
}
Plain sh steps: a non-zero exit fails the stage immediately, the build goes red, and every subsequent stage is skipped. That is the entire mechanism of a blocking gate.
The same stage in notify-only form, for the rollout period described in Part 6:
stage('Lint and Format') {
steps {
script {
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 so analysis can run.'
currentBuild.result = 'UNSTABLE'
}
}
}
}
Stage 5 — Test and coverage
stage('Test') {
steps { sh 'npm run test:cov' }
}
The coverage report is the point. jest --coverage writes coverage/lcov.info, which sonar.javascript.lcov.reportPaths picks up in the next stage. No coverage file means SonarQube reports 0% coverage — and a coverage gate condition that can never pass.
lcov.info is never written, SonarQube sees no coverage data, and the New Code coverage condition has nothing to evaluate. The gate can then show green on a codebase with zero passing tests. Always check that coverage data actually arrived — the scanner log states explicitly whether a coverage report was imported.Stage 6 — SonarQube scan and gate
stage('SonarQube Scan') {
steps {
withSonarQubeEnv('SonarQube Server') {
sh '/opt/sonar-scanner/bin/sonar-scanner -Dsonar.projectKey=$SONAR_PROJECT_KEY'
}
}
}
stage('Quality Gate') {
steps {
timeout(time: 5, unit: 'MINUTES') {
waitForQualityGate abortPipeline: true
}
}
}
abortPipeline: true is the switch that turns SonarQube from a reporting tool into a gate. With it, a failed gate aborts the run; without it, the result is recorded and the pipeline sails on to deploy.
Stage 7 — Deploy guard
stage('Deploy to Staging') {
when {
expression { currentBuild.currentResult == 'SUCCESS' }
}
steps {
// SSH deploy — covered in Part 5
}
}
when block is the most important four lines in the file.
It is a second, independent safety net. Even if a future edit accidentally makes an earlier gate non-blocking, the deploy still refuses to run unless the whole build is green. Defence in depth, expressed in pipeline syntax.
currentBuild.currentResult is the running result at that moment — as opposed to currentBuild.result, which is null until something explicitly sets it. Using the wrong one produces a deploy guard that never fires.
Confirming the guard works
Two console messages prove the deploy was blocked, depending on how the failure happened:
Stage "Deploy to Staging" skipped due to when conditional
Stage "Deploy to Staging" skipped due to earlier failure(s)
The first appears when the build is UNSTABLE and the when expression evaluates false. The second appears when an earlier stage failed outright. Both mean the same thing: nothing was deployed.
Post block
post {
always {
script {
def committerEmail = sh(script: "git log -1 --pretty=format:'%ae'", returnStdout: true).trim()
def committerName = sh(script: "git log -1 --pretty=format:'%an'", returnStdout: true).trim()
def commitMsg = sh(script: "git log -1 --pretty=format:'%s'", returnStdout: true).trim()
def notifyTo = committerEmail?.trim() ? committerEmail : 'devops@example.com'
def buildStatus = currentBuild.currentResult ?: 'UNKNOWN'
def label = buildStatus == 'SUCCESS' ? 'SUCCESS' :
buildStatus == 'UNSTABLE' ? 'WARNING' : 'FAILED'
try {
emailext(
to: "${notifyTo}, lead@example.com",
subject: "[STG] ${label} — ${env.JOB_NAME} #${env.BUILD_NUMBER}",
body: """Hi ${committerName ?: 'team'},
Build #${env.BUILD_NUMBER} finished with status: ${buildStatus}
Commit: "${commitMsg}"
Console: ${env.BUILD_URL}console"""
)
} catch (Exception e) {
echo "WARNING: post-build email failed (${e.message})"
}
}
}
}
| Condition | Runs when |
|---|---|
always | Every build, any result |
success / failure / unstable | Only for that result |
changed | Result differs from the previous build — useful for "back to normal" alerts |
cleanup | After all other post conditions; for workspace teardown |
Groovy gotchas
| Issue | Detail |
|---|---|
| Single vs double quotes | '...' is a plain Groovy string — no ${} interpolation. "..." interpolates. Shell scripts that use $VAR belong in single quotes so the shell expands them, not Groovy. |
returnStdout keeps the newline | Always chain .trim() or the trailing newline ends up inside email subjects and comparisons. |
Declarative needs script {} | Conditionals, variables, and loops only work inside a script block within a declarative pipeline. |
currentBuild.result is null initially | Use currentBuild.currentResult for the live value; ?: for a default. |
| Nested quoting in SSH commands | Triple-double-quoted Groovy → single-quoted SSH → double-quoted inner bash. Getting this wrong produces bizarre remote errors. Detail in Part 5. |