From 3cdd68d0306dde9847dc1effd962092254c2bdeb Mon Sep 17 00:00:00 2001 From: Vyncint Ng <115854244+vyncint@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:15:44 +0700 Subject: [PATCH] ci: the DCO rewrite exemption keys on the committer, not the address shape #44 widened the exemption for GitHub's squash rewrite by asking whether either address is the +@users.noreply.github.com form. That shape is a symptom, not the rule. An outside contributor's squash landed authored as while every commit they pushed was authored and signed off as : two real addresses, neither a noreply form, and the exemption could not see it. Two commits on main fail the scan for this reason and no follow-up commit can clear them, because the scan walks every reachable commit. The signature of a GitHub-composed commit is the committer, which is always GitHub . That is what the exemption tests now, which is the rule oxidelake and termlens already use. For such a commit the requirement is that a DCO assertion is present, not that it matches an address GitHub chose. Matching stays enforced where it means something: this job runs on every pull request over the contributor's own commits, which they authored and pushed, and those take the exact-match branch. A squash cannot reach main without that having passed. Exercised against crafted commits, every case in isolation. Must fail, and does: a human commit signed by a different person, a human commit with no trailer, a web-flow commit with no trailer at all, one name with two unrelated addresses and no web-flow marker, a bot author, an AI co-author trailer, a generated-with watermark. Must pass, and does: a human commit with a matching trailer, the contributor-account-address shape, the maintainer-trailer-only shape, and the owner flip #44 was written for. The scan is green over all 36 commits of main. What it does not catch is a maintainer dropping a contributor's trailer while composing a squash body, which is how one of those two commits lost its. The PR-time check is what stands behind the assertion; CONTRIBUTING now says to carry those trailers. Signed-off-by: Vyncint Ng <115854244+vyncint@users.noreply.github.com> --- .github/workflows/no-ai-attribution.yml | 54 ++++++++++++++++--------- CONTRIBUTING.md | 4 ++ 2 files changed, 40 insertions(+), 18 deletions(-) diff --git a/.github/workflows/no-ai-attribution.yml b/.github/workflows/no-ai-attribution.yml index 51ced68..eca6743 100644 --- a/.github/workflows/no-ai-attribution.yml +++ b/.github/workflows/no-ai-attribution.yml @@ -47,27 +47,45 @@ jobs: if printf '%s' "$body" | grep -q $'\xF0\x9F\xA4\x96'; then echo "::error::$sha contains the robot emoji"; fail=1 fi - # DCO: the trailer must match the author. One documented - # equivalence, and it runs in **both** directions: GitHub's - # squash/rebase merges rewrite the author email, pairing an - # account's real address with its - # +@users.noreply.github.com form. Which of the two - # lands in the author field is GitHub's choice and has changed: - # every merge on this branch before e1ceae0 was authored as the - # noreply address with a real address in the trailer, and - # e1ceae0 is the same person the other way round. The rule the - # original comment states — same person, so a trailer matching - # the author NAME satisfies it — covers both; only the test - # assumed one side. + # DCO: the trailer must match the author, except on commits + # GitHub composed rather than a human. # - # So the exemption asks whether *either* address is a GitHub - # noreply form, which is the signature of a rewrite, and then - # requires the names to be equal. A commit with neither form - # still needs an exact email match, which is every commit made - # on a branch by a human. + # A squash or rebase merge performed by github.com writes the + # author field itself, choosing from the addresses on the + # account. It is GitHub's choice, not an assertion by the + # person: #44 widened this for the pairing of an account's real + # address with its +@users.noreply.github.com form, + # but that shape is not the rule. An outside contributor's + # squash landed as while every commit + # they pushed was authored and signed off as + # — two real addresses, neither a + # noreply form, and the old exemption could not see it. + # + # The signature of a GitHub-composed commit is the COMMITTER, + # which is always GitHub . That is what this + # tests now, and it is the same rule oxidelake and termlens use. + # + # For such a commit the requirement is that a DCO assertion is + # present, not that it matches an address GitHub picked. The + # matching is already enforced where it means something: this + # same job runs on every pull request over the contributor's own + # commits, which they authored and pushed, and those take the + # exact-match branch below. A squash cannot reach main without + # that having passed first. + # + # What this does NOT catch: a maintainer writing a custom squash + # body that drops the contributor's trailer. ca56368d is exactly + # that and its record is poorer for it. The PR-time check is what + # stands behind the assertion; keep contributors' Signed-off-by + # lines when composing a squash body. + committer_email=$(git log -1 --format='%ce' "$sha") trailer_email=$(printf '%s' "$body" | sed -n 's/^Signed-off-by: .*<\(.*\)>$/\1/p' | head -1) noreply='^[0-9]+\+.*@users\.noreply\.github\.com$' - if ! printf '%s' "$body" | grep -q "Signed-off-by:.*<$author_email>"; then + if [ "$committer_email" = "noreply@github.com" ]; then + if ! printf '%s' "$body" | grep -qi '^signed-off-by:'; then + echo "::error::$sha was composed by GitHub and carries no Signed-off-by at all"; fail=1 + fi + elif ! printf '%s' "$body" | grep -q "Signed-off-by:.*<$author_email>"; then if { printf '%s' "$author_email" | grep -qE "$noreply" \ || printf '%s' "$trailer_email" | grep -qE "$noreply"; } \ && printf '%s' "$body" | grep -q "Signed-off-by: $author_name <"; then diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52af151..e9e994d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -7,6 +7,10 @@ them before opening a PR — they are enforced by CI, not by goodwill. - **DCO sign-off and a cryptographic signature.** `git commit -sS` is the only spelling. The `Signed-off-by:` trailer must match the author. + Maintainers: when you compose a squash body by hand, carry every + contributor's `Signed-off-by:` into it. GitHub writes the author field of + a squash from the account, which may be an address that appears on none of + the commits, so the squash is the only place their assertion is recorded. - **Conventional Commits**: `feat:`, `fix:`, `docs:`, `test:`, `ci:`, `chore:`, `refactor:`, `perf:`, plus `exercise:` for changes to the exercise ladder.