Tutorial9 min read

How to Migrate from GitHub Actions to GitLab CI: A Practical Guide

GitHub Actions and GitLab CI look interchangeable until you actually convert one. This is the end-to-end migration playbook — what maps cleanly, the five things that will bite you, and how to test the new pipeline without breaking production.

Migrating a CI pipeline between providers sounds boring until you actually try it. The platforms model the same work — jobs that run on a runner, depend on each other, produce artifacts — but they disagree about almost every key, default, and reusable primitive. Hand-translating a GitHub Actions workflow to a .gitlab-ci.yml is two hours of sed, four hours of reading docs, and one hour of debugging that one weird thing about caching that nobody warned you about.

This guide walks through the migration end-to-end with a working example, with the parts you can automate clearly separated from the parts that require judgment.

Why GitHub Actions and GitLab CI Look Similar but Convert Poorly

Both platforms write CI configs in YAML. Both run jobs on Linux containers by default. Both let jobs depend on other jobs and emit artifacts. The vocabulary mostly maps cleanly:

GitHub Actions GitLab CI
runs-on: image: (the runner has tags, the container has the image)
needs: needs: plus a stages: block at the top
${{ secrets.NAME }} $NAME (variables, configured in CI/CD settings)
${{ github.ref_name }} $CI_COMMIT_REF_NAME
if: github.ref == 'refs/heads/main' rules: - if: '$CI_COMMIT_BRANCH == "main"'
actions/checkout@v4 (implicit — GitLab clones automatically)

That last row is the first thing that bites people. GitLab CI checks out your repository before the first script line; GitHub Actions does not. Every GitHub job starts with actions/checkout@v4 because nothing else has happened yet. Every GitLab job assumes the working directory already contains the repository. Forgetting to drop the checkout step on the GitLab side leaves you with a confusing error about a directory that already exists.

The bigger structural difference is stages. GitLab requires a top-level stages: list and assigns each job to a stage. Stages run sequentially; jobs within a stage run in parallel. GitHub Actions has no concept of stages — the DAG lives entirely in needs:. When you migrate, you either invent a flat stages: [test, deploy] list and assign jobs accordingly, or you skip stages entirely and rely on needs: like GitHub does (this is supported but unidiomatic on GitLab).

The Working Example

Here's a typical GitHub Actions workflow that runs lint and test on push, and deploys on a tag:

name: ci
on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - run: npm run lint

  test:
    runs-on: ubuntu-latest
    needs: [lint]
    strategy:
      matrix:
        node: [18, 20, 22]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node }}
          cache: npm
      - run: npm ci
      - run: npm test

  deploy:
    runs-on: ubuntu-latest
    needs: [test]
    if: startsWith(github.ref, 'refs/tags/v')
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci && npm run build
      - run: ./scripts/deploy.sh
        env:
          DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

And the equivalent .gitlab-ci.yml:

stages:
  - lint
  - test
  - deploy

variables:
  npm_config_cache: $CI_PROJECT_DIR/.npm

cache:
  key: $CI_COMMIT_REF_SLUG
  paths: [.npm/]

lint:
  stage: lint
  image: node:20
  script:
    - npm ci --cache .npm
    - npm run lint

test:
  stage: test
  image: node:$NODE
  parallel:
    matrix:
      - NODE: ["18", "20", "22"]
  script:
    - npm ci --cache .npm
    - npm test

deploy:
  stage: deploy
  image: node:20
  rules:
    - if: '$CI_COMMIT_TAG =~ /^v/'
  script:
    - npm ci --cache .npm
    - npm run build
    - ./scripts/deploy.sh

What changed:

  1. Three jobs became three stages. Every dependency now flows through stages: rather than needs:. (You could keep needs: if you want true DAG-style parallelism, but stages-only is more idiomatic on GitLab.)
  2. actions/setup-node@v4 disappeared. The container is node:20 — no install step needed.
  3. actions/cache@v4 became a job-level cache: block. This single change has the highest payoff per character of YAML and the highest debugging cost when you get it wrong.
  4. Matrix syntax changed shape. GitLab's parallel.matrix takes a list of objects, where each key contains an array of values. The matrix expands as the cartesian product, just like GitHub.
  5. Conditionals became rules:. GitHub if: becomes a single GitLab rule. The string is rewritten from GitHub expression syntax (startsWith(github.ref, 'refs/tags/v')) to GitLab DSL ($CI_COMMIT_TAG =~ /^v/).
  6. Secrets are bare environment variables. ${{ secrets.DEPLOY_TOKEN }} becomes $DEPLOY_TOKEN. You recreate the secret in the GitLab UI under Settings → CI/CD → Variables (mark it as Masked and Protected if appropriate).

The Five Things That Will Bite You

Even after you map the easy keys, five things break in subtle ways during a real migration.

