The Declarative Pipeline

The seven blocks every pipeline is made of, what agent placement costs you, and the post conditions that make a pipeline report the truth.

intermediate 20 min lesson hands-on task included

Declarative pipelines have a fixed shape. Learning the seven blocks and what each is for makes almost every Jenkinsfile you will read immediately legible.


Topic 1: The Blocks

EVERY DECLARATIVE PIPELINE IS THE SAME SEVEN BLOCKS pipeline { } the outermost block — everything lives inside it agent WHERE it runs: any · none · label · docker · kubernetes options timeout, retry, buildDiscarder, disableConcurrentBuilds environment variables — and credentials() bindings parameters what a human supplies at build time triggers cron, pollSCM, upstream (webhooks are configured on the repo) stages { stage { steps } } the actual work — stage names are your UI post always · success · failure · unstable · changed DECLARATIVE OR SCRIPTED? Declarative unless you can name the reason. It validates before running, restarts from a stage, and is readable by people who do not write Groovy.
Read any Jenkinsfile top to bottom against this list. The two blocks people underuse are `options`, which prevents whole classes of stuck build, and `post`, which is where reporting belongs.
pipeline {
    agent { label 'linux' }

    options {
        timeout(time: 30, unit: 'MINUTES')
        buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '5'))
        disableConcurrentBuilds(abortPrevious: true)
        timestamps()
        ansiColor('xterm')
        skipDefaultCheckout()          // when you want to control checkout yourself
    }

    environment {
        REGISTRY = 'ghcr.io/acme'
        IMAGE    = "${REGISTRY}/checkout"
        VERSION  = "${env.GIT_COMMIT.take(7)}"
    }

    parameters {
        choice(name: 'ENVIRONMENT', choices: ['staging', 'production'], description: 'Deploy target')
        booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: 'Emergency only')
        string(name: 'RELEASE_NOTE', defaultValue: '', description: 'Shown in the deploy record')
    }

    triggers {
        cron('H 2 * * 1-5')            // nightly, spread across the hour
    }

    stages {
        stage('Build')  { steps { sh 'make build' } }
        stage('Test')   { steps { sh 'make test' } }
        stage('Deploy') { steps { sh "make deploy ENV=${params.ENVIRONMENT}" } }
    }

    post {
        always  { junit allowEmptyResults: true, testResults: '**/target/surefire-reports/*.xml' }
        success { echo "Deployed ${env.IMAGE}:${env.VERSION}" }
        failure { slackSend channel: '#builds', message: "FAILED ${env.BUILD_URL}" }
        cleanup { cleanWs() }
    }
}

H in a cron expression is not a typo. It means “hash of the job name” — Jenkins spreads jobs across the interval instead of starting 200 builds at exactly 02:00. Use H 2 * * *, never 0 2 * * *.


Topic 2: Where agent Goes, and What It Costs

