✍️ DCO и CLA: приём внешнего вклада

Статус: разбор + инвентаризация · 2026-08-17. Текст DCO 1.1 и механика приложения проверены по первоисточникам (developercertificate.org, dcoapp/app); состояние наших репозиториев — по коду, а не по докам. ⚠️ Эта заметка отменяет прежнюю рекомендацию из oss-services («поставить DCO app как времянку, CLA позже, форкнуть contributor-assistant») — оказалось, что полноценный CLA у нас уже реализован, и лучше, чем предлагавшийся сторонний вариант.

1. Что такое DCO

Developer Certificate of Origin 1.1 (© 2004, 2006 The Linux Foundation) — придуман для ядра Linux после иска SCO, чтобы иметь дешёвый след «кто и на каком основании прислал этот код». Это не подпись под договором, а утверждение самого контрибьютора в четырёх пунктах. Делая вклад, он удостоверяет, что:

  • (a) вклад создан им (полностью или частично) и он имеет право отправить его под лицензией, указанной в файле; либо
  • (b) вклад основан на предыдущей работе под подходящей открытой лицензией, и он имеет право отправить её с модификациями под той же лицензией; либо
  • (c) вклад передан ему напрямую человеком, удостоверившим (a), (b) или (c), и он его не изменял;
  • (d) он понимает, что проект и вклад публичны, а запись о вкладе (включая всю личную информацию и сам sign-off) хранится бессрочно и может распространяться вместе с проектом.

Технически «подпись» — это трейлер в сообщении коммита:

Fix the dice pool reset on forced pass
 
Signed-off-by: Random J Developer <random@developer.example.org>

Git умеет добавлять его сам: git commit -s.

Не путать sign-off и signed commits

Signed-off-by (DCO) — обычная строка текста, юридическое утверждение. Signed commits (git commit -S) — криптографическая подпись GPG/SSH, подтверждающая авторство технически. Это совершенно разные вещи, и требуются они разными настройками (у GitHub есть правило «require signed commits» — оно про GPG, не про DCO).

2. Как DCO проверяется на pull request’ах

Стандартный инструмент — DCO GitHub App (probot, репозиторий dcoapp/app, ISC). Живой и поддерживаемый: последний коммит — 2026-08-16, ~341 звезда. Что делает:

  • на каждый PR создаёт check run, требующий, чтобы у каждого коммита (кроме коммитов ботов и merge-коммитов) был Signed-off-by с email, совпадающим с автором коммита;
  • если подписи нет — блокирует PR, пока автор не перепушит подписанную версию коммита (модель ядра Linux);
  • перезапуск проверки — ревью с строкой @dcoapp recheck.

Полезные настройки через .github/dco.yml в дефолтной ветке:

НастройкаЧто даёт
allowRemediationCommits: individual: trueавтор может «дописать» подпись отдельным follow-up коммитом вместо перезаписи истории
allowRemediationCommits: thirdParty: trueподписать за автора может третье лицо (требует включённого individual)
require: members: falseне требовать подпись с коммитов участников организации — но ⚠️ только с тех, что подписаны GPG («authored and signed by members»). В репозитории, принадлежащем пользователю, владелец может пушить без sign-off

Чтобы проверка реально блокировала мерж, а не просто краснела, её надо сделать required status check — то есть нужна защита ветки. Напоминание: на Free-плане branch protection доступна только для публичных репозиториев (org-migration-benefits §2).

3. DCO против CLA: в чём принципиальная разница

DCOCLA
Природасамоутверждение контрибьюторадоговор между контрибьютором и владельцем проекта
Трениеноль (git commit -s)нужно «подписать» (реестр, форма или SaaS)
Что даёт проектуподтверждение права отправить код под лицензией репозиторияявную лицензию на копирайт и обычно патентный грант владельцу
Право релицензировать вкладнетда (если так написано в CLA)

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

