diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index f848981..ae29b80 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -14,6 +14,7 @@ - [ ] **`scripts/check.sh` green** under the project's interpreter. - [ ] **Independent review** requested for anything touching authorization, identity, delegation, the gateway, an adapter or the store. - [ ] **Nothing in `src/` merges on green CI alone**; a maintainer reads it. +- [ ] **Signed off.** Every commit carries `Signed-off-by` with its author's email (`git commit -s`); CONTRIBUTING.md's *Sign your work* says what that certifies. ## Mutation table diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ed7a3fd..e015923 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -408,3 +408,38 @@ jobs: timeout 60 /tmp/qs/bin/ctrlrun demo /tmp/qs/bin/ctrlrun init test -f ctrlrun.yaml + + dco: + # Every commit on a pull request carries a `Signed-off-by:` trailer naming its author's + # email: the Developer Certificate of Origin, per CONTRIBUTING.md. A shell loop rather than + # the DCO app, so the rule lives in the repository, pinned like everything else, with + # nothing to install and nothing that can be quietly uninstalled. Only git's own trailer + # block counts, so a sentence in the body that happens to mention the trailer does not. + # Nobody is exempt, bots included: an exemption keyed on a name or an email is a string + # anyone can set, and dependabot signs its commits off anyway. + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + + - name: Every commit is signed off by its author + env: + BASE: ${{ github.event.pull_request.base.sha }} + HEAD: ${{ github.event.pull_request.head.sha }} + run: | + missing=0 + for sha in $(git rev-list --no-merges "$BASE..$HEAD"); do + name="$(git log -1 --format=%an "$sha")" + email="$(git log -1 --format=%ae "$sha")" + # `only` restricts the output to the trailer block git itself recognises, and + # `valueonly` leaves the `Name ` part; the value has to end in the author's + # email, angle brackets included. + if ! git log -1 --format='%(trailers:key=Signed-off-by,valueonly,only)' "$sha" \ + | awk -v want="<$email>" 'substr($0, length($0) - length(want) + 1) == want { found = 1 } END { exit !found }'; then + echo "::error::$sha ($(git log -1 --format=%s "$sha")) has no 'Signed-off-by: $name <$email>' trailer. Add one with 'git commit --amend -s', or to every commit on the branch with 'git rebase --signoff $BASE'." + missing=1 + fi + done + [ "$missing" -eq 0 ] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 00e6354..825a9c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -152,7 +152,26 @@ opening the pull request. A pull request touching only tooling, docs or CI merges on green. - Merge stacked pull requests bottom-up, and never delete a branch another open pull request targets. -- Commit messages say what changed and why, in prose. No attribution trailers. +- Commit messages say what changed and why, in prose, and end with the `Signed-off-by` + trailer described below. No attribution trailers. + +## Sign your work + +Every commit carries a `Signed-off-by` trailer, and the trailer certifies the +[Developer Certificate of Origin](DCO): that you wrote the change or have the right to submit +it under the licence in [LICENSE](LICENSE), and that you know the contribution and its record +are public. That is the whole agreement. There is no contributor licence agreement and no +copyright assignment: your copyright stays yours, and what you contribute goes out under +Apache-2.0 like the rest of the code. + +`git commit -s` adds the trailer from your git identity: + + Signed-off-by: Your Name + +The email has to be the commit's author email. CI's `dco` job reads every commit on a pull +request and names any that lack the trailer; `git rebase --signoff main` adds it to a whole +branch at once. Only git's own trailer block counts, and nobody is exempt, bots included: +dependabot signs its commits off already. ## Releases diff --git a/DCO b/DCO new file mode 100644 index 0000000..49b8cb0 --- /dev/null +++ b/DCO @@ -0,0 +1,34 @@ +Developer Certificate of Origin +Version 1.1 + +Copyright (C) 2004, 2006 The Linux Foundation and its contributors. + +Everyone is permitted to copy and distribute verbatim copies of this +license document, but changing it is not allowed. + + +Developer's Certificate of Origin 1.1 + +By making a contribution to this project, I certify that: + +(a) The contribution was created in whole or in part by me and I + have the right to submit it under the open source license + indicated in the file; or + +(b) The contribution is based upon previous work that, to the best + of my knowledge, is covered under an appropriate open source + license and I have the right under that license to submit that + work with modifications, whether created in whole or in part + by me, under the same open source license (unless I am + permitted to submit under a different license), as indicated + in the file; or + +(c) The contribution was provided directly to me by some other + person who certified (a), (b) or (c) and I have not modified + it. + +(d) I understand and agree that this project and the contribution + are public and that a record of the contribution (including all + personal information I submit with it, including my sign-off) is + maintained indefinitely and may be redistributed consistent with + this project or the open source license(s) involved. diff --git a/tests/test_repository_signals.py b/tests/test_repository_signals.py index 8cb174f..3a8701c 100644 --- a/tests/test_repository_signals.py +++ b/tests/test_repository_signals.py @@ -181,6 +181,9 @@ def test_the_community_files_exist_and_say_what_they_must(): "CTRLRun/ctrlrun-docs", "tools/docs_audit", "trusted publishing", + # The contribution agreement is the DCO and nothing more; the file has to say so. + "Developer Certificate of Origin", + "git commit -s", ): assert phrase in contributing, phrase @@ -215,6 +218,7 @@ def test_the_community_files_exist_and_say_what_they_must(): "Mutation table", "CLAIMS.md", "docs_audit", + "Signed-off-by", ): assert phrase in pr_template, phrase @@ -223,6 +227,21 @@ def test_the_community_files_exist_and_say_what_they_must(): ) +def test_every_pull_request_commit_is_signed_off(): + """CONTRIBUTING.md asks for a `Signed-off-by` trailer on every commit; the `dco` job is + what makes that a gate rather than a request. It runs on pull requests only: a push to + `main` is a merge of commits the job already read.""" + job = _workflow("ci.yml")["jobs"]["dco"] + assert job["if"] == "github.event_name == 'pull_request'" + run = "\n".join(step.get("run", "") for step in job["steps"]) + assert "--no-merges" in run + # Only git's parsed trailer block counts, so a sentence in the body that mentions the + # trailer cannot satisfy the check; and there is no exemption, because one keyed on a + # name or an email is a string anyone can set. + assert "trailers:key=Signed-off-by" in run + assert "[bot]" not in run + + def test_the_citation_names_the_repository_the_version_and_the_tagline(): citation = yaml.safe_load((REPO_ROOT / "CITATION.cff").read_text(encoding="utf-8")) with (REPO_ROOT / "pyproject.toml").open("rb") as handle: