Привет, Хабр!
Монорепозитории удобны, пока CI не начинает пробегаться по всему дереву. Сегодня рассмотрим, как на GitLab собрать внятный pipeline для монорепы так, чтобы на каждое изменение реагировали только нужные куски. Базовых кирпичиков тут три: rules:changes, условные include и тонкое клонирование репозитория.
Начнём с цели. В монорепозитории у нас, как правило, несколько сервисов и библиотек. Хочется, чтобы при изменении кода в services/api запускались только сборка и тесты api, а frontend не дёргался. Также нужны быстрые клоны, чтобы pipeline не тратил время на историю коммитов километровой длины, и аккуратная работа с переменными и доступами в merge request pipeline.
Минимальный каркас
Сначала определим, когда вообще создавать pipeline. Это делает блок workflow: rules. Обычно включают только три события: merge request, теги и дефолтную ветку. Так избегаем дубляжа и шумных пушей в feature-ветках.
# .gitlab-ci.yml
workflow:
rules:
# MR-pipeline
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
# релизные теги
- if: $CI_COMMIT_TAG
# пуши в основную ветку
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
# иначе не создаём pipeline
- when: never
что workflow управляет созданием pipeline целиком, а rules внутри job — включением конкретных заданий.
Быстрый git checkout для монорепы
Дальше ускоряем доставку исходников в job. GitLab Runner по умолчанию использует shallow clone. Для монореп рекомендуют маленькую глубину и fetch-стратегию по умолчанию. Ещё полезно отключить теги и субмодули, если они не нужны, и убрать лишний clean. Всё это делается переменными.
# .gitlab-ci.yml (продолжение)
default:
image: alpine:3.20
variables:
GIT_STRATEGY: fetch # быстрее для повторных запусков
GIT_DEPTH: "10" # берём только хвост истории
GIT_SUBMODULE_STRATEGY: none
GIT_FETCH_EXTRA_FLAGS: "--no-tags"
# опционально, если храните артефакты между job
GIT_CLEAN_FLAGS: none
Если в редких случаях нужен чистый clone, переключаемся на уровне job:
full_clone_build:
stage: build
variables:
GIT_STRATEGY: clone
GIT_DEPTH: "0" # полный клон
script:
- make build
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
Сами переменные и их поведение описаны в документации по Runner. Слишком агрессивное GIT_DEPTH: "1" при очереди job может ломать retry.
Условные include как основной механизм для монорепы
Теперь к главному. Хотим подключать конфиги отдельных компонентов только когда они действительно затронуты. Для этого используем include с rules:changes и wildcard-пути. GitLab умеет include:local с шаблонами вроде configs/**.yml, а также rules на include c if, exists и changes.
У каждого сервиса лежит свой .gitlab-ci.yml, а корневой файл подключает их условно.
# .gitlab-ci.yml (корень)
stages: [lint, build, test, package]
# включаем все конфиги сервисов, но только если в их дереве есть изменения
include:
- local: 'services/**/.gitlab-ci.yml'
rules:
- changes:
- services/**/*
when: always
- when: never
GitLab при валидации конфигурации пробежится по маске, подставит все найденные файлы и применит к каждому правила. rules:changes
смотрит на diff между исходной и целевой веткой для MR, а в просто branch-pipeline поведение иное. Для новых веток changes без сравнения с базой возвращает true, поэтому job могут всплывать лишними. Это известная фича, при необходимости используем compare_to, чтобы явно сравнить с main.
Пример с compare_to:
include:
- local: 'services/**/.gitlab-ci.yml'
rules:
- changes:
- services/**/*
# сравнить с main при запуске не из MR
# (обратите внимание: подстановка переменных в compare_to ограничена)
compare_to: "main"
Подстановка произвольных переменных в compare_to не всегда поддерживается.
Иногда удобнее включать не по изменённым путям, а по наличию маркерных файлов. Тогда есть rules:exists:
include:
- local: 'services/**/.gitlab-ci.yml'
rules:
- exists:
- "services/*/service.yaml"
- when: never
Внутри сервисных файлов: правила, кеши, зависимости
В каждом сервисном .gitlab-ci.yml не переусердствуйте с дублированием. Оставьте только специфику. Пример для services/api/.gitlab-ci.yml:
# services/api/.gitlab-ci.yml
.api_rules: &api_rules
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- services/api/**/* # MR-pipeline, целимся точно в каталог
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes:
- services/api/**/*
- when: never
api_lint:
stage: lint
image: golang:1.22
<<: *api_rules
script:
- go vet ./...
- golangci-lint run
artifacts:
when: always
reports:
codequality: gl-code-quality-report.json
api_build:
stage: build
image: golang:1.22
needs: ["api_lint"]
<<: *api_rules
script:
- go build -o bin/api ./cmd/api
artifacts:
paths: [bin/api]
api_test:
stage: test
image: golang:1.22
needs: ["api_build"]
<<: *api_rules
script:
- go test ./... -race -coverprofile=coverage.out
coverage: '/^total:\s+\(statements\)\s+(\d+\.\d+)%$/'
ules в job и rules на include — независимые уровни. include решает, попадёт ли файл в итоговую конфигурацию, а правила в job — запускать ли конкретную задачу.
Динамические child-pipeline для крупных направлений
Когда сервисов десятки, удобнее генерировать детский pipeline с job только для затронутых компонент и триггерить его из родителя. Классический паттерн — job генерирует YAML по списку изменений, кладёт его как артефакт, а следующий job запускает child-pipeline по этому YAML.
# .gitlab-ci.yml (фрагмент)
stages: [detect, child]
detect_changed:
stage: detect
image: python:3.12-slim
script:
- python3 ci/generate_child.py # скрипт выводит child.yml
artifacts:
paths: [child.yml]
run_child:
stage: child
trigger:
include:
- artifact: child.yml
job: detect_changed
strategy: depend
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
«Тонкое» клонирование: от shallow clone к sparse-checkout
Shallow clone через GIT_DEPTH — базовая оптимизация, но иногда выгоды мало, если рабочее дерево само по себе огромное. Тогда стоит сузить checkout до нужных каталогов с помощью sparse-checkout. Это уже функция git, а интегрировать её в GitLab Runner корректнее через hooks: pre_get_sources_script — скрипт, который запускается до получения исходников.
Вариант для сервиса, которому не нужно видеть всю монорепу:
# services/mobile/.gitlab-ci.yml
mobile_build:
stage: build
image: node:20-alpine
hooks:
pre_get_sources_script:
- git config --global init.defaultBranch main
- git init .
- git remote add origin "$CI_REPOSITORY_URL"
- git -c protocol.version=2 fetch --depth=10 origin "+refs/heads/$CI_COMMIT_REF_NAME:refs/remotes/origin/$CI_COMMIT_REF_NAME"
- git checkout --force "$CI_COMMIT_SHA"
- git sparse-checkout init --cone
- git sparse-checkout set services/mobile shared/config
- git read-tree -mu HEAD
script:
- corepack enable
- pnpm -C services/mobile install --frozen-lockfile
- pnpm -C services/mobile build
Здесь два момента. Во-первых, мы вручную инициализируем репозиторий и делаем выборочный fetch нужной ветки на текущий SHA. Во-вторых, включаем sparse-checkout в режиме cone и явно указываем каталоги.
Если не хочется руками писать init+fetch, можно оставить дефолт обработку Runner, а sparse-checkout выполнить в самом job до сборки. Тогда используем before_script и переключаем Git на работу в sparse-режиме. Однако при кешировании рабочей директории и повторных запусках нужно чистить конфиг sparse-checkout между job, иначе можно поймать остатки предыдущей маски.
Пара слов о partial clone. GitLab некоторое время назад анонсировал поддержку частичных клонов и фильтров, но для CI это по-прежнему требует танцев и часто сводится к ручному clone с ключами filter и дальнейшему sparse-checkout. Штатно shallow clone через GIT_DEPTH даёт наиболее предсказуемый выигрыш.
И ещё важное: тонкая настройка глубины клона доступна как переменная, так и в настройках проекта в разделе Git shallow clone. Максимум там ограничен, и пустое значение снимает ограничение.
Ошибки с rules:changes
Короткий список того, на чём чаще всего стреляют себе в ногу:
Ожидать, что rules:changes отработает одинаково в MR и в branch-pipeline. В MR сравнение идёт по diff между исходной и целевой ветками. В просто ветке для нового бранча changes может вернуться true и включить job. Лечится compare_to или переносом логики на MR-pipeline.
Пытаться подставить переменную в compare_to. Сейчас поддержка ограничена, и это задокументировано в issue. Проще явно указать ветку.
Забывать про маски. Одна звёздочка не захватывает вложенные каталоги, используйте ** для рекурсии. Это касается и include:local с wildcard.
Смешивать include:rules и needs на job из подключаемых файлов так, будто они уже существуют. Если job подключится только при выполнении правила, а needs на него стоит жёстко, валидатор справедливо ругнётся.
Практический монолитный пример
Соберём всё вместе. Корневой .gitlab-ci.yml:
# .gitlab-ci.yml
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_TAG
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- when: never
stages: [lint, build, test, package]
default:
image: alpine:3.20
variables:
GIT_STRATEGY: fetch
GIT_DEPTH: "10"
GIT_SUBMODULE_STRATEGY: none
GIT_FETCH_EXTRA_FLAGS: "--no-tags"
GIT_CLEAN_FLAGS: none
before_script:
- apk add --no-cache bash coreutils git
# базовая статическая проверка для всей репы
repo_lint:
stage: lint
script:
- ./ci/run_repo_linters.sh
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes:
- ".gitlab-ci.yml"
- "ci/**/*"
- "**/*.md"
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
- when: never
# сервисные конфиги подключаем только при изменениях в services/**
include:
- local: 'services/**/.gitlab-ci.yml'
rules:
- changes:
- services/**/*
compare_to: "main"
- when: never
# динамическое разветвление: опционально
detect_changed:
stage: lint
image: python:3.12-slim
script:
- python3 ci/generate_child.py
artifacts:
paths: [child.yml]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
run_child:
stage: build
trigger:
include:
- artifact: child.yml
job: detect_changed
strategy: depend
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
Сервисный файл для фронтенда с тонким получением исходников:
# services/frontend/.gitlab-ci.yml
frontend_build:
stage: build
image: node:20-alpine
hooks:
pre_get_sources_script:
- git config --global init.defaultBranch main
- git init .
- git remote add origin "$CI_REPOSITORY_URL"
- git -c protocol.version=2 fetch --depth=20 origin "+refs/heads/$CI_COMMIT_REF_NAME:refs/remotes/origin/$CI_COMMIT_REF_NAME"
- git checkout --force "$CI_COMMIT_SHA"
- git sparse-checkout init --cone
- git sparse-checkout set services/frontend shared/ui
- git read-tree -mu HEAD
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes: [ "services/frontend/**/*", "shared/ui/**/*" ]
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes: [ "services/frontend/**/*", "shared/ui/**/*" ]
- when: never
script:
- corepack enable
- pnpm -C services/frontend install --frozen-lockfile
- pnpm -C services/frontend build
artifacts:
paths: [ "services/frontend/dist" ]
Сервисный файл для backend без sparse, но с аккуратными правилами и зависимостями:
# services/api/.gitlab-ci.yml
.api_rules: &api_rules
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
changes: [ "services/api/**/*", "shared/**/go.mod", "shared/**/go.sum" ]
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes: [ "services/api/**/*", "shared/**/go.mod", "shared/**/go.sum" ]
- when: never
api_lint:
stage: lint
image: golang:1.22
<<: *api_rules
script:
- go vet ./...
- golangci-lint run
api_build:
stage: build
image: golang:1.22
needs: ["api_lint"]
<<: *api_rules
script:
- go build -o bin/api ./cmd/api
artifacts:
paths: [bin/api]
api_test:
stage: test
image: golang:1.22
needs: ["api_build"]
<<: *api_rules
script:
- go test ./... -race -coverprofile=coverage.out
coverage: '/^total:\s+\(statements\)\s+(\d+\.\d+)%$/'
Итог
Создаём pipeline только там, где он действительно нужен: MR, теги, дефолтная ветка.
Включаем сервисные конфиги через
include:local
сwildcard
иrules:changes
.Внутри сервисов дублируем минимум, используем anchors и needs.
Ускоряем checkout:
GIT_STRATEGY: fetch
,GIT_DEPTH
,--no-tags
.Для очень больших деревьев применяем hooks:
pre_get_sources_script
иgit sparse-checkout
.Защищаем секреты: секретные job — только на дефолтной ветке и под защищёнными ресурсами, MR-pipeline без доступа к продовым переменным.
В сложных монорепах — dynamic child pipelines для точной адресации.
В работе с монорепами и пайплайнами на GitLab часто всё упирается не только в конфигурацию .gitlab-ci.yml
, но и в то, насколько грамотно сам GitLab развернут. Ошибки на этом уровне выливаются в медленные билды, нестабильные пайплайны и проблемы с масштабированием.
Чтобы не тратить ресурсы на постоянные костыли и «пожарные» фиксы, записывайтесь на бесплатный урок «Архитектура развертывания GitLab: от тестовой среды до продакшна» (10 сентября, 20:00). Разберём реальные варианты развёртывания — Omnibus, Docker, Kubernetes, сравним их по отказоустойчивости и масштабируемости, а также обсудим типичные грабли, на которые наступают команды при росте нагрузки.
Кроме того, пройдите вступительное тестирование, чтобы оценить свой уровень и узнать, подойдет ли вам программа курса CI/CD на основе GitLab.