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.

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.
| Jenkins | GitHub Actions | What 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 test | None |
environment { } | env: at workflow, job or step level | None |
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 steps | Parallel 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 / unstash | actions/upload-artifact / download-artifact | Explicit upload and download by name |
tools { jdk; maven } | actions/setup-java with a version | A version number replaces a name from Global Tool Configuration |
@Library shared library | Reusable workflow or composite action | Rewrite |
Plugin steps (junit, slackSend) | An action, or a script | Rewrite |
script { } Groovy | A shell step or an expression | Rewrite |
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.
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.
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.
# 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: inheritThe migration checklist
Go through these for every Jenkinsfile before you open the pull request that adds its workflow.
- Runner labels. Every
agent { label }needs aruns-onvalue. 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 everywithCredentialsblock, recreate each value as a repository, organization or environment secret, and map it intoenv:where it is used. A secret that is never referenced is never available to the job. - Cache keys. Jenkins agents kept
~/.m2between builds for free. A fresh runner does not, so addcache: mavenonsetup-javaor anactions/cachestep keyed onhashFiles('**/pom.xml'). - Matrix. A
matrix { axes }block becomesstrategy.matrix;excludesbecomesexclude. Setfail-fast: falseif you want every cell to finish the way Jenkins matrix cells did. - Artifacts. Every
stash,unstashandarchiveArtifactsbecomes an upload or download step, and anything a later stage read from the shared workspace needs one too. - Concurrency.
disableConcurrentBuilds()andlockbecome aconcurrency:group at the workflow or job level. Jenkins documents thatdisableConcurrentBuilds()queues a later build, so addqueue: 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.
- Run the audit and sort pipelines by status. Successful first, then partially successful, then the ones built on shared libraries.
- 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.
- Compare results until they agree. A difference is almost always the workspace, a missing tool or a secret that was never mapped.
- Move the required status check in branch protection from the Jenkins check to the workflow's job, then disable the Jenkins job.
- Build the reusable workflows and composite actions that replace your shared libraries, then migrate the pipelines that call them.
- Move deploy pipelines last, each behind an environment with the same approvers your
inputsteps had.
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/appKey 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 intoenv: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?
Is there a tool to convert a Jenkinsfile to GitHub Actions?
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?
@Library('name@v2'). The Groovy inside has to be rewritten as shell or YAML.How do I use Jenkins credentials in GitHub Actions?
${{ 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.Related guides
References
- GitHub Docs: Migrating from Jenkins to GitHub Actions (read 2026-11-12)
- GitHub Docs: Migrating from Jenkins with GitHub Actions Importer, limitations and supported syntax
- Jenkins: Pipeline Syntax, including post conditions, credentials() and cron H
- Jenkins: Extending with Shared Libraries
- GitHub Docs: Workflow syntax for GitHub Actions, including steps[*].parallel
- GitHub Actions documentation