To migrate GitLab CI to GitHub Actions, turn each job into a job under jobs:, replace stages with explicit needs:, tags with runs-on, image with container, rules with if:, and cache and artifacts with actions/cache and the artifact actions. The keywords map quickly; the work is in five behaviors GitLab gave you implicitly, starting with artifacts that downloaded themselves and variables that reached every job.

Eighteen keywords and where each lands. Most amber rows are implicit GitLab behavior that GitHub Actions makes you write down.
Both systems write pipelines in YAML stored in the repository, so a first pass at a converted file looks finished early. It usually is not. A .gitlab-ci.yml leans on things GitLab does without being asked: later stages fetch earlier artifacts, project variables appear in every job, and extends merges templates at load time.
This page maps the keywords, converts a real pipeline with a template, a cache, a Postgres service and a gated deploy, and lists the behaviors that change silently. For the pricing side of the decision, see GitHub Actions vs GitLab CI.
The keyword map
GitHub's migration guide and its Importer reference cover most of this table. What they leave short is the behavior behind each rename, which is the last column here.
Two rows deserve a second look before you convert anything. include: is usually where a platform team keeps shared templates for every project, so it decides whether you build reusable workflows first. And extends: is often several levels deep; GitLab resolves the chain at load time, so translate from the pipeline editor's Full configuration tab, which shows include copied in and extends merged, rather than from the job as written.
| GitLab CI/CD | GitHub Actions | What changes |
|---|---|---|
stages: and stage:
|
needs: on each job |
A stage waits for the whole previous stage; needs: waits only for the jobs you list |
needs: |
needs: |
None |
script, before_script
|
run: steps |
None |
after_script |
A step with if: always()
|
None |
image: |
container: |
The job runs inside the image on the runner |
tags: |
runs-on: |
Labels select the runner, as tags did |
services: with alias:
|
services: keyed by label |
The label is the hostname only when the job runs in a container; otherwise use localhost and mapped ports |
rules: - if: |
if: |
Different variables: $CI_COMMIT_BRANCH is a branch name, github.ref is refs/heads/<branch>
|
only / except
|
on: branch and path filters, or if:
|
Already deprecated in GitLab in favor of rules
|
variables: and project variables |
env:, the vars context, secrets
|
Secrets reach a job only when the workflow references them |
cache: key: files: |
actions/cache with hashFiles()
|
Restorable from the current branch, the default branch and a pull request's base branch |
cache: fallback_keys: |
restore-keys: |
Prefix match, most recent first |
artifacts: paths: |
upload-artifact and download-artifact
|
Nothing downloads unless a step asks for it by name |
artifacts: expire_in: |
retention-days: |
Days, not a duration string |
artifacts: reports: |
No built-in equivalent | Rewrite with an action or drop |
environment: |
environment: |
Protection rules live on the GitHub environment |
resource_group: |
concurrency: with queue: max
|
Without queue: max, a new pending deploy cancels the one already waiting |
parallel: matrix: |
strategy: matrix: |
None |
include: |
Reusable workflow or composite action | Rewrite |
extends:, !reference
|
No keyword; YAML anchors & and * only |
Rewrite; <<: merge keys are not supported |
YAML anchors: GitHub's "Reusing workflow configurations" reference documents
&anchors and*aliases, and a GitHub engineer confirmed in actions/runner issue 1182 that merge keys are YAML 1.1 and not part of the YAML 1.2 support that shipped. GitLab documents<<:merging in "Optimize GitLab CI/CD configuration files". Read 2026-11-12.
Before: a .gitlab-ci.yml
A Node service in three stages. The cache is shared through an anchor with a merge key, the test job runs against Postgres reached by its alias, test receives dist/ from build without asking for it, and production deploys through a template with extends, a resource group and a branch rule.
stages: [build, test, deploy]
default:
image: node:22-bookworm
tags: [docker]
variables:
NODE_ENV: test
.npm-cache: &npm-cache
cache:
key:
files: [package-lock.json]
paths: [.npm/]
build:
stage: build
<<: *npm-cache
script:
- npm ci --cache .npm --prefer-offline
- npm run build
artifacts:
paths: [dist/]
expire_in: 1 week
test:
stage: test
<<: *npm-cache
services:
- name: postgres:16
alias: db
variables:
POSTGRES_PASSWORD: postgres
variables:
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
script:
- npm ci --cache .npm --prefer-offline
- npm test
.deploy:
stage: deploy
script: ./scripts/deploy.sh "$CI_ENVIRONMENT_NAME"
deploy-production:
extends: .deploy
environment: production
resource_group: production
rules:
- if: $CI_COMMIT_BRANCH == "main"
After: the converted workflow
The anchor and the template are gone: the cache step is written out in each job, and the .deploy template folded into the one job that used it. With several deploy targets, the template would become a reusable workflow instead.
Three lines exist only because GitLab used to do the work. download-artifact in test replaces the implicit artifact fetch. queue: max lets up to 100 deploys wait in the production group, the way jobs waited for a resource group. Leave it out and GitHub keeps one pending run: each new deploy cancels the one already waiting. And $CI_ENVIRONMENT_NAME became the literal name production.
The Postgres hostname still works as db because the job runs in a container. Drop container: and the service is on localhost, with a ports: mapping.
name: ci
on:
push:
branches: [main]
pull_request:
env:
NODE_ENV: test
jobs:
build:
runs-on: ubuntu-latest # was: tags: [docker]
container: node:22-bookworm
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
- run: npm ci --prefer-offline
- run: npm run build
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dist
path: dist/
retention-days: 7
test:
needs: build
runs-on: ubuntu-latest
container: node:22-bookworm
services:
db: # was: alias: db
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
env:
DATABASE_URL: postgres://postgres:postgres@db:5432/postgres
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.npm
key: npm-${{ hashFiles('package-lock.json') }}
- uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: dist
path: dist/
- run: npm ci --prefer-offline
- run: npm test
deploy-production:
needs: test
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
concurrency:
group: production
queue: max # was: resource_group: production
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- run: ./scripts/deploy.sh production
Pending runs and
queue: maxfrom GitHub Docs: Control the concurrency of workflows and jobs and the workflow syntax reference: the defaultqueue: singlecancels and replaces an existing pending run,maxholds up to 100, andqueue: maxwithcancel-in-progress: trueis a validation error. Read 2026-11-12.
Five behaviors that change without an error
Artifacts stop arriving. GitLab downloads every artifact from earlier stages into a job unless dependencies or needs says otherwise. Actions downloads nothing by default, so a converted test job fails on a missing file rather than on a config error.
Secrets stop arriving. GitLab project and group variables are present in every job's environment. GitHub's documentation states that Actions can only read a secret the workflow explicitly includes. A script that read $DEPLOY_TOKEN from the environment gets an empty string until you add it to env:.
Branch conditions compare different strings. $CI_COMMIT_BRANCH == "main" compares a branch name. The Importer maps CI_COMMIT_BRANCH to github.ref, which is refs/heads/main, so a literal comparison against main is false on every run; use github.ref_name or the full ref. GitHub's own guide uses contains(github.ref, 'master'), which also matches any branch with master in its name.
Caches are branch-scoped. A GitLab key built from files: is shared across branches. A GitHub cache restores only from the current branch, the default branch and, for a pull request, its base. A new branch starts from main's cache, not from a sibling's.
Stages stop being barriers. Without needs:, Actions jobs start together. A converted file that drops stage: without adding needs: runs deploy alongside build.
The migration checklist
Work through it per .gitlab-ci.yml, including every file pulled in with include:.
-
Runner labels. Every
tags:list needs aruns-onvalue. Self-managed GitLab runners become self-hosted runners you register, or a hosted label. - Secrets. Export the names of project, group and instance variables; masked values cannot be read back, so get them from their source. GitHub's Importer lists masked variable values as a manual task.
-
Cache keys.
key: files:becomeshashFiles(),fallback_keysbecomesrestore-keys, andkey: $CI_COMMIT_REF_SLUGusually becomes nothing, since GitHub scopes by branch already. -
Matrix.
parallel: matrix:becomesstrategy: matrix:;parallel: 5becomes a matrix over a shard index. -
Artifacts. Add a download step wherever a job read an earlier stage's files, and map
expire_intoretention-days. -
Concurrency.
resource_groupbecomes aconcurrency:group withqueue: max, so waiting deploys line up instead of cancelling each other.interruptiblebecomescancel-in-progress: true, which cancels a superseded pull request run.
# GitLab
# rules:
# - if: $CI_COMMIT_BRANCH == "main"
# - if: $CI_PIPELINE_SOURCE == "merge_request_event"
jobs:
test:
if: github.ref_name == 'main' || github.event_name == 'pull_request'
runs-on: ubuntu-latest
concurrency:
group: test-${{ github.ref }}
cancel-in-progress: true # was: interruptible: true
steps:
- run: npm test
FAQ
Is there a tool to convert .gitlab-ci.yml to GitHub Actions?
GitHub Actions Importer converts GitLab pipelines, cloud or self-managed, and merges include: files into one job graph before converting. It leaves masked variable values and artifact reports as manual tasks, and does not carry caching between jobs of different workflows. Its audit command needs a GitLab organization account.
What is the GitHub Actions equivalent of GitLab extends?
There is no keyword. YAML anchors with & and * work, but <<: merge keys do not, so you cannot override one key of an anchored job the way extends allows. Copy the shared keys into each job, or move the shared part into a reusable workflow or composite action.
How do GitLab CI variables map to GitHub Actions?
Variables in the YAML become env: at the workflow, job or step level. Non-secret project variables become configuration variables read through vars, and protected or masked ones become secrets referenced with ${{ secrets.NAME }}. Predefined variables map to contexts, for example CI_COMMIT_SHA to github.sha.
Does GitHub Actions have stages like GitLab?
No. Jobs run in parallel unless needs: makes one wait for others, which is the same model as GitLab's needs: without stages. To keep stage order, give each job a needs: list naming every job in the previous stage.
References
- GitHub Docs: Migrating from GitLab CI/CD to GitHub Actions (read 2026-11-12)
- GitHub Docs: Migrating from GitLab with GitHub Actions Importer, supported syntax and variable mapping
- GitLab Docs: CI/CD YAML syntax reference
- GitHub Docs: Dependency caching reference, restrictions for accessing a cache
- actions/runner issue 1182: Support YAML Anchors
Top comments (0)