Перейти к основному содержимому

ADR 0028 — Защита веток: расхождение между providers и матрица одобрения

Контекст​

ADR 0001 нормализовал девять операций git в единый контракт. Восемь из них расходятся между GitHub и GitLab лишь ФОРМОЙ API — имя поля, формат id, код ошибки, — и нормализация это решает. protectBranch — исключение: там оба расходятся в модели, и перевод невозможен без потери смысла.

Расхождение было зафиксировано в комментарии в обоих providers с Фазы 2. Этот ADR повышает его до явного решения, потому что начиная с Фазы 4 у домена появилась собственная матрица одобрения — QA → SecOps → пользователь, с пределом замка merge в decide.ts, — и теперь существуют два источника полномочий над одним и тем же merge. Этого наложения не было, когда писался комментарий.

Что каждый provider делает сегодня​

ProtectBranchInput (packages/shared) несёт лишь externalId, branchName и accessToken — никакой конфигурации политики. Каждая реализация выбирает "самое строгое разумное", и это оказывается в разных местах:

providerмодель платформычто мы применяем
GitHubнезависимые правила в богатом payloadenforce_admins: true, required_approving_review_count: 1, без status checks, без ограничения push
GitLabдва уровня доступаpushAccessLevel: MAINTAINER, mergeAccessLevel: MAINTAINER
localплатформы не существуетcapabilities.protectBranch: false; вызов отклоняется с GitNotSupportedError

Шаг protect_branches в bootstrap проверяет capability перед действием, а контрактная suite утверждает по capability, а не по provider, — поэтому расхождение никогда не ломало тест: оба пути одинаково валидны для контракта.

Асимметрия, которую выявляет матрица одобрения​

Две защиты — не "одно и то же, записанное по-разному". Они взаимодействуют противоположным образом с матрицей домена:

  • В GitHub мы создаём второго носителя полномочий. required_approving_review_count: 1 требует одобрения ПЛАТФОРМЫ, которого домен не знает и не заполняет, — заключения QA и SecOps — это наши события, а не reviews GitHub. В сочетании с enforce_admins: true, который убирает обход администратором, ручной merge пользователя — который CLAUDE.md делает обязательным — может оказаться заблокированным платформой, когда нет второго человека для одобрения PR.
  • В GitLab мы не создаём никаких полномочий. Нет подсчёта одобрений: тот, у кого роль Maintainer, делает push и merge напрямую. Матрица домена — ЕДИНСТВЕННЫЕ ворота, и токен Maintainer обходит их целиком.
  • В local существуют только ворота домена, по построению.

То есть: одна и та же система строже задуманного у одного provider и мягче у другого, одним и тем же protectBranch() без аргументов.

Решение​

1. Матрица одобрения домена — источник истины. QA → SecOps → пользователь, с пределом decide.ts (merge с назначением в защищённую ветку никогда не автоодобряется). Защита платформы — эшелонированная защита от доступа в обход Brabo — это не ворота, и никакая логика домена не должна от неё зависеть.

2. Расхождение остаётся. Перевод двух моделей к общему знаменателю означал бы опустить GitHub до уровня GitLab (потеряв enforce_admins) или выдумать в GitLab понятие одобрения, которого платформа не имеет на бесплатном тарифе. Каждая сторона применяет самое строгое, что способна выразить, а контракт обещает лишь наблюдаемое: listBranches возвращает protected: true.

3. ProtectBranchInput сейчас НЕ получает конфигурацию. Добавление requiredApprovals, enforceAdmins и подобного создало бы словарь, который лишь один из providers умеет соблюдать, а другому пришлось бы молча игнорировать, — что хуже нынешнего расхождения, потому что начало бы лгать. Когда появится реальная необходимость, путь — нормализованный ProtectionPolicy, где provider объявляет через capabilities, что умеет применять, а bootstrap сообщает, что было проигнорировано.

Последствия​

Принятые:

  • Разная жёсткость по provider, задокументированная здесь и в комментариях обоих файлов, которые начинают указывать на этот ADR.
  • В GitHub у репозитория с единственным владельцем ручной merge может быть заблокирован той самой защитой, которую мы применяем. Обход — за оператором (снизить required_approving_review_count до 0 в репозитории), а не за кодом, — изменение по умолчанию ослабило бы защиту во всех репозиториях ради решения частного случая.

Не проверено:

  • Ни один из этих двух путей не был задействован против реального API в этом репозитории. Smokes (github-provider.smoke.spec.ts, gitlab-provider.smoke.spec.ts) ручные и пропускаются без GITHUB_TEST_TOKEN / GITLAB_TEST_TOKEN; CI использует LocalGitProvider, чья capability — false. Описанная выше блокировка merge — вывод из задокументированного поведения двух платформ, а не наблюдение. При подключении первого реального репозитория проверка этого — первый тест, который нужно сделать.

Вне области:

  • Отражать заключения QA/SecOps как status checks GitHub, что устранило бы наложение, превратив матрицу домена в условие merge платформы. Это правильное решение, и оно зависит от того, будет ли у Brabo публичный endpoint, который мог бы вызывать GitHub, — см. ожидающий пункт registry/публикации в ADR 0027.