Четыре пул-реквеста, четыре полных прогона по 17 минут; четвёртый уже содержит остальные три
Четыре пул-реквеста, четыре полных прогона по 17 минут; четвёртый уже содержит остальные три

Пока проект был маленьким, всё это не имело значения. Он вырос, и сьют вырос вместе с ним. Очень много тестов требуют очень много машинного времени, и это превращается в выбор, где оба ответа стоят денег: гонять весь сьют на каждом пул-реквесте — или проводить изменения вручную, перебазируя каждое за предыдущим. Мы пошли обычным путём: дешёвая проверка на пул-реквесте, а дорогой сьют один раз, позже, там где изменения объединяются перед посадкой. Ровно для этого и нужна merge queue.

Проблему это не закрыло. Очередь гоняет тот самый полный сьют один раз на каждый стоящий в ней пул-реквест. Четверо ждут посадки — четыре полных прогона, причём последний из них уже содержит три остальных. Статья про это: обязаны ли те три прогоняться, что происходит, когда их останавливаешь, и какие способы остановки безопасны.

Спросите себя сперва об одном: ждут ли ваши пул-реквесты посадки друг за другом? Если никогда не ждут — всё дальнейшее вас не касается. Если ждут — читайте.

И одно измерение, чтобы вы могли понять, ваши ли это числа. Наши: 15 621 тест, от 13 до 19 минут на полный прогон — и минуты делает не количество. Это мы тоже измерили. 93% гейта — шаг тестов; все линтеры, что у нас есть, вместе занимают 30 секунд. 40% шага тестов — инструментирование покрытия, а не тесты. А пол под всем этим — примерно 226 тестов, каждый из которых поднимает настоящий процесс и мигрирует настоящую базу: самый медленный процент держит 41% работы, тогда как остальные 10 750 тестов стоят между собой самое большее 47 секунд.

Вот почему удаление тестов нам не помогло, и вот число, которое стоит проверить прежде, чем покажется, что оно поможет вам. У читателя с пятнадцатью тысячами быстрых юнит-тестов задача другая, чем у читателя с двумя сотнями тестов, каждый из которых поднимает базу или ждёт браузерную ферму. Избыточность, о которой статья, стоит денег в обоих случаях; меняется лишь цена одного сэкономленного прогона.

§1. Как дорогой сьют оказался ровно в одном месте

Ничего здесь не начиналось со счёта. Началось с того, что доставка стала медленной, и каждый следующий шаг был разумным.

12 июля 2026. Первая жалоба была на то, что полный сьют — самый долгий шаг в CI и что его перезапускает всё подряд. Через шесть дней merge queue уже работала и была обязательной, и приняли её ради пропускной способности, а не ради денег: без неё каждый мерж двигал ствол, следующий пул-реквест должен был обновиться и перепрогнаться, и полоса шла последовательно — примерно полторы минуты на мерж.

Никто в команде раньше merge queue не пользовался и не знал, что такая штука есть. Её предложил ассистент, исходя из обычного понимания, зачем она нужна, — и это понимание есть отправная точка всей статьи, поэтому его стоит привести точно, а не пересказывать по памяти.

Перед публикацией мы поставили ту же ситуацию перед современной передовой моделью, вхолодную, без доступа к чему-либо из описанного ниже. В вопросе очереди не упоминались вовсе: пятнадцать тысяч тестов, семнадцать минут, гоняется на каждом пул-реквесте и ещё раз перед посадкой, двадцать-тридцать посадок в день, каждый прогон уже удешевлён как мы умеем. Что делать?

Она порекомендовала merge queue первым пунктом и описала её так:

Merge queue набирает N кандидатных PR и гоняет предмерж-сьют один раз на батч, а не на PR — если батч из 5 проходит, все 5 садятся с одного семнадцатиминутного прогона вместо пяти. Это единственный ход в списке, который меняет количество полных прогонов, а не только их стоимость.

И с прикидкой в придачу: примерно 1 + 1/B, где B — средний размер батча. При батче 5 это ~1,2.

Это и есть посылка, сформулированная лучше, чем сформулировали бы тогда мы сами, — и она неверна ровно в том месте, которое решает. Измеренное нами число — 1,76, по другую сторону единицы. Очередь не гоняет сьют раз на батч. Она гоняет его раз на пул-реквест в группе, и то, что группа мержится одним куском, на это не влияет никак.

Остаток §1 — о том, что происходило, пока на это число никто не смотрел.

20 июля 2026. «Почему CI стал медленным?» Тормозил тестовый джоб: от 11 до 16 минут на маленьком арендованном раннере, гейтом на каждом пул-реквесте и с перепрогоном в очереди. Тяжёлую работу перенесли на машины, которые у нас и так были и стояли без дела. Тот же сьют, занимавший 11–16 минут, отработал за 108 секунд.

25–27 июля 2026. Компьют переехал на платный облачный билдер, чтобы пережить волны пул-реквестов, и впервые появился счёт. За два дня он вырос примерно до ста долларов в день против нуля прежде. В худший день, 26 июля, очередь провела около 189 сборок — примерно 2,3 на пул-реквест — и каждая гоняла полный сьют. Гейтовую работу вернули на собственное железо, а за арендованным билдером оставили только то, чего объём не касается: образы контейнеров, реестр, деплой.

