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) с
предварительной подготовкой ветки-источника на стороне автора.
Ни одно слияние в проекте не создаёт и не пересоздаёт коммиты на стороне платформы: платформа только перемещает указатель ветки на уже существующие, уже подписанные объекты коммитов.
Правила по направлениям слияния
-
feature/*→develop,fix/*→develop,feature/*→support/*иfix/*→support/*. Ветка приводится автором к состоянию «ровно один коммит поверх актуального целевого состояния». Автор сам выполняетrebase/squashлокально и сам подписывает результирующий коммит, после чего слияние выполняется--ff-only:git rebase -i --gpg-sign origin/develop # или origin/support/vX.Y.xgit commit --amend -S -s # подпись автора на итоговом коммитеgit push --force-with-lease# приёмка на стороне платформы:git checkout develop && git merge --ff-only feature/xyz -
fix/*→release/*. Те же правила: один коммит, подписанный автором, слияние--ff-only. Допускается только до первого коммита платформенной подписи. После негоrelease/*заморожена (ADR 0005) и требуется новый-rc.X. Для патч-релизов изsupport/*правила аналогичны:fix/*вливается вsupport/*до начала процесса подписи патча. -
develop→release/*.release/*создаётся ответвлением от актуальногоdevelop(или отmainдля hotfix-релиза) — слияние как таковое не выполняется, история переносится без пересоздания коммитов. -
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.0git tag -a -s v1.0.0 -m "Release v1.0.0"
Общие правила реализации
- Все переписывающие историю операции (
rebase,squash,amend) выполняются только автором изменения локально, до приёмки, и завершаются повторной подписью автора. Платформа не переписывает коммиты никогда. - На уровне платформы для всех защищённых веток (
main,develop,release/*,support/*) отключены режимы «merge commit» и «squash and merge». Разрешен только fast-forward merge. - Если fast-forward невозможен (целевая ветка ушла вперёд), MR не принимается: автор перебазирует ветку, заново подписывает коммит и обновляет MR. Автоматический rebase на стороне платформы запрещён — он создаст коммиты без подписи автора.
- CI проверяет на каждом MR: валидность GPG-подписи каждого коммита ветки-источника, совпадение
ключа с
pgp_key_idавтора вCONTRIBUTORS.json, наличиеSigned-off-by:, возможность fast-forward. - Push и force-push в
mainиdevelopзапрещены всем участникам. force-push разрешён только автору в собственнойfeature/*/fix/*(--force-with-lease) — до приёмки. - Релизный тег — аннотированный и подписанный (
git tag -a -s) — ставится на последний коммитrelease/*, то есть на коммит, подписанный человеком. - Версия и метаданные сборки берутся из
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, — как страховка от изменения настроек.
- Mitigation: явное отключение обоих режимов в настройках защиты веток. CI-проверка, что
каждый коммит защищённой ветки имеет валидную подпись из
- Risk: контрибьютор выполняет
rebaseи забывает подписать результирующий коммит.- Mitigation: обязательная CI-проверка подписи на каждом MR. Локально —
commit.gpgsign trueиrebase.gpgSign trueв документации для контрибьюторов.
- Mitigation: обязательная CI-проверка подписи на каждом MR. Локально —
- Risk: fast-forward невозможен из-за расхождения ветки-источника с целевой, и релиз/приёмка
блокируются.
- Mitigation: проверка возможности fast-forward как обязательный шаг MR. Дисциплина
ответвления
release/*только от актуальногоmain/develop. Короткоживущиеfeature/*/fix/*.
- Mitigation: проверка возможности fast-forward как обязательный шаг MR. Дисциплина
ответвления
- Risk: сборка релиза зависит от метаданных коммита (
git describe, SHA, дата сборки), из-за чего любой новый коммит меняет артефакты и делает подпись невалидной.- Mitigation: версия и метаданные берутся только из
Cargo.toml. Воспроизводимая сборка — закреплённый toolchain,--locked,SOURCE_DATE_EPOCH,--remap-path-prefix, базовые образы по digest.
- Mitigation: версия и метаданные берутся только из
- Risk: изменение кода в
release/*после первого коммита платформенной подписи делает подписи артефактов недействительными незаметно для команды.- Mitigation: заморозка
release/*на уровне процесса и защиты ветки. Новый-rc.Xи повторная подпись всеми платформами.
- Mitigation: заморозка
Verification
Корректность решения проверяется автоматическими проверками CI/CD:
| Событие | QG | unit | performance | e2e (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
- ADR 0004: Подпись коммитов контрибьютором.
- ADR 0005: Подпись артефактов релиза контрибьютором по каждой платформе.
- ADR 0006: Модель ветвления.
RELEASING.ru.md,RELEASING.md.CONTRIBUTING.ru.md,CONTRIBUTING-CAA.ru.md.CONTRIBUTORS.json.