Ретроактивный переход DCO → CLA — это катастрофа

Если год принимать вклад по DCO, а потом понадобится CLA, придётся догонять каждого прошлого контрибьютора за разрешением (или переписывать его код с нуля). Классический кошмар open-core-проектов. Поэтому решение принимается до первого внешнего PR.

Важная лицензионная тонкость: под MIT/Apache релицензирование в закрытый продукт разрешает сама лицензия (MIT допускает sublicensing), поэтому для MIT-репозиториев CLA почти не нужен. Под AGPL — нужен обязательно.

4. Что у нас уже есть (проверено по коду)

CLA v1.0 полностью реализован в 4 ядровых репозиториях: dicechess-engine-scala, dicechess-play, dicechess-play-api, dicechess-analytics. В каждом: CLA.md (полноценный ICLA — определения, грант копирайта, патентный грант, заверения, «no obligation and no warranty»), реестр подписей .github/cla-signatures.json (сейчас пустой, claVersion: "1.0"), workflow CI: CLA и раздел в CONTRIBUTING.md, который прямо объясняет выбор CLA вместо DCO.

Реализация продумана и лучше того, что я раньше предлагал (форк архивированного contributor-assistant): никакого стороннего сервиса, ноль зависимостей от чужого SaaS, self-service подпись «добавь себя в JSON тем же PR — коммит и есть электронная подпись». Workflow на pull_request_target с честным комментарием, почему это безопасно: логика берётся из базовой ветки (PR не может её обезвредить), а реестр читается из head PR как данные (парсится как JSON, никогда не исполняется) — именно чтобы подпись можно было поставить в первом же PR. Action запинен по SHA. Проверяется и версия CLA: подпись под старой версией даёт внятную ошибку с просьбой перечитать и обновить запись.

Вывод: DCO app ставить не нужно. Он был бы слабее уже действующего механизма, а на репозиториях с CLA — ещё и путал бы контрибьюторов двумя разными обрядами.

5. Найденные пробелы (это и есть работа)

5.1 ⚠️ Мина миграции: owner-skip сломается в организации

В cla.yaml пропуск владельца сделан так:

if (author === context.repo.owner) { /* CLA not required */ }

context.repo.owner — владелец репозитория, в котором выполняется workflow. После переноса в организацию это станет fortemate, а автор PR — rabestro. Условие никогда не совпадёт → проверка начнёт падать на собственных PR владельца, требуя подписать CLA у самого себя.

Сломается ровно в момент переноса каждого из этих 4 репозиториев (brand-fortemate §8, фаза D). Варианты исправления (решить до переноса):

  1. Проверять права, а не имя — repos.getCollaboratorPermissionLevel и пропускать admin/maintain. Устойчиво к смене владельца и к появлению второго мейнтейнера (супруга).
  2. Явный allowlist мейнтейнеров в реестре или в самом workflow.
  3. Добавить себя в реестр подписей — работает, но юридически бессмысленно (подписывать договор с самим собой) и не решает проблему для будущих мейнтейнеров.

