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

Jenkins Shared Libraries: Sharing Pipeline Code with vars/ and src/

Mert Özen Nov 8, 2026 9 min 4 views
Jenkins Shared Libraries: Sharing Pipeline Code with vars/ and src/

A Shared Library keeps repeated pipeline code in a separate repository as vars/ steps and src/ classes; pipelines load it pinned to a tag with @Library('lib@v1.2.0'). The dotnetCi and angularCi steps, a global library via JCasC, the risk of an unpinned main, real output of the difference between a trusted global library and a sandboxed folder library, a JenkinsPipelineUnit test, and when not to write a library.

Version note: The examples in this post were run with Jenkins 2.580.2 LTS (Long-Term Support), Pipeline: Groovy Libraries, JenkinsPipelineUnit 1.31, Gradle 9.8 and Groovy 3.0.

Guess First

Twelve teams use a common Shared Library, and all of them write @Library('bookstore-lib') _ in their Jenkinsfiles, that is, without a version. The library's default version is the main branch. On a Friday evening a developer does a small "cleanup" in the library and merges it into main. By Monday morning, how many pipelines are affected, and what would have to exist for anyone to notice?

What Is a Shared Library, and When Do You Need One?

By the end of the ninth post Bookstore's Jenkinsfile was approaching 150 lines: .NET build and tests, Angular build and tests, packaging, deploys. Imagine the company has five more applications like Bookstore; each Jenkinsfile has the same dotnet test line, the same coverage threshold, the same JUnit path. When someone changes xUnit's report option, six PRs (Pull Request) are opened in six repositories. A Shared Library is the way to keep this common code in a separate Git repository and offer it to pipelines as a step.

But first an honest warning: writing a library for two pipelines is premature abstraction. A library has its own versioning, testing, documentation and maintenance; debugging is harder than in a Jenkinsfile too, because the code now lives in another repository. As a rule, move code into a library when it is repeated in three or more pipelines and really does the same thing. If what repeats is a few shell commands, putting them into a script is often enough; ci/deploy.sh from the ninth post is an example.

Folder Structure: vars/, src/, resources/

Bookstore's library repository:

pipeline-library/
├── build.gradle
├── src/org/bookstore/Versioning.groovy
├── test/DotnetCiTest.groovy
└── vars/
    ├── angularCi.groovy
    ├── dotnetCi.groovy
    ├── dotnetCi.txt
    └── jenkinsInfo.groovy

Every file under vars/ becomes a global step called by its name: vars/dotnetCi.groovy is used as dotnetCi(...) in a Jenkinsfile, and the file's call method runs. The .txt file next to it appears as the step's help text on the Pipeline Syntax page. src/ is a normal Groovy source folder; you use its classes with import. resources/ is for non-code files such as scripts and templates, read with the libraryResource step.

A small class that produces the version from the branch name and build number:

package org.bookstore

class Versioning implements Serializable {
    static String fromBuild(String branch, int buildNumber) {
        String base = "1.4.${buildNumber}"
        if (branch == null || branch == 'main') {
            return base
        }
        String suffix = branch.replaceAll(/[^A-Za-z0-9]+/, '-').toLowerCase()
        return "${base}-${suffix}"
    }
}

Serializable matters: the CPS (Continuation-Passing Style) rule from the third post applies to library code too; every object kept between pipeline steps must be writable to disk. The custom step for the .NET side:

import org.bookstore.Versioning

def call(Map config = [:]) {
    String solution = config.solution ?: 'Bookstore.slnx'
    double threshold = (config.coverageThreshold ?: 80) as double
    String version = Versioning.fromBuild(env.BRANCH_NAME, env.BUILD_NUMBER as int)

    sh "dotnet restore ${solution}"
    sh "dotnet build ${solution} -c Release --no-restore -p:Version=${version}"
    try {
        sh "dotnet test --solution ${solution} -c Release --no-build --results-directory TestResults --report-xunit-junit --coverage --coverage-output-format cobertura"
    } finally {
        junit testResults: 'TestResults/**/*.junit.xml'
        recordCoverage(tools: [[parser: 'COBERTURA', pattern: 'TestResults/**/*.cobertura.xml']],
                       sourceCodeRetention: 'NEVER',
                       qualityGates: [[threshold: threshold, metric: 'LINE', baseline: 'PROJECT', criticality: 'UNSTABLE']])
    }
    return version
}

