Skip to content
compiler.dev

Migrate GitLab CI to GitHub Actions: Step by Step

Last updated

Migrating GitLab CI to GitHub Actions is a fairly direct translation. Stages become needs, rules become on plus if, cache and artifacts map to actions/cache and upload-artifact, and services map to services. The sticking points are include and extends (use reusable workflows and composite actions), CI/CD variables (secrets do not export), and GitLab-only features such as the built-in container registry and Review Apps.

Related pages: GitLab CI vs GitHub Actions and GitLab CI alternatives.

Concept mapping

GitLab CIGitHub Actions
.gitlab-ci.yml.github/workflows/*.yml
stages:Job ordering with needs:
job:jobs.<id>
image:container: on the job, or use setup-* actions
services:services:
script:steps: - run:
before_script: / after_script:Earlier steps, or steps with if: always()
variables:env:
Project / group CI/CD variablesRepository, organisation or environment secrets and variables
rules: / only / excepton: triggers and if: expressions
needs: (DAG)needs:
parallel: matrixstrategy.matrix
cache: with key:filesactions/cache with hashFiles()
artifacts:actions/upload-artifact
artifacts: reports: junitTest reporter action or upload
dependencies: (artifact passing)actions/download-artifact
extends: / include: / templatesComposite actions, reusable workflows (workflow_call)
environment:environment:
when: manualEnvironment required reviewers, or workflow_dispatch
schedule pipelineson.schedule; see cron schedules
GitLab Runner (tags)Runner labels
Container registryGitHub Packages (ghcr.io) or another registry
$CI_COMMIT_SHA, $CI_COMMIT_BRANCH${{ github.sha }}, ${{ github.ref_name }}

A realistic pipeline, side by side

GitLab:

# .gitlab-ci.yml
stages: [build, test, deploy]

default:
  image: python:3.13
  cache:
    key:
      files: [poetry.lock]
    paths: [.cache/pip]

variables:
  PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip"

build:
  stage: build
  script:
    - pip install poetry
    - poetry build
  artifacts:
    paths: [dist/]
    expire_in: 1 week

test:
  stage: test
  parallel:
    matrix:
      - PYVER: ["3.12", "3.13"]
  image: python:$PYVER
  services:
    - postgres:16
  variables:
    POSTGRES_PASSWORD: postgres
    DATABASE_URL: postgres://postgres:postgres@postgres:5432/postgres
  script:
    - pip install -r requirements.txt
    - pytest --junitxml=report.xml
  artifacts:
    when: always
    reports:
      junit: report.xml

deploy:
  stage: deploy
  script: ./scripts/deploy.sh
  environment: production
  rules:
    - if: $CI_COMMIT_BRANCH == "main"
  needs: [test]

GitHub Actions:

# .github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]
  pull_request:

jobs:
  build:
    runs-on: ubuntu-latest        # or [self-hosted, Linux, compiler-ci]
    steps:
      - uses: actions/checkout@v6
      - run: pipx install poetry
      - uses: actions/setup-python@v7
        with:
          python-version: "3.13"
          cache: poetry
      - run: poetry build
      - uses: actions/upload-artifact@v4
        with:
          name: dist
          path: dist/
          retention-days: 7

  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        python: ["3.12", "3.13"]
    services:
      postgres:
        image: postgres:16
        env:
          POSTGRES_PASSWORD: postgres
        ports: ["5432:5432"]
        options: >-
          --health-cmd "pg_isready -U postgres"
          --health-interval 5s --health-retries 10
    env:
      DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
    steps:
      - uses: actions/checkout@v6
      - uses: actions/setup-python@v7
        with:
          python-version: ${{ matrix.python }}
          cache: pip
      - run: pip install -r requirements.txt
      - run: pytest --junitxml=report.xml
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: junit-${{ matrix.python }}
          path: report.xml

  deploy:
    needs: [build, test]
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v6
      - run: ./scripts/deploy.sh

What to notice:

  • GitLab runs each job in image:. In Actions the job runs on the runner VM, and you install the language with setup-*. Use container: when you need a specific image.
  • Services: in GitLab the service host is the image name (postgres). In Actions with a job on the runner, it is localhost with a mapped port; with container: it is the service key. Add a health check.
  • cache: key: files: [poetry.lock] becomes cache: poetry in setup-python, which hashes poetry.lock. See Poetry caching, pip caching and the general caching guide.
  • rules: if: $CI_COMMIT_BRANCH == "main" becomes the on trigger plus an if.
  • artifacts: reports: junit has no first-class equivalent; upload the file or use a JUnit reporter action.

Variables, secrets and environments

GitLab variables marked masked or protected become GitHub secrets. Protected variables, available only on protected branches, map to environment secrets with branch protection rules on the environment. You must copy values manually; there is no export of secret values. File-type variables become secrets you write to a file in a step. Predefined CI_ variables map to GitHub contexts (github., runner.*).

For cloud credentials, OIDC replaces stored keys: GitHub Actions OIDC with AWS. More hardening is in securing GitHub Actions.

Include, extends and templates

GitLab pipelines often share YAML through include: and extends:. Pick the Actions mechanism by size:

  • A shared sequence of steps: a composite action in a shared repo.
  • A whole shared job or pipeline: a reusable workflow with on: workflow_call and inputs/secrets.
  • Templates with many overrides: reusable workflows with inputs, or a generated YAML approach.

Reusable workflows cannot do everything extends can (for example, deep merge of arbitrary keys), so expect to restructure rather than translate.

What GitLab bundles that GitHub does not

  • Container registry: use GitHub Packages (ghcr.io) or an external registry.
  • Review Apps and environments dashboard: GitHub Environments and deployments cover part of it; preview environments need your own setup.
  • Built-in security scanning templates: GitHub has code scanning and Dependabot; see Dependabot setup.
  • Merge trains: GitHub's equivalent is the merge queue.
  • Child pipelines and dynamic pipelines: use workflow calls and matrix strategies.

Automated conversion: GitHub Actions Importer

GitHub's Importer is a gh extension. Per GitHub's docs (checked October 2026) it supports GitLab (cloud and self-hosted), Jenkins, CircleCI, Azure DevOps, Bamboo, Bitbucket Pipelines and Travis CI, and requires Docker and the GitHub CLI.

gh extension install github/gh-actions-importer
gh actions-importer configure
gh actions-importer audit gitlab --output-dir audit --namespace my-group

The documented commands are audit, forecast, dry-run and migrate. Run audit for a summary of what converts, forecast to estimate Actions minutes from your past GitLab pipeline history, then dry-run to write workflows locally. Check GitHub's Importer docs for the flags your GitLab setup needs. Treat the output as a draft: include/extends structures in particular need review.

Cutover plan

  1. Audit and forecast with the Importer.
  2. Port the main test pipeline and run it on a branch.
  3. Mirror for a while: keep GitLab CI running and compare results. If you keep the code on GitLab during this phase, note that Actions needs the repository on GitHub; plan the repository move as its own step.
  4. Move secrets, environments and deploy jobs. Test against staging.
  5. Update branch protection to require GitHub checks.
  6. Turn off GitLab CI, archive the old config.

Speed after the move

GitLab shared runners and GitHub's default 2 vCPU runners differ in size; compare before you judge. Larger GitHub runners cost more; see GitHub Actions pricing. compiler.dev runs the same workflows on 8 vCPU / 16 GB machines at $0.012 per minute through runs-on: [self-hosted, Linux, compiler-ci], with automatic fallback to your original runners; see pricing and faster runners.

FAQ

Do I have to move my repository from GitLab to GitHub?

GitHub Actions runs on repositories hosted on GitHub, so yes. Plan the code migration alongside the CI migration.

How do I replace GitLab include and extends?

Composite actions for shared steps, reusable workflows for shared jobs.

Do GitLab CI variables migrate automatically?

No. Secret values cannot be exported; re-enter them as GitHub secrets.

What replaces GitLab artifacts passing between stages?

upload-artifact in one job and download-artifact in the next, with needs.

Can I run both CI systems during the move?

Yes, on a mirrored or duplicated repo, and compare results before switching required checks.

Run this workflow on compiler.dev 200 free minutes

Start free