Представь типичный день в активной команде. Утром ты открыл PR-A: переименовал функцию getUser в fetchUser по всему модулю. Зелёный CI, ревью прошло, мержишь. Коллега в это же время открыл PR-B: добавил новый вызов getUser в другом файле. Его CI тоже зелёный - ведь когда он гонял тесты, твоего переименования ещё не было в main. Оба PR прошли проверки. Оба влиты. А main лежит: getUser больше не существует, сборка падает, и теперь вся команда сидит на сломанном trunk.
Это не баг тестов и не чья-то халатность. Это фундаментальная дыра классической модели "проверяй PR против main, потом мержи". CI каждого PR проверял старое состояние main, а не то, во что main превратится ПОСЛЕ серии вливаний. Конфликт тут не текстовый - git merge склеил бы файлы без единого collision-маркера. Это семантический (логический) конфликт: два изменения по отдельности корректны, а вместе ломают код. Git про такое не знает в принципе - он сравнивает байты, а не смысл.
Чем чаще команда мержит и чем ближе вы к trunk based merge (короткие ветки, десятки вливаний в день в один main), тем выше шанс этой гонки. На потоке в 50-100 PR в день "зелёный по отдельности" ломает main регулярно. Лечится это не дисциплиной, а механикой - очередью, которая проверяет кандидатов в том порядке и в том сочетании, в каком они реально лягут. Это merge queue (она же merge train в мире GitLab), и в 2026 это, пожалуй, ключевой сдвиг в code review - не про комментарии к строкам, а про то, КАК зелёный PR безопасно доезжает до trunk.