1. Cache scoping

GitHub actions/cache@v4 caches by an explicit key:, with optional fallback restore-keys:. GitLab caches by key: too, but the default scope is per-pipeline-ref (i.e. per-branch). If your GitHub workflow uses a key like npm-${{ hashFiles('package-lock.json') }}, the GitLab equivalent is key: { files: [package-lock.json] } — but only on GitLab 14.4+. On older runners you fall back to a string key like npm-$CI_COMMIT_REF_SLUG, which scopes per-branch and recomputes on lockfile changes through cache-busting tricks. The shape is similar; the defaults are different.

2. Artifact retention defaults

GitHub Actions defaults to 90-day artifact retention; GitLab defaults to 30 days for artifacts.paths and "until pipeline succeeds" for artifacts.reports. If your release pipeline depends on artifacts surviving for months, set expire_in: 1 year (or expire_in: never for protected refs) on every job that produces release artifacts.

3. Reusable workflows have no equivalent

A GitHub uses: org/repo/.github/workflows/foo.yml@ref is not a GitLab include:. The closest equivalent is a hidden job template (.template-job:) used via extends:, plus include: to pull templates from another repo. The conversion is more rewrite than translate. If your codebase relies heavily on reusable workflows, expect the most manual work here.

4. actions/github-script has no GitLab analog

If your workflow runs inline JavaScript with the GitHub API client, there's no actions/github-script on GitLab. The two paths forward: rewrite the script to call gh api ... (works against any Git provider via the GitHub or GitLab CLI), or use @octokit/rest from a Node script committed to your repo.

5. Concurrency cancellation differs

concurrency.group with cancel-in-progress: true on GitHub maps to interruptible: true on GitLab — but the cancellation guarantees differ. GitHub cancels strictly on group match; GitLab cancels only when a newer pipeline supersedes an older one on the same ref. If you depend on cross-ref cancellation behaviour, plan to test it explicitly after migration.

How to Test Your Converted Pipeline Without Breaking Production

Don't replace your .github/workflows/*.yml with a GitLab CI file in one commit. The safe sequence:

  1. Convert in a branch. Run DevZone's CI/CD Configuration Converter on each workflow to get a starting point. Review the audit log; the confidence-annotated output tells you exactly which steps are lossy.
  2. Lint locally. GitLab's CI Lint endpoint (Settings → CI/CD → Pipeline Editor → Lint) catches structural errors before any runner picks up the pipeline. It accepts pasted YAML and runs the same validator the platform uses.
  3. Run on a throwaway branch. Push the new .gitlab-ci.yml to a migrate-ci/<feature> branch. GitLab will run it; failures show up in the pipeline view without affecting main.
  4. Compare runtimes. Once both pipelines pass, run them in parallel for a week. Compare runner cost, queue time, and actual wallclock. If GitLab is slower (it sometimes is, especially for matrix jobs), you'll spot it before you commit to switching.
  5. Cut over with a feature flag. Some teams keep both .github/workflows/*.yml and .gitlab-ci.yml for a transition period and only delete the GitHub workflows once they're confident. There's no harm in having both — GitHub mirrors and GitLab mirrors don't conflict.

What the Automated Converter Actually Saves You

The mechanical translation work — keys, syntax, expression rewriting, cache hoisting, artifact restructuring — is roughly 80% of the work and 20% of the difficulty. The remaining 20% (custom Actions, reusable workflows, weird matrix shapes, secrets) is 80% of the difficulty.

Use the converter for the mechanical 80%, then walk the audit log line-by-line to handle the manual 20%. The audit log is generated locally — your YAML never leaves the browser — and it explicitly lists every transformation the converter made, including the ones flagged for review.

The combination of converter + GitLab CI Lint + a throwaway branch usually gets a typical Node.js or Python pipeline migrated in an afternoon. Pipelines with heavy custom-Action dependencies take longer, but the audit log keeps the manual work bounded and traceable rather than a guess-and-check loop.

When NOT to Migrate

Two cases where staying on GitHub Actions makes more sense than migrating:

  • Heavy reliance on the GitHub Actions Marketplace. If your pipeline pulls in 15+ community Actions, the migration cost is high and the risk of subtle behavioural differences is real. Audit the Marketplace dependencies first; if more than 5 don't have GitLab analogs, factor that into the decision.
  • Tight integration with GitHub-only features. Required reviewers on environments, branch protection rules, the Checks API for status reporting, GitHub Apps that post comments based on workflow output — all of these are first-class on GitHub and partially or fully missing on GitLab. If your workflow depends on them, the migration is a feature regression, not a CI swap.

For most teams, the migration is a 1-2 week project that pays back in plan cost or co-location with the rest of the GitLab toolchain (issues, MRs, container registry). The converter compresses week one into an afternoon. Spend the saved time on the audit log.

Try the tools