From 425a6b652c60e3e0f7b1b6ac9fda9f7e96150a7b Mon Sep 17 00:00:00 2001 From: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:45:50 -0400 Subject: [PATCH] ci: fail pull requests with commits that lack a DCO sign-off Add a DCO workflow that reads the pull request's commits from the API and fails when any commit, other than a merge commit, has no Signed-off-by line matching its author's name and email. It runs on pull requests only, skips drafts and title or body edits, needs no checkout and uses only gh and jq from the hosted runner. Document the sign-off and what it certifies in CONTRIBUTING.md. Refs CryptOS-PKI/.github#4 Signed-off-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com> --- .github/CONTRIBUTING.md | 26 ++++++++++++- .github/workflows/job-dco.yaml | 67 ++++++++++++++++++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/job-dco.yaml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 5826396..c947373 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -7,8 +7,10 @@ This repository follows the Bugs5382 standard workflow. 1. Open an issue from a template (free-form issues are disabled). For multi-step work, use a parent issue with ordered sub-issues, and put it on the active milestone. 2. Branch from `main` as `/-` (for example `feat/12-add-listener`). -3. Commit using Conventional Commits (`type(scope): description`). No attribution - trailers, no emoji in source or commit messages (emoji are fine in Markdown). +3. Commit using Conventional Commits (`type(scope): description`) and sign off every + commit with `git commit -s` (see [Developer Certificate of Origin](#developer-certificate-of-origin)). + The sign-off is the only trailer: no attribution trailers, and no emoji in source or + commit messages (emoji are fine in Markdown). 4. Open a PR with a Conventional Commit title. The autolabeler sets the category label from the title; fill the PR template, reference the issue (`Closes #N`), and add a closing summary before merge. @@ -19,6 +21,26 @@ Keep one concern per PR, even small ones. When editing GitHub Actions workflows, plain identifier (a letter or `_`, then alphanumerics/`-`/`_`); put emoji and display text in the job's `name:`. The Actionlint check enforces this. +## Developer Certificate of Origin + +Contributions are accepted under the [Developer Certificate of Origin](https://developercertificate.org/) +(DCO) 1.1. Signing off a commit certifies that you wrote the change, or otherwise have the +right to submit it under this repository's licence (Apache License 2.0), and that the +contribution and your sign-off are public and kept permanently. The organization's +[contributing guide](https://github.com/CryptOS-PKI/.github/blob/main/CONTRIBUTING.md#developer-certificate-of-origin) +quotes the full certificate. + +Sign off with `git commit -s`. Git builds the trailer from your `user.name` and +`user.email`, and it has to match the commit's author: + +```text +Signed-off-by: Jane Doe +``` + +The DCO check fails a pull request when any commit, other than a merge commit, has no +matching sign-off. To sign off commits you have already pushed, run +`git rebase --signoff origin/main` and force-push the branch. + ## Local setup Install the governance hooks once per clone: `bash .claude/hooks/install.sh`. They diff --git a/.github/workflows/job-dco.yaml b/.github/workflows/job-dco.yaml new file mode 100644 index 0000000..66d9bf4 --- /dev/null +++ b/.github/workflows/job-dco.yaml @@ -0,0 +1,67 @@ +name: DCO +# Developer Certificate of Origin: every commit in a pull request must carry a +# `Signed-off-by:` trailer (`git commit -s`) naming the commit's author, with +# the same name and email (compared case-insensitively). Merge commits are +# skipped, since they add no authored change of their own. +# +# The commit list comes from the pull request API, so the job needs no +# checkout, no Node action and no third-party DCO app: `gh` and `jq` ship on +# the hosted runner. The API returns at most 250 commits for a pull request; +# a longer one fails with a message rather than passing unchecked. +# +# Pull requests only, and not while the PR is a draft. Title and body edits +# don't change the commits, so they skip; a base change (retarget) reruns. +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened, ready_for_review, edited] +# One run per PR per workflow: a new push cancels the older PR run. A title or +# body edit gets its own group, so its skipped run never cancels a real one. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }}${{ github.event.action == 'edited' && github.event.changes.base == null && format('-edit-{0}', github.run_id) || '' }} + cancel-in-progress: true +permissions: + contents: read + pull-requests: read +jobs: + dco: + name: ✍️ DCO sign-off${{ github.event.action == 'edited' && github.event.changes.base == null && ' (edit, not run)' || '' }} + if: github.event.pull_request.draft == false && (github.event.action != 'edited' || github.event.changes.base != null) + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: ✍️ Check every commit is signed off by its author + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + EXPECTED: ${{ github.event.pull_request.commits }} + run: | + set -euo pipefail + commits="$(gh api --paginate "repos/$REPO/pulls/$PR/commits?per_page=100" --jq '.[]' | jq -s '.')" + total="$(jq 'length' <<<"$commits")" + if [ "$total" -lt "$EXPECTED" ]; then + echo "::error::The API listed $total of $EXPECTED commits (it stops at 250). Split the pull request so every commit can be checked." + exit 1 + fi + missing="$(jq -r ' + .[] + | select((.parents | length) < 2) + | . as $k + | ($k.commit.author.name | ascii_downcase) as $name + | ($k.commit.author.email | ascii_downcase) as $email + | select([$k.commit.message + | scan("(?m)^Signed-off-by:[ \t]*([^<\n]*?)[ \t]*<([^>\n]*)>[ \t]*$") + | select((.[0] | ascii_downcase) == $name and (.[1] | ascii_downcase) == $email) + ] | length == 0) + | "\($k.sha[0:12]) \($k.commit.author.name) <\($k.commit.author.email)>" + ' <<<"$commits")" + if [ -n "$missing" ]; then + while IFS= read -r line; do + echo "::error::Commit $line has no Signed-off-by line matching its author." + done <<<"$missing" + echo "Sign off each commit with 'git commit -s'. To fix commits already pushed," + echo "run 'git rebase --signoff origin/main' and force-push the branch." + exit 1 + fi + echo "All $total commits carry a sign-off matching their author."