GitLab’s pipeline configuration can feel like a black box, especially when you’re dealing with a monorepo where a single commit can touch dozens of projects. The default behavior is to run everything, which quickly becomes a massive time sink and a drain on CI minutes. The trick to making this manageable is to tell GitLab exactly which jobs need to run based on what changed.
Let’s see this in action. Imagine a monorepo with three distinct services: frontend, backend, and database. We want a pipeline that only builds and tests the frontend if frontend code changes, backend if backend code changes, and so on.
Here’s a simplified .gitlab-ci.yml that achieves this:
variables:
MONOREPO_ROOT: "." # Assumes CI is run from the root of the monorepo
stages:
- build
- test
# --- Frontend Jobs ---
build_frontend:
stage: build
script:
- echo "Building frontend..."
- cd $MONOREPO_ROOT/frontend && npm install && npm run build
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- frontend/**/*
build_frontend_tests:
stage: test
script:
- echo "Running frontend tests..."
- cd $MONOREPO_ROOT/frontend && npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- frontend/**/*
# --- Backend Jobs ---
build_backend:
stage: build
script:
- echo "Building backend..."
- cd $MONOREPO_ROOT/backend && npm install && npm run build
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- backend/**/*
run_backend_tests:
stage: test
script:
- echo "Running backend tests..."
- cd $MONOREPO_ROOT/backend && npm test
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- backend/**/*
# --- Database Jobs ---
run_database_migrations:
stage: test
script:
- echo "Running database migrations..."
- cd $MONOREPO_ROOT/database && ./run_migrations.sh
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- database/**/*
The magic here is the rules keyword. Each job has a rules section that specifies conditions under which it should run. The most critical part for monorepos is the changes keyword.
The changes keyword tells GitLab to compare the files modified in the current pipeline’s commit (or merge request) against a list of file paths. If any of the specified files are touched, the job is eligible to run.
In the example above:
build_frontendandbuild_frontend_testswill only run if files within thefrontend/directory (including subdirectories) are changed.build_backendandrun_backend_testswill only run if files within thebackend/directory are changed.run_database_migrationswill only run if files within thedatabase/directory are changed.
We’ve also added a common condition: $CI_PIPELINE_SOURCE == "merge_request_event". This is crucial because the changes keyword behaves slightly differently depending on the pipeline source. For merge requests, changes checks against the files that differ between the source and target branches. This is usually what you want for monorepo CI: run checks only when your proposed changes might affect a specific service.
This setup allows you to define a single .gitlab-ci.yml at the root of your monorepo that intelligently triggers only the necessary jobs, dramatically speeding up your CI/CD cycles. You can extend this pattern to include linting, security scans, deployment steps, or any other task, applying the changes rule to ensure they only execute when relevant.
The changes keyword can accept glob patterns, making it very flexible. For instance, changes: - 'packages/feature-x/**/*' would trigger a job if any file within the packages/feature-x directory changes. You can even specify multiple paths or negate patterns.
A common pitfall is forgetting to include the pipeline source condition, like $CI_PIPELINE_SOURCE == "merge_request_event". Without it, changes might behave unexpectedly on direct pushes to main or other branch pipelines, potentially running jobs even when you didn’t intend for them to, or not running them when you expect. Always consider how changes interacts with your primary branching strategy and pipeline triggers.
Beyond changes, GitLab CI offers other powerful rules conditions. For example, you might use if: '$CI_COMMIT_BRANCH == "main"' to ensure certain jobs only run on the main branch, regardless of file changes. Combining these conditions with changes gives you fine-grained control.
What most people don’t realize is that the changes keyword performs a diff against the previous commit on the branch for push events, and against the merge base for merge request events. This means that if you change a file, then commit again without pushing, the changes keyword in the next pipeline might not see your change if the subsequent commit also touched that file in a way that GitLab’s diff algorithm considers "resolved" or if the changes rule is too specific. For merge requests, it’s generally more robust as it compares against the common ancestor.
The next hurdle is often defining dependencies between these dynamically triggered jobs, especially when a change in one service might necessitate an update or test in another.