Skip to content
Latchkey LogoLatchkey home

Migrate Jenkins to GitHub Actions: what maps, what does not, and the order to move

To migrate Jenkins to GitHub Actions, translate the Declarative Jenkinsfile directive by directive (each stage becomes a job, agent becomes runs-on, environment becomes env, when becomes if, credentials() becomes a secret) and rewrite by hand the three things that never convert: plugin steps, shared libraries and Groovy logic. Move the plain build-and-test pipelines first and the deploy pipelines last, keeping the Jenkins job running until the workflow's results match.

Your code is on GitHub and your pipelines are Jenkinsfiles. Whether to move at all is a separate argument, made on GitHub Actions vs Jenkins; this page assumes you have decided and need the translation.

The syntax is the easy half. GitHub's own migration guide maps most Declarative directives in a handful of tables. The hard half is behavior Jenkins gave you without a keyword: stages that share a workspace on one agent, post conditions that remember the previous build, and a plugin for every step that is not sh. The map below marks which rows are a rename and which change how the pipeline runs.

Jenkinsfile constructs mapped to GitHub Actions keys, each marked Maps, Changes or Rewrite
Sixteen Declarative constructs and where each lands. The amber rows convert but behave differently; the coral rows are Groovy and have to be rebuilt.

The concept map, directive by directive

Unfamiliar syntax is rarely what breaks a converted pipeline on its first run. The row that says each job starts on its own machine is, so read What changes before you read the GitHub Actions column.

JenkinsGitHub ActionsWhat changes
pipeline { }A YAML file in .github/workflows/Groovy becomes YAML with ${{ }} expressions; there is no code to run while the file is read
agent { label 'linux' }runs-on:One label set per job, not per pipeline
agent { docker { image } }container:The job runs inside the image on the runner
stage('Test')jobs.test with needs:Jobs run in parallel unless needs: orders them, and each starts on a fresh machine with no shared workspace
sh 'mvn -B test'run: mvn -B testNone
environment { }env: at workflow, job or step levelNone
credentials('id')${{ secrets.NAME }} mapped into env:A username and password credential is two secrets; Actions does not create the _USR and _PSW variables
when { branch 'main' }if: github.ref == 'refs/heads/main'github.ref is the full ref, so compare against refs/heads/main
parallel { }Jobs without needs:, or a steps[*].parallel group of stepsParallel jobs land on separate machines; a step group shares one
post { always / failure / success }Steps with if: always(), failure() or success()changed, fixed and regression compare with the previous build and have no equivalent
triggers { cron('H 2 * * 1-5') }on: schedule: - cron:No H token, and the schedule runs on the default branch only
stash / unstashactions/upload-artifact / download-artifactExplicit upload and download by name
tools { jdk; maven }actions/setup-java with a versionA version number replaces a name from Global Tool Configuration
@Library shared libraryReusable workflow or composite actionRewrite
Plugin steps (junit, slackSend)An action, or a scriptRewrite
script { } GroovyA shell step or an expressionRewrite

Before: a Declarative Jenkinsfile

This is the shape most Java services have: a labelled agent, two tools from Global Tool Configuration, a username and password credential, a nightly cron, a parallel test stage, a publish stage gated on main, and a post block that collects test reports.

Two details matter for the conversion. when { branch } only works in a multibranch Pipeline, so this file is assumed to be one. And credentials('registry-creds') on a username and password credential sets three variables, REGISTRY, REGISTRY_USR and REGISTRY_PSW, which the publish stage uses.

Jenkinsfile
pipeline {
  agent { label 'linux' }
  tools {
    jdk 'jdk-21'
    maven 'maven-3.9'
  }
  environment {
    MAVEN_OPTS = '-Xmx2g'
    REGISTRY = credentials('registry-creds')
  }
  options { timeout(time: 30, unit: 'MINUTES') }
  triggers { cron('H 2 * * 1-5') }
  stages {
    stage('Build') {
      steps { sh 'mvn -B -DskipTests package' }
    }
    stage('Test') {
      parallel {
        stage('Unit') { steps { sh 'mvn -B test' } }
        stage('Integration') { steps { sh 'mvn -B verify -Pintegration' } }
      }
    }
    stage('Publish') {
      when { branch 'main' }
      steps {
        sh 'echo "$REGISTRY_PSW" | docker login registry.example.com -u "$REGISTRY_USR" --password-stdin'
        sh 'docker build -t registry.example.com/app:$BUILD_NUMBER .'
        sh 'docker push registry.example.com/app:$BUILD_NUMBER'
      }
    }
  }
  post {
    always { junit '**/target/surefire-reports/*.xml' }
    success { archiveArtifacts artifacts: 'target/*.jar' }
  }
}

After: the converted workflow

Each stage is a job, and that is the change that costs the most. In Jenkins the Unit and Integration stages ran on the same agent as Build and found the compiled classes already there. In Actions every job starts on a clean machine, so each job builds what it needs, Maven's dependency cache comes from setup-java, and the jar travels to the publish job as an artifact.

post { always } became a step with if: always() in each job that produces reports, because a post block is per pipeline and a status function is per step. The junit step is a plugin with no built-in counterpart, so the reports upload as an artifact. $BUILD_NUMBER became github.run_number, which counts runs of this workflow from 1 the way a Jenkins build number does.

The cron moved from H to minute 17. GitHub's documentation warns that scheduled runs can be delayed during high load, which includes the start of every hour, and recommends a different minute.

.github/workflows/ci.yml
name: ci
on:
  push:
    branches: [main]
  pull_request:
  schedule:
    - cron: '17 2 * * 1-5'
env:
  MAVEN_OPTS: -Xmx2g
jobs:
  build:
    runs-on: ubuntu-latest        # was: agent { label 'linux' }
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      - run: mvn -B -DskipTests package
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: app-jar
          path: target/*.jar
  unit:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      - run: mvn -B test
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        if: always()                # was: post { always { junit } }
        with:
          name: unit-reports
          path: '**/target/surefire-reports/*.xml'
  integration:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/setup-java@77dd5b2ce225ea11469928ac317850245a74ae78 # v6.2.0
        with:
          distribution: temurin
          java-version: '21'
          cache: maven
      - run: mvn -B verify -Pintegration
  publish:
    needs: [unit, integration]
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: app-jar
          path: target
      - env:
          REGISTRY_USR: ${{ secrets.REGISTRY_USR }}
          REGISTRY_PSW: ${{ secrets.REGISTRY_PSW }}
        run: |
          echo "$REGISTRY_PSW" | docker login registry.example.com -u "$REGISTRY_USR" --password-stdin
          docker build -t registry.example.com/app:${{ github.run_number }} .
          docker push registry.example.com/app:${{ github.run_number }}

What does not convert

Three things have no mechanical translation, and GitHub Actions Importer leaves them for you too: its Jenkins limitations list scripted pipelines, secrets, self-hosted runners, unknown plugins and mandatory build tools as manual work.

Plugins. Every step that is not sh, bat or a core step came from a plugin. Some have a direct action (archiveArtifacts is actions/upload-artifact), some are one line of shell, and some, like a test report UI or a Lockable Resources lock, become a different feature: a Marketplace action for reports, concurrency: for locks.

Shared libraries. A @Library call loads Groovy from another repository. The Actions equivalent depends on its size: a sequence of steps becomes a composite action, a whole job sequence becomes a reusable workflow called with uses: at the job level. Build these before you migrate the pipelines that call them, or every pipeline carries its own copy.

Groovy logic. script { } blocks, loops over lists and def variables run while Jenkins evaluates the pipeline. A workflow file has no such stage: logic moves into a shell step, a matrix, or an expression in if:. A deploy approval written as input becomes a job that references an environment with required reviewers.

.github/workflows/ci.yml
# Jenkinsfile, before:
#   @Library('ci-lib@v2') _
#   deployService(name: 'app', env: 'production')
jobs:
  deploy:
    needs: [unit, integration]
    uses: my-org/ci-workflows/.github/workflows/deploy.yml@v2
    with:
      service: app
      environment: production
    secrets: inherit

The migration checklist

Go through these for every Jenkinsfile before you open the pull request that adds its workflow.

  • Runner labels. Every agent { label } needs a runs-on value. Agents you maintained become either a hosted label or a self-hosted runner you register; the importer leaves self-hosted labels as a manual task.
  • Secrets. List every credentials() call and every withCredentials block, recreate each value as a repository, organization or environment secret, and map it into env: where it is used. A secret that is never referenced is never available to the job.
  • Cache keys. Jenkins agents kept ~/.m2 between builds for free. A fresh runner does not, so add cache: maven on setup-java or an actions/cache step keyed on hashFiles('**/pom.xml').
  • Matrix. A matrix { axes } block becomes strategy.matrix; excludes becomes exclude. Set fail-fast: false if you want every cell to finish the way Jenkins matrix cells did.
  • Artifacts. Every stash, unstash and archiveArtifacts becomes an upload or download step, and anything a later stage read from the shared workspace needs one too.
  • Concurrency. disableConcurrentBuilds() and lock become a concurrency: group at the workflow or job level. Jenkins documents that disableConcurrentBuilds() queues a later build, so add queue: max: by default GitHub keeps one pending run and cancels the one already waiting.

