The Jenkins controller did not fall over. It just quietly cost me a day a month in plugin upkeep, config drift and “who changed the agent” archaeology — and none of that day was building software.
This is a field note, not a sales pitch for GitHub Actions. I moved the bulk of a team’s pipelines off a self-hosted Jenkins instance onto Actions over a couple of months, and I kept a small number of jobs on Jenkins on purpose. Both of those decisions were deliberate, and both are defensible. What follows is the reasoning, the mechanics of the mapping, and the honest line on what should not move.
What actually pushed the decision
It was not speed and it was not fashion. It was maintenance surface. A self-hosted Jenkins is a service you run: a controller to patch, a JVM to keep alive, a plugin estate that updates on its own schedule and occasionally breaks a pipeline you have not touched in eight months. The pipeline logic lived in a Jenkinsfile in the repo, which was good, but the shared behaviour lived in a Groovy shared library in a separate repo that three people understood and one person maintained. Config drifted between what the Jenkinsfile declared and what the controller and agents actually had installed.
Free · 4 minutes
Do you actually know what you are running — and what it is about to cost you?
Fourteen questions on the systems you depend on, the ones nobody owns, and the support dates that turn a routine upgrade into a forced re-platform. Banded finding on screen, full sheet by email.
Actions removed a whole category of that. The workflow definition sits next to the code, in .github/workflows, versioned with the thing it builds. The runner image for hosted jobs is GitHub’s problem, not mine. There is no controller to keep alive. That is the real trade: I gave up control over the execution environment and got back the day a month I was spending being a Jenkins administrator rather than an engineer.
The cost side is just as real, so I will state it plainly. You are now dependent on GitHub’s availability for your ability to ship. Debugging is more awkward — no live controller UI to poke, more push-and-watch. And YAML expression syntax is its own small dialect you have to learn. None of that outweighed the maintenance saving for us. For some teams it will.
Mapping stages to jobs
The mental model translates cleanly. A Jenkins stage becomes an Actions job. Sequential stage blocks that were implicitly ordered by position become jobs wired together with needs. Steps inside a stage stay steps inside a job. Here is a scripted-pipeline stage and its Actions equivalent side by side.
// Jenkinsfile (declarative) — the shape I was moving away from
pipeline {
agent { label 'linux' }
stages {
stage('Build') {
steps { sh 'make build' }
}
stage('Test') {
steps { sh 'make test' }
}
}
}
# .github/workflows/ci.yml — the same pipeline in Actions
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make build
test:
needs: build # replaces implicit stage ordering
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make test
One thing to internalise early: Jenkins stages share a workspace on the same agent by default, so Test could see whatever Build left on disk. Actions jobs do not. Each job gets a fresh runner, so anything build produces that test needs has to be passed explicitly — via actions/upload-artifact and actions/download-artifact, or by collapsing the two into one job. That single assumption catches more migrations than any syntax difference, and caching earns its keep here too; I have written separately on dependency caching that actually hits rather than quietly missing.
Shared libraries become reusable workflows
The Groovy shared library was the part I was most attached to and the part that translated best. The common build-and-publish logic that every repo’s Jenkinsfile called into became a reusable workflow: a workflow file triggered by workflow_call, with typed inputs and declared secrets, called from each repo’s own workflow.
# .github/workflows/reusable-build.yml (in a central repo)
on:
workflow_call:
inputs:
target:
required: true
type: string
secrets:
registry_token:
required: true
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make ${{ inputs.target }}
# calling it from a downstream repo
jobs:
call-build:
uses: my-org/ci/.github/workflows/reusable-build.yml@v1
with:
target: release
secrets:
registry_token: ${{ secrets.REGISTRY_TOKEN }}
Pinning the call to a tag or SHA (@v1) gives you the versioning discipline the shared library never really had — downstream repos opt into a new version rather than silently inheriting whatever main happens to be. Where the shared logic was really a wrapper around a few shell steps, a composite action was the better fit. The distinction between the two, and when to reach for each, is the whole subject of a separate piece on DRY CI, so I will not re-tread it here.
What I kept on Jenkins, and why
Two categories stayed, and I would defend both in a review.
Builds tied to physical or licensed hardware. One pipeline drove a build against a licensed toolchain node-locked to a specific machine on the internal network, with a hardware dongle. That is exactly what Jenkins with a static agent is good at: a long-lived controller talking to a fixed, stateful agent it has always owned. Recreating that as a GitHub self-hosted runner was possible — you register the runner against the repo or org with a token and it appears as a runs-on target — but it bought nothing. The maintenance surface I was fleeing on the hosted side is still fully present on a self-hosted runner: you patch the box, you secure it, you keep it alive. If I am running the machine either way, the incumbent that already works is the cheaper answer.
Long, orchestration-heavy release trains. One release pipeline coordinated a dozen downstream systems with manual approval gates, hours-long soak steps and bespoke Groovy that encoded real institutional knowledge. Rewriting it as YAML would have been a multi-week project to reproduce something that already worked, with a fresh crop of bugs as the prize. That is negative-value migration. I left it, documented why, and moved on.
The one genuine win Actions handed me on the deploy side was credentials. The Jenkins pipeline held long-lived cloud secrets in its credential store; the Actions replacement uses short-lived OIDC tokens instead, which is the pattern behind keyless deploys via federated credentials. Retiring a static secret is worth a migration on its own.
The line I would draw for anyone else
Move the pipelines whose whole job is to build and test code that lives on GitHub — that is where close-to-the-code YAML and zero controller upkeep pay off fastest. Keep the pipelines whose value is orchestration, hardware affinity or hard-won Groovy that already works. The mistake is treating the migration as all-or-nothing: the honest answer is a smaller Jenkins doing the few things it is uniquely good at, and Actions doing the rest. A migration plan that cannot name what it is not moving has not been thought through.
Looking at an acquisition, supplier, or major project?
The greatest risks are rarely visible in the executive summary. The Sixteen Pillars framework surfaces the technology risks that diligence usually misses.