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
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
// 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, notDocker. - Keep steps short. A 40-line
shblock 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.