И вот ход, который решает всё. Чтобы перестать платить за полный сьют на каждом пул-реквесте, пул-реквест теперь гоняет только те тесты, которых его изменение касается. Цена была названа сразу: пул-реквест может пройти собственную проверку и всё равно завалить полный сьют. Поэтому полный сьют гоняется там, где изменения объединяются перед посадкой, — в merge queue.

Обратите внимание, чем это не является. Ни один тест не был удалён. Сьют вырос с 4936 в том июле до 15 621 сегодня; он не сокращался ни разу. Когда мы всё-таки затеяли кампанию по удалению, тремя неделями позже, собственное измерение её и растворило: гейт занимает 1007 секунд, из них 939 — шаг покрытия, и удаление 88% сьюта покупает самое большее 47 секунд. Рычагом была выборка, а не удаление, — и именно выборка перенесла дорогой сьют в одно место.

Следствие тогда никто не вывел. Как только уровень пул-реквеста стал гонять подмножество, полный сьют стал гоняться ровно в одном месте: в merge queue, по разу на каждый пул-реквест в группе.

20 августа 2026 эта очередь прогнала полный сьют 60 раз и посадила 34 пул-реквеста. Это 1,76 полного сьюта на посадку, или 12,5 машино-часов за день, на общем пуле из четырёх машин. Успешный прогон занимал по медиане 14 минут. 22 из 60 упали, а упавший прогон стоит столько же, сколько успешный: 13,5 минуты против 14.

Сборки на этом уровне не считал никто.

Стоимость CI — это два перемноженных числа: сколько раз сьют гоняется и во что обходится один прогон. Каждый ход выше бил по второму числу — машины быстрее, уровень дешевле, тестов на пул-реквест меньше. Первого не коснулся ни один. А последний из них перенёс дорогой сьют ровно в то место, где платят за каждого члена группы.

§2. Ответ, оплаченный той валютой, которую мы только что пообещали

Merge queue тестирует пул-реквесты группами: несколько PR берутся вместе и решаются как набор. Наша гоняет весь сьют по разу на каждый пул-реквест в группе. Если делать иначе, сьют гоняется один раз на группу вместо четырёх. Это экономит около 19 машино-минут на каждый посаженный пул-реквест ценой примерно четырёх лишних минут ожидания.

Прогнать группу можно четырьмя способами; безопасен и дешевле одновременно только один. Один мержит сломанный код; один способен заморозить группу; один безопасен, но стоит ровно того, что мы платим сейчас. Четвёртый — тот, о котором статья: наши собственные кандидатные конструкции, записанные в workflow и проверенные.

Экономия — это верхняя граница, измеренная на песочном стенде; ничего из описанного не гоняется на продакшене, причина в §8.

Четыре режима на одной сломанной группе: прогнать все четыре сьюта и посадить зелёный префикс; прогнать только последний и вмержить сломанный код; ждать и залипнуть; упасть вместе, прогнать один сьют и не посадить ничего
Четыре режима на одной сломанной группе: прогнать все четыре сьюта и посадить зелёный префикс; прогнать только последний и вмержить сломанный код; ждать и залипнуть; упасть вместе, прогнать один сьют и не посадить ничего

§3. Что очередь покупает и что продаёт

Без очереди посадка нескольких PR на один ствол означает ручной ребейз каждого всякий раз, когда садится предыдущий. Проверки при этом тоже перезапускаются, и внутри этого цикла стоит человек.

Без очереди человек ведёт цикл перебазирования из четырёх шагов; с очередью цикла нет, а ценой становится один полный сьют на каждый запрос в группе
Без очереди человек ведёт цикл перебазирования из четырёх шагов; с очередью цикла нет, а ценой становится один полный сьют на каждый запрос в группе

Merge queue убирает человека из цикла. Она берёт несколько PR, упорядочивает их, тестирует каждый против стоящих впереди и сажает прошедшие. Вот что она покупает: сериализацию без человека, который сериализует.

Спекулятивный гейтинг — тестировать поставленные в очередь изменения как ту группу, которая сядет, и мержить прошедшее вместе — старше GitHub. Zuul делает это открыто и на своём железе дольше, чем существует очередь GitHub. В собственном уведомлении о прекращении поддержки bors-ng отправляет своих пользователей в merge queue GitHub. GitHub спрятал механику за настройку: ни оператора, ни отдельного сервиса; команда из двух человек может её включить.

Продаёт же она сборку на каждый пул-реквест в группе, каждый раз, — и платят за это все сборки, стоящие позади. Без очереди человек тоже платит прогоном за каждый ребейз; работа переезжает, а не исчезает. Очередь берёт ровно то, что брал ручной ребейз, — только заметно, разом и в счёте, который кто-нибудь однажды прочитает. Из этой заметности и растёт вопрос: нужен ли каждому члену группы собственный полный прогон? Или последний уже содержит ответ за всех?

§4. Четыре пул-реквеста, четыре сборки

Кандидаты в merge queue образуют цепочку: каждый начинается там, где кончился предыдущий. Последний кандидат содержит всё, что содержат предыдущие. Если четвёртый прошёл — внутри него прошли и первые три. Если он красный из-за того, что сломал первый, эта поломка видна в падении четвёртого. Мы проверили утверждение на выброшенном репозитории, собранном под это.

