GitLab CI to GitHub Actions Migration: The Enterprise Guide
)
Enterprise scale migrations are tough - they don't need to be
CodeCargo Team
September 25, 2026
September 25, 2026
What is a GitLab CI to GitHub Actions migration?
A GitLab CI to GitHub Actions migration is the process of moving pipeline definitions out of
.gitlab-ci.yml files and into GitHub Actions workflows under .github/workflows/, so that building, testing and deploying happens on GitHub alongside the code. In an enterprise the migration is rarely one file. It is hundreds of pipelines, a runner fleet, a set of CI/CD variables that nobody has audited in three years, and a delivery schedule that cannot stop while the work happens.The good news is that GitLab CI and GitHub Actions are conceptually close. Both are YAML, both run jobs on runners, both build a dependency graph between jobs, both support service containers and matrices. Most of the translation is mechanical. The work that is not mechanical is the part worth planning for, and it is mostly about runners, secrets and the handful of GitLab features that have no one-to-one equivalent.
When should you move from GitLab CI to GitHub Actions?
The strongest reason is consolidation. If your code already lives on GitHub, or is moving there, running CI somewhere else means two permission models, two audit trails, two sets of runners to patch, and a pull request that cannot tell you whether it is safe to merge without leaving the page. Teams also move to reach the GitHub Actions ecosystem, to use GitHub's own security and compliance surface around the same repositories, and to stop paying to operate a second platform.
The weakest reason is that GitLab CI is bad, because it is not. It is a capable system with a genuinely elegant single-file model. Migrations that are sold internally as an escape from a broken tool tend to stall when engineers discover the old tool worked fine. Migrations sold as consolidation onto one platform survive contact with reality better.
GitLab CI vs GitHub Actions: what actually changes?
The mapping below covers the keywords that appear in almost every real pipeline and will help you develop a mental map of how a migration might work.
GitLab CI | GitHub Actions | What to watch |
|---|---|---|
script: | run: | Direct swap. GitLab runs scripts through a shell per job; Actions runs each run: step in its own shell invocation |
stages: plus stage: | needs: | Actions has no global stage list. Ordering is expressed per job as an explicit dependency graph, which is more verbose and more precise |
image: | container: | Per-job container. Many pipelines do not need it at all once they use runs-on plus a setup action |
services: | services: | Same concept and nearly the same shape |
variables: | env: | Scope carefully: workflow, job and step level all exist |
rules:, only:, except: | on: plus if: | Trigger conditions move up to on:, per-job conditions become if: expressions. This is the row that takes the most thought |
artifacts: | actions/upload-artifact | Artifacts become an explicit upload step, and a matching download step in the consuming job |
cache: | actions/cache | Explicit key and path, rather than a cache block on the job |
parallel: matrix: | strategy: matrix: | Close equivalent, including include and exclude style refinements |
needs: | needs: | Same name, same idea |
allow_failure: true | continue-on-error: true | Same behaviour |
timeout: | timeout-minutes: | Job level in both |
when: manual | workflow_dispatch, or an environment with required reviewers | A manual gate becomes either a separately triggered workflow or a deployment approval |
environment: | environment: | Both support named environments; GitHub attaches protection rules and secrets to them |
include:, extends: | Reusable workflows ( workflow_call) and composite actions | The most important row for a large estate, see below |
Runner tags | runs-on: labels | Your self-hosted fleet is re-registered against GitHub and addressed by label |
CI_COMMIT_SHA | github.sha | Every predefined variable has an equivalent in the github context, but the names all change |
Two rows deserve expanding.
include: and extends: are where the real estate savings are. A large GitLab estate usually has a shared templates repository that every .gitlab-ci.yml includes. The equivalent on GitHub is a reusable workflow called with workflow_call, or a composite action for a reusable block of steps. Porting the shared templates first, then pointing converted pipelines at them, is what keeps a migration from producing hundreds of copies of the same YAML. Converting pipeline by pipeline without doing this first is the single most common way a migration creates more maintenance than it removes.rules: is the row that hides the work. A mature GitLab pipeline often has rules that combine branch, file path, pipeline source and variable state. On GitHub, part of that expression belongs in on: (which events start the workflow, and with which branch or path filters) and part belongs in if: on the job. Splitting one rule set across two places is easy to get subtly wrong, and the failure mode is a job that quietly never runs.A worked example
A small but realistic GitLab pipeline:
stages: [build, test]
variables: NODE_ENV: test
build: stage: build image: node:20 script: - npm ci - npm run build artifacts: paths: [dist/]
test: stage: test image: node:20 needs: [build] script: - npm test rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event"CopyThe same thing as a GitHub Actions workflow:
name: build-and-test
on: pull_request: push: branches: [main]
env: NODE_ENV: test
jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' - run: npm ci - run: npm run build - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: dist path: dist/
test: runs-on: ubuntu-latest needs: [build] if: github.event_name == 'pull_request' steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: '20' - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: name: dist path: dist/ - run: npm testCopyNote what changed beyond the keywords. The implicit checkout became an explicit step. The implicit artifact hand-off between stages became an upload and a download. The
image: became a runner plus a setup action, which is usually faster. And the single rules: entry became a trigger in on: and a condition in if:.What has no clean equivalent?
- Predefined variables all change name. There is an equivalent for nearly everything in the
githubcontext, butCI_COMMIT_SHA,CI_COMMIT_REF_NAMEand friends will not resolve. Grep forCI_across the estate before you start; the count is usually higher than anyone expects. CI_JOB_TOKENis notGITHUB_TOKEN. Both are short-lived job credentials, but their permissions models differ, and anything that used the job token to reach another project needs deliberate redesign rather than a rename.- Auto DevOps has no counterpart. If any pipeline depends on it, that pipeline is a rewrite, not a conversion.
- Runner scale and tags. Moving the fleet is usually the longest pole, not the YAML. Plan capacity before cutover, not after the first slow week.
How do you migrate hundreds of pipelines without stopping delivery?
Pipeline-by-pipeline hand conversion does not finish. The pattern that does:
- Inventory first. Which pipelines have run successfully in the last 90 days, which have not run at all, and which have no named owner. A meaningful share of any large estate is dead, and converting dead pipelines is pure cost.
- Port the shared templates before the pipelines. Reusable workflows and composite actions first, so converted pipelines call them instead of duplicating them.
- Convert in batches, by similarity. Pipelines cluster into a handful of shapes. Convert one of each shape carefully, then apply it across the cluster.
- Run both for one cycle. The old pipeline and the new workflow on the same commits, comparing results, before anything is switched off.
- Make the new workflows compliant by construction. If the converted workflows land without pinned actions, least-privilege permissions or egress control, you have moved the problem rather than fixed it.
Where does CodeCargo fit?
CodeCargo is the control plane for GitHub, and migration is one of its core motions. Its Pipeline Migration Assistant imports from Jenkins, Azure DevOps and GitLab, including both GitLab cloud and self-managed instances, and produces CodeCargo workspaces ready for AI-assisted conversion into GitHub Actions. Converted jobs can be worked individually in an editor, or at scale with migration batches and the migration agent, which opens pull requests across many repositories for review rather than committing changes unattended.
The part that matters after the migration is what the workflows land into. CodeCargo scores every workflow against policy on each edit, surfaces violations inline on the pull request, detects drift, and opens auto-remediation pull requests. CargoWall applies a kernel-level egress firewall to the runners so a converted pipeline cannot quietly reach destinations it should not. A migration that ends with thousands of ungoverned workflows has traded one problem for a larger one.
Key takeaways
- The translation is mostly mechanical.
scripttorun,imagetocontainer,rulestoonplusif,artifactsto explicit upload and download steps. - Stages become an explicit graph. GitHub Actions has no global stage list; ordering is expressed per job with
needs. - Port shared templates first.
include:andextends:become reusable workflows and composite actions, and doing this first is what stops the estate duplicating itself. rules:splits in two. Part becomes a trigger inon:, part becomes anif:on the job, and getting it wrong produces jobs that silently never run.- Every
CI_variable changes name. Grep the estate before you begin. - The runner fleet is usually the long pole, not the YAML.
Key terms glossary
.gitlab-ci.ymlThe single file defining a GitLab CI pipeline for a project.- Stage A named ordering group in GitLab CI. Jobs in one stage run in parallel, and the next stage waits.
- Reusable workflow A GitHub Actions workflow another workflow can call with
workflow_call, the closest equivalent to a GitLabinclude:. - Composite action A packaged sequence of steps reusable inside a job, the closest equivalent to a shared
extends:block of steps. - Service container A supporting container, such as a database, available to a job. Both platforms call it
services. - Matrix Running one job many times across a set of parameter combinations.
- Pipeline Migration Assistant CodeCargo's importer, which brings Jenkins, Azure DevOps and GitLab pipelines into workspaces for AI-assisted conversion to GitHub Actions.
FAQ
How long does a GitLab CI to GitHub Actions migration take? For a single straightforward pipeline, under a day. For an enterprise estate, the timeline is driven by pipeline count, how much shared template logic exists, and how quickly the runner fleet can be stood up on GitHub, not by the YAML translation itself. The decisive variables are the number of distinct pipeline shapes and the number of owners you need agreement from, which is why an inventory pass comes first.
Can GitLab CI and GitHub Actions run side by side during a migration? Yes, and for anything larger than a handful of pipelines you should plan on it. Running the converted workflow next to the existing pipeline on the same commits for one delivery cycle is the cheapest way to find behavioural differences before you depend on the new one.
What is the GitHub Actions equivalent of GitLab stages? There is no global stage list. Each job declares the jobs it depends on with
needs:, which builds an explicit dependency graph. It is more verbose than stages and more precise, because two jobs that never truly depended on each other no longer wait for each other.How do GitLab CI/CD variables map to GitHub? Non-sensitive values become
env: at workflow, job or step level. Masked and protected variables become GitHub secrets, and secrets scoped to a deployment target belong on a GitHub environment so they inherit its protection rules. The predefined CI_ variables do not carry over by name and must be replaced with their github context equivalents.Does GitHub Actions support manual approval like
when: manual? The equivalent depends on intent. A job someone triggers on demand becomes a workflow with a workflow_dispatch trigger. A gate before a deployment becomes a GitHub environment with required reviewers, which is the stronger control because the approval is recorded against the deployment.What happens to our GitLab runners? Self-hosted runners are re-registered with GitHub and addressed by label in
runs-on: instead of by GitLab tag. Capacity planning is the part teams underestimate: a migration that lands hundreds of workflows on an under-provisioned fleet looks like a performance regression in the new platform when it is really a queueing problem.Can the conversion be automated? Substantially, yes. CodeCargo's Pipeline Migration Assistant imports GitLab pipelines, from cloud or self-managed instances, and produces workspaces for AI-assisted conversion, with migration batches opening pull requests across many repositories at once. Human review still matters, because the parts that need judgement, such as splitting
rules: across triggers and conditions, are exactly the parts a converter cannot decide for you.Should we convert every pipeline? No. Most large estates contain pipelines with no successful run in months, pipelines that never succeeded, and pipelines with no owner. Excluding those from scope is the cheapest decision available in the whole project.
C
CodeCargo Team
The CodeCargo team writes about GitHub workflow automation, developer productivity, and DevOps best practices.