Mert Özen Further With Every Line
DevOps Jenkins — #03

Your First Pipeline with a Jenkinsfile: Declarative Syntax, stages, steps, post and environment

Mert Özen Nov 8, 2026 10 min 3 views
Your First Pipeline with a Jenkinsfile: Declarative Syntax, stages, steps, post and environment

A Jenkinsfile describes the pipeline in the same repository as the code; in Declarative syntax agent, stages and steps form the skeleton, while environment, options, parameters, triggers and post define the behavior. Linter output, the quoting rule, the parameter the shell sees as empty on the first build, a stage skipped by when, Snippet Generator and Replay, Scripted Pipeline, and NotSerializableException with @NonCPS.

Version note: The examples in this post were run with Jenkins 2.580.2 LTS (Long-Term Support), Pipeline: Declarative (workflow-aggregator 608) and Gitea 28.

Guess First

You create the pipeline below as a new job and run it for the first time with the "Build" button. Two lines will land in the console: one from Groovy's echo, one from the shell. Do both print "Release"?

pipeline {
    agent { label 'linux' }
    parameters {
        choice(name: 'BUILD_CONFIGURATION', choices: ['Release', 'Debug'], description: 'dotnet build configuration')
    }
    stages {
        stage('Show') {
            steps {
                echo "params: ${params.BUILD_CONFIGURATION}"
                sh 'echo "shell: $BUILD_CONFIGURATION"'
            }
        }
    }
}

The Jenkinsfile Lives in the Repository

The pipeline's recipe lives in the same repository as the application, in a file named Jenkinsfile in the root folder. The first version I put into the Bookstore repository:

pipeline {
    agent { label 'linux' }

    options {
        timeout(time: 15, unit: 'MINUTES')
        timestamps()
        buildDiscarder(logRotator(numToKeepStr: '20'))
        disableConcurrentBuilds()
    }

    environment {
        APP_NAME = 'Bookstore.Api'
        VERSION = "1.4.${BUILD_NUMBER}"
    }

    stages {
        stage('Info') {
            steps {
                echo "Building ${APP_NAME} ${VERSION} from ${env.BRANCH_NAME}"
                sh 'git log -1 --format="%h %s"'
            }
        }
    }

    post {
        always {
            echo "Result: ${currentBuild.currentResult}"
        }
    }
}

On the Jenkins side there is a multibranch pipeline job watching this repository (we will set it up in detail in the sixth post). Selected lines from the log of the build after the push:

Obtained Jenkinsfile from 50a397b45b74956d7f0fb0ac21d1d168deefe814
Running on docker-agent-1 in /home/jenkins/agent/workspace/bookstore_main
using credential gitea-ci
Cloning repository http://jenkwork-gitea:3000/bookstore/bookstore.git
Checking out Revision 50a397b45b74956d7f0fb0ac21d1d168deefe814 (main)
Commit message: "Add Bookstore API, web app and first Jenkinsfile"
Timeout set to expire in 15 min
[2026-11-08T04:03:20.255Z] Building Bookstore.Api 1.4.1 from main
[2026-11-08T04:03:20.535Z] + git log -1 --format=%h %s
[2026-11-08T04:03:20.535Z] 50a397b Add Bookstore API, web app and first Jenkinsfile
[2026-11-08T04:03:20.561Z] Result: SUCCESS
Finished: SUCCESS

Notice that the first line says "I took the Jenkinsfile from this commit". Jenkins reads the Jenkinsfile from each build's own commit; so a change to the pipeline goes through a pull request like any other code change and can be reverted. In a declarative pipeline you also do not need to write checkout scm: the moment the build gets an agent, Jenkins checks out the same commit into the workspace.

The Skeleton: pipeline, agent, stages, steps

Declarative Pipeline's rules are strict, and that is a good thing. At the outermost level there is a single pipeline block. agent says where the pipeline runs; label 'linux' means "any agent with this label", and none means "do not take an agent at the top level, let each stage choose". stages contains one or more stage blocks; each stage name appears as a box in the Jenkins UI (User Interface) and its duration is measured separately. The actual work is in the steps inside steps: sh, echo, junit, archiveArtifacts and so on.

A Jenkinsfile that breaks these rules is rejected without being run. I sent a file where I had written stpes instead of steps to the controller's linter endpoint:

curl -s -u "admin:$JENKINS_ADMIN_PASSWORD" -H "$CRUMB" -X POST \
  -F "jenkinsfile=<Jenkinsfile" http://localhost:18400/pipeline-model-converter/validate