Четыре дерева проверок, каждое содержит всё, что стоит впереди; третье несёт поломку, и четвёртое тоже; все четыре гоняют полный сьют
Четыре дерева проверок, каждое содержит всё, что стоит впереди; третье несёт поломку, и четвёртое тоже; все четыре гоняют полный сьют

Четыре PR дали четыре групповые сборки, разосланные в пределах четырёхсекундного окна друг от друга. Через 20–30 секунд каждая сборка, спрошенная об остальных, сообщила, что все четыре всё ещё ждут своих проверок — включая её саму. Ничто не успело закончиться настолько рано, чтобы более мелкая сборка могла посмотреть на более глубокую и отступить.

Вот избыточность, которую продаёт merge queue: четыре сборки там, где один вердикт ответил бы за все четыре. И это опровергает простое решение «пусть мелкие просто подождут и посмотрят на глубокую». В тот момент, когда мелкая сборка стартует, ни у одной глубокой ответа ещё нет.

§5. Это вообще дело GitHub?

Мы спросили, не позволяет ли очередь GitHub отключить избыточность штатно. Документация GitHub говорит, что настройки мержа сборки не объединяют: одна на кандидата — это замысел. Мы проверили каждую настройку merge queue, которую GitHub открывает, по одной. Правило, решающее, судится ли кандидат против всего, что впереди него, или только против свежайшей записи, меняет то, что гейтит мерж. Оно не меняет, сколько сборок рассылается. Мы измерили это напрямую: четыре пул-реквеста, четыре сборки, при обеих настройках.

Другая крупная хостинговая площадка тоже не батчит: один пайплайн на запрос, настройки не требуется. Варианты для своего железа делятся так же. bors-ng — на bors-ng/bors-ng — батчит именно так, как нам нужно, но не поддерживается с апреля 2024. Kodiak, который поддерживается, не батчит вовсе. В обсуждениях сообщества #43988 и #58523 поднимается ровно эта удвоенная стоимость, и ни к одному не приложено опубликованного решения. Именно это отсутствие и делает осмысленным построить своё.

Две настройки, которые звучат как батчинг, и что они делают на самом деле

Посылка §1 опирается на настройку, которой не существует, и стоящую за этим путаницу стоит назвать, потому что она системная, а не небрежность.

Мы вернули своё измерение той же модели, что дала совет. Она поправилась сразу — четыре прогона, безусловно, — а затем назвала собственную ошибку лучше, чем это сделали бы мы. Словарь группировки делят между собой две разные операции: батчинг мержа — сколько уже зелёных пул-реквестов сворачиваются в один мерж-коммит, и батчинг сборки — один прогон CI, покрывающий несколько диффов сразу. Имена полей в API стоят рядом: minimumEntriesToMerge и maximumEntriesToMerge управляют первым, maximumEntriesToBuild ограничивает, сколько кандидатов собирается одновременно. Ни одно из трёх не сворачивает группу в один прогон.

Контрпример здесь самый прямой из возможных. В день, когда писался этот раздел, у репозитория стояло minimumEntriesToMerge равным 3. Три пул-реквеста стояли в очереди, и они смержились одной группой, в одну и ту же секунду. Они дали три кандидатных рефа — pr-3731-2590d60b, pr-3746-f5a8f042, pr-3780-9b1953ed, каждая база содержит предыдущую, — и три полных прогона сьюта. Настройка, про которую считалось, что она сворачивает группу в одну сборку, стояла ровно на размере этой группы.

Модель же и наше собственное отношение объяснила обратно нам, верно и в направлении, которое стоит проговорить: 1,76 — это выше единицы, а не ниже, потому что, выбрасывая запись, очередь пересобирает кандидатов позади неё. Шестьдесят прогонов на пятьдесят два различных рефа в день замера — восемь рефов собирались больше одного раза.

Ничто из этого не история про ненадёжного ассистента. Мнение это естественное, всякое описание merge queue говорит, что она тестирует изменения вместе, а наши собственные письменные заметки говорили, что полный сьют гоняется в merge queue, ни разу не сказав — сколько раз, что читается ровно так же. Не хватало с обеих сторон одного: измерения. Если вы спрашиваете ассистента, батчит ли ваша очередь, ждите уверенного «да» — и проверьте: список рефов и счётчик прогонов решают вопрос, и берутся одной командой.

§6. Стенд и что он вычисляет

Мы собрали один workflow из трёх джобов. Дешёвый джоб читает очередь и находит в ней собственный пул-реквест. Заглушка дорогого сьюта гейтится на этом ответе; третий — всегда идущая обязательная проверка. Джоб, чьё условие ложно, не рассылается вовсе — никакого машинного времени, что лучше, чем отправить его на машину подешевле. Бережливость была вынужденной: июльские прогоны уже потратили всё, что было отпущено на арендованный компьют, поэтому ненужные сборки были непозволительны. Какой из четырёх режимов работает — это одна настройка репозитория, так что все ячейки гоняют один и тот же код.

Как сборка мерж-группы вообще видит своих соседей по группе — это поверхность GitHub, а не стенда. Сборка знает, какой она пул-реквест, потому что об этом говорит её кандидатный реф — gh-readonly-queue/<base>/pr-NN-<sha>, — и решающий джоб достаёт номер из GITHUB_REF одним sed. Очередь вокруг — это один GraphQL-запрос, отправленный через gh api graphql с собственным secrets.GITHUB_TOKEN этого workflow:

