GitLab CI vs GitHub Actions
A comparison for people who know GitHub Actions deeply.
The Config File
| GitHub Actions | GitLab CI | |
|---|---|---|
| File location | .github/workflows/*.yml (one file per workflow) | .gitlab-ci.yml (one file, always at root) |
| Multiple workflows | Yes — separate files | No — everything in one file |
| Trigger | on: block | workflow: 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 Actions | GitLab CI | Notes |
|---|---|---|
| Workflow | Pipeline | The top-level thing that runs when you push |
| Job | Job | A unit of work that runs on a runner |
| Step | A line under script: | GHA steps are named objects; GitLab is just shell commands |
needs: | stages: | Both control execution order (see below) |
uses: actions/checkout@v4 | automatic | GitLab always checks out the repo automatically |
env: | variables: | Environment variables for jobs |
secrets.MY_SECRET | CI/CD variable (set in UI) | Both are masked in logs |
| Self-hosted runner | Self-hosted runner | Same concept, different registration process |
if: condition | rules: - if: | GitLab uses a custom expression syntax |
| Matrix builds | parallel: matrix: | Similar concept, slightly different syntax |
| Composite action | Not directly equivalent | GitLab has extends: for job templates |
| Reusable workflow | trigger: (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' jobsGitLab 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:
| Value | Meaning |
|---|---|
push | Someone pushed a commit |
merge_request_event | Opened/updated a merge request (= GitHub’s pull_request) |
pipeline | Triggered by another project’s pipeline (cross-project) |
web | Manually triggered in the UI |
schedule | Cron trigger |
api | Triggered 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:
- Predefined GitLab variables (
$CI_COMMIT_SHA, etc.) — always available - Project/group CI/CD variables (set in UI → Settings → CI/CD)
- Variables defined in
.gitlab-ci.ymlundervariables: - 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_BRANCHThis 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 Actions | GitLab CI |
|---|---|
actions/upload-artifact / actions/download-artifact | artifacts: block in job |
actions/cache | cache: 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 runsQuick 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