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

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. developrelease/*. 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