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

> Migrate Jenkins to GitHub Actions directive by directive: a real Jenkinsfile converted, what never converts, and the order to move pipelines in.

Source: https://latchkey.dev/learn/migrate/jenkins-to-github-actions  
Updated: 2026-11-12

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](/learn/runners/github-actions-vs-jenkins-compared); 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 `post` to "None"; the `if:` status functions are the closest equivalent for `always`, `failure` and `success`. The step group is [`jobs.<job_id>.steps[*].parallel`](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsparallel), 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.

```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](/learn/migrate/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
```

## 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](/learn/runners/github-actions-vs-jenkins-compared).

### 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](/learn/migrate/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)](https://docs.github.com/en/actions/tutorials/migrate-to-github-actions/manual-migrations/migrate-from-jenkins)
- [GitHub Docs: Migrating from Jenkins with GitHub Actions Importer, limitations and supported syntax](https://docs.github.com/en/actions/tutorials/migrate-to-github-actions/automated-migrations/jenkins-migration)
- [Jenkins: Pipeline Syntax, including post conditions, credentials() and cron H](https://www.jenkins.io/doc/book/pipeline/syntax/)
- [Jenkins: Extending with Shared Libraries](https://www.jenkins.io/doc/book/pipeline/shared-libraries/)
- [GitHub Docs: Workflow syntax for GitHub Actions, including steps[*].parallel](https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax#jobsjob_idstepsparallel)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