Как устроена github merge queue: спекулятивная сборка
Идея простая до гениальности. Вместо того чтобы каждый PR гонять против текущего main и мержить "вслепую", очередь выстраивает кандидатов друг за другом и проверяет каждого против того состояния main, в котором он реально окажется, когда дойдёт его черёд.
Когда ты вместо обычного "Merge" жмёшь "Merge when ready", PR попадает не в main, а в хвост очереди. GitHub берёт кандидатов по порядку (FIFO) и собирает для каждого спекулятивный временный коммит - merge group:
- кандидат в голове очереди собирается поверх HEAD ветки main;
- следующий кандидат собирается поверх main + предыдущий кандидат;
- третий - поверх main + первый + второй, и так далее.
Код: Выделить всё
Очередь: [A] -> [B] -> [C]
base(A) = main
base(B) = main + A (CI ловит конфликт A<->B здесь)
base(C) = main + A + B
A зелёный -> вливается в main
B красный -> выпадает, C перебазируется на main+A
Событие merge_group ci: где гоняются проверки
Тут спотыкаются почти все при первой настройке. Твой обычный workflow в GitHub Actions висит на событиях pull_request и push. Но merge queue собирает СВОЙ временный коммит (merge group), и для него GitHub генерирует отдельное событие - merge_group. Если твой required status check не подписан на merge_group, то при попадании PR в очередь проверка просто не запустится. GitHub будет вечно ждать чек, которого нет, и PR зависнет в очереди.
Поэтому правило железное: каждый workflow, который ты делаешь обязательным для слияния, должен слушать и merge_group тоже.
Код: Выделить всё
# .github/workflows/ci.yml
name: CI
on:
pull_request:
merge_group: # <- БЕЗ этой строки очередь зависнет
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: make test
Внутри merge_group в контексте (github.event.merge_group) лежат base_sha и head_sha - база и голова спекулятивного коммита. checkout по умолчанию выкачивает уже собранную merge group, так что тесты бегут по будущему состоянию trunk, а не по голой фичеветке.
Настройка через repository rulesets, а не classic protection
Большой сдвиг 2026: GitHub уводит команды со старой "classic branch protection" на repository rulesets - они в GA и стали рекомендуемым способом защиты веток. Rulesets гибче: их можно держать на уровне организации, накладывать слоями, включать в режиме evaluate (логировать нарушения, не блокируя), таргетить ветки по шаблону fnmatch. Merge queue включается именно как правило ruleset.
Пошагово, для ветки main:
- Settings -> Rules -> Rulesets -> New branch ruleset, таргет - ветка main (по имени или шаблону).
- Включи правило "Require merge queue".
- Включи "Require status checks to pass" и добавь туда ровно те проверки, что слушают merge_group. Это связка, без которой очередь бессмысленна: required status checks - это и есть критерий, по которому кандидат проходит или выпадает.
- В настройках самой очереди задай параметры группировки.
- Maximum group size (напр. 5) - сколько кандидатов GitHub склеивает в одну merge group и гоняет одним прогоном CI. Прошла группа целиком - все 5 вливаются разом, экономия раннеров кратная. Упала - очередь делит группу пополам и перепроверяет половины (бинарный поиск виновника), пока не вычислит сломавший PR и не выкинет его.
- Minimum group size + wait time - очередь придержит старт CI, пока не наберётся минимум кандидатов ИЛИ не выйдет таймаут (что раньше). Минимум 3, ожидание 5 минут: стартуем либо когда в очереди 3 PR, либо через 5 минут. Это размен между утилизацией раннеров и задержкой вливания.
- Only merge non-failing и таймауты на чек - страховка от флапающих тестов, чтобы один нестабильный прогон не держал всю очередь.
merge train gitlab: тот же принцип, другой словарь
В GitLab та же механика называется merge train, и понимать различие словаря полезно, если ты живёшь в обеих экосистемах. Поезд (train) - это очередь merge request'ов в целевую ветку. Каждый вагон проверяется не на изоляции, а на merged results: изменения MR склеиваются с целевой веткой И со всеми MR, что стоят впереди в поезде. Первый MR проверяется против целевой ветки, второй - против цель+первый, и так далее - один в один спекулятивная сборка GitHub.
Поезд трогается, когда очередь пуста и ты жмёшь Merge (или Set to auto-merge). Подъезжает следующий MR - запускается его merge train pipeline поверх предыдущего, и пайплайны идут параллельно. Падает пайплайн в середине поезда - этот MR не вливается, GitLab выкидывает его из поезда и перезапускает пайплайны для всех вагонов, что стояли позади (ведь у них поменялась база - сломанного впереди больше нет). Слова разные - merge train против merge queue, merged results против merge group, - суть идентична: проверяем кандидата против будущего состояния trunk, а не прошлого.
Грабли и когда очередь избыточна
- Забыл merge_group в workflow. Классика. PR влетает в очередь и виснет навсегда, потому что required-чек не стартовал. Всегда дублируй триггер: on: [pull_request, merge_group].
- Флапающие тесты. Нестабильный тест в очереди опаснее, чем в PR: ложное падение выкидывает невиновный кандидат и заставляет пересобирать хвост. Очередь беспощадно вскрывает флаки - чините их, иначе trunk-based merge превратится в лотерею. Помогают retry на нестабильных шагах и "only merge non-failing".
- Слишком большой maximum group size при дорогом CI. Группа 10 при частых падениях - это лавина бинарного поиска и долгие пересборки. На шумном тесте начинай с группы 2-3.
- Очередь не спасёт от плохого ревью. Она ловит логические конфликты между PR и регрессии в сборке, но не заменяет глаза человека на дизайне и читаемости. Это слой поверх review, а не вместо него.
- Когда оно избыточно. Команда из 2-3 человек, вливаний несколько в день, гонок практически нет - очередь добавит задержку (PR ждёт прогона merge_group перед вливанием) и сложность настройки без видимой выгоды. Окупается, когда параллельных PR в один trunk реально много и main ломается от их сочетаний. Нет потока - нет проблемы - не тащи очередь ради моды.
Понадобится репозиторий на GitHub, где ты админ. По шагам:
- Создай файл .github/workflows/ci.yml с триггерами on: [pull_request, merge_group] и одним шагом, который что-то проверяет (хоть run: echo ok && exit 0).
- Settings -> Rules -> Rulesets -> New branch ruleset, таргет - main. Включи "Require status checks to pass", добавь свою джобу CI. Включи "Require merge queue". Сохрани.
- Открой два PR, которые по отдельности зелёные, но конфликтуют логически. Простейший вариант: PR-A удаляет строку echo ok из ci.yml и пишет echo a, PR-B рассчитывает на старую строку через grep. Или проще - сделай так, чтобы тест в одном PR ожидал переменную, которую другой PR переименовывает.
- Нажми "Merge when ready" на обоих. Зайди в Settings -> Merge queue (или вкладку очереди в PR) и смотри, как кандидаты выстраиваются, как для второго собирается merge group поверх первого, и как падение на merge_group выкидывает виновника из очереди, не тронув main.
- Открой Actions и найди прогоны с event: merge_group - это и есть проверки спекулятивных коммитов, отдельные от pull_request.
Контрольные вопросы
- Почему два PR, зелёные на pull_request, могут вместе сломать main, и какой именно тип конфликта тут срабатывает (текстовый или семантический)?
- Против какого состояния merge queue проверяет третий кандидат C, если в очереди стоят A, B, C? Запиши формулу базы.
- Что произойдёт, если required status check подписан только на pull_request, но не на merge_group, и PR попал в очередь?
- Зачем нужен параметр maximum group size и что делает очередь, когда группа из 5 кандидатов падает целиком?
Классическое "проверь против main, потом мержи" имеет гонку: CI видит прошлое trunk, а не будущее, и логические конфликты между параллельными PR прорываются в main зелёными. Merge queue (GitHub, через repository rulesets) и merge train (GitLab) закрывают эту дыру спекулятивной сборкой: каждый кандидат проверяется против состояния trunk, в котором реально окажется - с учётом всех, кто впереди. Ключевая деталь настройки - событие merge_group ci, на которое обязаны слушать твои required-чеки. Это must-have для trunk based merge на высокой частоте и честно избыточно для маленькой команды. Не путай инструмент с серебряной пулей: очередь чинит интеграцию кандидатов, а не качество кода - ревью и зелёные стабильные тесты по-прежнему на тебе.