Skip to content

The Same Pipeline on Two CI Engines: Jenkins and GitHub Actions

I implemented an identical DevSecOps pipeline twice for three applications, once as a Jenkinsfile and once as a GitHub Actions workflow. Same stages, same tools, same artifacts. Jenkins consistently needed about 40 percent more code. This is where that difference comes from, which of the divergences actually mattered, and the one place the two implementations had quietly stopped being identical without either file looking wrong.

The Numbers

Application Jenkinsfile GitHub Actions Difference
Java monolith 911 lines 664 lines +37%
Python monolith 1,092 lines 869 lines +26%
Node three-tier 1,257 lines 649 lines +94%

The Node gap is the outlier and it has a specific cause covered below. The Java and Python gaps are the honest baseline.

Before going further: a meaningful share of that difference is comments. I documented the Jenkins versions far more heavily, and that was not stylistic. Jenkins pipelines carry invisible dependencies that produce useless errors when they are missing, so I wrote the dependency list into the file. That decision is itself a comment on the platform, and it is the first real difference.

Difference One: Plugins Are Invisible Dependencies

The Jenkins pipeline uses DSL methods that do not exist in Jenkins by default:

withMaven(globalMavenSettingsConfig: 'maven-settings') {
    sh 'mvn clean verify'
}

withSonarQubeEnv('sonar-server') {
    sh "${SCANNER_HOME}/bin/sonar-scanner ..."
}

recordCoverage(tools: [[parser: 'JACOCO', pattern: '**/jacoco.xml']])

Each one requires a plugin: Pipeline Maven Integration, SonarQube Scanner, Coverage, Config File Provider, AnsiColor. Install none of them and the pipeline fails with:

java.lang.NoSuchMethodError: No such DSL method 'withMaven' found among steps

That message names the method and nothing else. It does not say which plugin provides it, which version, or where to get it. Someone cloning the repository onto a fresh Jenkins gets a failure that looks like a syntax error and is actually a missing dependency.

There is no manifest for this. A package.json declares dependencies; a Jenkinsfile does not. The only durable place to record them is a comment block, which is why every one of my Jenkinsfiles opens with:

// ── REQUIRED JENKINS PLUGINS ─────────────────────────────
//   - Pipeline Maven Integration Plugin  → provides withMaven()
//   - SonarQube Scanner Plugin           → provides withSonarQubeEnv()
//   - Coverage Plugin                    → provides recordCoverage()
//   - Config File Provider Plugin        → provides globalMavenSettingsConfig

GitHub Actions has no equivalent problem because it has no plugin layer. A step is shell, or it is a marketplace action pinned by name and version in the file itself:

- uses: aquasecurity/trivy-action@master

The dependency is declared where it is used. This is more verbose per step and considerably more honest.

The trade is real in both directions. withMaven genuinely does useful work: it configures the settings file, publishes test results, and archives artifacts as one wrapped operation. Reproducing that in GitHub Actions took four explicit steps. Jenkins bought me brevity in exchange for a dependency I had to document manually.

Difference Two: Secret Handling

Jenkins has the Config File Provider plugin, which stores a settings.xml centrally and injects it:

withMaven(globalMavenSettingsConfig: 'maven-settings') { ... }

The Nexus credentials live in Jenkins, not in the repository, and the pipeline references them by an ID.

GitHub Actions has no managed config files, so the whole document becomes a secret and gets written to disk at runtime:

- name: Configure Maven settings (Nexus credentials)
  env:
    MAVEN_SETTINGS: ${{ secrets.MAVEN_SETTINGS_XML }}
  run: |
    mkdir -p ~/.m2
    printf '%s' "$MAVEN_SETTINGS" > ~/.m2/settings.xml

Two details in there are deliberate. The secret goes through an env: block rather than direct interpolation into the run: script, so the value never appears in the rendered command. And printf '%s' rather than echo because echo interprets backslash sequences in some shells, which will silently corrupt XML.

GitHub Actions made me handle this by hand. Doing it by hand meant thinking about where the value lands, which Jenkins had abstracted away from me entirely.

Difference Three: Artifacts

This one is a straightforward GitHub Actions win.

- name: Upload Trivy image report (audit artifact)
  uses: actions/upload-artifact@v4
  if: always()
  with:
    name: trivy-image-report
    path: trivy-image-report.json
    retention-days: 30

Trivy JSON, JaCoCo HTML, JUnit XML, Bandit output, ESLint reports and the built JAR all become downloadable from the run page. if: always() means the reports survive a failed build, which is when you most want them.

Jenkins needs archiveArtifacts plus a plugin to render anything, and the result is less discoverable. For a pipeline where a large part of the value is the audit trail, this matters more than the line count.

Difference Four: Concurrency, and the One That Mattered

