# Migrate GitLab CI to GitHub Actions, keyword by keyword

> Migrate GitLab CI to GitHub Actions: every keyword mapped, a .gitlab-ci.yml converted line by line, and five behaviors that change without an error.

Source: https://latchkey.dev/learn/migrate/gitlab-ci-to-github-actions  
Updated: 2026-11-12

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](/learn/runners/github-actions-vs-gitlab-ci-compared).

## 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.

```.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
```

> Pending runs and `queue: max` from [GitHub Docs: Control the concurrency of workflows and jobs](https://docs.github.com/en/actions/how-tos/write-workflows/choose-when-workflows-run/control-workflow-concurrency) 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.

```.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
```

## 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)](https://docs.github.com/en/actions/tutorials/migrate-to-github-actions/manual-migrations/migrate-from-gitlab-cicd)
- [GitHub Docs: Migrating from GitLab with GitHub Actions Importer, supported syntax and variable mapping](https://docs.github.com/en/actions/tutorials/migrate-to-github-actions/automated-migrations/gitlab-migration)
- [GitLab Docs: CI/CD YAML syntax reference](https://docs.gitlab.com/ci/yaml/)
- [GitHub Docs: Dependency caching reference, restrictions for accessing a cache](https://docs.github.com/en/actions/reference/workflows-and-actions/dependency-caching)
- [actions/runner issue 1182: Support YAML Anchors](https://github.com/actions/runner/issues/1182)

---

Latchkey runs CI/CD that repairs its own failures. Agent entry points: https://latchkey.dev/agent.txt, https://latchkey.dev/openapi.json, https://latchkey.dev/llms.txt
