Skip to main content
Jenkins · lesson 5 of 5
Jenkins intermediate 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 workflowNotes
agent { docker { image 'python:3.12-slim' } }container: python:3.12-slim on the jobBoth pin the exact toolchain instead of trusting whatever’s on the host
stages { stage('Test') { steps { sh 'pytest -v' } } }steps: - run: pytest -v1: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.gzSame 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

  1. 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.com

    Same rule applies: never echo the variable, never pass -v/set -x in a way that would print the header.

  2. The Shared Library step buildDockerImage(registry: ..., imageName: ..., tag: ...) from lesson 4 builds and pushes an image. Sketch the GitHub Actions equivalent using docker build and docker push directly (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 deeper github-actions advanced tier.

  3. Rewrite the when { branch 'main' } production-deploy gate from lesson 3 (which also used an input approval step) as a GitHub Actions job. GitHub Actions’ equivalent of input is an environment with required reviewers configured in repo settings, referenced via environment: production on the job.

    Solution
    deploy-production:
      needs: test
      if: github.ref == 'refs/heads/main'
      runs-on: ubuntu-latest
      environment: production
      steps:
        - run: ./scripts/deploy.sh production

    The pause-for-approval behavior isn’t YAML at all — it’s configured once on the production environment in the repo’s Settings, not per-workflow.

  4. Run pytest --junitxml=results.xml -v yourself, then tar -czf test-results.tar.gz results.xml and 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.xml

    The 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.

  5. Add a stage('Lint') running python -m py_compile app.py to the Jenkinsfile in section 3, then add the matching step to the GitHub Actions workflow (no matrix needed — just another step after the Test step).

    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.

Frequently Asked Questions

Do I need to rewrite my build/test commands to migrate from Jenkins to GitHub Actions?
No. Both systems just invoke a shell (`sh` in Jenkins, `run:` in GitHub Actions) with the exact commands you already use — pytest, npm test, docker build. What changes is the YAML/Groovy wrapper around those commands, not the commands themselves.
What's the GitHub Actions equivalent of a Jenkins agent?
`runs-on` in a job. A Jenkinsfile's `agent { docker { image '...' } }` becomes a `container:` key on the job, and `agent { label 'linux-x64' }` becomes picking a `runs-on` runner (e.g. `ubuntu-latest`) or a self-hosted runner with a matching label.
How do Jenkins credentials map to GitHub Actions secrets?
`withCredentials([string(credentialsId: 'API_TOKEN', ...)])` becomes a repository or organization secret referenced as `${{ secrets.API_TOKEN }}`, usually passed to a step through `env:`. Both keep the value out of the log by default.
Does Jenkins' `parallel` block behave the same as a GitHub Actions matrix?
They solve the same problem — running independent work concurrently — but differently. `parallel` fans out stages within one job on whatever agents are free. A GitHub Actions `matrix` fans out into separate jobs, each with its own runner, which is closer to Jenkins running each parallel branch on a distinct agent.
Can I run both Jenkins and GitHub Actions during a migration?
Yes — this is the common path. Keep the Jenkinsfile as the source of truth and add the GitHub Actions workflow alongside it, watch both pipelines agree on the same commits for a few weeks, then delete the Jenkinsfile once GitHub Actions has matched Jenkins' pass/fail record.