query($owner: String!, $name: String!) {
  repository(owner: $owner, name: $name) {
    mergeQueue {
      entries(first: 50) {
        nodes {
          position
          state
          pullRequest { number }
        }
      }
    }
  }
}

Каждая запись возвращает свою position (больше — глубже), своё state (MERGEABLE, UNMERGEABLE или ни то ни другое — всё ещё ждёт проверок) и number пул-реквеста. Весь грант прав у workflow — это permissions: с contents: read, checks: read, pull-requests: read; чтение очереди не требует ни отдельного токена, ни права на запись.

Этот запрос не процитирован из документации. Он был выполнен против живой очереди этого репозитория 29 августа 2026, а набор полей подтверждён интроспекцией схемы: MergeQueueEntry несёт position, state, enqueuedAt, headCommit, baseCommit, estimatedTimeToMerge, solo, jump, enqueuer и pullRequest, а entries несёт totalCount рядом с nodes. Пустая очередь отвечает {"totalCount": 0, "nodes": []}, а не ошибкой, — и это важно, потому что именно такую форму решающий джоб видит чаще всего и именно её первая реализация вероятнее всего обработает неверно.

Между этим запросом и таблицей настроек ниже сидит одна ловушка, и её стоит назвать, потому что она стоила неверного запроса с первой попытки. Это два разных API с разными именами для одних и тех же семи настроек. REST API рулсетов — тот, что использует рецепт установки, — пишет их как grouping_strategy, min_entries_to_merge, min_entries_to_merge_wait_minutes, max_entries_to_build, max_entries_to_merge, check_response_timeout_minutes, merge_method. GraphQL-овский MergeQueueConfiguration пишет те же семь как mergingStrategy, minimumEntriesToMerge, minimumEntriesToMergeWaitTime, maximumEntriesToBuild, maximumEntriesToMerge, checkResponseTimeout, mergeMethod — и длительности у него в секундах там, где REST-имена говорят про минуты. Спросить у GraphQL REST-овское имя — громкая ошибка undefinedField, и это хороший случай; тихий случай — прочитать checkResponseTimeout как 3600 минут. Собственные настройки этого репозитория, прочитанные через GraphQL в тот же день, дают ALLGREEN, 3, 300, 2, 10, 3600, SQUASH — те же семь значений, что сообщает чтение рулсета, только в единицах другого API.

Проверка на PR и проверка в merge queue ведут себя по-разному нарочно: иначе «поломка в первом члене» и «поломка в другом месте» выглядели бы снаружи одинаково.

Исходы мы ранжировали в фиксированном порядке: сперва надёжность (мержится ли что-нибудь небезопасное), затем живость (завершается ли группа), затем стоимость, затем сколько садится. Драйвер — шелл-скрипт, ведущий целое плечо, — открывает PR, ставит режим, следит за очередью, записывает, что сделала каждая ячейка. Надёжность и живость он измеряет напрямую; стоимость и посадку мы считываем с ячеек руками.

§7. Случаи: что проверяли и как каждый себя повёл

Четыре режима, одна сетка, по четыре вопроса на каждый: как устроено, чего ждали, что вышло, что опровергнуто.

Гонять каждого кандидата. Устройство: каждый кандидат гоняет собственный полный сьют, независимо от остальных. Ожидание: безопасно, верно по построению. Вышло: надёжно в каждом прогоне, каждая группа разрешилась, четыре полных сьюта рассылались всякий раз. Не опровергает ничего; это база.

Пропускать всех, кроме последнего. Устройство: настоящий сьют гоняет только самый глубокий кандидат, остальные рапортуют успех, не запуская его. Ожидание: на три сборки меньше в группе из четырёх, если бухгалтерия очереди выдержит. Вышло: опровергнуто. Механизм — таймер ожидания. При минимуме записей для мержа, равном четырём, и пятиминутном ожидании группа, поставленная в 13:33:23, смержилась в 13:38:57. Это 5 минут 34 секунды — таймер плюс бухгалтерия. Второй прогон уложился в 5 минут 36; вторая пара лежит в репозитории и проверена руками. В обоих прогонах, когда таймер истекал, а зелёных было меньше минимума, очередь мержила самый длинный зелёный префикс из имевшихся. Это опровергает мысль, что рапорт режима «пропуск» может заменить настоящий прогон: в такой префикс может попасть кандидат, который на самом деле не гонялся.

Ждать более глубокого вердикта. Устройство: мелкий кандидат держится, пока не отчитается более глубокий. Ожидание: безопасно ценой некоторого ожидания. Вышло: надёжно и мертво. Голова группы помечается как немержабельная, GitHub никогда её не выбрасывает, и все более мелкие кандидаты стоят за ней. Ничего не двигается ни на восьмидесятой секунде, ни на седьмой минуте. Залипало в каждом прогоне, при обоих правилах группировки. Очевидное объяснение — свойство более строгого правила группировки — не выдержало. Та же форма при более мягком правиле залипает точно так же. Это опровергает правило группировки как причину; после того теста мы не знаем, чем это вызвано. Наша логика режима считает «немержабельно» ещё не решённым; почему платформа никогда не выбрасывает голову — вот чего мы не знаем.