Taking parameters as a Map means old calls do not break when you later add a new option to the step. The sh here uses double quotes because only non-secret values such as the solution name and the version are interpolated; the rule from the fifth post is for secrets. try/finally makes sure the report is recorded even when tests fail. angularCi has the same structure; it runs npm ci, ng lint, ng test and ng build inside dir(appDir).

Defining the Library and Pinning the Version

I defined the global library on the controller with JCasC (Jenkins Configuration as Code):

unclassified:
  globalLibraries:
    libraries:
      - name: "bookstore-lib"
        defaultVersion: "v1.2.0"
        implicit: false
        allowVersionOverride: true
        retriever:
          modernSCM:
            scm:
              git:
                remote: "http://jenkwork-gitea:3000/bookstore/pipeline-library.git"
                credentialsId: "gitea-ci"

implicit: false means the library is loaded only into pipelines that explicitly ask for it; with true it would be loaded into every pipeline automatically. I made the default version a tag, not a branch. On the Jenkinsfile side I still write the version explicitly:

@Library('bookstore-lib@v1.2.0') _

pipeline {
    agent none
    options {
        timeout(time: 30, unit: 'MINUTES')
        timestamps()
    }
    stages {
        stage('Build and Test') {
            parallel {
                stage('API') {
                    agent {
                        docker {
                            image 'mcr.microsoft.com/dotnet/sdk:10.0'
                            label 'docker'
                            args '-v bookstore-nuget:/nuget'
                        }
                    }
                    environment {
                        NUGET_PACKAGES = '/nuget/packages'
                        DOTNET_NOLOGO = '1'
                    }
                    steps {
                        script {
                            String version = dotnetCi(solution: 'Bookstore.slnx', coverageThreshold: 80)
                            echo "Built version ${version}"
                        }
                    }
                }
                stage('Web') {
                    agent {
                        docker {
                            image 'node:24-trixie'
                            label 'docker'
                            args '-v bookstore-npm:/npm'
                        }
                    }
                    environment {
                        npm_config_cache = '/npm'
                    }
                    steps {
                        angularCi(appDir: 'bookstore-web')
                    }
                }
            }
        }
        stage('Info') {
            agent { label 'linux' }
            steps {
                jenkinsInfo()
            }
        }
    }
}

The _ at the end is a Groovy detail: the annotation has to be attached to something, and _ is the conventional way to say "nothing to import here". Using a step's return value requires a script block; if the value is not used, it can be called directly inside steps, like angularCi(...). I pushed this Jenkinsfile to the feature/shared-library branch; from the build log:

Loading library bookstore-lib@v1.2.0
Attempting to resolve v1.2.0 from remote references...
Found match: refs/tags/v1.2.0 revision beb3a44f4c4e454309e0c04528e711c1639978d8
Resolved tag v1.2.0 revision beb3a44f4c4e454309e0c04528e711c1639978d8
Checking out Revision beb3a44f4c4e454309e0c04528e711c1639978d8 (v1.2.0)
[2026-11-08T04:54:19.768Z] + dotnet restore Bookstore.slnx
[2026-11-08T04:54:22.740Z] + dotnet build Bookstore.slnx -c Release --no-restore -p:Version=1.4.1-feature-shared-library
[2026-11-08T04:54:23.222Z] + npx ng lint
[2026-11-08T04:54:24.927Z] + dotnet test --solution Bookstore.slnx -c Release --no-build --results-directory TestResults --report-xunit-junit --coverage --coverage-output-format cobertura
[2026-11-08T04:54:26.881Z] [Coverage]    - [Whole project - Line Coverage]: ≪Success≫ - (Actual value: 87.50%, Quality gate: 80.00)
[2026-11-08T04:54:26.887Z] Built version 1.4.1-feature-shared-library
[2026-11-08T04:54:29.548Z] Running on Jenkins 2.580.2
Finished: SUCCESS

