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

ADR: Стратегия слияния веток

  • Status: Accepted by Andrei Ganiushkin andrey@ganyushkin.ru
  • Date: 2026-07-26
  • Authors: Andrei Ganiushkin andrey@ganyushkin.ru
  • Related issues: -

Context​

К проекту предъявлено требование: прослеживаемость истории изменений и подтверждение авторов изменений (с гарантиями подписи коммитов).

Это требование уже частично реализовано двумя принятыми решениями:

  • ADR 0004 — каждый коммит контрибьютора обязан быть подписан его личным GPG-ключом (git commit -S) и снабжён трейлером Signed-off-by:. Ключ сверяется с pgp_key_id в CONTRIBUTORS.json.
  • ADR 0005 — артефакты релиза подписываются человеком отдельно по каждой целевой платформе, а файлы хешей и подписей коммитятся в release/* подписанным коммитом.

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

Отсюда ключевой вопрос данного ADR: как выполнять слияние веток в рамках модели ветвления (ADR 0006), чтобы оригинальные подписи коммитов сохранялись в репозитории и оставались проверяемыми на всей глубине истории.

Почему это отдельное решение​

Требование «коммиты подписаны» бессмысленно, если подписи теряются при интеграции изменений. Возможны три типа поведения при слиянии, и каждый по-разному влияет на подписи:

ОперацияЧто происходит с исходными коммитамиПодписи авторов
fast-forwardуказатель ветки переносится на существующие коммитысохраняются побитово
merge-commit (--no-ff)исходные коммиты сохраняются, добавляется новый коммит слияниясохраняются, но добавляется коммит, который тоже нужно подписывать
squash / rebase на стороне платформысоздаются новые объекты коммитовуничтожаются

Таким образом, требование к репозиторию формулируется как инвариант:

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

Дополнительное ограничение со стороны релиза​

Для release/* и main действует более жёсткое следствие (ADR 0005): CD пересобирает релиз по тегу и проверяет уже существующую человеческую подпись хеш-сумм. Значит, коммит, на который ставится релизный тег, обязан давать побитово те же артефакты, что и коммит, подписанный maintainer: дерево тегового коммита должно совпадать с деревом подписанного коммита. Любое слияние, изменяющее содержимое относительно подписанного состояния, ломает подпись артефактов молча — до момента пересборки в CD.

Ветки, к которым применяется решение​

Модель ветвления (ADR 0006) определяет шесть типов веток и шесть направлений слияния, для каждого из которых нужна стратегия:

  • feature/* → develop — приём новой функциональности;
  • feature/* → support/* — заказная функциональность в LTS-версию;
  • fix/* → develop — приём исправлений;
  • fix/* → release/* — hotfix в замороженный релиз;
  • fix/* → support/* — исправление в поддерживаемую старую версию;
  • develop/release/* → main — выпуск релиза (release/* — единственный шлюз в main).

Decision​

Выбрать единую стратегию для всех направлений слияния: fast-forward-only (--ff-only) с предварительной подготовкой ветки-источника на стороне автора.

Ни одно слияние в проекте не создаёт и не пересоздаёт коммиты на стороне платформы: платформа только перемещает указатель ветки на уже существующие, уже подписанные объекты коммитов.

Правила по направлениям слияния​

  1. feature/* → develop, fix/* → develop, feature/* → support/* и fix/* → support/*. Ветка приводится автором к состоянию «ровно один коммит поверх актуального целевого состояния». Автор сам выполняет rebase/squash локально и сам подписывает результирующий коммит, после чего слияние выполняется --ff-only:

    git rebase -i --gpg-sign origin/develop # или origin/support/vX.Y.x
    git commit --amend -S -s # подпись автора на итоговом коммите
    git push --force-with-lease
    # приёмка на стороне платформы:
    git checkout develop && git merge --ff-only feature/xyz
  2. fix/* → release/*. Те же правила: один коммит, подписанный автором, слияние --ff-only. Допускается только до первого коммита платформенной подписи. После него release/* заморожена (ADR 0005) и требуется новый -rc.X. Для патч-релизов из support/* правила аналогичны: fix/* вливается в support/* до начала процесса подписи патча.

  3. develop → release/*. release/* создаётся ответвлением от актуального develop (или от main для hotfix-релиза) — слияние как таковое не выполняется, история переносится без пересоздания коммитов.

  4. release/* → main. Только --ff-only. Роль маркера релиза играют последние коммиты release/* — подписанные maintainer коммиты платформенных подписей (Sign release artifacts for vX.Y.Z (<P>)). При необходимости после них добавляется пустой подписанный коммит Release vX.Y.Z, который не меняет дерево и потому не ломает подписи артефактов:

    git commit --allow-empty -S -s -m "Release v1.0.0"
    git checkout main && git merge --ff-only release/v1.0.0
    git tag -a -s v1.0.0 -m "Release v1.0.0"

Общие правила реализации​

  1. Все переписывающие историю операции (rebase, squash, amend) выполняются только автором изменения локально, до приёмки, и завершаются повторной подписью автора. Платформа не переписывает коммиты никогда.
  2. На уровне платформы для всех защищённых веток (main, develop, release/*, support/*) отключены режимы «merge commit» и «squash and merge». Разрешен только fast-forward merge.
  3. Если fast-forward невозможен (целевая ветка ушла вперёд), MR не принимается: автор перебазирует ветку, заново подписывает коммит и обновляет MR. Автоматический rebase на стороне платформы запрещён — он создаст коммиты без подписи автора.
  4. CI проверяет на каждом MR: валидность GPG-подписи каждого коммита ветки-источника, совпадение ключа с pgp_key_id автора в CONTRIBUTORS.json, наличие Signed-off-by:, возможность fast-forward.
  5. Push и force-push в main и develop запрещены всем участникам. force-push разрешён только автору в собственной feature/*/fix/* (--force-with-lease) — до приёмки.
  6. Релизный тег — аннотированный и подписанный (git tag -a -s) — ставится на последний коммит release/*, то есть на коммит, подписанный человеком.
  7. Версия и метаданные сборки берутся из Cargo.toml, а не из git describe/хеша коммита, — иначе любой новый коммит изменит артефакты и сломает подпись релиза.

Considered Alternatives​

Alternative A: Merge-commit без fast-forward (--no-ff) для всех веток​

Каждое слияние оформляется отдельным коммитом слияния, создаваемым платформой.

  • Pros: явная точка интеграции в истории. Видно, из какой ветки пришло изменение. Исходные коммиты не пересоздаются, их подписи формально сохраняются.
  • Cons: коммит слияния создаётся автоматикой платформы и по умолчанию не подписан — в истории появляются коммиты без подтверждённого автора, что напрямую нарушает требование «каждый коммит подписан» (ADR 0004). Подписать его от имени человека платформа не может, а подпись сервисным ключом уже отклонена в ADR 0004 как подмена личности автора конвейером. Нелинейная история усложняет аудит и git bisect. Для main тег оказывается на коммите слияния, а не на подписанном коммите, из-за чего дерево может не совпасть с подписанным и подпись артефактов станет невалидной уже после приёмки релиза.
  • Reason for rejection: вводит в историю неподписанные коммиты, созданные автоматикой, — то есть ровно то, что требование о гарантиях подписи запрещает.

Alternative B: Squash and merge на стороне платформы​

Платформа сворачивает все коммиты ветки в один новый коммит в целевой ветке.

  • Pros: максимально компактная и линейная история. Один MR = один коммит. Не требует от автора дисциплины при подготовке ветки.
  • Cons: squash создаёт новый объект коммита, поэтому GPG-подписи всех исходных коммитов уничтожаются безвозвратно. Результирующий коммит либо не подписан, либо подписан сервисным ключом платформы. Авторство сводится к полю author, которое тривиально подделывается и не даёт криптографической гарантии. Теряется связь «подпись — состояние кода», а для release/* дополнительно исчезают коммиты платформенных подписей.
  • Reason for rejection: прямо уничтожает предмет требования — оригинальные подписи авторов вместо гарантии остаётся декларация авторства.

Alternative C: Автоматический rebase ветки-источника на стороне платформы​

Платформа сама перебазирует ветку на целевую перед слиянием и делает fast-forward.

  • Pros: линейная история без ручной работы автора. fast-forward гарантирован. Устраняет необходимость повторного перебазирования при гонке MR.
  • Cons: rebase пересоздаёт коммиты с новыми хешами родителей, поэтому подписи авторов становятся невалидными. Платформа не имеет и не должна иметь приватных ключей контрибьюторов, чтобы переподписать результат. Итог — линейная, но неподтверждённая история.
  • Reason for rejection: технически невозможно сохранить подпись автора при пересоздании коммита третьей стороной. Отличается от Alternative B только моментом потери подписи.

Alternative D: Смешанная стратегия (squash в develop, FF в main)​

Для веток разработки — squash ради компактности, для релизного пути — строгий fast-forward.

  • Pros: компромисс между чистотой истории разработки и строгостью релизного пути. main сохраняет валидные подписи и корректный тег.
  • Cons: подписи авторов теряются уже на этапе feature/* → develop, а значит в main попадают коммиты, которые никто из авторов не подписывал. Требование прослеживаемости выполняется только формально — на уровне релизных коммитов, но не на уровне вкладов. Два разных набора правил усложняют автоматизацию и обучение контрибьюторов.
  • Reason for rejection: гарантия подписи должна действовать на всей глубине истории. Потеря подписи в develop делает подписи в main бессмысленными, так как исходное авторство уже неподтверждаемо.

Alternative E: Fast-forward-only без требования «один коммит на MR»​

Слияние только fast-forward, но ветка может содержать произвольное число коммитов автора.

  • Pros: полностью сохраняет все подписи. Сохраняет детальную историю работы автора. Меньше требований к автору перед приёмкой.
  • Cons: история целевых веток засоряется промежуточными коммитами (wip, fix typo), по которым git bisect и аудит менее информативны. Каждый промежуточный коммит всё равно должен быть подписан и пройти проверки, что увеличивает объём проверок.
  • Reason for rejection: не отклонён по сути — это тот же механизм, что и в принятом решении. Требование «ровно один коммит» добавлено как дисциплина оформления, а не как ограничение безопасности, и зафиксировано в модели ветвления.

Consequences​

Positive​

  • Оригинальные объекты коммитов сохраняются во всех ветках проекта: GPG-подписи авторов остаются валидными на всей глубине истории, а не только на момент MR.
  • В истории отсутствуют коммиты, созданные автоматикой платформы, — каждый коммит имеет подтверждённого человека-автора из CONTRIBUTORS.json.
  • История всех веток линейна: упрощаются аудит, git bisect и разбор инцидентов «кто, когда и с каким согласием внёс изменение».
  • Релизный тег стоит на коммите, подписанном человеком, поэтому пересобранные CD артефакты побитово совпадают с подписанными хешами и подпись остаётся валидной без дополнительных проверок равенства деревьев.
  • Расхождение веток обнаруживается на этапе fast-forward-слияния — до приёмки, а не после того как подпись перестала соответствовать содержимому.
  • Гарантия переносима между платформами: она обеспечивается свойствами Git-объектов, а не функциональностью конкретного хостинга.

Negative​

  • Нагрузка переносится на автора: перед приёмкой он обязан перебазировать ветку, свернуть коммиты и заново подписать результат.
  • При высокой частоте слияний возможна «гонка» MR: пока автор готовит ветку, целевая ветка уходит вперёд, и подготовку нужно повторять.
  • Нельзя пользоваться удобными кнопками «Squash and merge» / «Rebase and merge» платформ — требуется отдельная настройка защиты веток и обучение контрибьюторов.
  • Детализация промежуточной работы автора теряется из-за требования «один коммит на MR» (сознательный размен на читаемость истории).

Risks and Mitigations​

  • Risk: платформа хостинга по умолчанию включает squash/merge-commit, и неподписанный коммит попадает в защищённую ветку незаметно.
    • Mitigation: явное отключение обоих режимов в настройках защиты веток. CI-проверка, что каждый коммит защищённой ветки имеет валидную подпись из CONTRIBUTORS.json, — как страховка от изменения настроек.
  • Risk: контрибьютор выполняет rebase и забывает подписать результирующий коммит.
    • Mitigation: обязательная CI-проверка подписи на каждом MR. Локально — commit.gpgsign true и rebase.gpgSign true в документации для контрибьюторов.
  • Risk: fast-forward невозможен из-за расхождения ветки-источника с целевой, и релиз/приёмка блокируются.
    • Mitigation: проверка возможности fast-forward как обязательный шаг MR. Дисциплина ответвления release/* только от актуального main/develop. Короткоживущие feature/*/fix/*.
  • Risk: сборка релиза зависит от метаданных коммита (git describe, SHA, дата сборки), из-за чего любой новый коммит меняет артефакты и делает подпись невалидной.
    • Mitigation: версия и метаданные берутся только из Cargo.toml. Воспроизводимая сборка — закреплённый toolchain, --locked, SOURCE_DATE_EPOCH, --remap-path-prefix, базовые образы по digest.
  • Risk: изменение кода в release/* после первого коммита платформенной подписи делает подписи артефактов недействительными незаметно для команды.
    • Mitigation: заморозка release/* на уровне процесса и защиты ветки. Новый -rc.X и повторная подпись всеми платформами.

Verification​

Корректность решения проверяется автоматическими проверками CI/CD:

СобытиеQGunitperformancee2e (use-case)Доп. проверки
push в feature/*✅✅——подпись каждого коммита. Signed-off-by:
push в fix/*✅✅——подпись каждого коммита. Signed-off-by:
MR feature/*/fix/* → develop✅✅✅✅ровно один коммит. FF возможен. Права и ключ по CONTRIBUTORS.json
MR feature/*/fix/* → support/*✅✅✅✅ровно один коммит. FF возможен. Права по CONTRIBUTORS.json
после слияния в develop✅✅✅✅все коммиты ветки подписаны. Отсутствие merge-коммитов
push в release/*✅✅✅✅обязательный суффикс -rc.X
push в support/*✅✅✅✅только maintainer. Подпись каждого коммита
MR fix/* → release/*✅✅✅✅ровно один коммит. FF возможен. Ветка не заморожена
коммит подписи платформы в release/*✅✅——автор — maintainer. gpg --verify подписи хешей ключом из CONTRIBUTORS.json
MR release/* → main✅✅✅✅версия = тег. Валидные подписи по всем платформам. FF возможен
push тега v*————пересборка по каждой платформе, sha256sum -c, gpg --verify, публикация артефактов с подписями

Дополнительно проверяется:

  • Тест сохранности подписей: после слияния в целевую ветку хеши коммитов ветки-источника не изменились, а git verify-commit успешен для каждого из них.
  • Тест отсутствия автоматических коммитов: git log --merges в main и develop пуст.
  • Тест FF-слияния: git merge --ff-only <source> завершается успешно и не создаёт коммит слияния. При расхождении веток MR отклоняется.
  • Тест соответствия дерева: дерево тегового коммита в main совпадает с деревом коммита, подписанного maintainer в release/*.
  • Тест валидности подписи после пересборки: пересборка релиза в CD по тегу с последующими sha256sum -c и gpg --verify для каждой платформы проходит успешно.

References​