DEV Community

Cover image for Migrate GitLab CI to GitHub Actions, keyword by keyword
Kay
Kay

Posted on Originally published at latchkey.dev

Migrate GitLab CI to GitHub Actions, keyword by keyword

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.

GitLab CI keywords mapped to GitHub Actions keys, each marked Maps, Changes or Rewrite
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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Pending runs and queue: max from GitHub Docs: Control the concurrency of workflows and jobs and the workflow syntax reference: the default queue: single cancels and replaces an existing pending run, max holds up to 100, and queue: max with cancel-in-progress: true is 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 a runs-on value. 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: becomes hashFiles(), fallback_keys becomes restore-keys, and key: $CI_COMMIT_REF_SLUG usually becomes nothing, since GitHub scopes by branch already.
  • Matrix. parallel: matrix: becomes strategy: matrix:; parallel: 5 becomes a matrix over a shard index.
  • Artifacts. Add a download step wherever a job read an earlier stage's files, and map expire_in to retention-days.
  • Concurrency. resource_group becomes a concurrency: group with queue: max, so waiting deploys line up instead of cancelling each other. interruptible becomes cancel-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
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)