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