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 ActionsGitLab CI/CDMeaning
${{ github.repository }}$CI_PROJECT_PATH"owner/repo"
${{ github.event.pull_request.number }}$CI_MERGE_REQUEST_IIDMR/PR number
${{ github.event.pull_request.head.sha }}$CI_COMMIT_SHACommit SHA
${{ github.event.pull_request.base.ref }}$CI_MERGE_REQUEST_TARGET_BRANCH_NAMETarget branch
${{ github.actor }}$GITLAB_USER_LOGINUsername
${{ 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:

FlagMeaning
ProtectedOnly accessible on protected branches
MaskedMasked in logs (like a secret in GitHub)
ExpandedIf 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

CaseConfiguration
Coverage reportspaths: [coverage.xml], expire_in: 7 days
Build artifactspaths: [dist/], expire_in: 30 days
Test resultsreports: junit: results.xml (GitLab renders them)
Claude analysispaths: [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:

  1. Extracts the MR's diff
  2. Runs the Claude SDK to analyze
  3. 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 on prepare)
  • Stage publish → publishes results (depends on analyze)

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:

  1. 3 stages: prepare, analyze, publish
  2. Pass the diff via artifacts
  3. Protected/masked variables for keys
  4. Pip cache
  5. 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

  1. GitLab CI/CD Documentation — Complete official documentation
  2. GitLab CI/CD: Predefined Variables — Complete list of CI_* variables
  3. GitLab CI/CD: rules — Rules syntax
  4. GitLab Docker Executor — Executor configuration
  5. GitLab Container Registry — For hosting custom images
  6. GitLab vs GitHub Actions migration guide — Official comparison