GitLab CI vs GitHub Actions

A comparison for people who know GitHub Actions deeply.


The Config File

GitHub ActionsGitLab CI
File location.github/workflows/*.yml (one file per workflow).gitlab-ci.yml (one file, always at root)
Multiple workflowsYes — separate filesNo — everything in one file
Triggeron: blockworkflow: block (or implicit)

In GitHub Actions you split things into multiple workflow files. In GitLab everything lives in one .gitlab-ci.yml, which can get large but keeps things in one place.


Equivalent Concepts

GitHub ActionsGitLab CINotes
WorkflowPipelineThe top-level thing that runs when you push
JobJobA unit of work that runs on a runner
StepA line under script:GHA steps are named objects; GitLab is just shell commands
needs:stages:Both control execution order (see below)
uses: actions/checkout@v4automaticGitLab always checks out the repo automatically
env:variables:Environment variables for jobs
secrets.MY_SECRETCI/CD variable (set in UI)Both are masked in logs
Self-hosted runnerSelf-hosted runnerSame concept, different registration process
if: conditionrules: - if:GitLab uses a custom expression syntax
Matrix buildsparallel: matrix:Similar concept, slightly different syntax
Composite actionNot directly equivalentGitLab has extends: for job templates
Reusable workflowtrigger: (bridge job)Closest equivalent — see below

Ordering Jobs: needs: vs stages:

GitHub Actions lets you express direct dependencies between jobs:

# GitHub Actions
jobs:
  test:
    runs-on: ubuntu-latest
    steps: [...]
 
  build:
    needs: test         # explicitly depends on test
    runs-on: ubuntu-latest
    steps: [...]
 
  deploy:
    needs: build        # explicitly depends on build
    runs-on: ubuntu-latest
    steps: [...]

GitLab CI uses stages — a simpler model where you group jobs into buckets that run sequentially:

# GitLab CI
stages:
  - test
  - build
  - deploy
 
run-tests:
  stage: test
 
build-image:
  stage: build     # all jobs in 'build' wait for all 'test' jobs to finish
 
deploy:
  stage: deploy    # waits for all 'build' jobs

GitLab also has needs: for direct job-to-job dependencies (bypassing stage ordering), but stages are the common pattern.

Key difference: in GitLab, if any job in stage N fails, all jobs in stage N+1 are skipped — they are never started, not just failed. This is different from GitHub where downstream jobs with needs: show as “cancelled.”


Triggers / Events

GitHub Actions:

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]

GitLab CI:

# workflow: controls whether a pipeline is created at all
workflow:
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
 
# Individual jobs can also have rules:
my-job:
  rules:
    - if: $CI_COMMIT_BRANCH == "main"

GitLab uses $CI_PIPELINE_SOURCE to distinguish how a pipeline was started:

ValueMeaning
pushSomeone pushed a commit
merge_request_eventOpened/updated a merge request (= GitHub’s pull_request)
pipelineTriggered by another project’s pipeline (cross-project)
webManually triggered in the UI
scheduleCron trigger
apiTriggered via API call

Cross-Project Triggers (the GitLab-specific thing)

GitHub doesn’t have a native equivalent of this. The closest is repository_dispatch (calling another repo’s workflow via API) or workflow_call (reusable workflows).

In GitLab, a bridge job uses trigger: instead of script: to create a pipeline in a different project:

# In repo A (.gitlab-ci.yml)
call-repo-b:
  trigger:
    project: my-org/repo-b    # create a pipeline here
    branch: main
    strategy: depend          # wait for it, fail if it fails
  variables:
    MY_VAR: $CI_COMMIT_SHA    # pass data to the downstream pipeline
# In repo B (.gitlab-ci.yml)
my-job:
  rules:
    - if: $MY_VAR != null     # only run when triggered from repo A
  script:
    - echo "triggered with $MY_VAR"

Authentication between repos: the Job Token

In GitHub, cross-repo access uses a GITHUB_TOKEN with explicit permissions (permissions: contents: read) or a PAT. Fine-grained control is done via the token’s scope.

In GitLab, every job gets a $CI_JOB_TOKEN. To use it to access another project (clone a repo, trigger a pipeline), that project must add you to its inbound job token allowlist — a list of which other projects’ CI jobs are allowed to use their token to access it.

# Project B must explicitly allow Project A's jobs to access it
# (set in GitLab UI: Settings → CI/CD → Job token permissions)
# or in Terraform:

resource "gitlab_project_job_token_scope" "a_to_b" {
  project           = gitlab_project.b.id   # B's allowlist
  target_project_id = gitlab_project.a.id   # A is allowed in
}

This is more granular than GitHub’s approach but requires explicit setup for every cross-repo relationship.


Variables: A Source of Confusion

In GitHub Actions, variables in env: are just environment variables — straightforward.

In GitLab, variables come from multiple sources with a precedence order:

  1. Predefined GitLab variables ($CI_COMMIT_SHA, etc.) — always available
  2. Project/group CI/CD variables (set in UI → Settings → CI/CD)
  3. Variables defined in .gitlab-ci.yml under variables:
  4. Variables passed when triggering a pipeline (pipeline-level variables)

The gotcha: GitLab projects have a setting restrict_user_defined_variables (on by default in new projects) that blocks pipeline-level variables (category 4) from being set unless the caller has Maintainer access. This breaks cross-project triggers that pass variables. Turn it off explicitly.


Merge Requests vs Pull Requests

These are the same concept. The GitLab name is merge request (MR).

One notable difference: in GitLab, an MR pipeline is a distinct pipeline source (merge_request_event), and you have to explicitly opt in to running pipelines on MRs vs branch pushes. It’s common to write rules like:

rules:
  - if: $CI_PIPELINE_SOURCE == "merge_request_event"
  - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

This says: “run on MRs, and also run on pushes to main.” Without the second line, merging to main wouldn’t trigger a pipeline.


Artifacts and Caching

GitHub ActionsGitLab CI
actions/upload-artifact / actions/download-artifactartifacts: block in job
actions/cachecache: block in job

GitLab artifacts are files produced by a job that can be passed to later jobs or downloaded from the UI. Caching speeds up jobs by persisting directories (like node_modules) between pipeline runs.

build:
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/          # passed to downstream jobs automatically
  cache:
    paths:
      - node_modules/  # persisted between pipeline runs

Quick Reference

GitHub Actions term      →  GitLab CI term
─────────────────────────────────────────
Workflow                 →  Pipeline
Job                      →  Job
Step                     →  Line in script:
on: pull_request         →  if: $CI_PIPELINE_SOURCE == "merge_request_event"
on: push (branch: main)  →  if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
needs:                   →  stages: (or needs: for DAG-style)
secrets.X                →  CI/CD variable (masked)
GITHUB_TOKEN             →  CI_JOB_TOKEN (but needs allowlist config)
repository_dispatch      →  trigger: (bridge job)
workflow_call            →  trigger: with strategy: depend
actions/checkout         →  automatic (always done)
runs-on: ubuntu-latest   →  tags: - shared (or specific runner tag)
if: always()             →  when: always
if: failure()            →  when: on_failure