Jenkins resolved the tag to a commit and loaded the library from that commit. The version came from the Versioning class, based on the branch name: 1.4.1-feature-shared-library. After this build I pushed a new commit to the library's main branch that adds tests; since the tag stayed where it was, Bookstore's pipeline was not affected.

The answer to the opening question is here: with @Library('bookstore-lib') _ and a default version of main, every commit that lands on the library's main takes effect at the same time in the next build of all twelve teams. If it has a bug, twelve pipelines are red on Monday morning and none of their Jenkinsfiles changed. Version the library like an application: semantic-versioned tags, a changelog, and pipelines pinned to a tag. Moving to a new version should be each team changing @v1.2.0 to @v1.3.0 in its own PR; if it breaks, it breaks in that PR.

Trusted and Untrusted Libraries: the Sandbox

Global libraries (defined in Jenkins's management settings) are considered "trusted": they run outside the Groovy sandbox and can access Jenkins's internal APIs (Application Programming Interface). The jenkinsInfo step does exactly that:

def call() {
    echo "Running on Jenkins ${jenkins.model.Jenkins.get().getVersion()}"
}

Called from the global library, it printed Running on Jenkins 2.580.2, as in the log above. I defined the same repository as a folder library (team-web-lib) in the team-web folder and called the same step from there:

Loading library team-web-lib@v1.2.0
Checking out Revision beb3a44f4c4e454309e0c04528e711c1639978d8 (v1.2.0)
Running on ssh-agent-1 in /home/jenkins/agent/workspace/team-web/lib-check
Scripts not permitted to use staticMethod jenkins.model.Jenkins get. Administrators can decide whether to approve or reject this signature.
org.jenkinsci.plugins.scriptsecurity.sandbox.RejectedAccessException: Scripts not permitted to use staticMethod jenkins.model.Jenkins get
Finished: FAILURE

A folder library is "untrusted": since anyone who can manage the folder can change it, it runs in the same sandbox as Jenkinsfiles. This distinction is a security boundary. Someone who can commit to a trusted library can run any code they want on the controller; so write access to the global library repository should be granted as carefully as Jenkins admin rights, and changes should go through review. You can approve signatures one by one from the sandbox with "script approval", but every approval opens that signature to every pipeline; think twice before approving.

Testing the Library: JenkinsPipelineUnit

Library code is the pipeline code that most deserves tests, because when it breaks it affects everyone. JenkinsPipelineUnit is a test framework that loads vars/ scripts without Jenkins and records step calls. The Gradle configuration:

plugins {
    id 'groovy'
}

repositories {
    mavenCentral()
    maven { url = 'https://repo.jenkins-ci.org/releases/' }
}

sourceSets {
    main { groovy { srcDirs = ['src'] } }
    test { groovy { srcDirs = ['test'] } }
}

dependencies {
    implementation 'org.codehaus.groovy:groovy-all:3.0.25'
    testImplementation 'com.lesfurets:jenkins-pipeline-unit:1.31'
    testImplementation 'junit:junit:4.13.2'
}

test {
    testLogging {
        events 'passed', 'failed'
        exceptionFormat = 'full'
    }
}

Since version 1.2, JenkinsPipelineUnit is published not on Maven Central but in Jenkins's own repository; that is why the second maven line is there. The test:

import com.lesfurets.jenkins.unit.BasePipelineTest
import org.bookstore.Versioning
import org.junit.Before
import org.junit.Test

import static org.junit.Assert.assertEquals
import static org.junit.Assert.assertTrue

class DotnetCiTest extends BasePipelineTest {

    @Override
    @Before
    void setUp() {
        super.setUp()
        helper.registerAllowedMethod('junit', [Map]) { }
        helper.registerAllowedMethod('recordCoverage', [Map]) { }
        binding.setVariable('env', [BRANCH_NAME: 'main', BUILD_NUMBER: '42'])
    }

    @Test
    void restoresBuildsAndTestsInOrder() {
        def dotnetCi = loadScript('vars/dotnetCi.groovy')

        String version = dotnetCi.call(solution: 'Bookstore.slnx')

        assertEquals('1.4.42', version)
        List<String> commands = helper.callStack.findAll { it.methodName == 'sh' }.collect { it.argsToString() }
        assertEquals(3, commands.size())
        assertTrue(commands[0].startsWith('dotnet restore Bookstore.slnx'))
        assertTrue(commands[2].contains('--report-xunit-junit'))
    }

    @Test
    void featureBranchesGetASuffix() {
        assertEquals('1.4.7-feature-stock-badge', Versioning.fromBuild('feature/stock-badge', 7))
    }
}

On the first run the junit line was missing and the test broke like this:

groovy.lang.MissingMethodException: No signature of method: dotnetCi.junit() is applicable for argument types: (LinkedHashMap) values: [[testResults:TestResults/**/*.junit.xml]]

JenkinsPipelineUnit registers commonly used steps out of the box, but not every signature; since I call junit with a map parameter, I had to register that signature separately. After the fix, running Gradle in a container:

$ docker run --rm -u "$(id -u):$(id -g)" -e GRADLE_USER_HOME=/w/.gradle-home -v "$PWD":/w -w /w gradle:9.8.2-jdk21 gradle test --no-daemon --console=plain
> Task :test
DotnetCiTest > restoresBuildsAndTestsInOrder PASSED
DotnetCiTest > featureBranchesGetASuffix PASSED
BUILD SUCCESSFUL in 3s

These tests do not imitate a real Jenkins; they only verify which steps the library calls, in which order and with which parameters. Still, that is enough to catch mistakes like "someone removed --report-xunit-junit from the dotnet test line" before a tag is created. The library repository's own pipeline should run these tests on every PR.

In Production

Manage the library like a product: a README, a .txt help file for every step, a changelog and semantic versioning. A breaking change means a major version.

Keep steps small and composable. "Does everything" libraries that hide the whole pipeline behind a single bookstorePipeline() call look attractive at first, but the day teams want to add a stage, the library gets parameter after parameter.

Library code is also interpreted on the controller with CPS. Do not do heavy work there; jobs such as parsing large JSON (JavaScript Object Notation) files still belong on the agent, inside sh.

Common Pitfalls

Pulling the library unpinned from main. The Friday evening scenario above.

Writing a library for two pipelines. Its maintenance costs more than twenty copied lines.

Giving everyone write access to the global library. Trusted code runs outside the sandbox; the library repository is equivalent to Jenkins admin rights.

Keeping non-serializable state in src/ classes. A class's fields are written to disk between steps; a non-Serializable field means a NotSerializableException.

Not testing the library. When it breaks, every team is affected at once; at least a few JenkinsPipelineUnit tests should run before a tag is created.

A Small Experiment

★☆☆ Write a one-line sayHello step under vars/ in your own library, define it as a global library and call it from a Jenkinsfile. Look at how the .txt file appears on the Pipeline Syntax page.

★★☆ Create one pipeline that calls the library with @main and one that calls it with @v1.2.0. Push a change to the library's main and compare the next builds of the two pipelines.

★★★ Write a JenkinsPipelineUnit test for angularCi: it should verify that the dir step is called with the appDir parameter and that ng test contains --watch=false. Then make a change that deliberately breaks the test.

Pipeline code now lives in repositories and in a versioned library. But the controller itself, meaning plugins, credentials, agent definitions and the library setting, still lives on a single server. In the next post we manage Jenkins itself as code: JCasC, plugins.txt, backups and upgrades.