The Jenkinsfile Rewrite Was Mostly Deletions¶
I rewrote a working Jenkins pipeline. The version that replaced it is longer, and the changes that mattered most were the two blocks I removed.
Both of them did the same thing: they asked the Jenkins server to supply something the pipeline needed. Taking them out is what made the file portable, and it is the single change I would make first to any Jenkinsfile I inherited.
The Two Deletions¶
tools {} is gone.
That instructs Jenkins to look up an installation named maven3 in Manage Jenkins → Tools and put it on the job's PATH. It works, and it means the pipeline cannot run anywhere that name is not configured.
The name is not a version, not a path, and not anything the file can verify. It is a string that has to match a form field on a server, and nothing in the repository records what maven3 actually resolves to. Move the job to another controller and it fails at the first mvn with an error about a tool, not about Maven.
Now Maven, JDK 21, Docker, Trivy, kubectl, Helm, Terraform, Ansible and the AWS CLI are installed system-wide on the agent by the same bootstrap process that builds the machine. They are on PATH, so shell steps use them directly.
The shift is small to describe and structural in effect:
- Before: Jenkins manages tool paths, through its UI.
- After: the operating system manages tool paths, through the image build.
SCANNER_HOME is gone.
Same shape, second instance. tool 'sonar-scanner' is another UI lookup, and the resolved path is then interpolated into a shell command, so a rename in the Jenkins configuration produces a broken path at runtime.
That pattern is only correct when you are running the standalone Sonar CLI. This is a Maven project, so the analysis now runs through Maven:
Maven already knows the source roots, the compiled classes, the test lifecycle and where JaCoCo wrote its report. The standalone scanner has to be told all of that through properties, and every one of those properties is a chance to point at the wrong directory. Using the plugin deletes an entire category of path configuration rather than moving it.
withSonarQubeEnv still references a server name configured in Jenkins, so this is not a complete escape. It injects a URL and a credential, which is exactly the kind of thing a server should own. There is a real distinction between "the server holds a secret" and "the server holds my toolchain", and only the second one makes the file unportable.
What Replaced Them Is Longer¶
The rewrite added an options {} block, a larger environment {}, a dedicated versioning stage, dir(APP_DIR) on every stage, and split what had been one push step into separate stages per registry.
That is more lines. It is also more of the pipeline's behaviour stated in the file rather than assumed from context.
The options {} block is the clearest example:
options {
disableConcurrentBuilds(abortPrevious: true)
buildDiscarder(logRotator(numToKeepStr: '5'))
}
Neither line changes what the pipeline does on a successful run. Both change what it does under conditions the happy path never exercises: two pushes arriving together, and disk filling up over months. Those are the behaviours that are invisible until they are urgent, and the default for both is "whatever Jenkins does", which is not a decision anyone made.
Why the Pushes Are Separate Stages¶
The old file pushed to registries in one step. The new one has a stage per registry, each with its own withCredentials and an explicit docker login.
Three registries in one stage means one failure mode reported for three independent operations. A Docker Hub rate limit and a GHCR permission problem look identical from the outside: the push stage went red.
Separate stages give you the registry name in the stage that failed, which is most of the diagnosis. It also means the credential binding is scoped to the stage that needs it, rather than three sets of credentials being in scope for the duration of one long step.
The cost is repetition. Three nearly identical stages that a loop could express in one. I think that is the correct trade in a Jenkinsfile: the loop would be shorter to write and worse to read at 3am, and pipeline code is read under conditions where cleverness is expensive.
The Stage That Exists to Fail Early¶
Trivy's filesystem scan runs before the build rather than after it.
Nothing forces that order. Scanning source for hardcoded secrets and dependency CVEs could equally happen while Maven compiles.
Putting it first means a commit containing a leaked credential fails in seconds rather than after a full compile, test and package cycle. The feedback arrives at a point where the developer is still looking at the terminal.
The general principle is worth stating because it generalises past security scanning: order stages by how fast they can say no. Anything that can reject the commit without needing build artefacts belongs before the build, regardless of where it sits conceptually in the pipeline.
The Commented-Out Stage¶
There is an entire ECR push stage in the file, complete, commented out, with a list of the five prerequisites needed to enable it.
The instinct is to delete unused code. I kept it deliberately, because the alternative to a commented block is not clean code, it is a missing capability nobody knows was considered. Someone adding ECR later would rediscover the credential binding, the login command, and the tag format from scratch.
The condition for this being acceptable rather than clutter: the block has to say why it is off and what turns it on. A commented-out stage with no explanation is dead code. One with a prerequisite list is a documented decision that happens to be executable.
The Change That Was Not a Preference¶
One addition was forced rather than chosen. dir(APP_DIR) now wraps every stage that touches source.
The old pipeline assumed the application sat at the workspace root, which was true when it was written. The repository is now structured so the source lives under a subdirectory, and a Jenkins step executes from the workspace root unless told otherwise.
Without it, mvn runs where there is no pom.xml, docker build gets the wrong context, and Trivy scans the wrong tree. The Maven failure is at least loud. The Trivy one is not: scanning a directory with no application source produces a clean report, and a clean report from the wrong directory looks exactly like a clean report from the right one.
That is the pattern worth extracting. A step that operates on "here" is a step whose correctness depends on where it runs, and moving the code is enough to break it silently. Anything path-relative in a pipeline is a dependency on repository layout, whether or not it is written down as one.
Where the Rewrite Actually Started¶
The honest origin is smaller than the result. Coverage publishing was producing empty reports, and fixing it meant learning four things I had assumed I knew.
Scope matters in declarative pipelines. A directive can be syntactically valid and structurally wrong because it is in the wrong block. Publishing coverage from inside a stage rather than from post is legal and does not do what you want.
Plugin generation matters. jacoco(...) and recordCoverage(...) look like alternatives and belong to different plugin eras. Which one is correct depends on what is installed on the controller, which is again server state the file cannot see.
File format matters, not just existence. Pointing the coverage step at jacoco.exec instead of jacoco.xml fails to parse. The path exists, the file exists, and the format is wrong, so the error is about parsing rather than about a missing file.
A valid shell command can still be a wrong pipeline step. Jenkins has its own scope and DSL rules layered over the shell, and being fluent in one does not give you the other.
None of those are in the rewrite's headline changes. They are why the rewrite happened.
What I Actually Learned¶
The rewrite began because coverage publishing was in the wrong scope and the reports were empty. Fixing that meant reading how the post {} block, dir() and the coverage DSL interact, which turned into reading the whole file properly for the first time since writing it.
That is the honest origin. Not a plan to modernise the pipeline, but one broken report that could not be fixed without understanding the file.
The pattern I would take to the next one: a pipeline that depends on server state is not reviewable. You cannot tell from the repository what maven3 resolves to, whether it still exists, or what changed when it stopped working. Everything the file needs should either be in the file, in the image the agent runs, or in a credential the server injects deliberately. Anything else is configuration hiding in a form field.
Source¶
- The full annotated Jenkinsfile write-up, every decision with the old version alongside
- The Jenkinsfile itself
- Twelve defects fixed in the same file, which is a different pass over the same code
- The same pipeline on GitHub Actions
Related
- The pipeline before the rewrite: Twelve Fixes to a Pipeline That Was Already Passing
- The same contract on the other engine: The Same Pipeline on Two CI Engines
- The null this rewrite removed, in company: Ten Things That Failed Silently