In Jenkins I wrote one line and moved on:

options {
    disableConcurrentBuilds()
}

GitHub Actions made me be specific, and being specific exposed a failure mode I had not considered:

concurrency:
  group: ${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: ${{ github.event_name == 'pull_request' }}

Cancel in-progress runs on pull requests. Never on main.

A pull request build has no write side effects. Nothing is pushed anywhere. Cancelling it when a newer commit lands saves compute and costs nothing.

A main build pushes each image to three registries. Cancel it partway through and a registry can be left with a partially updated tag while the others move on. No error is reported, because every push that completed did succeed. The full failure mode and why it is specifically the mutable tag that is at risk (never the immutable one) is covered in Release Identity, which is entirely about what a tag means and what is allowed to depend on it.

That distinction does not exist in my Jenkins version, and not because Jenkins prevents it. disableConcurrentBuilds() serialises everything, so the situation cannot arise, and I never had to think about it. GitHub Actions gave me a knob, the knob forced a decision, and the decision surfaced a real risk.

Being made to decide is sometimes worth more than a good default.

Why the Node Gap Is 94 Percent

The Node application ships two images, a client and a server, which doubles the build, scan and push stages. In Jenkins each of those is a separate stage block with its own steps, script and error handling. In GitHub Actions the same work is often two steps differing by a couple of parameters, sitting in the same job with shared setup.

Jenkins declarative syntax has a per-stage overhead that GitHub Actions does not, and multiplying stages multiplies that overhead.

Related decision worth recording: those paired stages run sequentially, not in a parallel block, even though they are independent. Both builds share one Docker daemon on a self-hosted agent, and running two multi-stage builds at once produces CPU and memory contention plus interleaved log output that makes failures hard to attribute. The measured saving was around 90 seconds. Not worth it.

Difference Five: The Gates Drifted, and Only a Side-by-Side Read Showed It

This is the divergence I did not intend and did not notice until I put the six files next to each other to write this post.

The stages are the same. The tools are the same. The Trivy invocations look the same. What differs is the one argument that decides whether a scan is a gate or a log line:

Trivy pass Java Python Node
Filesystem, CRITICAL fail / warn fail / warn fail / fail
Image, libraries, CRITICAL fail / warn fail / fail fail / fail

Jenkins on the left, GitHub Actions on the right. Every Jenkinsfile fails the build on a critical filesystem finding. On GitHub Actions, only Node does.

Some of that is deliberate. The Java GitHub Actions workflow reports rather than blocks on library criticals because BankApp is inherited code, and hard-failing a build on someone else's dependency tree makes a call that is not mine to make. That reasoning, and the flag that reverses it, is in The Vulnerability Gate That Blocked Every Build.

The rest is drift. The two implementations were written weeks apart, and exit-code is a single character that changes a pipeline from advisory to enforcing while every stage name, every tool and every log line stays identical. Nothing in either file looks wrong on its own. A reviewer reading only the Jenkinsfile sees a strict pipeline. A reviewer reading only the workflow sees a permissive one. Both are the same pipeline by every other measure.

The lesson is narrower than "keep them in sync". If you implement one pipeline twice, the parts that will silently diverge are not the stages or the tool versions, which are obvious in a diff. They are the single-token policy values buried inside otherwise-identical steps: exit codes, severity thresholds, timeouts, retry counts. Those are worth extracting into a named variable at the top of the file precisely so that a drift becomes a one-line diff instead of something you find months later while writing a blog post.

What Ported Without Friction

Worth stating plainly, because it is most of the pipeline.

The stage sequence is identical: checkout, filesystem scan, compute version, build and test, static analysis, quality gate, build image, scan image, publish, hand off to the deployment repository.

Nearly everything expressed as a shell command moved unchanged. mvn clean verify, the version computation, npm audit, the deployment repository handoff. All of it ported for free, because it was never CI-engine-specific in the first place. The Trivy calls are the exception noted above, and even there the command shape is identical and only the gating argument differs.

The single most portable thing about a pipeline is the part written in shell. The least portable is everything the CI system offers to do for you.

What I Would Tell Someone Doing This

Budget real time for the second implementation. It is not a translation exercise. The syntax conversion is the fast part; the slow part is discovering which platform assumptions you had absorbed without noticing.

Write the shell first. Anything expressed as a plain command moves between engines for free. Anything expressed as a platform feature has to be rebuilt.

If you use Jenkins, write the plugin list into the file. Nobody will find it otherwise, and the error message will not help them.

And if you are only ever going to run one engine, implement it once. Doing this twice was worth it because I wanted to know what was design and what was habit. That is a legitimate reason. Portability for its own sake is not.

Source

All three applications carry both implementations in the same repository:


Series: CI Pipeline Engineering Across Three Applications (Part 4 of 8)