Three constructs decide whether a pipeline is a straight line that always runs everything, or something that adapts to the branch it is on and finishes in a third of the time.
Topic 1: when — Running a Stage Only When It Matters
stage('Deploy to staging') {
when {
branch 'main'
not { changeRequest() }
}
steps { sh './deploy.sh staging' }
}
Conditions are ANDed by default. The ones worth knowing:
when { branch 'main' }
when { branch pattern: 'release/.*', comparator: 'REGEXP' }
when { changeRequest() } // any PR
when { changeRequest target: 'main' } // PR targeting main
when { tag 'v*' }
when { environment name: 'DEPLOY', value: 'true' }
when { expression { params.ENVIRONMENT == 'production' } }
when { changeset '**/*.sql' } // files touched
when { equals expected: 2, actual: currentBuild.number }
when { anyOf { branch 'main'; branch 'develop' } }
when { allOf { branch 'main'; expression { params.DEPLOY } } }
when { not { branch 'main' } }
when { buildingTag() }
beforeAgent true is the option that saves real time and money:
stage('Deploy') {
when {
beforeAgent true
branch 'main'
}
agent { label 'deploy' }
steps { sh './deploy.sh' }
}
Without it, Jenkins allocates the agent, checks out the repository, then evaluates the condition and skips the stage. With it, the condition is evaluated first and no agent is claimed. On a pipeline with several environment-specific stages this is the difference between provisioning five agents and one.
Its siblings, beforeInput and beforeOptions, work the same way for the other two costs.
Topic 2: parallel
stage('Verify') {
parallel {
stage('Unit') {
agent { label 'linux' }
steps { sh 'make test-unit' }
post { always { junit 'reports/unit/*.xml' } }
}
stage('Integration') {
agent { label 'linux' }
steps { sh 'make test-integration' }
}
stage('Lint & static analysis') {
agent { label 'linux' }
steps { sh 'make lint' }
}
}
}
Properties to plan around:
- All branches must finish before the parent stage completes. The stage takes as long as its slowest branch.
failFast trueaborts the remaining branches as soon as one fails. Good for fast feedback; bad when you wanted the full picture of what is broken. Choose per stage rather than globally.- Each branch needs its own agent, or its own directory. Two branches sharing one workspace overwrite each other’s files, and the resulting failures look random.
- Parallelism buys nothing without free executors. On a saturated controller, four parallel branches queue and the pipeline gets slower because of the scheduling overhead.
For dynamic parallelism — one branch per item in a list — you need script:
script {
def regions = ['eu-west-1', 'us-east-1', 'ap-south-1']
parallel regions.collectEntries { r ->
["deploy-${r}": { sh "./deploy.sh --region ${r}" }]
}
}
Topic 3: matrix
Matrix generates the combinations that you would otherwise copy and paste:
stage('Cross-platform tests') {
matrix {
axes {
axis { name 'PLATFORM'; values 'linux', 'windows' }
axis { name 'JDK'; values '17', '21' }
}
excludes {
exclude {
axis { name 'PLATFORM'; values 'windows' }
axis { name 'JDK'; values '17' }
}
}
agent { label "${PLATFORM}" }
stages {
stage('Test') {
steps { sh "./test.sh --jdk ${JDK}" }
}
}
post {
always { junit "reports/${PLATFORM}-${JDK}/*.xml" }
}
}
}
Two axes of two values each is four cells; the excludes block removes one, leaving three. Cells run in parallel by default, so a matrix can consume a lot of executors quickly — options { throttle(…) } or a smaller axis is the answer when it saturates the controller.
Matrix is for genuinely identical work across variants. When the cells start needing when conditions and different steps, they are not a matrix; they are separate stages that happen to look similar.
Topic 4: Failing Deliberately
// Fail the build
error 'Deployment target not configured'
// Mark unstable but keep going
unstable 'Integration tests failed — investigating'
// Capture an exit code instead of failing immediately
script {
def rc = sh(script: './flaky-check.sh', returnStatus: true)
if (rc != 0) { unstable("check exited ${rc}") }
}
// Contain a failure and keep the pipeline going
catchError(buildResult: 'UNSTABLE', stageResult: 'FAILURE') {
sh './optional-report.sh'
}
// Retry transient work — network, registry, flaky infra
retry(3) {
sh 'curl -fsSL https://artifacts.example/api/deploy'
}
// Bound anything that can hang
timeout(time: 10, unit: 'MINUTES') {
sh './integration-tests.sh'
}
retry belongs on infrastructure operations, not on tests. Retrying a flaky test hides the flakiness and makes the suite slower and less trustworthy; retrying a registry push that failed on a 503 is exactly right. Deciding which one you are doing is the whole judgement.
catchError with buildResult: 'UNSTABLE' is how an optional stage — a report, a nice-to-have scan — fails visibly without blocking a release.
Topic 5: Sequential Stages Inside Parallel
Sometimes a parallel branch needs several ordered steps of its own:
stage('Environments') {
parallel {
stage('EU') {
stages {
stage('Deploy EU') { steps { sh './deploy.sh eu' } }
stage('Smoke EU') { steps { sh './smoke.sh eu' } }
}
}
stage('US') {
stages {
stage('Deploy US') { steps { sh './deploy.sh us' } }
stage('Smoke US') { steps { sh './smoke.sh us' } }
}
}
}
}
Each region deploys and verifies in order, and the two regions run concurrently. This is the shape most multi-region deployments want, and it renders correctly in the stage view — which matters when someone is watching a release.
Topic 6: Making the Pipeline Faster, in Order
Measure first: the stage view and currentBuild.duration per stage tell you where the time is. Then, roughly in order of return:
1. Delete work that does not need to run on every commit.
Nightly is a legitimate schedule for a 20-minute security scan.
2. beforeAgent true on every conditional stage.
Stops agent allocation for stages that will be skipped.
3. Cache dependencies. A mounted ~/.m2, ~/.npm or a build cache
often halves the build stage on its own.
4. Parallelise the longest independent stages — after 1–3, because
parallelising waste just wastes it concurrently.
5. Shrink the slowest test. One test is usually 30% of the suite.
6. Use a bigger agent for the one stage that needs it, rather than
sizing every agent for the worst case.
The number to watch is time-to-first-failure, not total duration. A pipeline that tells a developer they broke something in 90 seconds is more valuable than one that takes 25 minutes to give a complete report.
Try it yourself: run three test suites sequentially, then in parallel, and record both durations. Then run the parallel version on a busy controller with no free executors and record it again. The third number is the one that decides whether parallelism is your bottleneck or your fix.
Common mistake: adding retry(3) around a test stage to make a flaky suite green. The build passes, the flakiness stays, the suite takes three times as long on its bad days, and everyone stops believing the pipeline. Quarantine the flaky test, fix it or delete it — those are the three honest options.