When I finally decided to migrate our CI/CD pipeline from Jenkins to GitHub Actions, I expected some bumps. What I didn’t expect was which things broke — and how different the debugging process felt.
Here’s my honest list of what broke, what surprised me, and how I fixed it.
- Pipeline state and workspace persistence (or lack thereof) Jenkins keeps its workspace around between builds by default. That means you can rely on things like git stash, incremental builds, or caching build artifacts across runs.
GitHub Actions starts fresh every time. Your workspace is ephemeral.
What broke:
Our incremental Docker layer caching. Jenkins would keep the previous build’s layers, speeding up rebuilds. In Actions, every run was rebuilding from scratch.
Fix:
Switched to GitHub’s actions/cache for Docker layers and build dependencies. Not identical, but close enough.
yaml
- uses: actions/cache@v3 with: path: /tmp/.buildx-cache key: ${{ runner.os }}-buildx-${{ github.sha }} restore-keys: | ${{ runner.os }}-buildx-
- Matrix builds work — but differently Jenkins’ matrix plugin is verbose but powerful. GitHub Actions has matrix strategy built-in, which is cleaner — until you hit two things:
No dynamic matrix (you can’t generate matrix values from a script at runtime without workarounds)
No exclusive axis control (e.g., run os: [ubuntu, windows] × python: [3.8,3.9] but skip one combination)
What broke:
A test matrix where certain version combos were invalid. Jenkins skipped them easily. Actions ran them and failed.
Fix:
Used if: conditions inside jobs to exclude combos, plus github.event.inputs for manual dynamic control.
- Groovy vs YAML scripting limits Jenkins gives you full Groovy — you can loop, parse JSON, make HTTP calls, and write complex logic inline.
GitHub Actions uses YAML with expressions that are intentionally limited. No loops, no inline scripting beyond simple conditionals.
What broke:
A custom deployment script that parsed a JSON manifest and looped over environments. In Groovy: trivial. In Actions: impossible to do natively.
Fix:
Moved logic into a shell script or a small Node.js action. That’s the intended pattern, but it meant rewriting 50+ lines of Groovy into a separate file.
- Manual triggers and input types Jenkins pipeline with parameters lets you use choice, boolean, string, file, and even multi-line text inputs.
GitHub Actions supports workflow_dispatch with input types — but no textarea or file upload.
What broke:
A manual promotion workflow where release engineers pasted multi-line release notes or uploaded a small file. No direct replacement.
Fix:
We hacked around it by accepting file content as a base64 string input (ugly). Eventually moved that step to a separate reusable workflow with a dedicated script.
- Shared libraries / reusable workflows — not 1:1 Jenkins Shared Libraries are powerful: you write Groovy code once, import across pipelines, and version it via Git.
GitHub Actions has reusable workflows and composite actions.
What broke:
Our shared library wasn’t just step sequences — it had utility functions that parsed version strings, called internal APIs, and transformed data. Composite actions can’t do that. Reusable workflows can call other workflows, but no “helper function” concept.
Fix:
We built custom JavaScript actions for complex logic. More boilerplate, but more explicit and testable. Not a straight migration — more a redesign.
- Agent availability and concurrency surprises Jenkins — you own the agents. You know exactly how many executors you have.
GitHub-hosted runners — unlimited-ish, but there’s a concurrency limit per repository (soft limit ~60ish, can throttle). Self-hosted? Then you’re back to managing infrastructure.
What broke:
During a big merge day, 40+ PR checks queued. GitHub throttled us. Builds took 4x longer due to waiting for runner slots. In Jenkins (self-hosted), we’d have just added more agents.
Fix:
Moved some non-critical jobs to self-hosted runners on spot instances. Not ideal, but necessary.
- Plugins you take for granted Jenkins plugins cover everything: build-user-vars-plugin, email-ext, artifact-promotion, job-dsl, parameterized-trigger.
GitHub Actions has actions, not plugins. The ecosystem is large but not identical.
What broke:
We used the Jenkins build user vars plugin to know who triggered a deployment. GitHub Actions has github.actor — but that’s always the user or bot who triggered the workflow, which wasn't always the deployer in our model.
Fix:
Re-engineered our deployment audit to rely on GitHub’s API + commit SHAs. Lost some niceness, gained clarity.
The bottom line
Moving from Jenkins to GitHub Actions is worth it — faster setup, simpler YAML, native PR checks, no Jenkinsfile DSL weirdness. But don’t assume it’s a drop-in replacement.
The things that “break” are usually:
Reliance on mutable workspace
Complex Groovy logic
Manual UI steps you’ve automated
Internal DSL features without a direct Action equivalent
My advice:
Do a small pilot with one pipeline. Identify every place you assume persistent workspace, scripting power, or a specific plugin. Those will be your migration hotspots.
And keep Jenkins running for a while. Just in case.
Top comments (1)
This guy’s heart