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.

Sixteen Declarative constructs and where each lands. The amber rows convert but behave differently; the coral rows are Groovy and have to be rebuilt.
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 |
Mappings from docs.github.com "Migrating from Jenkins to GitHub Actions" and the Workflow syntax reference, and jenkins.io Pipeline Syntax, read 2026-11-12. GitHub's guide maps
postto "None"; theif:status functions are the closest equivalent foralways,failureandsuccess. The step group isjobs.<job_id>.steps[*].parallel, which runs its steps at once and waits for all of them; it cannot be used inside a composite action.
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: 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 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/app FAQ
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
- 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