The order to migrate in

Start with an inventory rather than a favorite pipeline. GitHub Actions Importer audits a whole Jenkins server and reports which pipelines convert fully, partially or not at all, which is the order you want: the fully converted ones are your first week.

  1. Run the audit and sort pipelines by status. Successful first, then partially successful, then the ones built on shared libraries.
  2. Pick one commit-triggered build-and-test pipeline with no deploy step. Add its workflow beside the Jenkinsfile and let both run on every push.
  3. Compare results until they agree. A difference is almost always the workspace, a missing tool or a secret that was never mapped.
  4. Move the required status check in branch protection from the Jenkins check to the workflow's job, then disable the Jenkins job.
  5. Build the reusable workflows and composite actions that replace your shared libraries, then migrate the pipelines that call them.
  6. Move deploy pipelines last, each behind an environment with the same approvers your input steps had.
Terminal
gh extension install github/gh-actions-importer
gh actions-importer update
gh actions-importer configure
gh actions-importer audit jenkins --output-dir tmp/audit
gh actions-importer dry-run jenkins --output-dir tmp/dry-run \
  --source-file-path ./Jenkinsfile --source-url https://jenkins.example.com/job/app

Key takeaways

  • A stage becomes a job, and a job starts on a clean machine: pass files with artifacts, not the workspace.
  • credentials() becomes secrets you map into env: by name; nothing is injected automatically.
  • Plugins, shared libraries and Groovy logic never convert mechanically, so build their replacements first.
  • Migrate from the audit, simplest pipelines first, deploys last, with both systems running until they agree.

Frequently asked questions

Can GitHub Actions replace Jenkins?
For pipelines that build, test and deploy code hosted on GitHub, yes: every Declarative directive has a counterpart or a documented workaround. It is a poorer fit when your repositories live elsewhere or builds are triggered by systems that do not emit repository events. The trade-offs are on GitHub Actions vs Jenkins.
Is there a tool to convert a Jenkinsfile to GitHub Actions?
GitHub Actions Importer, a gh CLI extension that runs in Docker. It converts Declarative Jenkinsfiles and Freestyle jobs but not scripted pipelines, and lists what it could not convert as manual tasks. GitHub Actions Importer covers its commands and limits.
What is the GitHub Actions equivalent of a Jenkins shared library?
A composite action for a sequence of steps, or a reusable workflow for a whole job or chain of jobs. Both live in a repository and are referenced by tag or commit SHA, much like @Library('name@v2'). The Groovy inside has to be rewritten as shell or YAML.
How do I use Jenkins credentials in GitHub Actions?
Recreate each credential as a GitHub secret and reference it with ${{ secrets.NAME }}, usually in a step's env:. GitHub only exposes a secret the workflow explicitly references. A Jenkins username and password credential becomes two secrets, since Actions does not split one value into _USR and _PSW variables.

References

Leaving the Jenkins agents behind? Latchkey runs each job on a fresh VM from one runs-on line. Start free → 30-day trial · No credit card