Jenkins · lesson 5 of 5
Also part of: GitHub Actions
Migrating a Jenkins Pipeline to GitHub Actions
Map a declarative Jenkinsfile stage by stage onto a GitHub Actions workflow — agents, parallel stages, secrets, and artifacts — with the same commands producing the same output on both sides.
By SkillByExample · Published
You already have a working Jenkinsfile from the earlier lessons in this track. This lesson maps it, stage by stage, onto a GitHub Actions workflow — using the same underlying commands so the output doesn’t change, only the orchestrator running it.
1) The project under test
Both pipelines below build and test this tiny project:
# app.py
def add(a, b):
return a + b
def divide(a, b):
return a / b
# test_app.py
from app import add, divide
def test_add():
assert add(2, 3) == 5
def test_divide():
assert divide(10, 2) == 5
$ pytest -v
============================= test session starts =============================
platform win32 -- Python 3.13.5, pytest-9.0.3, pluggy-1.6.0
rootdir: C:\work\jenkins-demo
collecting ... collected 2 items
test_app.py::test_add PASSED [ 50%]
test_app.py::test_divide PASSED [100%]
============================== 2 passed in 0.55s ==============================
This is the command both the Jenkinsfile and the GitHub Actions workflow below actually run.
Nothing about pytest’s output changes based on which system invoked it — that’s the whole
point of the mapping.
2) Stage-by-stage mapping
| Jenkinsfile (declarative) | GitHub Actions workflow | Notes |
|---|---|---|
agent { docker { image 'python:3.12-slim' } } | container: python:3.12-slim on the job | Both pin the exact toolchain instead of trusting whatever’s on the host |
stages { stage('Test') { steps { sh 'pytest -v' } } } | steps: - run: pytest -v | 1:1 — same shell command either way |
parallel { stage('Unit') {...} stage('Lint') {...} } | strategy: matrix: task: [unit, lint] + one job that branches on ${{ matrix.task }} | Jenkins fans out within one job; Actions fans out into separate jobs |
when { branch 'main' } | if: github.ref == 'refs/heads/main' | Both gate a stage/step on the branch |
withCredentials([string(credentialsId: 'API_TOKEN', variable: 'API_TOKEN')]) | env: API_TOKEN: ${{ secrets.API_TOKEN }} | Both keep the value out of the log |
archiveArtifacts artifacts: 'test-results.tar.gz' | uses: actions/upload-artifact@v4 with path: test-results.tar.gz | Same idea, different verb |
post { failure { emailext(...) } } | a step with if: failure() | Jenkins’ post block has no direct equivalent — model it as a conditional step at the end of the job |
3) Full pipeline, side by side
Jenkinsfile from lesson 1 and lesson 2, condensed to test + archive:
pipeline {
agent { docker { image 'python:3.12-slim' } }
stages {
stage('Install') {
steps { sh 'pip install pytest' }
}
stage('Test') {
steps { sh 'pytest --junitxml=results.xml -v' }
}
stage('Archive') {
steps {
sh 'tar -czf test-results.tar.gz results.xml'
archiveArtifacts artifacts: 'test-results.tar.gz'
}
}
}
}
The equivalent workflow:
# .github/workflows/test.yml
name: test
on: [push]
jobs:
test:
runs-on: ubuntu-latest
container: python:3.12-slim
steps:
- uses: actions/checkout@v4
- run: pip install pytest
- run: pytest --junitxml=results.xml -v
- run: tar -czf test-results.tar.gz results.xml
- uses: actions/upload-artifact@v4
with:
name: test-results
path: test-results.tar.gz
Running the install-then-archive sequence locally shows exactly what both pipelines produce:
$ pytest --junitxml=results.xml -v
============================= test session starts =============================
platform win32 -- Python 3.13.5, pytest-9.0.3, pluggy-1.6.0
rootdir: C:\work\jenkins-demo
collecting ... collected 2 items
test_app.py::test_add PASSED [ 50%]
test_app.py::test_divide PASSED [100%]
- generated xml file: C:\work\jenkins-demo\results.xml -
============================== 2 passed in 0.22s ==============================
$ tar -czf test-results.tar.gz results.xml
$ ls -la test-results.tar.gz
-rw-r--r-- 1 abhishek 197121 315 Sep 14 15:41 test-results.tar.gz
The Jenkinsfile needed an explicit agent and stage wrapper around each shell call; the
workflow needed an explicit uses: actions/checkout@v4 — Jenkins checks out the repo
implicitly when it can see the Jenkinsfile came from SCM, GitHub Actions never does unless
a step asks for it.
4) parallel vs. matrix
Jenkins’ parallel block from lesson 3:
stage('Test Suite') {
parallel {
stage('Unit Tests') {
steps { sh 'pytest -v -k "not slow"' }
}
stage('Lint') {
steps { sh 'python -m py_compile app.py' }
}
}
}
The matrix equivalent — one job definition, two runner instances:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
task: [unit, lint]
steps:
- uses: actions/checkout@v4
- if: matrix.task == 'unit'
run: pytest -v -k "not slow"
- if: matrix.task == 'lint'
run: python -m py_compile app.py
The Jenkins version runs both branches as sub-processes of one job on whichever agents are
free; the matrix version starts two entirely separate jobs, each getting its own runner,
its own checkout, and its own log — closer to Jenkins running each parallel branch on a
distinct labeled agent than to running them on one.
Practice
-
Take the credentials example from lesson 2 — a
withCredentials([string(credentialsId: 'API_TOKEN', ...)])block calling a curl command — and write the GitHub Actions step that does the same thing with${{ secrets.API_TOKEN }}.Solution
- name: Call external API env: API_TOKEN: ${{ secrets.API_TOKEN }} run: | curl -sS -H "Authorization: Bearer $API_TOKEN" https://api.example.comSame rule applies: never echo the variable, never pass
-v/set -xin a way that would print the header. -
The Shared Library step
buildDockerImage(registry: ..., imageName: ..., tag: ...)from lesson 4 builds and pushes an image. Sketch the GitHub Actions equivalent usingdocker buildanddocker pushdirectly (no library needed — a workflow file lives in one repo, so the “shared” problem Shared Libraries solve mostly doesn’t apply at small scale).Solution
- run: docker build -t registry.example.com/myapp:${{ github.sha }} . - run: docker push registry.example.com/myapp:${{ github.sha }}At larger scale, GitHub Actions’ answer to Shared Libraries is a reusable workflow (
uses: org/repo/.github/workflows/build.yml@main) or a composite action — worth a follow-up if this repo builds out a deepergithub-actionsadvanced tier. -
Rewrite the
when { branch 'main' }production-deploy gate from lesson 3 (which also used aninputapproval step) as a GitHub Actions job. GitHub Actions’ equivalent ofinputis an environment with required reviewers configured in repo settings, referenced viaenvironment: productionon the job.Solution
deploy-production: needs: test if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: production steps: - run: ./scripts/deploy.sh productionThe pause-for-approval behavior isn’t YAML at all — it’s configured once on the
productionenvironment in the repo’s Settings, not per-workflow. -
Run
pytest --junitxml=results.xml -vyourself, thentar -czf test-results.tar.gz results.xmland confirm the archive step’s output matches what’s shown above — that this lesson’s output block wasn’t typed from memory.Solution
$ pytest --junitxml=results.xml -v test_app.py::test_add PASSED [ 50%] test_app.py::test_divide PASSED [100%] ============================== 2 passed in 0.22s ============================== $ tar -czf test-results.tar.gz results.xmlThe exact byte size and timestamp on your machine will differ from the lesson’s — that’s expected, the command and its shape are what should match.
-
Add a
stage('Lint')runningpython -m py_compile app.pyto the Jenkinsfile in section 3, then add the matching step to the GitHub Actions workflow (no matrix needed — just another step after theTeststep).Solution
stage('Lint') { steps { sh 'python -m py_compile app.py' } }- run: python -m py_compile app.py
Next steps
github-actions— the full track this lesson bridges into: matrix builds, reusable workflows, and caching in more depth than the mapping above covers.- Jenkins Shared Libraries — if you’re not migrating away, this is Jenkins’ own answer to keeping the Jenkinsfile above out of every repo.