Skip to content

Commit a044756

Browse files
authored
ci: fail pull requests with commits that lack a DCO sign-off (#127)
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> Co-authored-by: Bugs5382 <12115015+Bugs5382@users.noreply.github.com>
1 parent 21a0b15 commit a044756

2 files changed

Lines changed: 91 additions & 2 deletions

File tree

‎.github/CONTRIBUTING.md‎

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ This repository follows the Bugs5382 standard workflow.
77
1. Open an issue from a template (free-form issues are disabled). For multi-step work,
88
use a parent issue with ordered sub-issues, and put it on the active milestone.
99
2. Branch from `main` as `<type>/<issue#>-<slug>` (for example `feat/12-add-listener`).
10-
3. Commit using Conventional Commits (`type(scope): description`). No attribution
11-
trailers, no emoji in source or commit messages (emoji are fine in Markdown).
10+
3. Commit using Conventional Commits (`type(scope): description`) and sign off every
11+
commit with `git commit -s` (see [Developer Certificate of Origin](#developer-certificate-of-origin)).
12+
The sign-off is the only trailer: no attribution trailers, and no emoji in source or
13+
commit messages (emoji are fine in Markdown).
1214
4. Open the PR as a draft. CI skips drafts, so run the checks locally first and mark the
1315
PR ready for review when it is finished; that starts CI. PRs from forks run the same
1416
checks.
@@ -22,6 +24,26 @@ Keep one concern per PR, even small ones. When editing GitHub Actions workflows,
2224
plain identifier (a letter or `_`, then alphanumerics/`-`/`_`); put emoji and display text in the
2325
job's `name:`. The Actionlint check enforces this.
2426

27+
## Developer Certificate of Origin
28+
29+
Contributions are accepted under the [Developer Certificate of Origin](https://developercertificate.org/)
30+
(DCO) 1.1. Signing off a commit certifies that you wrote the change, or otherwise have the
31+
right to submit it under this repository's licence (Apache License 2.0), and that the
32+
contribution and your sign-off are public and kept permanently. The organization's
33+
[contributing guide](https://github.com/CryptOS-PKI/.github/blob/main/CONTRIBUTING.md#developer-certificate-of-origin)
34+
quotes the full certificate.
35+
36+
Sign off with `git commit -s`. Git builds the trailer from your `user.name` and
37+
`user.email`, and it has to match the commit's author:
38+
39+
```text
40+
Signed-off-by: Jane Doe <jane@example.com>
41+
```
42+
43+
The DCO check fails a pull request when any commit, other than a merge commit, has no
44+
matching sign-off. To sign off commits you have already pushed, run
45+
`git rebase --signoff origin/main` and force-push the branch.
46+
2547
## Local setup
2648

2749
Install the governance hooks once per clone: `bash .claude/hooks/install.sh`. They

‎.github/workflows/job-dco.yaml‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
name: DCO
2+
# Developer Certificate of Origin: every commit in a pull request must carry a
3+
# `Signed-off-by:` trailer (`git commit -s`) naming the commit's author, with
4+
# the same name and email (compared case-insensitively). Merge commits are
5+
# skipped, since they add no authored change of their own.
6+
#
7+
# The commit list comes from the pull request API, so the job needs no
8+
# checkout, no Node action and no third-party DCO app: `gh` and `jq` ship on
9+
# the hosted runner. The API returns at most 250 commits for a pull request;
10+
# a longer one fails with a message rather than passing unchecked.
11+
#
12+
# Pull requests only, and not while the PR is a draft. Title and body edits
13+
# don't change the commits, so they skip; a base change (retarget) reruns.
14+
on:
15+
pull_request:
16+
branches: [main]
17+
types: [opened, synchronize, reopened, ready_for_review, edited]
18+
# One run per PR per workflow: a new push cancels the older PR run. A title or
19+
# body edit gets its own group, so its skipped run never cancels a real one.
20+
concurrency:
21+
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}${{ github.event.action == 'edited' && github.event.changes.base == null && format('-edit-{0}', github.run_id) || '' }}
22+
cancel-in-progress: true
23+
permissions:
24+
contents: read
25+
pull-requests: read
26+
jobs:
27+
dco:
28+
name: ✍️ DCO sign-off${{ github.event.action == 'edited' && github.event.changes.base == null && ' (edit, not run)' || '' }}
29+
if: github.event.pull_request.draft == false && (github.event.action != 'edited' || github.event.changes.base != null)
30+
runs-on: ubuntu-latest
31+
timeout-minutes: 5
32+
steps:
33+
- name: ✍️ Check every commit is signed off by its author
34+
env:
35+
GH_TOKEN: ${{ github.token }}
36+
REPO: ${{ github.repository }}
37+
PR: ${{ github.event.pull_request.number }}
38+
EXPECTED: ${{ github.event.pull_request.commits }}
39+
run: |
40+
set -euo pipefail
41+
commits="$(gh api --paginate "repos/$REPO/pulls/$PR/commits?per_page=100" --jq '.[]' | jq -s '.')"
42+
total="$(jq 'length' <<<"$commits")"
43+
if [ "$total" -lt "$EXPECTED" ]; then
44+
echo "::error::The API listed $total of $EXPECTED commits (it stops at 250). Split the pull request so every commit can be checked."
45+
exit 1
46+
fi
47+
missing="$(jq -r '
48+
.[]
49+
| select((.parents | length) < 2)
50+
| . as $k
51+
| ($k.commit.author.name | ascii_downcase) as $name
52+
| ($k.commit.author.email | ascii_downcase) as $email
53+
| select([$k.commit.message
54+
| scan("(?m)^Signed-off-by:[ \t]*([^<\n]*?)[ \t]*<([^>\n]*)>[ \t]*$")
55+
| select((.[0] | ascii_downcase) == $name and (.[1] | ascii_downcase) == $email)
56+
] | length == 0)
57+
| "\($k.sha[0:12]) \($k.commit.author.name) <\($k.commit.author.email)>"
58+
' <<<"$commits")"
59+
if [ -n "$missing" ]; then
60+
while IFS= read -r line; do
61+
echo "::error::Commit $line has no Signed-off-by line matching its author."
62+
done <<<"$missing"
63+
echo "Sign off each commit with 'git commit -s'. To fix commits already pushed,"
64+
echo "run 'git rebase --signoff origin/main' and force-push the branch."
65+
exit 1
66+
fi
67+
echo "All $total commits carry a sign-off matching their author."

0 commit comments

Comments
 (0)