Валить всю группу разом. Устройство: мелкий кандидат зеркалит самый глубокий вердикт, который видит. Глубокий мержабелен — он отступает; глубокий немержабелен — он падает, и группа падает вместе с ним; вердикта ещё нет — он гоняет по-настоящему. Ожидание: безопасно и живо, ценой потери целой группы из-за одного плохого члена. Вышло: надёжно в каждом прогоне; разрешилась каждая группа, кроме одной, на самой короткой из пробованных настроек живости.

Выборка — четыре режима на две стратегии группировки: восемь ячеек, у каждой вердикт, пустых нет. Поломка сидит третьей из четырёх. Перепрогоны с тремя красными из четырёх и затем со всеми четырьмя, при обоих правилах, не изменили ни одного вердикта по надёжности. Не покрыто: другие размеры групп, поломки вне перепрогнанных позиций и стенд под нагрузкой.

Та единственная неразрешившаяся группа не была отказом надёжности — небезопасного не смержилось ничего. Это была граница живости на агрессивном значении, чисто разрешавшаяся на более длинном. Остальные три режима каждый чем-то поступились: «пропуск» — надёжностью, «ожидание» — живостью, «гонять каждого» — той самой экономией, о которой статья. Этот не поступился ничем по двум вопросам, стоящим первыми.

Непроверенное дерево не доходит здесь до ствола: за каждым мержем стоит настоящий прогон где-то в цепочке, а §8 назначает этой безопасности цену. Это победивший режим. Он опровергает допущение, что безопасность и живость в такой конструкции обмениваются друг на друга: «падать вместе» покупает обе.

Включение состоит из двух половин, и настройкой является только одна. Режим — это логика вашего собственного workflow: решающий джоб на дешёвых арендованных раннерах выясняет, где стоит его пул-реквест. Сьют запускается, только когда он так скажет. Гейт-джоб рапортует под if: always(), чтобы обязательная проверка никогда не осталась неотрапортованной; ничто из этого не переключается со страницы настроек. Скелет этой половины, как его гоняет стенд, — три идентификатора джобов, рёбра между ними и два условия, несущие конструкцию. Всё опущенное — скрипт решающего джоба и тело рапорта гейта, оба лежат в файле стенда по ссылке в конце раздела:

on:
  pull_request:
  merge_group:

permissions:
  contents: read
  checks: read
  pull-requests: read

jobs:
  decide:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    outputs:
      verdict: ${{ steps.place.outputs.verdict }}
      mode: ${{ steps.place.outputs.mode }}
    steps:
      - name: Decide what this candidate owes
        id: place
        # ...опущено: прочитать очередь (§6), записать verdict=RUN|SKIP|FAIL в $GITHUB_OUTPUT
  suite:
    needs: decide
    if: needs.decide.outputs.verdict == 'RUN'
    runs-on: ubuntu-latest          # заглушка ДОРОГОГО пула
    steps:
      - uses: actions/checkout@v4
      - run: bash ci/check.sh
  gate:
    needs: [decide, suite]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Report the one context the queue waits on
        # ...опущено: КРАСНЫЙ, если decide упал, если вердикт FAIL или если сьют гонялся и не прошёл

Ожидание решающего джоба ограничено, и граница заслуживает печати. Он опрашивает очередь раз в 15 секунд, не более 60 раз — пятнадцать минут ожидания вердикта от более глубокой записи — под джоб-уровневым timeout-minutes: 20. Когда ожидание истекает без вердикта, джоб не пропускает и не виснет: он пишет verdict=RUN с записью в лог timed-out (fail safe), и кандидат гоняет настоящий сьют. RUN — безопасный исход истечения, потому что пропуск зарабатывается только положительной уликой: более глубокая запись действительно наблюдалась как MERGEABLE. И всякий путь, который не может решить, скатывается к RUN: реф не несёт номера пул-реквеста, чтение очереди упало, этого пул-реквеста в очереди больше нет, ожидание вышло. Каждый попадает на ту базу, за которую очередь и так платила, поэтому медленная или нечитаемая очередь стоит денег и не может стоить надёжности — ранжирование §6 ставит надёжность первой, а стоимость третьей. Этот цикл общий с режимом wait, и разделяет два режима одна ветка, которой у wait нет. Для wait более глубокая запись в состоянии UNMERGEABLE — просто ещё не зелёная, случая для наблюдённого красного у него нет, поэтому удержание продолжается, и группа ждёт голову, которую платформа не выбрасывает. Победивший режим превращает наблюдённый красный в FAIL немедленно, и батч падает целиком. В этом вся разница между ними в файле. Почему платформа никогда не выбрасывает голову — остаётся там, где это оставил §10; а граница покупает то, что ненаступивший вердикт кончается прогоном, а не зависанием.

Платформенная половина — стратегия группировки merge queue у GitHub, с двумя значениями: ALLGREEN, где каждая запись проходит сама по себе, и HEADGREEN, гейтящий по голове. HEADGREEN без режима мержит сломанный код ровно так же, как ALLGREEN. Режим без HEADGREEN верен, но медленнее: очередь собирает все вердикты, прежде чем батч упадёт. Сначала постройте режим, понаблюдайте, потом меняйте стратегию.