Errors encountered validating Jenkinsfile:
WorkflowScript: 4: Unknown stage section "stpes". Starting with version 0.5, steps in a stage must be in a ‘steps’ block. @ line 4, column 9.
           stage('Build') {
           ^

WorkflowScript: 4: Expected one of "steps", "stages", or "parallel" for stage "Build" @ line 4, column 9.
           stage('Build') {
           ^

For a correct file the same endpoint returns only Jenkinsfile successfully validated.. Wire this small curl command into a git hook or your editor and you escape the "push, wait for the build to break, fix" loop.

The Pipeline's Settings Blocks

Around the skeleton there are five blocks that say how the pipeline behaves. All are optional, but in a real pipeline you use most of them.

environment and the Quoting Question

Variables in the environment block can be read throughout the pipeline both from Groovy (env.VERSION or simply VERSION) and from inside sh ($VERSION). There is one rule you need to know here, but it matters: quotes. In Groovy a double-quoted string interpolates: "1.4.${BUILD_NUMBER}" becomes 1.4.1 on the Groovy side when the build starts. A single-quoted string stays as it is: everything inside sh 'git log -1' is interpreted by the shell. When you write sh "echo $VERSION", Groovy fills in the variable; when you write sh 'echo $VERSION', the shell does. For ordinary variables both give the same result; for secrets the difference is vital. That is exactly the topic of the fifth post.

options: How the Build Behaves

options are settings applied to the pipeline itself. The four in our Jenkinsfile belong in almost every pipeline:

  • timeout(time: 15, unit: 'MINUTES'): a stuck build must not hold an executor forever. The Timeout set to expire in 15 min line in the log is this.
  • timestamps(): a timestamp at the start of every line. The cheapest way to find the slow step.
  • buildDiscarder(logRotator(numToKeepStr: '20')): keep the last 20 builds, delete the rest. Protects the controller disk.
  • disableConcurrentBuilds(): two builds of the same job must not run at the same time. Especially important for pipelines that deploy.

An important detail we saw in the first post: the top-level timeout starts after an agent has been found. Time spent waiting in the queue is not part of it.

parameters, when and the First Build Trap

parameters offer choices to whoever starts the build, and when makes a stage conditional. The real output of the pipeline from the start of the post was this. The first build:

params: Release
+ echo shell:
shell: 

The second build, this time with "Build with Parameters" and the default value selected:

Started by user admin
params: Release
+ echo shell: Release
shell: Release

Here is why: the parameters block registers the job's parameter definitions only after the pipeline has run once. The first build is a build started without parameters; declarative fills the params object with default values, so Groovy sees "Release", but since there is no real parameter attached to the build there is no environment variable either, and the shell sees an empty string. If you rely on a parameter inside a single-quoted sh, you will hit this surprise on the first build (and on the first run after every change to the parameter list). Using params.X on the Groovy side, or passing the value explicitly in the environment block as BUILD_CONFIGURATION = "${params.BUILD_CONFIGURATION}", closes this gap.

On the when side, in the build I started with RUN_TESTS=false the stage was skipped and the log said so clearly:

stage('Test') {
    when {
        expression { params.RUN_TESTS }
    }
    steps {
        sh 'echo "pretend: dotnet test -c $BUILD_CONFIGURATION"'
    }
}
[2026-11-08T03:40:22.206Z] Building Bookstore.Api 1.4.3 (Debug)
Stage "Test" skipped due to when conditional
[2026-11-08T03:40:22.578Z] Result so far: SUCCESS
[2026-11-08T03:40:22.584Z] Build is green

Inside when there are ready-made conditions such as branch 'main', changeRequest(), tag 'v*', environment name: 'DEPLOY', value: 'true', plus allOf, anyOf and not to combine them. We will use the branch and PR (Pull Request) conditions in the multibranch post.

triggers and post

triggers ties the build to an event. For a nightly build:

triggers {
    cron('H 2 * * 1-5')
}

H means "hash": Jenkins derives the minute from the job name, so a hundred jobs do not all start at exactly 02:00:00. After a pipeline containing this block ran once, a TimerTrigger stored as <spec>H 2 * * 1-5</spec> appeared in the job's configuration; so triggers, just like parameters, is registered on the first run. pollSCM is written in the same place, but instead of querying the Git server every minute, using a webhook is both faster and cheaper.

post runs after the build finishes, depending on the result. always, success and failure are the most common; there are also unstable (tests failed but the build completed), aborted, fixed (the previous one was red, this one is green), regression and cleanup, which runs last. Sending notifications, publishing test reports and cleaning the workspace are the job of post.

Tools: Snippet Generator, Replay and the Linter

You do not need to memorize the parameters of any step. Every pipeline job page has a "Pipeline Syntax" link; the Snippet Generator there lets you fill in a step through a form and generates its Groovy equivalent, and the "Declarative Directive Generator" generates blocks such as options, when and parameters. Because it changes with the installed plugins, it shows the syntax that is valid on your Jenkins; it is more reliable than old examples on the internet.

Replay lets you edit the Jenkinsfile of a finished build in the browser and run it again without committing. Very handy for quickly checking a small experiment, but it is not a permanent change: you must write the fix into the repository afterwards. You have already seen the linter above.

Scripted Pipeline and the script {} Escape Hatch

In old Jenkinsfiles you will see Scripted Pipeline starting with node:

node('linux') {
    stage('Info') {
        sh 'echo "Scripted on $NODE_NAME"'
    }
    if (env.BRANCH_NAME == null) {
        echo 'Not a multibranch build'
    }
}
Running on docker-agent-1 in /home/jenkins/agent/workspace/scripted
+ echo Scripted on docker-agent-1
Scripted on docker-agent-1
Not a multibranch build

Since it is plain Groovy, if, loops and try/catch are free to use. The price: no linter, no ready-made structures like options, post and when; you write everything by hand. When you need Groovy inside Declarative, you open a script { } block. A good rule: if a script block grows beyond a few lines, that code should move either into a shell script or into the Shared Library we will see in the tenth post.

Groovy CPS Traps

Jenkins does not run the Groovy code in a pipeline directly; it passes it through a transformation called CPS (Continuation-Passing Style). The goal is that a pipeline can resume where it left off even if the controller restarts. For that, Jenkins writes the program's state to disk at every step (every sh, every echo). If an object that cannot be written to disk is in memory at that moment, the pipeline blows up:

script {
    def matcher = ('Bookstore.Api-1.4.7' =~ /(\d+)\.(\d+)\.(\d+)/)
    sh 'echo checkpoint'
    echo "major: ${matcher[0][1]}"
}
an exception which occurred:
	in field com.cloudbees.groovy.cps.impl.BlockScopeEnv.locals
	in object com.cloudbees.groovy.cps.impl.BlockScopeEnv@79acd7ca
	...
Caused: java.io.NotSerializableException: java.util.regex.Matcher
Finished: FAILURE

java.util.regex.Matcher is not serializable, and it is still sitting in a local variable when the sh step is entered. The fix is to move the non-serializable work into a method marked @NonCPS and return only a plain value (a string, a number):

@NonCPS
String majorVersion(String text) {
    def matcher = (text =~ /(\d+)\.(\d+)\.(\d+)/)
    return matcher ? matcher[0][1] : 'unknown'
}

pipeline {
    agent { label 'linux' }
    stages {
        stage('Version') {
            steps {
                script {
                    String major = majorVersion('Bookstore.Api-1.4.7')
                    sh 'echo checkpoint'
                    echo "major: ${major}"
                }
            }
        }
    }
}
+ echo checkpoint
checkpoint
major: 1
Finished: SUCCESS

A @NonCPS method runs in one go like normal Groovy and its state is not saved. That is why you do not call pipeline steps like sh or echo from inside it; if you do, you will see unexpected behavior. And remember: all this Groovy runs in the controller's JVM (Java Virtual Machine), not on the agent. Reading a 50 MB JSON (JavaScript Object Notation) file with readFile and parsing it in Groovy slows down the whole controller. Do heavy work on the agent: sh 'jq ... report.json' or a small script.

In Production

Put a timeout on every pipeline. A process hanging in a test holds the executor for hours, and every build behind it waits.

Without buildDiscarder, every build's log and record stays on the controller disk forever. In a multibranch project with hundreds of branches, that adds up to gigabytes within a few months.

Keep the pipeline as thin as possible. A Jenkinsfile should be like a conductor: which tool, in which order, with which result. The real work (dotnet test, npm ci, ng build) should also run with the same commands on a developer's machine. This both reduces the CPS load and shortens the "it breaks on Jenkins but works for me" debates.

Common Pitfalls

The parameter gap on the first build. As you saw above, on the first build of a new job parameters do not arrive as environment variables. If you read a critical value such as a deploy target from a parameter, do not leave the default empty, and validate the value.

Interpolating a secret with double quotes in Groovy. sh "curl -H 'Authorization: $TOKEN' ..." can leak the secret into the process list and the log. We will see this trap with real output in the credentials post.

Fixing something with Replay and forgetting to write it to the repository. The change in Replay goes away with that build; the next push breaks again with the old Jenkinsfile.

Turning Declarative into Scripted with script blocks. A Jenkinsfile that is half script { } loses the validation and readability that Declarative brings.

Carrying non-serializable objects across steps. Matcher, JsonSlurper's lazy map, an open stream: all are causes of NotSerializableException.

A Small Experiment

★☆☆ Add the four settings from the options block to your own Jenkinsfile and see the timestamps and the Timeout set to expire line in the log. Then make a deliberate typo such as stpes and send the file to the linter endpoint.

★★☆ Run the parameter example from the start of the post in a new job and confirm that on the first build the shell sees an empty string. Then show that by passing the value explicitly through the environment block you get the right value on the first build too.

★★★ Blow up the CPS example on your own Jenkins, then fix it in two different ways: once with a @NonCPS method, and once by moving the regex work to the shell with sh(script: '...', returnStdout: true). Which one is more readable, which one puts less load on the controller?

So far every build has run with label 'linux', on "any Linux agent". In the next post we look at the agents themselves: the SSH (Secure Shell) agent, the inbound agent connecting over WebSocket, label expressions, and running the build inside the mcr.microsoft.com/dotnet/sdk:10.0 container.