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:
- Three jobs became three stages. Every dependency now flows through
stages:rather thanneeds:. (You could keepneeds:if you want true DAG-style parallelism, but stages-only is more idiomatic on GitLab.) actions/setup-node@v4disappeared. The container isnode:20— no install step needed.actions/cache@v4became a job-levelcache:block. This single change has the highest payoff per character of YAML and the highest debugging cost when you get it wrong.- Matrix syntax changed shape. GitLab's
parallel.matrixtakes a list of objects, where each key contains an array of values. The matrix expands as the cartesian product, just like GitHub. - Conditionals became
rules:. GitHubif: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/). - 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:
- 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.
- 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.
- Run on a throwaway branch. Push the new
.gitlab-ci.ymlto amigrate-ci/<feature>branch. GitLab will run it; failures show up in the pipeline view without affectingmain. - 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.
- Cut over with a feature flag. Some teams keep both
.github/workflows/*.ymland.gitlab-ci.ymlfor 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.