Где сидит поломка, важно не меньше, чем какой режим её гоняет. Разделяющая форма — поломка третьей из четырёх: достаточно мелко, чтобы кому-то пришлось ждать или гадать, и достаточно глубоко, чтобы цепочка донесла её до половины. Одна пара прогонов — поломка третьей при обоих правилах — прибивает залипание режима «ожидание» к режиму, а не к правилу. Второй прогон убивает надежду, что правило группировки по голове, судящее кандидата только против текущей головы группы, спасёт ждущий режим. Он залипает и при этом правиле.

Два режима на одной временной шкале: ждущий застывает на полпути и не разрешается; режим «падаем вместе» краснеет одним блоком и возвращается в очередь
Два режима на одной временной шкале: ждущий застывает на полпути и не разрешается; режим «падаем вместе» краснеет одним блоком и возвращается в очередь

Полная сетка, с опознанной каждой ячейкой, лежит в репозитории стенда — https://github.com/saharkit/windowsill/tree/main/docs/merge-queue — для всех, кто захочет воспроизвести.

§8. Во что обходится безопасный режим

«Валить всю группу разом» не побеждает даром. Ячейка с дефектом мержит ничего: вся группа падает и встаёт в очередь заново. При режиме «гонять каждого» на той же форме пул-реквесты впереди поломки всё-таки садятся.

За первой ценой прячется вторая, и это очевидное возражение: когда группа упала, кто её сломал? Вердикт группы — один бит, и виновника он не называет. У этого вопроса есть ответ, и у нас он есть, и он нарочно оставлен за пределами статьи — это граница, а не пробел. Едет то, что едет. Граф джобов, API очереди, которое он читает, и политика ожидания решающего джоба ложатся на любой репозиторий с merge queue, каким бы ни был стек, и все три выше. Назвать члена, сломавшего группу, не едет: это чтение результатов сборок того репозитория его же инструментами, и ответ, скроенный по нашему, был бы ответом про наш стек. Режиму это не нужно, чтобы быть безопасным — непроверенное не мержится в любом случае, — а покупает это потолок повторов из конца раздела. Эту часть строит тот, кто внедряет.

Наши входные данные: 9 дефектов на 40 изменений (22,5%), группы по четыре, семнадцатиминутный сьют; таблица — на каждый пул-реквест, который в итоге мержится. Семнадцать минут — круглая цифра и слегка щедрая: измеренная медиана в §1 равна 14, и всякая машино-минута ниже масштабируется вместе с ней. 40 изменений — маленькая выборка, а привычки одного репозитория не равны привычкам другого; это тот единственный вход, который стоит заменить своим.

гонять каждого

падать вместе

сборок

1,82

0,69

машино-минут

30,9

11,8

ждать посадки

7,7 мин

11,8 мин

Модель и измерение сходятся: 1,82 сборки на смерженный пул-реквест здесь против измеренных в §1 1,76 — шестьдесят прогонов против тридцати четырёх посадок.

Экономия — в машино-минутах, а не в долларах, потому что цена машино-минуты зависит от того, чья это машина. Четыре опубликованных прайса, наблюдённых 28 августа 2026, — доллары США за минуту, Linux x64.

vCPU

GitHub Actions

Google Cloud Build

Blacksmith

BuildJet

2

0,006

0,006

0,004

0,004

8

0,022

0,0156

0,016

0,016

32

0,082

0,0624

0,064

0,048

Три из этих колонок печатаются по размерам. Blacksmith печатает одну цифру, 0,004 доллара за минуту, рядом с переключателем vCPU, который её не переопределяет. Его ячейки на 8 и 32 ядра выведены из его же документированного правила: минуты тратятся пропорционально числу vCPU, так что десять минут на четырёхъядерном раннере тратят двадцать двухъядерных минут.

Всё это — линуксовые минуты, а платформа множит сильнее, чем разброс между поставщиками. По опубликованным ставкам GitHub, наблюдённым 28 августа 2026: стандартный двухъядерный раннер на Windows стоит 0,010 против 0,006 у Linux — в 1,67 раза. Windows на 32 ядрах — 0,162 против 0,082 у Linux, почти вдвое. Стандартный macOS-раннер на трёх-четырёх ядрах — 0,062, то есть в 10,33 раза дороже Linux.

Арифметика этой статьи — о том, сколько раз гоняется сьют, поэтому всё, что множит цену одного прогона, множит и весь результат. Гонять сьют четыре раза вместо одного стоит тех же четырёх крат на любой платформе; на macOS каждый прогон стартует в десять раз выше. Одна граница: публичные репозитории не тарифицируются вовсе, включая Windows и macOS; эти цифры кусают только приватные.

Подставьте свою ставку — и деньги посчитаете сами. Три вещи, которые таблица говорит попутно. Специалисты держат одну ставку за vCPU и не гнут её. Blacksmith берёт 0,002 за vCPU-минуту и на двух ядрах, и на тридцати двух; BuildJet держится вровень до тридцати двух, где падает до 0,0015. Платформы дают скидку на vCPU по мере роста раннера — GitHub с 0,0030 до 0,0026, облачный билдер с 0,0030 до 0,0020, — но стартуют выше обоих специалистов. На тридцати двух ядрах эта скидка их обгоняет: 0,0624 против 0,082 у площадки и 0,064 у Blacksmith. Арендованный компьют не является равномерно дорогим выбором. Сравнительные заявления вендоров преувеличивают их же прайсы. BuildJet начинает с «вдвое быстрее и дешевле» и отзыва клиента «урезали вдвое»; его собственный прайс идёт на 27–41% ниже.

