Who this migration is for
This migration usually comes up when your current Jenkins setup has become the thing that slows releases down. The trigger is often a mix of fragile plugins, slow pipeline edits, aging agents, and the fact that your code already lives in GitHub and can run close to the repo in GitHub Actions.
When this migration is worth it, and when it is not
A Jenkins to GitHub Actions migration is usually worth doing when most pipelines are repo-centric, developers already work in pull requests, and the build logic can be expressed as shell commands, Docker commands, test commands, deploy scripts, or reusable workflow blocks.
It is a good fit when you want:
- Pull request checks defined in the same repository as the code.
- Less plugin maintenance and fewer controller upgrades.
- Ephemeral hosted runners for ordinary build and test jobs.
- Simpler secret scoping at the repository, environment, or organization level.
- Clearer ownership of CI changes through normal code review.
It may not be worth doing yet if your Jenkins estate depends heavily on custom Groovy shared libraries, niche plugins, long-running stateful jobs, air-gapped agents, or complex approval flows that already work well. GitHub Actions can handle many of these cases, but the migration becomes a platform project instead of a pipeline cleanup.
The main trade-off is control. Jenkins gives you a central controller and full freedom to customize the runtime. GitHub Actions gives you tighter GitHub integration, clean YAML workflows, and managed runners, but you need to be precise about permissions, runner isolation, secret scopes, and job names used by branch protection.
Prerequisites and pre-migration checklist
Before you write the first workflow, inventory what Jenkins is doing today. Do this per repository rather than at the controller level, because the migration unit is usually one repo and its release path.
- List every Jenkins job that builds, tests, scans, packages, or deploys the repository.
- Record triggers, including pull requests, pushes, tags, cron schedules, manual parameters, and upstream job triggers.
- Record agent requirements, including OS, CPU architecture, Docker access, private network access, and installed tools.
- List all credentials used by the Jenkinsfile, shared library, and deployment scripts.
- Record build artifacts, test reports, images, release assets, and retention needs.
- Check branch protection rules and required status checks in GitHub.
- Choose a pilot pipeline with real value but low blast radius.
- Decide whether each job should use GitHub-hosted runners or self-hosted runners.
Start with the Jenkinsfile and any shared pipeline code:
git clone git@github.com:ORG/REPO.git
cd REPO
grep -R -E "withCredentials|credentialsId|agent|parameters|post|archiveArtifacts|junit|stash|unstash" Jenkinsfile .jenkins vars 2>/dev/null || true
If Jenkins jobs are configured in the UI, export their configuration before you change anything:
curl -fsSLO https://jenkins.example.com/jnlpJars/jenkins-cli.jar
java -jar jenkins-cli.jar \
-s https://jenkins.example.com/ \
-auth "$JENKINS_USER:$JENKINS_TOKEN" \
get-job folder/job-name > jenkins-job-name.xml
If your jobs are managed through Job DSL, Jenkins Configuration as Code, or another Git-backed system, use that source instead of an XML export.
Step-by-step migration path
1. Create a migration branch and a minimal workflow
Create the workflow in a branch so Jenkins remains the source of truth while you test Actions in parallel.
git checkout -b migration/github-actions
mkdir -p .github/workflows
cat > .github/workflows/ci.yml <<'YAML'
name: ci
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Set up Node.js
if: ${{ hashFiles('package-lock.json') != '' }}
uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Install Node dependencies
if: ${{ hashFiles('package-lock.json') != '' }}
run: npm ci
- name: Run Node tests
if: ${{ hashFiles('package-lock.json') != '' }}
run: npm test
- name: Set up Java
if: ${{ hashFiles('pom.xml') != '' }}
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
- name: Run Maven tests
if: ${{ hashFiles('pom.xml') != '' }}
run: mvn -B test
YAML
git add .github/workflows/ci.yml
git commit -m "Add GitHub Actions CI workflow"
Replace the language setup with the versions your Jenkins agents use today. A migration is easier to debug when you change the CI platform first and upgrade runtimes later.
2. Map Jenkins concepts to GitHub Actions concepts
Do not try to translate every line of Groovy into YAML. Map the behavior first, then write the simplest workflow that produces the same result.
| Jenkins | GitHub Actions |
|---|---|
| Jenkinsfile | Workflow file in .github/workflows/*.yml |
| Stage | Job or step, depending on isolation needs |
| Agent label | runs-on label |
| Credentials binding | Repository, environment, or organization secrets |
| Build parameters | workflow_dispatch inputs |
post block |
Steps with if: always() |
archiveArtifacts |
actions/upload-artifact |
stash and unstash |
Artifacts, cache, or job outputs |
| Shared library | Reusable workflow, composite action, or checked-in script |
3. Port one Jenkins pipeline path at a time
Start with build and test. Leave deployment in Jenkins until the new workflow is stable.
For example, this Jenkinsfile builds a Node app, creates a Docker image, and archives reports:
pipeline {
agent { label 'linux-docker' }
environment {
REGISTRY = 'ghcr.io/acme/app'
}
stages {
stage('Test') {
steps {
sh 'npm ci'
sh 'npm test'
}
}
stage('Build image') {
steps {
sh 'docker build -t $REGISTRY:$GIT_COMMIT .'
}
}
}
post {
always {
junit 'reports/*.xml'
archiveArtifacts artifacts: 'dist/**', fingerprint: true
}
}
}
The equivalent GitHub Actions workflow can keep the same test and image tag behavior:
name: build
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
packages: write
jobs:
test:
name: test
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- run: npm ci
- run: npm test
- name: Upload test reports
if: always()
uses: actions/upload-artifact@v4
with:
name: junit-reports
path: reports/*.xml
if-no-files-found: ignore
- name: Upload build output
if: always()
uses: actions/upload-artifact@v4
with:
name: dist
path: dist/**
if-no-files-found: ignore
image:
name: image
runs-on: ubuntu-latest
needs: test
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and optionally push image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ghcr.io/acme/app:${{ github.sha }}
Keep pull request behavior safe. The example builds images on pull requests but only pushes images on trusted non-PR events.
4. Move secrets and variables intentionally
Do not copy every Jenkins credential into repository secrets by default. Use repository secrets for repo-specific values, environment secrets for deployment targets, and organization secrets for values shared by many repositories.
gh auth login
gh secret set NPM_TOKEN --repo ORG/REPO < /secure/path/npm-token.txt
gh secret set PROD_KUBECONFIG --repo ORG/REPO --env production < /secure/path/prod-kubeconfig
gh variable set IMAGE_NAME --repo ORG/REPO --body ghcr.io/acme/app
gh secret list --repo ORG/REPO
gh secret list --repo ORG/REPO --env production
gh variable list --repo ORG/REPO
Prefer cloud identity federation over long-lived cloud keys when your cloud account supports it. For AWS, that usually means GitHub OIDC and a role with a trust policy limited to the repository, branch, and environment you intend to deploy from.
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
aws-region: us-east-1
5. Choose hosted or self-hosted runners per job
Use GitHub-hosted runners for normal build and test jobs unless you need private network access, special hardware, unusually large caches, or tools that are hard to install on each run. Use self-hosted runners for deployment into private networks or for workloads that require local infrastructure access.
If you add a repository-level self-hosted Linux runner, generate the registration token with the GitHub CLI and install the current runner release on the host:
sudo install -d -o "$USER" -g "$USER" /opt/actions-runner
cd /opt/actions-runner
RUNNER_URL="$(gh api /repos/actions/runner/releases/latest --jq '.assets[] | select(.name | test("linux-x64.*tar.gz$")) | .browser_download_url' | head -n 1)"
curl -fsSL "$RUNNER_URL" -o actions-runner-linux-x64.tar.gz
tar xzf actions-runner-linux-x64.tar.gz
RUNNER_TOKEN="$(gh api --method POST /repos/ORG/REPO/actions/runners/registration-token --jq .token)"
./config.sh \
--url https://github.com/ORG/REPO \
--token "$RUNNER_TOKEN" \
--labels linux,docker,private-network \
--unattended
sudo ./svc.sh install
sudo ./svc.sh start
Do not run untrusted pull request code on a self-hosted runner that can reach production systems, internal networks, or cloud metadata services. Keep pull request jobs on hosted runners, and reserve private runners for trusted branches, tags, or protected environments.
6. Migrate deployment after build parity is proven
Once build and test match Jenkins for several commits, add deployment. Use GitHub environments for production approvals and environment-scoped secrets.
name: deploy
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
name: deploy-production
runs-on: ubuntu-latest
environment: production
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- name: Configure kubeconfig
run: |
mkdir -p "$HOME/.kube"
printf '%s' "$KUBECONFIG_DATA" > "$HOME/.kube/config"
chmod 600 "$HOME/.kube/config"
env:
KUBECONFIG_DATA: ${{ secrets.PROD_KUBECONFIG }}
- name: Deploy image
run: |
kubectl version --client
kubectl -n app set image deployment/app app=ghcr.io/acme/app:${{ github.sha }}
kubectl -n app rollout status deployment/app --timeout=5m
If your cluster can pull only from a private registry, make sure the cluster has the correct image pull secret before you switch deployment. The workflow credential that pushes an image and the Kubernetes credential that pulls an image are usually different credentials.
7. Run both systems in parallel, then cut over
Run Jenkins and GitHub Actions on the same commits until the outputs match. Compare duration, test results, artifacts, images, deployment behavior, and failure notifications.
gh workflow list --repo ORG/REPO
gh workflow run ci.yml --repo ORG/REPO --ref migration/github-actions
RUN_ID="$(gh run list --repo ORG/REPO --workflow ci.yml --branch migration/github-actions --limit 1 --json databaseId --jq '.[0].databaseId')"
gh run watch "$RUN_ID" --repo ORG/REPO
gh run view "$RUN_ID" --repo ORG/REPO --log
When you are ready to cut over, update branch protection to require the new check name. If branch protection is managed with Terraform, update the existing resource rather than creating a second rule.
resource "github_branch_protection" "main" {
repository_id = github_repository.app.node_id
pattern = "main"
required_status_checks {
strict = true
contexts = ["ci / test"]
}
}
After branch protection uses the Actions check, disable the Jenkins job or remove the Jenkins webhook. Keep the job definition and credentials intact until rollback is no longer needed.
java -jar jenkins-cli.jar \
-s https://jenkins.example.com/ \
-auth "$JENKINS_USER:$JENKINS_TOKEN" \
disable-job folder/job-name
gh api repos/ORG/REPO/hooks --jq '.[] | select(.config.url | contains("jenkins")) | .id'
Delete the webhook only after you confirm the hook ID belongs to the Jenkins integration you intend to remove.
Common failure modes and how to avoid them
Required checks stop merges
GitHub branch protection depends on exact check names. If you rename the workflow or job, the required check name can change. Keep stable job names such as ci / test, and update branch protection during the cutover.
Secrets are in the wrong scope
A secret stored at the repository level is not the same as a secret stored in a GitHub environment. If a job uses environment: production, check both the repository and environment secret lists. GitHub does not show secret values, so validate by running a harmless command that confirms the expected credential can authenticate.
The workflow has too much or too little permission
Set permissions explicitly. Start with contents: read, then add permissions only when a job needs them, such as packages: write for pushing to GitHub Container Registry or id-token: write for OIDC.
Self-hosted runners expose internal systems
A self-hosted runner can run arbitrary workflow code if you allow the wrong trigger. Do not combine untrusted pull request code with a runner that has production network access. Use hosted runners for PR validation and protected environments for deployment.
Jenkins workspace assumptions break the build
GitHub-hosted runners are ephemeral. Anything that relied on a warm Jenkins workspace, installed global tools, or files left by a previous build must move into the workflow, a Docker image, a cache, or an artifact.
Artifacts and test reports disappear
Jenkins plugins often archive files without much visible configuration. In Actions, upload artifacts explicitly and use if: always() for reports that should be captured after failed test runs.
Triggers behave differently
Jenkins multibranch jobs and GitHub Actions events do not map perfectly. Check branch filters, tag filters, schedules, and manual inputs. Be especially careful with release workflows that should run only on tags.
Rollback plan
A safe rollback plan keeps Jenkins available until GitHub Actions has handled normal development, at least one release path, and one failed build recovery.
- Keep the Jenkinsfile and Jenkins job definitions in place during the parallel run.
- Do not delete Jenkins credentials during the first cutover.
- Record the previous branch protection required checks before changing them.
- Keep the GitHub Actions workflow in a single revertable commit or pull request when practical.
- Test re-enabling the Jenkins job before the first production deployment through Actions.
If you need to roll back CI, disable the workflow and re-enable Jenkins:
gh workflow disable ci.yml --repo ORG/REPO
java -jar jenkins-cli.jar \
-s https://jenkins.example.com/ \
-auth "$JENKINS_USER:$JENKINS_TOKEN" \
enable-job folder/job-name
If you need to roll back a workflow change in Git:
git checkout main
git pull
git revert WORKFLOW_COMMIT_SHA
git push origin main
If deployment fails after cutover, roll back the application the same way you did before the migration. For Kubernetes, that may be a rollout undo:
kubectl -n app rollout undo deployment/app
kubectl -n app rollout status deployment/app --timeout=5m
How to validate the migration succeeded
The migration is done when GitHub Actions can replace Jenkins for the repository without hidden manual steps. Use evidence rather than a feeling that the new workflow is cleaner.
- Pull requests show the expected required checks, and merges are blocked on failures.
- Build duration and queue time are acceptable compared with Jenkins.
- Artifacts, test reports, images, and release outputs are present in the expected locations.
- Deployment uses protected environments, scoped secrets, and a clear approval path where needed.
- Self-hosted runners are patched, monitored, and isolated from untrusted PR code.
- Failure notifications reach the same team that owned Jenkins failures.
- The rollback path has been tested at least once in a non-emergency situation.
Useful validation commands include:
gh run list \
--repo ORG/REPO \
--workflow ci.yml \
--limit 20 \
--json status,conclusion,createdAt,updatedAt,headSha
kubectl -n app rollout status deployment/app --timeout=5m
kubectl -n app get deploy app \
-o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
After one or two normal release cycles, remove the old Jenkins webhook, archive the job configuration, and rotate credentials that were copied during the migration. If several repositories share the same Jenkins patterns, turn the stable parts of the Actions workflow into reusable workflows or composite actions before migrating the rest.
Getting help with the migration
MeteorOps helps teams move Jenkins pipelines to GitHub Actions when the work needs senior DevOps and platform engineering hands, especially around runner design, secrets, deployment safety, and branch protection. If your Jenkins setup has years of shared library logic or production deployment edge cases, bring in help before the cutover rather than after a broken release.




