Mastering Monorepos in GitLab CI/CD: Dynamic Child Pipelines & Path-Based Triggers
When you consolidate dozens of microservices, frontend packages, AI agents, and shared libraries into a single Monorepo, code sharing and atomic refactoring become effortless.
However, your CI/CD pipeline often turns into a bottleneck:
- The 3,000-Line YAML Monster: A single root
.gitlab-ci.ymlbecomes impossible to read or maintain. - Wasted Compute: Changing a single line of CSS in
services/web-portalaccidentally triggers a 20-minute C++/CUDA compilation inservices/inference-engine. - Job Collisions: Different teams step on each other's global variables and stage definitions.
GitLab CI/CD solves the monorepo scaling problem natively with two powerhouse features:
- Parent-Child Pipelines with
rules:changes: Isolates each sub-project’s CI configuration into its own directory and triggers it only when files in that path are modified. - Dynamic Child Pipeline Generation: Uses a lightweight script to inspect the git diff at runtime and programmatically generate the exact CI jobs needed for that commit.
In this architectural guide, I will show you how to implement both patterns to keep your monorepo builds blazingly fast.
1. The Architecture: Monolithic vs. Parent-Child Pipelines
+-------------------------------------------------------------+ | Root Monorepo (.gitlab-ci.yml) | | Inspects git diff across 50+ sub-projects | +-------------------------------------------------------------+ | | | (Changed: /ai-agent) (Unchanged: /web-ui) (Changed: /cuda-core) | | | v v v +-------------------+ +-----------------+ +--------------------+ | Trigger Child CI | | SKIPPED (0s) | | Trigger Child CI | | ai-agent/.ci.yml | | Zero resources | | cuda-core/.ci.yml | +-------------------+ +-----------------+ +--------------------+ 2. Pattern 1: Modular Parent-Child Pipelines with rules:changes
Instead of stuffing every job into the root .gitlab-ci.yml, place a dedicated .gitlab-ci.yml inside each sub-project folder:
monorepo/ ├── .gitlab-ci.yml # Root orchestrator (50 lines) ├── packages/ │ ├── ai-gateway/ │ │ ├── .gitlab-ci.yml # Python / FastAPI pipeline │ │ └── pyproject.toml │ ├── web-dashboard/ │ │ ├── .gitlab-ci.yml # Next.js / TypeScript pipeline │ │ └── package.json │ └── gpu-runtime/ │ ├── .gitlab-ci.yml # C++20 / CUDA pipeline │ └── CMakeLists.txt The Root .gitlab-ci.yml:
stages: - triggers trigger_ai_gateway: stage: triggers trigger: include: packages/ai-gateway/.gitlab-ci.yml strategy: depend # Root pipeline reflects child success/failure status rules: - changes: - packages/ai-gateway/**/* trigger_web_dashboard: stage: triggers trigger: include: packages/web-dashboard/.gitlab-ci.yml strategy: depend rules: - changes: - packages/web-dashboard/**/* trigger_gpu_runtime: stage: triggers trigger: include: packages/gpu-runtime/.gitlab-ci.yml strategy: depend rules: - changes: - packages/gpu-runtime/**/* Why strategy: depend is Crucial:
By default, GitLab marks a trigger job as successful the moment the child pipeline starts. Adding strategy: depend forces the parent pipeline to wait for the child pipeline to finish and mirrors its exact pass/fail status on your Merge Request!
3. Pattern 2: Dynamic Child Pipelines (For 50+ Projects)
When your monorepo grows to 50+ projects, even writing 50 trigger_* blocks in the root .gitlab-ci.yml becomes repetitive.
With Dynamic Child Pipelines, a tiny Python or shell script inspects the modified directories and generates a dynamic-child-pipeline.yml artifact on the fly!
Step A: Write the Generator Script (scripts/generate_ci.py)
import os import subprocess import yaml def get_changed_projects(): # Compare current commit against target branch or previous commit target = os.environ.get("CI_MERGE_REQUEST_TARGET_BRANCH_NAME", "HEAD~1") cmd = ["git", "diff", "--name-only", f"origin/{target}...HEAD"] try: files = subprocess.check_output(cmd, text=True).splitlines() except Exception: files = subprocess.check_output(["git", "diff", "--name-only", "HEAD~1"], text=True).splitlines() projects = set() for f in files: parts = f.split("/") if len(parts) >= 2 and parts[0] == "projects": projects.add(parts[1]) return sorted(projects) def build_pipeline(projects): if not projects: # Fallback no-op job if only root README changed return { "stages": ["noop"], "nothing_to_build": { "stage": "noop", "script": ["echo 'No sub-projects modified. Skipping builds.'"] } } pipeline = {"stages": ["test", "build"]} for proj in projects: pipeline[f"test_{proj}"] = { "stage": "test", "image": "alpine:latest", "script": [ f"echo 'Running test suite for projects/{proj}...'", f"cd projects/{proj} && ./test.sh" ] } return pipeline if __name__ == "__main__": changed = get_changed_projects() config = build_pipeline(changed) with open("generated-config.yml", "w") as f: yaml.dump(config, f, sort_keys=False) Step B: Execute & Trigger in Root .gitlab-ci.yml
stages: - setup - dynamic_execution generate_pipeline: stage: setup image: python:3.12-slim before_script: - apt-get update && apt-get install -y git - pip install pyyaml script: - python scripts/generate_ci.py - cat generated-config.yml artifacts: paths: - generated-config.yml expire_in: 1 hour run_dynamic_child_pipeline: stage: dynamic_execution needs: [generate_pipeline] trigger: include: - artifact: generated-config.yml job: generate_pipeline strategy: depend 4. What Happens in the GitLab UI?
When you open a Merge Request that touches 2 out of 52 projects:
-
generate_pipelineruns in 4 seconds, outputting a tailored YAML containing only those 2 projects. -
run_dynamic_child_pipelinespawns a visual downstream child graph in the GitLab Pipeline UI. - Only the 2 modified projects are built and tested. The remaining 50 projects consume zero runner minutes.
5. Key Takeaways for Monorepo Architects
- Decouple Ownership: Each sub-project folder owns its
.gitlab-ci.yml, Docker image, and variables without polluting global state. - Use
changes:with Care: Combinerules:changeswithif: $CI_PIPELINE_SOURCE == "merge_request_event"for deterministic git diff evaluation. - Generate Dynamically at Scale: When managing dozens of packages, generate the child YAML programmatically via
artifact:includes.
Resources & Links
- GitLab Profile: gitlab.com/hrlpavan
- GitLab Contributor Profile: contributors.gitlab.com/users/hrlpavan
- GitHub: @hrlpavan
Do you prefer multi-repo setups or monorepos for your engineering projects? Share your architecture thoughts in the comments below!