The tenth time you copy a Jenkinsfile, the copies have already diverged. A shared library is how a build convention becomes code that is versioned, tested and fixed in one place — and how a mistake reaches every repository simultaneously.
Topic 1: The Layout
A shared library is a Git repository with a fixed structure:
├── vars/
│ ├── buildApp.groovy → a global step called buildApp()
│ ├── buildApp.txt → documentation, rendered in Jenkins
│ └── deployTo.groovy
├── src/
│ └── org/acme/ci/
│ ├── Docker.groovy → classes, for real logic
│ └── Slack.groovy
├── resources/
│ └── org/acme/ci/
│ └── pod-agent.yaml → non-Groovy files
└── test/
└── BuildAppSpec.groovy → yes, you test this
vars/ — one file per global step. The filename is the step name, so vars/buildApp.groovy provides buildApp(). It must define call:
// vars/buildApp.groovy
def call(Map config = [:]) {
def image = config.image ?: error('image is required')
def deployTo = config.deployTo ?: []
pipeline {
agent { kubernetes { yaml libraryResource('org/acme/ci/pod-agent.yaml') } }
options {
timeout(time: 30, unit: 'MINUTES')
buildDiscarder(logRotator(numToKeepStr: '30'))
}
stages {
stage('Build') { steps { container('maven') { sh 'mvn -B package' } } }
stage('Image') {
steps {
container('kaniko') {
sh "/kaniko/executor --destination=${image}:${env.GIT_COMMIT} --cache=true"
}
}
}
stage('Deploy') {
when { branch 'main' }
steps {
script { deployTo.each { envName -> deployTo(envName, image) } }
}
}
}
post { failure { slackNotify('#builds', 'FAILED') } }
}
}
src/ — ordinary Groovy classes, in packages. Use this for anything with logic worth testing:
// src/org/acme/ci/Version.groovy
package org.acme.ci
class Version implements Serializable {
static String fromGit(script) {
def desc = script.sh(returnStdout: true, script: 'git describe --tags --always --dirty').trim()
return desc
}
}
implements Serializable is not optional. Jenkins serialises pipeline state at every step so a build can survive a controller restart; a class that is not serialisable throws NotSerializableException at a point unrelated to where the mistake is.
resources/ — non-Groovy files, read with libraryResource('path'). Pod specs, templates, policy files.
Topic 2: Loading a Library
// pinned to a tag — this is the form to use
@Library('acme-ci@v3.2.0') _
// a branch — for developing the library itself
@Library('acme-ci@feature/new-scan') _
// several libraries
@Library(['acme-ci@v3.2.0', 'acme-security@v1.1.0']) _
// dynamic, inside the pipeline
library 'acme-ci@v3.2.0'
The trailing _ is required — it is the target of the annotation, and its absence produces a confusing syntax error.
Libraries are configured in Jenkins (or JCasC) as global, folder-level, or loaded implicitly:
# JCasC
unclassified:
globalLibraries:
libraries:
- name: acme-ci
defaultVersion: v3.2.0
implicit: false # require an explicit @Library
allowVersionOverride: true
retriever:
modernSCM:
scm:
git:
remote: https://github.com/acme/jenkins-library.git
Set defaultVersion to a tag and leave implicit: false. An implicit library that consumers do not declare is a dependency nobody can see in the Jenkinsfile.
Topic 3: The Versioning Discipline
This is the part that decides whether a library helps or hurts.
@Library('acme-ci@main') means untested library code reaches production the moment someone merges. Fifty repositories, one merge, no rollout. It will happen once and then the team will distrust the library permanently.
The workable convention:
main development, never consumed directly
v3.2.0 (tag) what consumers pin to
v3 (moving tag) optional: consumers who accept minor updates
- Tag every release and change consumers deliberately.
- Semver it. A change to a step’s parameters is a major version; a new optional parameter is minor.
- Keep a CHANGELOG, because a consumer upgrading from v2 to v3 needs to know what breaks.
- Deprecate rather than delete. Keep an old step working for a release, with a warning, so fifty teams can migrate at their own pace.
def call(Map config = [:]) {
if (config.containsKey('dockerRegistry')) {
echo "WARNING: 'dockerRegistry' is deprecated and will be removed in v4. Use 'registry'."
config.registry = config.dockerRegistry
}
…
}
Topic 4: Testing a Library
A library used by fifty repositories is production code, and the loop of “commit, push, run a real pipeline, read the error” is far too slow to iterate with.
JenkinsPipelineUnit runs library code against a mocked pipeline:
// test/BuildAppSpec.groovy
class BuildAppSpec extends BasePipelineTest {
@Test
void 'fails without an image'() {
def script = loadScript('vars/buildApp.groovy')
helper.registerAllowedMethod('error', [String]) { msg -> throw new Exception(msg) }
shouldFail(Exception) { script.call([:]) }
}
@Test
void 'deploys only on main'() {
binding.setVariable('env', [BRANCH_NAME: 'feature/x', GIT_COMMIT: 'abc123'])
def script = loadScript('vars/buildApp.groovy')
script.call(image: 'ghcr.io/acme/api')
assertJobStatusSuccess()
// assert the deploy step was not called
}
}
Then run the library’s own pipeline on every pull request to it: lint, unit tests, and — the highest-value check — run a real consumer pipeline against the library branch in a sandbox repository. That is what catches “the step works in isolation and breaks in a real Jenkinsfile”.
Topic 5: What Belongs in a Library, and What Does Not
Belongs:
- Steps every service needs identically — notifications, image build, SBOM generation, deploy.
- Policy that must be consistent — required stages, mandatory scanning, tagging conventions.
- Boilerplate nobody should retype — pod agent specs, credential wiring, retry semantics.
Does not belong:
- Anything specific to one service. That goes in that service’s Jenkinsfile.
- The entire pipeline, hidden behind one call.
standardPipeline()and nothing else means the owning team cannot see or debug their own build. - Business logic. A library that decides what to deploy rather than how has become an application with no tests.
The balance that works in practice: a library provides steps, and the Jenkinsfile composes them. The service team can read their pipeline and see the shape of it, while the platform team owns the implementation of each step.
@Library('acme-ci@v3.2.0') _
pipeline {
agent { kubernetes { yaml acmeAgent('java21') } }
stages {
stage('Build') { steps { acmeBuild() } }
stage('Scan') { steps { acmeScan(failOn: 'high') } }
stage('Deploy') { when { branch 'main' }; steps { acmeDeploy('staging') } }
}
}
Readable, composable, and every step is centrally owned.
Topic 6: The Sandbox and Script Approval
Library code loaded from vars/ and src/ runs outside the Groovy sandbox by default when the library is trusted (global libraries are trusted; folder-level ones can be configured either way). That means library code can do anything on the controller — read secrets/, call internal APIs, modify jobs.
Two consequences worth being explicit about:
- The library repository needs the same protection as production infrastructure: required review, protected branches, no direct pushes. Write access to the shared library is effectively administrative access to Jenkins.
- Prefer
shsteps that run pinned containers over Groovy that manipulates the controller. A step that shells out to a tool is auditable, portable, and cannot read the credential store.
Try it yourself: point one consumer at @main and another at a tag, then push a breaking change to the library’s main branch. One build breaks immediately, the other does not. That five-minute demonstration is the entire argument for pinning, and it is much cheaper than the version where it happens to fifty repositories.
Common mistake: building a library that wraps everything so tightly that a service team cannot see what their pipeline does — often justified as “standardisation”. The first time a build fails for a reason inside the library, the owning team cannot debug it, files a ticket, and waits. Standardise the steps, not the pipeline, and keep the Jenkinsfile something its owners can read.