Module 3: GitLab CI/CD and Headless SDK
GitLab CI/CD: Stages, Jobs, Artifacts
GitLab CI/CD: Stages, Jobs, Artifacts
Overview
This capsule teaches you the architectural model of GitLab CI/CD compared to GitHub Actions. It's not a basic GitLab tutorial — it's a comparative view focused on what matters for running Claude Code: pipeline structure, variables, artifacts, and the Docker executor that is the foundation of jobs.
If you copy a GitHub Actions workflow to GitLab by changing only the syntax, you'll have problems. The models are conceptually different, not just syntactically. Understanding these differences is the difference between a fragile pipeline and one that takes good advantage of the platform.
By the end, you'll be able to read a .gitlab-ci.yml with confidence, understand the stages/jobs/artifacts hierarchy, and design GitLab pipelines that leverage its strengths (not ones that merely "imitate" GitHub Actions).
The Fundamental Difference: Mental Models
GITHUB ACTIONS:
Workflow (1 .yml file)
└── Jobs (run on separate machines)
└── Steps (sequential actions in the job)
GITLAB CI/CD:
Pipeline (1 .gitlab-ci.yml file)
└── Stages (ordered phases: build, test, deploy)
└── Jobs (run in parallel within a stage)
└── Script (shell commands)
The key difference: GitLab has an explicit intermediate level — stages. In GitHub, "phases" are simulated with needs: between jobs.
Visual comparison
GITHUB ACTIONS GITLAB CI/CD
Workflow Pipeline
├── Job: lint Stage: build
│ └── needs: [] ├── Job: lint
├── Job: test └── Job: typecheck
│ └── needs: [lint]
├── Job: build Stage: test
│ └── needs: [test] ├── Job: unit
└── Job: deploy ├── Job: integration
└── needs: [build] └── Job: e2e
Stage: deploy
├── Job: deploy-staging
└── Job: deploy-prod
(manual gate)
In GitLab, jobs in the same stage run in parallel. Stages run in order. If a job in a stage fails, the following stages don't run.
Basic Structure of .gitlab-ci.yml
# Define the phases in order
stages:
- build
- test
- deploy
# Variables available in all jobs
variables:
PYTHON_VERSION: "3.11"
CLAUDE_MODEL: "claude-haiku-4-5"
# Base image for all jobs (override-able)
default:
image: python:3.11-slim
before_script:
- pip install --upgrade pip
# Jobs
lint:
stage: build
script:
- pip install ruff
- ruff check .
test:
stage: test
script:
- pip install pytest
- pytest
deploy:
stage: deploy
script:
- ./scripts/deploy.sh
only:
- main
Anatomy of a Job
job-name: # unique name of the job
stage: test # which stage it belongs to
image: python:3.11-slim # image override
variables: # job-specific variables
PYTEST_ARGS: "-v"
before_script: # commands before the main script
- pip install pytest
script: # the main commands (what the job does)
- pytest $PYTEST_ARGS
after_script: # cleanup, runs even if the script fails
- echo "Job done"
artifacts: # files to preserve
paths:
- test-results/
expire_in: 7 days
rules: # when this job runs
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Variables: GitLab vs GitHub
GitLab provides variables with the CI_ prefix (not GITHUB_):
| GitHub Actions | GitLab CI/CD | Meaning |
|---|---|---|
${{ github.repository }} | $CI_PROJECT_PATH | "owner/repo" |
${{ github.event.pull_request.number }} | $CI_MERGE_REQUEST_IID | MR/PR number |
${{ github.event.pull_request.head.sha }} | $CI_COMMIT_SHA | Commit SHA |
${{ github.event.pull_request.base.ref }} | $CI_MERGE_REQUEST_TARGET_BRANCH_NAME | Target branch |
${{ github.actor }} | $GITLAB_USER_LOGIN | Username |
${{ secrets.X }} | $X (CI/CD variable) | Secrets/variables |
Note: GitLab uses "Merge Request" (MR) instead of "Pull Request" (PR). Same concept, different name.
CI/CD Variables (equivalent to GitHub Secrets)
In GitLab: Settings → CI/CD → Variables
Each variable has important flags:
| Flag | Meaning |
|---|---|
| Protected | Only accessible on protected branches |
| Masked | Masked in logs (like a secret in GitHub) |
| Expanded | If you want it expanded like ${OTHER_VAR} |
Recommendation: API keys always with Protected: true and Masked: true. Only accessible from main/release branches.
Rules: GitLab's if
GitLab uses rules (not if like GitHub) to condition execution:
review-bot:
stage: review
script:
- python scripts/review.py
rules:
# Only on MR events, not on direct push
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: on_success
# If it's not an MR, skip
- when: never
Common rules
rules:
# Specific MRs
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# Push to main
- if: $CI_COMMIT_BRANCH == "main"
# Any push except to main
- if: $CI_COMMIT_BRANCH != "main"
# Only if a certain path changed
- changes:
- "src/**/*.py"
- "package.json"
# Manual (requires a click to run)
- if: $CI_COMMIT_BRANCH == "main"
when: manual
# Combinations
- if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "main"
Important difference from GitHub: the rules are evaluated in order. The first one that matches defines the behavior. That's why it's common to end with a - when: never as a fallback.
Artifacts: Passing Data Between Jobs
Artifacts in GitLab are files that a job preserves and the following stages can consume.
extract-diff:
stage: prepare
script:
- git diff origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD > pr_diff.txt
artifacts:
paths:
- pr_diff.txt
expire_in: 1 day
review:
stage: analyze
script:
- python scripts/review.py # has access to pr_diff.txt automatically
needs:
- extract-diff # ensures the previous job finishes first
Difference from GitHub Actions
GITHUB ACTIONS:
→ Each job is a new machine
→ To pass files: actions/upload-artifact + actions/download-artifact
→ Explicit and verbose
GITLAB CI/CD:
→ artifacts: paths: [...] preserves files
→ Following jobs receive them automatically
→ Implicit, less code
Typical use cases
| Case | Configuration |
|---|---|
| Coverage reports | paths: [coverage.xml], expire_in: 7 days |
| Build artifacts | paths: [dist/], expire_in: 30 days |
| Test results | reports: junit: results.xml (GitLab renders them) |
| Claude analysis | paths: [review_result.json], expire_in: 7 days |
Docker Executor: The Foundation of Jobs
In GitLab CI/CD, jobs always run in containers. The Docker executor is what runs each job.
The image determines the environment
job:
image: python:3.11-slim # ← container where the script runs
script:
- python --version # prints "Python 3.11.x"
Custom images for Claude Code
For CI with Claude Code, you can create a custom image that has everything preloaded:
# .gitlab/Dockerfile.review
FROM python:3.11-slim
RUN pip install --no-cache-dir \
anthropic>=0.39.0,<1.0.0 \
requests \
python-gitlab
WORKDIR /workspace
COPY scripts/ /scripts/
# .gitlab-ci.yml
review:
image: registry.gitlab.com/$CI_PROJECT_PATH/review-image:latest
script:
- python /scripts/code_review.py
Advantage: the dependencies are installed once (when building the image), not on every job run. A faster pipeline.
Services (DBs, Redis, etc.)
test:
image: python:3.11-slim
services:
- postgres:15
- redis:7
variables:
POSTGRES_DB: testdb
POSTGRES_PASSWORD: testpass
script:
- pytest
services are additional containers that run alongside the job. Useful for tests that need a real DB.
Caching for Speed
As in GitHub Actions, the dependency cache speeds things up a lot:
cache:
key: $CI_COMMIT_REF_SLUG
paths:
- .pip-cache/
- node_modules/
review:
before_script:
- pip install --cache-dir=.pip-cache anthropic requests
script:
- python scripts/review.py
key defines how the cache is versioned. $CI_COMMIT_REF_SLUG = the branch name, so each branch has its own cache. key: "global" would be a shared cache.
Complete Example: Code Review Pipeline
A pipeline that:
- Extracts the MR's diff
- Runs the Claude SDK to analyze
- Posts comments to the MR via the API
stages:
- prepare
- analyze
- publish
variables:
PYTHON_VERSION: "3.11"
CLAUDE_MODEL: "claude-haiku-4-5"
default:
image: python:3.11-slim
cache:
key: pip-cache
paths:
- .pip-cache/
extract-diff:
stage: prepare
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- apt-get update && apt-get install -y git
- git fetch origin $CI_MERGE_REQUEST_TARGET_BRANCH_NAME
- git diff origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...HEAD > pr_diff.txt
- echo "Diff size: $(wc -l < pr_diff.txt) lines"
artifacts:
paths:
- pr_diff.txt
expire_in: 1 day
claude-analysis:
stage: analyze
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
needs:
- extract-diff
before_script:
- pip install --cache-dir=.pip-cache anthropic
script:
- python scripts/code_review.py
artifacts:
paths:
- review_result.json
expire_in: 7 days
publish-review:
stage: publish
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
needs:
- claude-analysis
before_script:
- pip install --cache-dir=.pip-cache python-gitlab
script:
- python scripts/publish_to_mr.py
variables:
GITLAB_TOKEN: $CI_GITLAB_TOKEN
Clean structure:
- Stage
prepare→ extracts the diff - Stage
analyze→ runs the Claude SDK (depends onprepare) - Stage
publish→ publishes results (depends onanalyze)
If any stage fails, the following ones don't run. If everything goes well, the artifacts pass automatically between stages.
Common Pitfalls
Error 1: Assuming if works like in GitHub
Symptom: You try if: $CI_PIPELINE_SOURCE == "merge_request_event" directly on the job and it fails.
Why it happens: GitLab uses rules: with the sub-key if:. It's not a direct if:.
How to fix it: Correct structure:
job:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
when: on_success
Error 2: Variables without Protected: true for keys
Symptom: The API key is exposed on runs of unprotected feature branches.
Why it happens: The default for variables is accessible from any branch.
How to fix it: Settings → CI/CD → Variables → ANTHROPIC_API_KEY → Edit → check "Protected" and "Masked".
Error 3: Not using needs: for dependencies between jobs
Symptom: A job that needs an artifact from a previous job runs before it and fails.
Why it happens: Without needs:, the stages run in order but the jobs within a stage run in parallel.
How to fix it: Use needs: [previous-job] to force an explicit dependency and get the artifacts.
Error 4: An image with dependencies but a redundant before_script
Symptom: The pipeline takes a long time installing dependencies in each job.
Why it happens: Each job does pip install, even though the dependencies could be in a custom image.
How to fix it: For projects with many jobs that share deps, create a custom image with the deps preinstalled.
Error 5: Artifacts without expire_in
Symptom: The org's storage grows nonstop, reaching the quota.
Why it happens: The default is 30 days. For ephemeral artifacts (PR analysis), it's excessive.
How to fix it: Configure expire_in: 7 days (or less) on non-critical artifacts.
Diagnosis
Question 1: Do you know the conceptual difference between stages and jobs?
Stages = sequential phases. Jobs = units of work (parallel within a stage). Without understanding this difference, GitLab pipelines feel arbitrary.
Question 2: Does your API key have `Protected: true` and `Masked: true`?
If not, you can expose it on feature branch runs or in logs.
Question 3: Do you use `rules:` or are you trying to use `only/except`?
only/except is deprecated. rules: is the modern and more expressive approach.
Question 4: Do your jobs that depend on artifacts use `needs:` explicitly?
Without needs:, GitLab can run jobs in parallel even if they depend on each other (same stage).
Question 5: Did you configure `expire_in` on your artifacts?
If not, the default of 30 days can saturate storage. For PR CI, 7 days is usually enough.
Exercises
Exercise 1: Minimal pipeline (Easy)
Create a .gitlab-ci.yml with 2 stages (build, test) and one job in each. Verify that they run in order when you push.
Exercise 2: Variables and rules (Medium)
Configure a CI/CD variable MY_TOKEN (Protected, Masked). Make a job that only runs on MRs and uses that variable.
See solution
test-job:
stage: test
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
script:
- echo "Token available (masked): $MY_TOKEN"
In Settings → CI/CD → Variables: add MY_TOKEN with the Protected and Masked flags.
Exercise 3: Complete code review pipeline (Hard)
Adapt the "Complete Example" from the previous section to your project:
- 3 stages: prepare, analyze, publish
- Pass the diff via artifacts
- Protected/masked variables for keys
- Pip cache
- Verify it runs end-to-end on an MR
Summary
- GitLab model: Pipeline → Stages → Jobs → Script (vs GitHub: Workflow → Jobs → Steps)
- Stages run in order; jobs within a stage run in parallel
- Variables are equivalent to GitHub secrets — always Protected + Masked for keys
- Rules replace
only/except(deprecated) — more expressive - Artifacts pass files between jobs/stages automatically with
needs: - The Docker executor is the foundation — every job runs in a container
- A custom image with preinstalled deps speeds up pipelines
Next capsule: 04 — GitLab pipeline with the headless SDK. You combine everything: the SDK you learned in capsule 02 with the pipeline model you learned here. Result: a working GitLab CI/CD pipeline that runs Claude Code on every MR.
Additional Resources
- GitLab CI/CD Documentation — Complete official documentation
- GitLab CI/CD: Predefined Variables — Complete list of
CI_*variables - GitLab CI/CD: rules — Rules syntax
- GitLab Docker Executor — Executor configuration
- GitLab Container Registry — For hosting custom images
- GitLab vs GitHub Actions migration guide — Official comparison