Реализовано и обкатано в dicechess-bot-java (PR #46) вариантом лучше первоначального: вместо запроса прав через API используется author_association из webhook-пейлоада (OWNER для личных репо, MEMBER для участников организации) — ноль лишних API-вызовов, никаких дополнительных прав токена, устойчиво к переносу. Оттуда и брать при бэкпорте.

Компромисс, который стоит назвать: освобождение всех MEMBER означает, что любой будущий участник организации сможет вносить вклад без подписи. Для организации из 1–2 мейнтейнеров это верно (DCO app делает тот же выбор через require.members: false); пересмотреть, если организация вырастет за пределы мейнтейнеров.

5.1b ⚠️ Вторая дыра: версия соглашения читалась из head PR

Найдено ревью CodeRabbit на PR #46 и исправлено там же. Исходная логика читала из head PR весь реестр — и подписи, и авторитетный claVersion:

const current = registry.claVersion;                        // из head — под контролем автора PR
if (entries.some((e) => e.claVersion === current)) { PASS }  // из head

Контрибьютор мог в своём же PR (а) выставить произвольную версию и «подписать» её — тогда запись в истории не привязана к реальному тексту соглашения; (б) удалить верхнеуровневое поле — тогда undefined === undefined даёт true, и проверка проходит с записью вообще без версии.

Исправление: авторитетный claVersion читается из базового коммита (context.payload.pull_request.base.sha) и валидируется как непустая строка; подписи по-прежнему читаются из head — чтобы подпись в первом же PR продолжала работать. Скос версий при бампе соглашения не мешает: бамп — это PR мейнтейнера, а мейнтейнеры освобождены.

🔑 Обе дыры (5.1 и 5.1b) есть дословно в engine-scala, play, play-api, analytics — workflow копировался оттуда. Бэкпортить обе вместе, до переноса этих репо в организацию.

5.2 AGPL-репозиторий без CLA: dicechess-bot-java

Лицензии бот-репозиториев:

РепозиторийЛицензияCLAНужен ли
dicechess-bot-javaAGPL-3.0❌ нет✅ да — AGPL не даёт права релицензирования
dicechess-bot-pythonMIT❌ нетопционально (MIT сам разрешает sublicensing)
dicechess-bot-typescriptMIT❌ нетопционально
dicechess-bot-randomMIT❌ нетопционально
dicechess-reference-botMIT❌ нетопционально
dicechess-bot-runtimeMIT❌ нетопционально

dicechess-bot-java — самый вероятный получатель внешнего PR (флагманский стартер-шаблон, живёт в организации, витрина платформы) — был под AGPL и без CLA, без CONTRIBUTING.md вообще.

✅ Закрыто 17.08 (решение владельца: раскатать CLA, MIT невозможен — репо линкует AGPL-движок). PR #46 в fortemate/dicechess-bot-java: CLA.md v1.0 (текст идентичен ядру), реестр подписей, workflow CI: CLA с обоими исправлениями выше, CONTRIBUTING.md с разделом «почему CLA, а не DCO» и — важно для стартера — явной фразой, что форк шаблона для своего бота не требует подписывать ничего (CLA только про вклад обратно; желающим permissive указаны MIT-стартеры).

Остальные стартеры (bot-python, bot-typescript, bot-random, reference-bot, bot-runtime) под MIT — CLA им правомерно не нужен: sublicensing разрешает сама лицензия. Именно поэтому разное обращение с bot-java и reference-bot — не непоследовательность, хотя оба линкуют движок: решает лицензия самого репозитория.

5.3 Мелочи

  • В MIT-репозиториях нет CONTRIBUTING.md — даже без CLA полезно описать, как присылать вклад.
  • При squash-мерже трейлеры (Signed-off-by, Co-Authored-By) сохраняются в теле squash-коммита только если их не вырезали из сообщения. Проверки это не касается (она смотрит коммиты PR), но след в истории main зависит от аккуратности мерж-сообщения.

6. Про ИИ-ассистированный вклад

Ни DCO, ни наш CLA не содержат отдельного пункта про сгенерированный ИИ код. Практика, которой мы фактически следуем: ответственность несёт человек, отправляющий PR (он и удостоверяет право на отправку), а участие инструмента документируется трейлером Co-Authored-By по ранбуку dc-pr. Юридическая определённость тут в индустрии пока не устоялась — если проект начнёт принимать внешний вклад, стоит один раз явно написать политику в CONTRIBUTING.md, а не оставлять её подразумеваемой.

🔗 Связанное

  • oss-services — каталог сервисов; рекомендация про DCO app там отменена этой заметкой.
  • org-migration-benefits — миграция в организацию; §5.1 выше добавляет ей ещё один пункт цены переноса.
  • brand-fortemate §8 — фазовый план (фаза D).