One Pipeline Contract, Three Language Ecosystems¶
I wrote the same DevSecOps pipeline for a Spring Boot app, a Flask app and a Node three-tier app. The stage order came out identical in all three. The stage count did not: 14, 16 and 21. This is what stayed fixed, what diverged, and why trying to force uniformity made every pipeline worse.
This post is about divergence by language. Divergence by CI engine (the same pipeline built in both Jenkins and GitHub Actions) is a separate question, covered in The Same Pipeline on Two CI Engines.
The Contract¶
Before writing any of them I decided what a build had to guarantee, independent of language:
The Pipeline Contract
Every build produces a scanned, versioned container image, and hands its tag to the deployment repository. No pipeline holds cluster credentials.
That sentence is the whole contract. Everything else is implementation.
It translates into a stage sequence that never changed across the three:
checkout
→ scan source
→ compute version
→ build and test
→ static analysis
→ quality gate
→ build image
→ scan image
→ publish
→ hand tag to deployment repo
Ten steps in that order, in all three pipelines, on both CI engines. If a language needed something extra, it went inside one of those steps or immediately adjacent to it. The order itself was never negotiable, because each step depends on the one before producing a trustworthy result.
What the Stage Counts Actually Mean¶
| Application | Stages | Images produced |
|---|---|---|
| Java (Spring Boot) | 14 | 1 |
| Python (Flask) | 16 | 1 |
| Node (Express + React) | 21 | 2 |
Those numbers are not a quality ranking. They are a measurement of how much work each ecosystem leaves for the pipeline versus how much its own tooling absorbs.
Java: The Build Tool Does the Work¶
The Java pipeline is the shortest because Maven is doing four jobs behind one command.
mvn clean verify compiles, runs the test suite, generates a JaCoCo coverage report and packages the JAR. Four distinct concerns, one stage, and the build fails on test failure because Surefire exits non-zero.
That absorption is why the pipeline has no separate environment setup, no separate test stage and no separate packaging stage. The convention is doing the work.
Java also gets a stage the others do not have:
A JAR is a genuine artifact. Somebody might depend on it directly, without ever touching the container image. Publishing it to a Maven repository makes it consumable by a build tool, which is a use case that simply does not exist for the other two.
Security tooling is minimal: a Trivy filesystem scan on source, then SonarQube. Maven's dependency resolution is deterministic enough that the container library scan catches what matters.
Python: The Pipeline Builds the Environment¶
Python has no build step, and that absence creates work rather than saving it.
stage('Setup Python Environment') {
sh '''
python -m venv .venv-ci
. .venv-ci/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
pip install bandit pip-audit pytest-cov
'''
}
There is no target/ directory, no artifact, no compilation step that would have validated imports. So the pipeline creates an isolated environment before it can do anything at all. That stage has no counterpart in the other two.
The virtualenv is deliberate even on GitHub Actions runners that ship Python. Installing into system Python risks conflicting with runner-managed packages, and using the same isolation strategy on both CI engines keeps the two implementations honest.
Note the last line: bandit, pip-audit and pytest-cov are installed separately from requirements.txt. They are CI concerns, not application dependencies, and mixing them means shipping a scanner inside the production image.
Python then adds two stages Java has no equivalent for:
stage('Bandit SAST') { ... } // HIGH severity fails the build
stage('pip-audit Dependency Scan') { ... }
Bandit finds Python-specific issues that a generic scanner will not: subprocess with shell=True, yaml.load without a safe loader, hardcoded credentials, weak cryptographic defaults. These are language-level patterns, not dependency CVEs, and no amount of Trivy will surface them.
pip-audit exists because Python dependency resolution is less deterministic than Maven's. What pip install -r requirements.txt produces depends on when you run it unless every version is pinned exactly, so auditing the resolved set is worth doing separately.
Python publishes no artifact. There is nothing to publish but the image.
Node: Everything Doubles¶
Node is the heaviest, for three compounding reasons.
A build step that must happen before containerisation. Webpack compiles the React client into client/public/, and Dockerfile.client copies that output. If the build has not run, Docker copies an empty directory and produces a broken image that starts successfully. So the client build is its own stage, before any Docker work.
Static analysis runs twice, with different configurations.
stage('ESLint SAST: Server') { ... } // eslint-plugin-security
stage('ESLint SAST: Client') { ... } // + react, react-hooks, XSS rules
The server and client are different codebases with different risk profiles living in one repository. The server needs eslint-plugin-security for injection and unsafe-regex patterns. The client needs React-specific rules and XSS checks, and does not need the server rules. One shared configuration would either miss client issues or fire false positives on server code.
npm audit also runs per package, because client/ and server/ have separate dependency trees.
Two images. Each one needs a build stage, a Trivy scan and a push. Three stages become six.
Docker Build: Client
Docker Build: Server
Trivy Image Scan: Client
Trivy Image Scan: Server
Push Client Image
Push Server Image
Those pairs run sequentially rather than in parallel. They are independent and could overlap, but both share one Docker daemon on a self-hosted agent, and concurrent multi-stage builds cause resource contention plus interleaved output that makes failures hard to attribute. Measured saving from parallelising: roughly 90 seconds. Not worth the ambiguity.
The Security Tooling Diverges Most¶
This is the clearest illustration of why uniformity would have been wrong.
| Concern | Java | Python | Node |
|---|---|---|---|
| Source filesystem scan | Trivy | Trivy | Trivy |
| Language SAST | SonarQube only | Bandit | ESLint ×2 with security and React plugins |
| Dependency CVEs | Trivy library pass | pip-audit + Trivy | npm audit per package + Trivy |
| Quality gate | SonarQube | SonarQube | SonarQube |
| Image scan | Trivy, 2 passes, no type split | Trivy, 3 passes | Trivy, 3 passes ×2 images |
Trivy and SonarQube are constant. Everything between them is native.
Python and Node use the same three-pass image scan: OS packages report but never block, application libraries block on critical, and a full JSON report is archived. The split is by who owns the fix. I can bump a dependency; I cannot patch a base distribution on its maintainers' behalf. That reasoning is covered in its own post.
Java is the exception, and it is worth being precise rather than tidy about it. The Java Jenkinsfile scans the image in two passes with no --vuln-type split at all: one pass fails on any critical, OS or library, and a second reports high, medium and low. That is the older posture, written before the three-pass split existed, and the Java pipeline never got retrofitted. It works because the runtime base moved to Ubuntu Jammy and the critical OS count went to zero, so the pass that would have blocked has nothing to block on. That is a fragile reason for a gate to be green, and it is the honest state of the file.
The gates also diverge by engine, not just by language, which the stage tables above do not show. Details in The Same Pipeline on Two CI Engines.
Versioning: One Format, Three Sources¶
Every image is tagged {version}-{7-char-sha}-{build-number}. The composition is identical; where the version comes from is not.
# Java: from the POM
APP_VERSION=$(mvn help:evaluate -Dexpression=project.version -q -DforceStdout)
# Node: from package.json
APP_VERSION=$(node -p "require('./server/package.json').version")
# Python: from a VERSION file, with a hard failure if absent
APP_VERSION=$(cat VERSION | tr -d '[:space:]')
Java and Node already have a canonical location for a version, so the pipeline reads it. Python does not, so the repository adds a VERSION file and the pipeline treats a missing or empty one as a build failure rather than defaulting to something meaningless.
Full reasoning on tag composition and the handoff is in Release Identity.
The Attempt at Uniformity, and Why It Failed¶
For a while I tried to make all three literally identical: same stage names, same count, same structure, with unused stages left in as no-ops for symmetry.
It made every pipeline worse.
Java grew an environment setup stage that did nothing, because Maven already handles it. Python grew an artifact publish stage with no artifact to publish. Node lost the second ESLint configuration, because there was only one "SAST" slot in the shared shape, which meant either client rules ran against server code or client-specific issues went undetected.
The symmetry was cosmetic and the cost was real. A pipeline is not more maintainable because it looks like another pipeline. It is more maintainable when each stage exists for a reason someone can state.
The Distinction Worth Keeping¶
Portability is not uniformity.
What I standardised: the guarantee a build makes, the order of operations, the image scan policy, the tag format, and the handoff that ends every pipeline at a git commit rather than a cluster.
What I let diverge: how the environment is prepared, which SAST tools run, whether an artifact is published, how many images are produced, and where the version is read from.
The test for whether something belongs in the fixed part is whether it would still be true in a language I have not used yet. "Fail the build on critical library CVEs" survives that test. "Run Bandit" does not.
Source¶
Each repository contains both a Jenkinsfile and a GitHub Actions workflow implementing the same pipeline:
- Java monolith: 14 stages
- Python monolith: 16 stages
- Node three-tier: 21 stages, two images
If you copy one, the parts to change are the credential IDs and the registry names. Everything else is stack-specific and labelled in the comments.
Series: CI Pipeline Engineering Across Three Applications (Part 6 of 8)