qasim@wiki:~$

Jenkinsfile Anatomy

A declarative pipeline built stage by stage, with the reasoning behind each block.

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.

Node Version Check ~1s — fail fast on environment drift Sanity Check ~1s — record node/npm versions in the log Install ~30s — npm ci Dependency Audit ~5s — notify-only TypeScript Compile ~30s — does it even build? Lint + Format ~5s BLOCKING Test + Coverage ~90s — produces lcov for SonarQube SonarQube Scan ~11m — the expensive one Quality Gate ~10s BLOCKING Deploy ~60s — guarded on currentResult == SUCCESS

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 { /* … */ }
}
Everything client-specific lives in 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 cinpm install
LockfileRequired; installs exactly what it specifiesMay rewrite it
node_modulesDeleted and rebuiltIncrementally updated
ReproducibleYesNo

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.

When 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

ElementEffect
returnStatus: trueCaptures exit code; the step never throws
currentBuild.result = 'UNSTABLE'Marks the build yellow — visible, but not a failure
emailext in try/catchNotifies the committer; mail problems can't fail the build
Deploy guard reads SUCCESSUNSTABLE still blocks the deploy — the safety property is preserved
Why UNSTABLE rather than SUCCESS. UNSTABLE is honest — something is wrong — while still allowing later stages to run so you can collect a full picture in one build. And because the deploy stage requires 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'
      }
    }
  }
}
Leave the blocking version in a comment directly above the notify-only code. When the exception is lifted, the change is a two-line edit rather than an archaeology exercise.

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.

Failing tests silently disable the coverage gate. If the test run dies, 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
  }
}
This 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})"
      }
    }
  }
}
ConditionRuns when
alwaysEvery build, any result
success / failure / unstableOnly for that result
changedResult differs from the previous build — useful for "back to normal" alerts
cleanupAfter all other post conditions; for workspace teardown

Groovy gotchas

IssueDetail
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 newlineAlways 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 initiallyUse currentBuild.currentResult for the live value; ?: for a default.
Nested quoting in SSH commandsTriple-double-quoted Groovy → single-quoted SSH → double-quoted inner bash. Getting this wrong produces bizarre remote errors. Detail in Part 5.