How to Migrate Jenkins to GitHub Actions: Step-by-Step Guide (2026)
This guide is for teams with a Jenkins controller, a pile of declarative Jenkinsfiles and probably a shared library or two, who want to move to GitHub Actions without a weekend-long cutover. By the end you will have an inventory of jobs, plugins and credentials; an audit and usage forecast from GitHub Actions Importer; converted workflows you have dry-run and hand-tuned; credentials moved into GitHub secrets, with cloud keys replaced by OIDC; shared libraries rebuilt as reusable workflows; and self-hosted runners on Kubernetes through Actions Runner Controller (ARC). Commands and versions were checked against the official docs and release pages on 31 October 2026: Jenkins LTS 2.580.2, the gh-actions-importer CLI extension, actions/checkout v7.0.1, ARC 0.15.1 and runner 2.338.2. I rendered the ARC charts locally with Helm 3.14 and parsed every YAML block, but the importer output shown is the documented format, not a run against a live controller. You need Docker, the GitHub CLI, a Jenkins API token, organisation admin on GitHub and, for Step 8, a Kubernetes cluster with Helm 3. If you are still choosing a platform, read GitHub Actions vs GitLab CI vs Jenkins (2026) first.
Step 1: Inventory Jenkins jobs, plugins and credentials
Before converting anything, find out what you have. The Jenkins remote API returns every job with its type, and the plugin manager API returns what the controller depends on. Use an API token from a dedicated user (User, Security, API Token) and keep it out of shell history:
export JENKINS_URL="https://jenkins.example.com"
export JENKINS_USER="migration-bot"
read -rs JENKINS_TOKEN && export JENKINS_TOKEN # paste the token, press Enter
# Every job up to three folders deep. -g stops curl treating [] as a URL glob.
curl -sg -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$JENKINS_URL/api/json?tree=jobs[fullName,_class,jobs[fullName,_class,jobs[fullName,_class]]]" \
| jq -r '.. | objects | select(has("fullName")) | [.fullName, ._class] | @tsv' > jobs.tsv
# Count by type: WorkflowJob = Jenkinsfile, FreeStyleProject, WorkflowMultiBranchProject
cut -f2 jobs.tsv | sort | uniq -c | sort -rn
# Active plugins and versions, for the "unknown plugins" conversation later
curl -sg -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$JENKINS_URL/pluginManager/api/json?depth=1&tree=plugins[shortName,version,active]" \
| jq -r '.plugins[] | select(.active) | "\(.shortName) \(.version)"' | sort > plugins.txt
Then scan the repositories themselves. These greps pull out shared library references, credential IDs, agent labels and any Jenkinsfile that is not declarative (no pipeline { block, so it is scripted):
# Run from a directory containing checkouts of every repo with a Jenkinsfile
grep -rhoE "@Library\(['\"][^'\"]+" --include='Jenkinsfile*' . | sed -E "s/@Library\(['\"]//" | sort | uniq -c
grep -rhoE "(credentialsId: *|credentials\()['\"][^'\"]+" --include='Jenkinsfile*' . | sed -E "s/.*['\"]//" | sort | uniq -c
grep -rhoE "(label|node\() *['\"][^'\"]+" --include='Jenkinsfile*' . | sed -E "s/.*['\"]//" | sort | uniq -c
grep -rLE "^\s*pipeline\s*\{" --include='Jenkinsfile*' .
On a two-repo test checkout the output looks like this, which is already a migration plan in miniature: two library versions in use, one credential shared across repos, a Windows agent, and one scripted pipeline the importer will not touch.
1 ci-lib@v2
1 ci-lib@v3
1 aws-deploy-key
2 nexus-deploy
1 linux-docker
1 windows
svc-b/Jenkinsfile
Step 2: Install and configure GitHub Actions Importer
GitHub Actions Importer is a Docker image driven by a gh CLI extension. It does not need to run on the Jenkins server, only to reach it over the network.
docker info --format '{{.ServerVersion}}' # Docker must be running
gh extension install github/gh-actions-importer
gh actions-importer update # pulls the current importer image
gh actions-importer -h # lists audit, forecast, dry-run, migrate
# Only if the image pull is denied: log in to GitHub Container Registry first
# echo "$GHCR_PAT" | docker login ghcr.io -u YOUR_GITHUB_USERNAME --password-stdin
Run gh actions-importer configure, choose Jenkins, and answer the prompts. The answers land in .env.local in the current directory, which you should add to .gitignore straight away:
# .env.local, written by `gh actions-importer configure`
GITHUB_ACCESS_TOKEN=ghp_xxx # classic PAT with repo and workflow scopes
GITHUB_INSTANCE_URL=https://github.com
JENKINS_ACCESS_TOKEN=xxx # Jenkins API token
JENKINS_USERNAME=migration-bot # the user who created that token
JENKINS_INSTANCE_URL=https://jenkins.example.com
The Jenkins-specific docs list these variables, plus an optional JENKINSFILE_ACCESS_TOKEN if the Jenkinsfiles live in repositories the main token cannot read. For forecast, install the Paginated Builds plugin on the controller, which lets the importer page through build history without timing out.
Step 3: Run an audit and a forecast
The audit converts every pipeline it can find and reports how well it went. Point it at the whole controller first, then at folders (-f <folder_path>) once you start planning waves:
gh actions-importer audit jenkins --output-dir tmp/audit
gh actions-importer forecast jenkins --output-dir tmp/forecast --start-date 2026-07-01
tmp/audit/audit_summary.md sorts pipelines into Successful, Partially successful, Unsupported and Failed, then breaks down build steps into Known, Unknown and Unsupported, and lists every action the converted workflows would use plus the manual tasks (mostly secrets and runners). Per GitHub's importer labs, Unsupported means a job type the importer cannot read, typically a scripted pipeline, and Failed usually means bad credentials or a broken job. The actions list in that summary (also in tmp/audit/workflow_usage.csv) is what to hand your security team: it is every third-party action you are about to trust.
tmp/forecast/forecast_report.md gives job count, total, median and P90 execution minutes, queue time and concurrent jobs, broken down per Jenkins runner queue. Multiply execution minutes by the runner price ($0.006 a minute for Linux 2-core) to estimate hosted cost, and use P90 concurrency per label as the starting maxRunners for self-hosted scale sets in Step 8. Pick a start date that covers a release crunch, not a quiet fortnight.
Step 4: Dry-run one pipeline and add custom transformers
Pick one boring, well-tested pipeline and convert it without opening a pull request:
gh actions-importer dry-run jenkins \
--source-url "$JENKINS_URL/job/payments/job/payments-api" \
--output-dir tmp/dry-run
# Multibranch jobs, or a Jenkinsfile the importer cannot fetch: pass the file
gh actions-importer dry-run jenkins \
--source-url "$JENKINS_URL/job/payments/job/payments-api" \
--source-file-path ./Jenkinsfile \
--output-dir tmp/dry-run
Open the workflow under tmp/dry-run/ and search for # This item has no matching transformer. Each one is a Jenkins step the importer did not know. Custom transformers are a small Ruby DSL that fixes these once for every pipeline: runner maps a Jenkins label to a runner label, env overrides a variable, and transform rewrites a step.
# transformers.rb, used with --custom-transformers on dry-run and migrate
runner "linux-docker", "linux-build" # Jenkins agent label -> ARC scale set (Step 8)
env "MAVEN_OPTS", "-Xmx2g"
# Jenkins `sleep time: 80` had no transformer; emit a plain shell step
transform "sleep" do |item|
seconds = item["arguments"][0]["value"]["value"]
{ "name" => "Sleep #{seconds}s", "run" => "sleep #{seconds}", "shell" => "bash" }
end
# Upload JUnit XML with the official artifact action instead of the default conversion
transform "junit" do |item|
results = item["arguments"].find { |a| a["key"] == "testResults" }
[{
"name" => "Upload test reports",
"if" => "always()",
"uses" => "actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9", # v7.0.2
"with" => { "name" => "junit", "path" => results.dig("value", "value") }
}]
end
Rerun the dry run with --custom-transformers transformers.rb and diff. To see what a step's item looks like before writing a transformer, make the block puts item and run once.
Step 5: Map Jenkinsfile stages to GitHub Actions jobs
The importer handles syntax; it does not know your intent. These are the mappings I end up doing by hand, based on GitHub's manual migration reference:
| Jenkins | GitHub Actions | Watch out for |
|---|---|---|
agent { label 'x' } |
runs-on: x |
Hosted label, or an ARC scale set name |
stages / stage |
jobs / a job |
Jobs do not share a workspace |
stash / unstash |
upload-artifact / download-artifact |
Artifacts count against storage |
parallel |
separate jobs or strategy.matrix |
Jobs run in parallel by default |
when { branch 'main' } |
if: github.ref == 'refs/heads/main' |
Expressions, not Groovy |
post { always / failure } |
if: always() / if: failure() |
Put it on a step or a job |
input |
environment with required reviewers |
Private repos need Enterprise |
tools { jdk 'x' } |
actions/setup-java |
Same for node, python, go |
options { timeout(...) } |
timeout-minutes |
Hard limit is 6 hours on hosted |
cron('H 2 * * 1-5') |
schedule: - cron: '0 2 * * 1-5' |
UTC only, no H |
${BUILD_NUMBER} |
${{ github.run_number }} |
The importer maps it to run_id |
Here is a typical declarative Jenkinsfile, followed by the workflow I would ship for it:
pipeline {
agent { label 'linux-docker' }
tools { jdk 'temurin-21' }
options { timeout(time: 30, unit: 'MINUTES') }
triggers { cron('H 2 * * 1-5') }
stages {
stage('Build') { steps { sh 'mvn -B -ntp package -DskipTests' } }
stage('Test') {
parallel {
stage('Unit') { steps { sh 'mvn -B -ntp test' } }
stage('Integration') { steps { sh 'mvn -B -ntp -Pit verify' } }
}
}
stage('Deploy') {
when { branch 'main' }
steps {
input message: 'Deploy to production?'
withCredentials([string(credentialsId: 'aws-deploy-key', variable: 'AWS_SECRET_ACCESS_KEY')]) {
sh './deploy.sh'
}
}
}
}
post { always { junit '**/target/surefire-reports/*.xml' } }
}
# .github/workflows/build.yml
name: build
on:
push:
branches: [main]
pull_request:
schedule:
- cron: "0 2 * * 1-5" # Jenkins H 2 * * 1-5; GitHub runs schedules in UTC
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-24.04 # pin: ubuntu-latest moves to 26.04 by 19 Nov 2026
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- suite: unit
goals: test
- suite: integration
goals: -Pit verify
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: "21"
cache: maven
- run: mvn -B -ntp ${{ matrix.goals }}
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
if: always() # replaces post { always { junit ... } }
with:
name: surefire-${{ matrix.suite }}
path: "**/target/surefire-reports/*.xml"
deploy:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
environment: production # required reviewers replace the input step
permissions:
contents: read
id-token: write # OIDC instead of a stored AWS key (Step 6)
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
with:
role-to-assume: arn:aws:iam::123456789012:role/payments-api-deploy
aws-region: eu-west-1
- run: ./deploy.sh
Two deliberate changes. The separate Build stage disappeared, because each matrix leg compiles anyway and jobs cannot see each other's target/ directory without artifacts. And the deploy job only runs on pushes to main, never on pull requests, which is what when { branch 'main' } meant in a multibranch job.
Step 6: Move Jenkins credentials to GitHub secrets and OIDC
Work through the credential IDs from Step 1. Secret text becomes one secret; a username and password (which Jenkins' credentials() helper exposes as _USR and _PSW) becomes two; SSH keys and small files go in as-is, up to the 48 KB secret limit. Secrets used by many repositories belong at organisation level, scoped to named repos:
# Repository secret, value read from a file so it never hits shell history
gh secret set NEXUS_PASSWORD --repo my-org/payments-api < nexus-password.txt
# Organisation secret visible to selected repositories only
gh secret set NEXUS_USERNAME --org my-org --repos payments-api,ledger-api --body "ci-deployer"
# Environment secret, only released to jobs that pass the environment's reviewers
gh secret set SLACK_WEBHOOK --repo my-org/payments-api --env production < webhook.txt
Long-lived cloud keys should not be migrated at all. Replace them with OIDC: the workflow above requests a token with id-token: write, and the IAM role trusts GitHub's issuer only for this repository's production environment (the AWS walkthrough covers creating the provider):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:my-org/payments-api:environment:production"
}
}
}]
}
When a job names an environment, the sub claim becomes repo:OWNER/REPO:environment:NAME, so a pull request build cannot assume the deploy role (set the environment's deployment branches to main as well). GCP and Azure follow the same pattern, which I covered in the OIDC guide for all three clouds. For the input replacement, required reviewers allow up to six people or teams and can block self-approval, but on Free, Pro and Team plans they work only in public repositories.
Step 7: Turn shared libraries into reusable workflows
A Jenkins shared library usually does two jobs: whole-pipeline templates (vars/mavenBuild.groovy called as mavenBuild()) and small helpers. In GitHub Actions the first becomes a reusable workflow triggered by workflow_call, the second a composite action. Put both in one repository, say my-org/ci-templates, and tag releases the way you tagged the library.
# my-org/ci-templates/.github/workflows/maven-build.yml
name: maven-build
on:
workflow_call:
inputs:
java-version:
type: string
default: "21"
goals:
type: string
default: verify
secrets:
NEXUS_USERNAME:
required: true
NEXUS_PASSWORD:
required: true
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-java@b843ebf9b913146b38da2eaa7aeddf76074ae1ad # v6.1.0
with:
distribution: temurin
java-version: ${{ inputs.java-version }}
cache: maven
- run: mvn -B -ntp $GOALS # inputs via env, never interpolated into the script
env:
GOALS: ${{ inputs.goals }}
NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
# Caller in each service repo: replaces @Library('ci-lib@v3') _ plus mavenBuild()
jobs:
build:
uses: my-org/ci-templates/.github/workflows/maven-build.yml@0123456789abcdef0123456789abcdef01234567 # v3.0.0
with:
goals: verify
secrets:
NEXUS_USERNAME: ${{ secrets.NEXUS_USERNAME }}
NEXUS_PASSWORD: ${{ secrets.NEXUS_PASSWORD }}
Replace the placeholder SHA with your tag's commit. Pass secrets explicitly rather than secrets: inherit: GitHub's 2026 security roadmap plans to stop implicit secret inheritance into reusable workflows, and explicit lists are easier to review anyway. Limits worth knowing: up to ten levels of nesting, 50 unique reusable workflows per workflow file, and caller env values are not passed through. If ci-templates is private, open its Settings, Actions, General, and under Access choose "Accessible from repositories in the organization".
Step 8: Run self-hosted runners on Kubernetes with ARC
Keep GitHub-hosted runners for anything that does not need your network. For builds that reach private registries, databases or special hardware, ARC replaces the Jenkins agent pool with ephemeral pods. Create a GitHub App on the organisation with Self-hosted runners set to read and write (Metadata read-only comes by default), install it, then:
# Controller, pinned to the chart version
helm install arc \
--namespace arc-systems --create-namespace \
--version 0.15.1 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
# GitHub App credentials for the runner scale set
kubectl create namespace arc-runners
kubectl create secret generic arc-github-app \
--namespace arc-runners \
--from-literal=github_app_id=123456 \
--from-literal=github_app_installation_id=654321 \
--from-file=github_app_private_key=./arc-app.private-key.pem
# runner-values.yaml for the gha-runner-scale-set chart 0.15.1
githubConfigUrl: "https://github.com/my-org"
githubConfigSecret: arc-github-app
runnerScaleSetName: "linux-build" # what workflows put in runs-on
runnerGroup: "default"
minRunners: 1 # one warm runner; 0 scales to nothing
maxRunners: 20 # start from P90 concurrency in the forecast
containerMode:
type: "dind" # Docker builds; runs a privileged dind sidecar
controllerServiceAccount: # explicit, so GitOps renders do not need cluster lookups
namespace: arc-systems
name: arc-gha-rs-controller
template:
spec:
containers:
- name: runner
image: ghcr.io/actions/actions-runner:2.338.2 # chart default is :latest
command: ["/home/runner/run.sh"]
helm install linux-build \
--namespace arc-runners \
--version 0.15.1 \
-f runner-values.yaml \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
helm list -A # expect arc (arc-systems) and linux-build (arc-runners)
kubectl get pods -n arc-systems # controller plus a linux-build-...-listener pod, Running
kubectl get pods -n arc-runners -w # runner pods appear when jobs are queued
Rendering this values file with helm template produces an AutoscalingRunnerSet whose pod has the runner container, a docker:dind sidecar with privileged: true, and DOCKER_HOST pointing at it. That privilege is the price of Docker-in-Docker; if your builds do not need Docker, drop containerMode and the sidecar goes with it. Workflows then use runs-on: linux-build, which is why the transformer in Step 4 maps the Jenkins label to that name. Keep runner images current: GitHub Enterprise Cloud stopped registering runners older than 2.329.0 on 29 September 2026.
Step 9: Migrate, run in parallel, then cut over
migrate does what dry-run did and opens a pull request in the target repository with the workflow and a checklist of manual tasks:
gh actions-importer migrate jenkins \
--source-url "$JENKINS_URL/job/payments/job/payments-api" \
--target-url https://github.com/my-org/payments-api \
--output-dir tmp/migrate \
--custom-transformers transformers.rb
# [..] Pull request: 'https://github.com/my-org/payments-api/pull/1'
Edit that pull request with your Step 5 changes, merge it, and let both systems build the same commits for a week or two. Compare test counts, artifact checksums and build times. When the Actions run is boringly green, disable the Jenkins job rather than deleting it, so history stays readable:
curl -s -X POST -u "$JENKINS_USER:$JENKINS_TOKEN" \
"$JENKINS_URL/job/payments/job/payments-api/disable"
Migrate in waves by folder, easiest first, and leave scripted pipelines and anything with exotic plugins for the end, when the team has the patterns down.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
Cannot connect to the Docker daemon from any importer command |
Docker is not running | Start Docker, check docker info, rerun |
gh actions-importer update fails with denied or unauthorized |
Not logged in to ghcr.io | docker login ghcr.io with a token that can read packages |
| Audit lists pipelines as Failed with 401 or 403 | JENKINS_USERNAME is not the token's owner, or the user lacks Job/Read |
Regenerate the token as the right user; rerun configure |
forecast times out or returns no jobs |
Paginated Builds plugin missing, or --start-date too recent |
Install the plugin; widen the date range |
| Pipeline marked Unsupported | Scripted pipeline | Convert by hand, or rewrite as declarative first |
# This item has no matching transformer in output |
Unknown plugin step | Add a transform block and pass --custom-transformers |
| Test job cannot find the build output | Jobs do not share a workspace | upload-artifact and download-artifact, or merge the jobs |
Not authorized to perform sts:AssumeRoleWithWebIdentity |
Missing id-token: write, or sub does not match (environment vs branch) |
Add the permission; match the trust policy to the job's sub |
Caller fails with workflow was not found |
Private template repo not shared, or wrong ref | Enable organisation access on ci-templates; check the SHA |
| Jobs stuck on "Waiting for a runner to pick up this job" | runs-on differs from runnerScaleSetName, or the listener cannot authenticate |
Match the names; check the listener pod logs and the App's runner permission |
FAQ
Can GitHub Actions Importer convert scripted Jenkins pipelines?
No. GitHub's docs list scripted pipelines among the items to migrate manually, and the audit reports them as Unsupported. The importer reads declarative Jenkinsfiles, freestyle jobs and multibranch projects. Rewrite scripted pipelines by hand, or convert them to declarative first if the team knows Groovy better than YAML.
What replaces Jenkins shared libraries in GitHub Actions?
Reusable workflows (on: workflow_call) replace whole-pipeline templates, and composite actions replace step-level helpers. Keep them in one repository, tag releases, and have callers pin a commit SHA, exactly as you would pin a library version with @Library('lib@v3').
What replaces the Jenkins input step?
A job that targets a GitHub environment with required reviewers. Up to six people or teams can approve, and you can prevent self-review. On Free, Pro and Team plans this only works in public repositories, so private repos need GitHub Enterprise or a different approval mechanism.
Do I need self-hosted runners after leaving Jenkins?
Only for builds that need your private network, special hardware, or where the forecast shows hosted minutes would cost more than machines you already own. Standard Linux hosted runners cost $0.006 a minute and are a fresh VM per job, which is better isolation than a long-lived Jenkins agent.
The first thing I check on any Jenkins migration is the credentials list from Step 1, because that is where the real risk sits: it usually contains long-lived cloud keys that OIDC can retire on the way over. In production I keep GitHub-hosted runners as the default, give ARC only the jobs that genuinely need the private network, pin every action and the runner image by version, and treat the ci-templates repository like a library with its own reviews and releases. The official references are the GitHub Actions Importer docs for Jenkins, the manual Jenkins migration guide, the ARC quickstart and ARC authentication pages, and the ARC releases.
Related: How to Use GitHub Actions OIDC With AWS, GCP and Azure (2026), GitHub Actions Security in 2026: 76 Hijacked Tags and the Fixes That Work and How to Speed Up GitHub Actions CI: Caching, Sharding, Docker (2026).
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Set Up Argo CD for GitOps: Step-by-Step Guide (2026)
Install Argo CD 3.5.5 with Helm chart 10.10.5, structure a GitOps repo, bootstrap with app-of-apps and ApplicationSet, configure sync policies, waves and hooks, add SSO and RBAC, deploy Helm and Kustomize apps, and fix OutOfSync, Degraded and ComparisonError.
14 minOct 20, 2026How to Set Up Backstage as an Internal Developer Portal (2026)
Step-by-step Backstage 1.55 setup for 2026: create-app on Node 22 or 24, PostgreSQL, GitHub sign-in, catalog-info.yaml, software templates, TechDocs, a Kubernetes deploy with the official Helm chart, alternatives, and fixes for common errors.
14 minOct 20, 2026How to Do Canary Deployments with Argo Rollouts 1.10 (2026)
Step-by-step Argo Rollouts canary guide for 2026: install v1.10.1 with Helm and the Gateway API plugin v0.17.0, write a Rollout with weighted steps, add a Prometheus AnalysisTemplate, watch automated rollback, and fix the common errors.
14 min