|
| 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