Колонки, которой в таблице нет, — это железо, которое у вас уже есть. Его минута стоит амортизацию плюс электричество, размазанные по фактически отработанным минутам, поэтому минута простаивающей машины стоит бесконечно много. Это потолок ёмкости из §1, записанный формулой.

Девятнадцать машино-минут — это потолок. Модель предполагает, что каждый повтор берёт свежий независимый батч. Настоящая упавшая группа так себя не ведёт: она возвращается с тем же дефектом внутри. Для настоящего бага повтор той же группы не удастся никогда. Режимы здесь несимметричны: «гонять каждого» ставит в очередь заново только то, что за первой поломкой; «падать вместе» — всю группу. То есть динамика, которую модель опускает, штрафует рекомендуемый нами режим сильнее базового, а не слабее. Аудит модели нашёл и то и другое. Экономия реальна только там, где между попытками что-то определяет, какой пул-реквест сломал группу. Этот шаг — прочитать результаты упавшей группы, назвать сломавшего, поставить остальных обратно без него — не построен.

§9. Три вещи, в которых мы ошиблись, прежде чем сделали правильно

В первом плече к очереди не была прицеплена обязательная проверка статуса. Она смержила все четыре PR немедленно и разослала ноль групповых сборок. Ноль — не маленький ответ: очереди было нечего ждать, и это другой отказ, не тот, что мы измеряли. Ценой стало всё плечо: ничто произведённое им нельзя было переиспользовать, когда проверку вернули. Очередь, которой нечего гейтить, не может сказать вам, во что обходится гейтинг.

Вторая: наша первая сетка гоняла только форму «поломка в первом члене», и вернулась зелёной по всем режимам. Какое-то время мы принимали это за единицу. Это отсутствие единицы: самая ранняя возможная поломка даёт каждому режиму самый лёгкий случай. Сетка, построенная только так, не может отделить безопасную конструкцию от везучей. Ценой стала вторая полная сетка, где положение поломки добавлено собственной осью. Второй проход нашёл залипание в ждущем режиме — дефект, который лёгкая форма показать не могла.

Третья: наш первый заход на вердикт живости не отличал «правильно падает» от «залип». В одной ячейке четыре записи стояли замороженными с восьмидесятой секунды; в другой две записи уже ушли. Вердикт, читавший второе как случай первого, осудил режим, который оказался правильным. Починка стоила второй, более тонкой функции вердикта и перечитывания каждой ячейки, которую грубая уже назвала.

У всех трёх одна форма: самое дешёвое число подменяло то, которое имело значение, пока мы не измерили трудное. Лёгкие измерения по умолчанию вытесняют дорогие — ничто не заставляет взяться за трудное. А при уже потраченном июльском бюджете дорогое означало дорогое в самом прямом смысле.

§10. Чего это не устанавливает и возражение, на которое мы не можем ответить

Мы не знаем, почему ждущий режим залипает. Единственное объяснение, что у нас было — более строгое правило группировки, — опровергнуто в §7; на смену ему не пришло ничего.

Конкуренцию между сборками merge queue и обычными сборками пул-реквестов, делящими четыре раннера, мы наблюдали один раз, напрямую, на настоящем репозитории. На стенде это не воспроизводилось, и частоты у нас нет — только тот инцидент. Считайте это поводом проверить собственный репозиторий, а не утверждением о том, как часто такое бывает. Шаг, который поднял бы потолок §8 — определение, какой пул-реквест сломал группу, — описан, а не построен. Ничто здесь не выкатывалось на настоящий репозиторий.

Возражение, которое мы выдвинули бы против себя: заявление о стоимости выведено, а не измерено, и опирается на число, которого мы не наблюдали. Это правда. Наш ответ: надёжность измерена напрямую, стоимость выведена из модели с названным смещением, и весят эти два по-разному. Ранжирование в §6 ставит надёжность первой, а стоимость третьей.

§11. Проверьте сами

Пять значений конфигурации, один файл скопировать, одну команду запустить — здесь не напечатано ничего из этого. Значения, файл, команда и два консольных инструмента, которые ей нужны, выписаны в стенде: https://github.com/saharkit/windowsill/tree/main/docs/merge-queue. Постороннему нужны ещё три вещи: командная строка, аутентифицированная в GitHub, два обычных консольных инструмента и право на пуш в целевой репозиторий.

Один термин: рулсет — это именованный набор правил, который репозиторий вешает на ветку. Он покрывает обязательные проверки, кто может пушить и работает ли merge queue вообще. Стенду нужен числовой идентификатор этого рулсета, и GitHub показывает его на странице настроек рядом с именем.

Жирным, потому что ошибка стоит настоящего: режим пропуска мержит сломанный код. Гоняйте это на репозитории, который вам не жалко.

Значения минимума записей и таймера ожидания из §7 прибиты в конфигурации стенда; поведение «пропуска» от них зависит. Поменяете — и пятиминутный таймер из §7 больше не применим. Этот раздел выходит только потому, что драйвер работает. Драйвер последний раз проверялся на ec709e33, коммите, который его добавил, и с тех пор не менялся; инструкции по установке стенда дописывались уже после, поэтому текущий стенд — коммит более поздний, чем тот, с которого гонялся драйвер.

