Skip to content
Latchkey LogoLatchkey home

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.

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.

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.

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/CDGitHub ActionsWhat changes
stages: and stage:needs: on each jobA stage waits for the whole previous stage; needs: waits only for the jobs you list
needs:needs:None
script, before_scriptrun: stepsNone
after_scriptA 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 labelThe 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 / excepton: branch and path filters, or if:Already deprecated in GitLab in favor of rules
variables: and project variablesenv:, the vars context, secretsSecrets 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-artifactNothing downloads unless a step asks for it by name
artifacts: expire_in:retention-days:Days, not a duration string
artifacts: reports:No built-in equivalentRewrite with an action or drop
environment:environment:Protection rules live on the GitHub environment
resource_group:concurrency: with queue: maxWithout queue: max, a new pending deploy cancels the one already waiting
parallel: matrix:strategy: matrix:None
include:Reusable workflow or composite actionRewrite
extends:, !referenceNo keyword; YAML anchors & and * onlyRewrite; <<: merge keys are not supported

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.

.gitlab-ci.yml
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.

.github/workflows/ci.yml
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

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.
.github/workflows/ci.yml
# 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

Key takeaways

  • Stages become needs: lists; without them every job starts at once.
  • Artifacts and secrets no longer arrive on their own: download them and reference them by name.
  • github.ref is the full ref, so compare with refs/heads/main or use github.ref_name.
  • extends, include and <<: merge keys have no keyword equivalent: inline them or build reusable workflows.

Frequently asked questions

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

Swapping tags: for runs-on:? Make the label Latchkey and each job gets its own single-use VM. Start free → 30-day trial · No credit card