WHERE agent IS DECLARED DECIDES HOW MANY WORKSPACES YOU GET agent AT PIPELINE LEVEL pipeline { agent any … ONE workspace, every stage Files written in one stage are there in the next. Simple, and it pins one executor for the whole run — including while it waits for an approval. agent PER STAGE (with agent none) pipeline { agent none … stage { agent { docker … A FRESH workspace per stage Different tools per stage, no executor held during an input step. Files do NOT carry over — you must stash them. MOVING FILES BETWEEN AGENTS stash name: 'app', includes: 'target/*.jar' → unstash 'app' Stash is for small build outputs inside one run. For anything large or lasting, publish to an artifact repository instead. THE MISTAKE THAT WASTES EXECUTORS A pipeline-level agent plus an input step for production approval holds a build slot for hours. Use agent none and put the agent on the stages that work.
Pipeline-level agent gives you one workspace and holds an executor for the whole run. Per-stage agents give a fresh workspace each time — which is why files must be stashed to cross the boundary.
// One agent, one workspace, held for the entire run
pipeline {
    agent { label 'linux' }
    stages { … }
}
// No global agent — each stage asks for what it needs
pipeline {
    agent none
    stages {
        stage('Build') {
            agent { docker { image 'maven:3.9-eclipse-temurin-21'; args '-v $HOME/.m2:/root/.m2' } }
            steps {
                sh 'mvn -B package'
                stash name: 'jar', includes: 'target/*.jar'
            }
        }
        stage('Approve') {
            // no agent — an executor is NOT held while waiting
            steps {
                timeout(time: 4, unit: 'HOURS') {
                    input message: 'Deploy to production?', submitter: 'release-managers'
                }
            }
        }
        stage('Deploy') {
            agent { label 'deploy' }
            steps {
                unstash 'jar'
                sh './deploy.sh'
            }
        }
    }
}

The pattern in that second example is the important one: an input step inside a stage with an agent holds a build executor for as long as the approval takes. Four hours of waiting occupies a slot that other builds need. agent none at the top, with agents only on the stages that do work, fixes it.

Docker agents run the stage inside a container, which is how you pin the toolchain per stage rather than installing every tool on every agent:

agent {
    docker {
        image 'node:22-alpine'
        args '-v $HOME/.npm:/root/.npm'
        reuseNode true            // use the same workspace as the outer agent
    }
}

Topic 3: environment, and Reading Values Correctly

environment {
    // A literal
    REGISTRY = 'ghcr.io/acme'
    // Computed from another variable
    IMAGE = "${REGISTRY}/checkout"
    // From a credential — see the credentials lesson for what this really does
    NPM_TOKEN = credentials('npm-publish-token')
    // From a shell command
    GIT_SHORT = """${sh(returnStdout: true, script: 'git rev-parse --short HEAD').trim()}"""
}

Two Groovy details that cause most of the confusion here:

Double quotes interpolate, single quotes do not. sh "echo ${VERSION}" is expanded by Groovy before the shell sees it; sh 'echo $VERSION' is expanded by the shell. The second is what you want for anything secret, because the first puts the value into the command line where it can appear in logs and process lists.

env is the map, and it is available everywhere:

echo "${env.BUILD_NUMBER} on ${env.NODE_NAME} for ${env.BRANCH_NAME}"

The variables worth knowing: BUILD_NUMBER, BUILD_URL, JOB_NAME, NODE_NAME, WORKSPACE, GIT_COMMIT, GIT_BRANCH, BRANCH_NAME, CHANGE_ID (pull requests only), CHANGE_TARGET.


Topic 4: post, and Reporting the Truth

post {
    always    { … }   // every outcome, including aborted
    success   { … }
    failure   { … }
    unstable  { … }   // tests failed but the build did not error
    changed   { … }   // outcome differs from the previous build
    fixed     { … }   // previous failed, this succeeded
    regression{ … }   // previous succeeded, this failed
    aborted   { … }
    cleanup   { … }   // runs last, after everything else
}

unstable is a distinct state and it is underused. A build where the compile succeeded but three tests failed is not the same as a build that could not compile. Marking it unstable rather than failed lets notification rules and downstream jobs treat them differently:

steps {
    sh(script: './run-flaky-integration-tests.sh', returnStatus: true) == 0 ?: unstable('integration tests failed')
}

changed, fixed and regression are how you stop notification fatigue. Alerting on every failure of a long-broken branch trains people to ignore the channel; alerting on regression and fixed tells them when something actually changed.

cleanup runs last, always, which makes it the right place for cleanWs() — and worth having, because a workspace left behind on every build is a disk-full incident waiting on a static agent.


Topic 5: Parameters and Inputs

parameters are supplied when the build starts. There is one wrinkle worth knowing: a parameter added to the Jenkinsfile does not exist until the pipeline has run once to register it — the first run after adding a parameter uses the defaults.

input pauses mid-pipeline for a human:

stage('Deploy to production') {
    steps {
        timeout(time: 8, unit: 'HOURS') {
            script {
                def approval = input(
                    message: 'Deploy to production?',
                    submitter: 'release-managers,platform-team',
                    submitterParameter: 'APPROVER',
                    parameters: [text(name: 'REASON', defaultValue: '', description: 'Why now?')]
                )
                echo "Approved by ${approval.APPROVER}: ${approval.REASON}"
            }
        }
    }
}

Three properties to get right: always wrap input in a timeout so an unanswered prompt does not hold the build forever; set submitter so approval means something; and capture who approved with submitterParameter so the record exists.


Topic 6: Making a Pipeline Readable

A Jenkinsfile is read far more often than it is written, usually by someone debugging at speed.

  • Name stages after what they achieve, not after the tool: Build image, not Docker.
  • Keep steps short. A 40-line sh block belongs in a script in the repository, which can be run locally and reviewed with syntax highlighting.
  • One concern per stage, so the stage view tells you where it broke.
  • Push repetition into a shared library, not into copy-paste. That is the next stage of this module.
  • Do not hide the pipeline behind a single library call either — a Jenkinsfile that reads standardPipeline() and nothing else cannot be debugged by the team that owns the service.

Try it yourself: write a pipeline with agent none, a docker agent on one stage and a label agent on another, then write a file in the first and try to read it in the second. The failure is immediate and it makes the workspace model permanent.

Common mistake: omitting timeout from options. A build that hangs — waiting on a network call, an unanswered prompt, a test that never exits — holds its executor indefinitely. On a busy controller a handful of these silently removes your build capacity, and the symptom is “builds are queued” rather than anything pointing at the cause.