§12. Счёт, ещё раз

В прогонах: сегодня на группу приходится четыре полных сьюта; при безопасном режиме — около 0,69 на посаженный пул-реквест. Против 12,5 машино-часов, фактически потраченных 20 августа 2026, модельное отношение 0,69 к 1,82 ставит тот же день около 4,8 — экономия примерно 7,8. Посчитано с другой стороны: 0,69 прогона на 34 посадки — это около 23 прогонов. При измеренной медиане в 14 минут это 5,6 машино-часа, экономия около 7. Два пути сходятся в пределах машино-часа — это максимум, что стоит заявлять для любого из них, и всё ещё верхняя граница (§8).

Одна рамка, которую мы попробовали и отозвали: четыре раннера на двадцать четыре часа как потолок в 96 машино-часов. Это потолок утилизации при ста процентах, а не измеренный предел ёмкости, и ничто здесь не показывает, что узкое место — именно раннеры. Что мы наблюдали напрямую: в 14:43 UTC все четыре раннера были заняты, и одна мерж-сборка простояла в очереди восемь минут. На репозитории ничего не мержилось два часа двадцать минут. Мерж-сборки черпают из того же пула, что и обычные сборки пул-реквестов, — это факт про общий пул, а не про машино-часы вообще.

Деньги, одной строкой: в июле мы купили Cloud Build на 10 920,84 турецкой лиры (около 234 долларов), в августе — на 237,56 лиры (около 5 долларов). Вся эта разница — то, сколько сервиса мы решили покупать. Ход из §1, ответивший на июльский счёт — возврат гейтовой работы на машины, которые у нас уже были, — унёс высокообъёмную работу со счётчика. Покупаем мы теперь только то, что осталось: сборку образов, реестр артефактов, деплой. Счёт переехал. Он не исчез.

Пока мы месяц сидели внутри очереди, арендованная сторона собрала три образа и держала их в своём реестре. Она деплоила в кластер на каждый мерж, без единого нашего инцидента. Эта тишина и сделала месяц возможным: разобрать одну вещь можно было потому, что всё под ней держалось.

Множитель, за которым мы гнались, никогда не сидел в цене прогона.

§13. Написано, чтобы по нему действовали

Эта статья написана для двух читателей, и второй — не метафора.

Первый — человек, решающий, применимо ли что-либо из этого к его репозиторию. Второй — агент, чей оператор дал ему ссылку и одну инструкцию: посмотри и скажи, годится ли нам это. Такой читатель не хочет, чтобы его убеждали. Он хочет знать, может ли он начать: назван ли механизм, настоящий ли запрос, напечатаны ли числа, от которых зависит конструкция, — или подразумеваются.

Поэтому мы на него тестируем, и тест — это не мнение о прозе.

До публикации статью прочитал агент, живущий в другом репозитории, проинструктированный как агент того репозитория, ничего не знающий о нашем, и спрошенный об одном, с четырьмя буквальными вариантами ответа: может ли он начать сегодня, может ли начать после одного названного уточнения, отсутствует ли механизм, или какой-то пассаж настолько двусмыслен, что два прочтения построят разные системы. Первые два круга вернули ещё нет и назвали недостающее — API, которым сборка мерж-группы перечисляет свою группу, и политику ожидания: интервал опроса, потолок, таймаут джоба и что происходит по истечении. Ни то ни другое не отличает работающий режим от залипающего; это называет §7, и там одна ветка. Оба теперь в тексте, потому что тот читатель сказал, что без них не может переписать джоб.

Планка нарочно узкая. Это не воспроизведёте ли вы наши числа и не стоит ли вашей команде это принимать — такие решения принадлежат собственным измерениям читателя, и статья, оставляющая их там, поступает правильно. Это только одно: не отсутствует ли что-то, что мешает вам начать.

Что это значит на практике, для любого из двух читателей:

  • GraphQL-запрос из §6 был выполнен против живой очереди, а набор его полей подтверждён интроспекцией схемы, а не процитирован из документации. Форма ответа на пустую очередь напечатана, потому что именно её решающий джоб видит чаще всего.

  • Скелет workflow приведён дословно из стенда, и обе купюры помечены как купюры, а не срезаны молча.

  • Политика ожидания напечатана до числа — интервал опроса, потолок, таймаут джоба и что происходит по истечении, — потому что переписать цикл по его описанию читатель не может.

  • Каждое измерение говорит, каким прибором получено, а те, что смоделированы, а не измерены, говорят об этом в той же фразе, которая их несёт.

  • Исходник прозы этой страницы — study.md, рядом в том же каталоге. Стенд, на который она ссылается, лежит в том же репозитории. Ничего здесь не спрятано за формой.

Чего мы не публикуем — так это рабочего журнала: раундов, брифов, стоимостей, имён машин. Он сохранён, и названо, что сохранён, а не оставлен дырой, потому что читатель, наткнувшийся на необъяснённый пробел, перестаёт доверять и тем частям, которые на месте.


Английский оригинал этой статьи — https://saharkit.github.io/windowsill/merge-queue/. Рабочий стенд, воркфлоу и полная сетка случаев лежат в репозитории: https://github.com/saharkit/windowsill/tree/main/docs/merge-queue.