Collapsing GitLab CI rules instead of the control-plane pattern
GitLab's recommended control-plane include looked like the clean fix for ~40 repeated rules blocks. I evaluated it, found it would break my manual fallbacks and my lint tool, and collapsed the duplicated if-blocks in place instead.
Every auto-running job in this monorepo’s pipeline carried two near-identical rules blocks: one for merge-request pipelines, one for push/web/api, each repeating the same changes path list word for word. Across ten project files that was roughly forty copies of the same idea. The cleanup I reached for first was GitLab’s own recommended fix. I ended up declining it and collapsing the duplication in place instead.
Duplication and constraints
A typical build job looked like this:
# ci/projects/backend.yml
backend:test:fast:
extends: .backend_test_template
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
changes:
- .gitlab-ci.yml
- ci/templates/backend.yml
- backend/src/**/*
# … nine more paths
when: on_success
- if: '$CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"'
changes:
- .gitlab-ci.yml
- ci/templates/backend.yml
- backend/src/**/*
# … the same nine more paths
when: on_success
- when: manual
allow_failure: true
Two if conditions, two copies of an eleven-path list, for one decision: “run on relevant changes, else stay available as a manual fallback.” Multiply that across backend, web-app, mobile-app, admin-app, and the rest, and the file→trigger mapping was spread over forty-odd blocks. The duplication wasn’t dangerous yet, but it was the kind of thing that rots: add a path to one if and forget the other, and the two sources silently disagree.
Chosen refactor
The obvious modernization is GitLab’s control-plane include with rules:changes, generally available since 16.4. The root .gitlab-ci.yml becomes a single map of “this directory changed → include this project’s file,” and the per-project files lose their rules entirely. GitLab’s own guide calls the per-job-anchor approach I was using the legacy pattern: “a lot of redundant code and room for human error.” I started planning the migration.
Three things stopped me, found by testing rather than assuming.
The control plane deletes the manual fallback by design. When an include rule doesn’t match, the file’s jobs are not added to the pipeline at all; there is no “present but manual” state. That trailing - when: manual, allow_failure: true exists for a specific reason: on web/api pipelines and a new branch’s first push, rules:changes evaluates unreliably (always true, or based on an awkward previous-SHA diff). The clickable “rebuild backend” button is the safety net for those cases. The control-plane pattern removes it.
My local lint tool could not render it. This repo validates CI changes with a local scripts/gitlab_ci_lint.py script, which inlines - local: includes before handing the merged YAML to glab ci lint. An include entry that pairs local: with a sibling rules: key breaks that renderer: it strips the local: line and orphans the rules:, producing invalid YAML. I confirmed this by feeding it a control-plane file. So the migration was not one change but two: the CI restructure and a rewrite of the lint tool that guards it.
It would double the file count. The control plane includes or excludes whole files, so every project’s manual deploy/publish/backup jobs, which must stay available with no code change, would need their own always-included ops.yml alongside the gated build.yml. Ten project files would become twenty, for a repository maintained by one person.
The Zenika “GitLab CI: 10+ Best Practices” guide pointed at a lighter move that fit better: deduplicate with shared structure, don’t reach for child pipelines or a re-architecture you don’t need yet.
I collapsed the two if blocks into one combined source-gated rule and kept the fallback:
# ci/projects/backend.yml
.backend_test_changes: &backend_test_changes
- .gitlab-ci.yml
- ci/templates/backend.yml
- backend/src/**/*
# … nine more paths
backend:test:fast:
extends: .backend_test_template
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event" || $CI_PIPELINE_SOURCE == "push" || $CI_PIPELINE_SOURCE == "web" || $CI_PIPELINE_SOURCE == "api"'
changes: *backend_test_changes
when: on_success
- when: manual
allow_failure: true
The combined if deliberately excludes schedule, because rules:changes is also always true in scheduled pipelines; without that exclusion, the weekly security-scan schedule would auto-run every build. The one path list genuinely shared by two jobs (backend:test:fast and backend:test:postgres watch identical inputs) became a changes: anchor, the same idiom the other project files already used.
Pipeline behavior after the refactor
Eleven files changed, 32 insertions and 203 deletions: −171 lines. The backend project file went from six if blocks to three. glab ci lint and its dry-run both pass, and gitlab-ci-local --list confirms every build/test job still resolves on its auto-run path and every deploy/publish/backup job still resolves as manual. The fallback I was worried about losing is intact.
Tradeoffs
The honest tradeoff is that the path→project mapping stays distributed, one hidden anchor per project file, instead of centralized in the root. That is the control-plane pattern’s one real advantage, and I gave it up. For a single maintainer who owns every file, distributed-but-deduplicated reads fine; for a team where many contributors edit CI in parallel, the central map earns its keep and the manual-fallback loss is worth rethinking. I noted the conditions for revisiting (contributor growth, or project count past a dozen) rather than treating the decision as permanent.
The broader lesson I’m keeping is that “the pattern the vendor promotes” and “the right pattern for this repo” are different questions. The control-plane migration would have looked clean in a diff and removed the duplication, but it would have quietly reversed a behavior I depend on and cost a tool rewrite. The collapse did most of the cleanup at a fraction of the risk.
References
- Building a GitLab CI/CD pipeline for a monorepo the easy way. GitLab’s official case for the control-plane
include: rules:changespattern and why the per-job approach is the legacy one - GitLab CI: 10+ Best Practices to Avoid Widespread Anti-Patterns. The independent practitioner guide; rule #6 (abstract without YAML anchors) and #10 (avoid child pipelines) shaped the lighter approach
- Specify when jobs run with rules. The
rules:changespitfalls: always true on a new branch/tag’s first push and in scheduled pipelines, which is the reason the manual fallback and thescheduleexclusion both exist - Scoping a GitLab CI monorepo pipeline by what actually changed. The preceding post; this one resolves its deferred follow-up
This post was written with AI assistance.