Привет, Хабр!

Монорепозитории удобны, пока 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

Короткий список того, на чём чаще всего стреляют себе в ногу:

  1. Ожидать, что rules:changes отработает одинаково в MR и в branch-pipeline. В MR сравнение идёт по diff между исходной и целевой ветками. В просто ветке для нового бранча changes может вернуться true и включить job. Лечится compare_to или переносом логики на MR-pipeline.

  2. Пытаться подставить переменную в compare_to. Сейчас поддержка ограничена, и это задокументировано в issue. Проще явно указать ветку.

  3. Забывать про маски. Одна звёздочка не захватывает вложенные каталоги, используйте ** для рекурсии. Это касается и include:local с wildcard.

  4. Смешивать 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+)%$/'

Итог

  1. Создаём pipeline только там, где он действительно нужен: MR, теги, дефолтная ветка.

  2. Включаем сервисные конфиги через include:local с wildcard и rules:changes.

  3. Внутри сервисов дублируем минимум, используем anchors и needs.

  4. Ускоряем checkout: GIT_STRATEGY: fetch, GIT_DEPTH, --no-tags.

  5. Для очень больших деревьев применяем hooks: pre_get_sources_script и git sparse-checkout.

  6. Защищаем секреты: секретные job — только на дефолтной ветке и под защищёнными ресурсами, MR-pipeline без доступа к продовым переменным.

  7. В сложных монорепах — dynamic child pipelines для точной адресации.


В работе с монорепами и пайплайнами на GitLab часто всё упирается не только в конфигурацию .gitlab-ci.yml, но и в то, насколько грамотно сам GitLab развернут. Ошибки на этом уровне выливаются в медленные билды, нестабильные пайплайны и проблемы с масштабированием.

Чтобы не тратить ресурсы на постоянные костыли и «пожарные» фиксы, записывайтесь на бесплатный урок «Архитектура развертывания GitLab: от тестовой среды до продакшна» (10 сентября, 20:00). Разберём реальные варианты развёртывания — Omnibus, Docker, Kubernetes, сравним их по отказоустойчивости и масштабируемости, а также обсудим типичные грабли, на которые наступают команды при росте нагрузки.

Кроме того, пройдите вступительное тестирование, чтобы оценить свой уровень и узнать, подойдет ли вам программа курса CI/CD на основе GitLab.

Комментарии (0)