From dbe617e89c739346247cba4afdb3c6390ef38a06 Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Wed, 11 Nov 2026 22:01:04 -0500 Subject: [PATCH 1/2] CLA: save signatures on CLA's signatures branch; exemption that can't be claimed by email (1.3.0) - branch: "signatures", the bot-only branch in CLA (main refuses the bot's push since 10-27) - the job runs, and mints the App token, only for pull request events or a pull request comment that is "recheck" or contains the sign phrase - members and named bots are exempt only when the pull request's opener is one of them; otherwise only a member's signed commits, committed by that member, are exempt - a pull request with more than 100 commits fails: the action reads only the first 100 - the ICLA link points at a fixed commit --- .github/workflows/cla-reusable.yml | 117 +++++++++++++++++------------ README.md | 2 +- pyproject.toml | 2 +- tests/test_cla_check.py | 108 ++++++++++++++++++++++++++ uv.lock | 2 +- 5 files changed, 182 insertions(+), 49 deletions(-) create mode 100644 tests/test_cla_check.py diff --git a/.github/workflows/cla-reusable.yml b/.github/workflows/cla-reusable.yml index c4f4da5..d3ee5c0 100644 --- a/.github/workflows/cla-reusable.yml +++ b/.github/workflows/cla-reusable.yml @@ -11,6 +11,13 @@ permissions: {} jobs: CLAAssistant: + # Run (and mint the App token) only for pull request events, or for a comment on a pull request + # that is exactly "recheck" or contains the sign phrase. contains() ignores case, like the action's matcher. + if: >- + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request && + (github.event.comment.body == 'recheck' || + contains(github.event.comment.body, 'I have read the CLA Document and I hereby sign the CLA'))) permissions: pull-requests: write statuses: write @@ -28,73 +35,89 @@ jobs: permission-contents: write permission-members: read - - name: Check all committers against org and allowlist + - name: Decide who is exempt id: cla-check env: GH_TOKEN: ${{ steps.app-token.outputs.token }} PR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }} run: | - if [ "${{ github.event_name }}" = "pull_request_target" ]; then - PR_NUM="${{ github.event.pull_request.number }}" - else - PR_NUM="${{ github.event.issue.number }}" + set -euo pipefail + # A commit's author is only an email, so a commit can claim to be from a member or a bot. + # The pull request's opener is verified by GitHub. Members and named bots are exempt only + # when the opener is one of them; otherwise only a member's signed (verified) commits, + # committed by that same member, are exempt. A failed membership lookup means "not exempt". + NAMED_BOTS=" dependabot[bot] github-actions[bot] renovate[bot] bls-signaturebot[bot] bls-release[bot] " + is_named_bot() { case "$NAMED_BOTS" in *" $1 "*) return 0 ;; esac; return 1; } + is_member() { gh api "orgs/blacklanternsecurity/members/$1" > /dev/null 2>&1; } + + PR_JSON=$(GH_TOKEN="$PR_TOKEN" gh api "repos/$REPO/pulls/$PR_NUM") + OPENER=$(jq -r '.user.login' <<< "$PR_JSON") + N_COMMITS=$(jq -r '.commits' <<< "$PR_JSON") + if [ "$N_COMMITS" -gt 100 ]; then + echo "::error::This pull request has $N_COMMITS commits; the CLA check reads only the first 100. Split it or squash it." + exit 1 fi - COMMITTERS=$(GH_TOKEN="$PR_TOKEN" gh api "repos/${{ github.repository }}/pulls/$PR_NUM/commits" --paginate --jq '.[].author.login' | sort -u) - ALL_EXEMPT=true - EXEMPT_LOGINS="" + OPENER_EXEMPT=false + if is_named_bot "$OPENER"; then + OPENER_EXEMPT=true; echo "opener $OPENER is a named bot: exempt" + elif is_member "$OPENER"; then + OPENER_EXEMPT=true; echo "opener $OPENER is an org member: exempt" + else + echo "opener $OPENER is not exempt" + fi - for LOGIN in $COMMITTERS; do - if [ -z "$LOGIN" ] || [ "$LOGIN" = "null" ]; then - echo "Unknown committer (no GitHub login): not exempt" - ALL_EXEMPT=false + EXEMPT=" " + REQUIRED=" " + while IFS=$'\t' read -r AUTHOR COMMITTER VERIFIED; do + if [ "$AUTHOR" = "null" ]; then + echo "a commit author has no GitHub login: CLA required" + REQUIRED="$REQUIRED " continue fi - - EXEMPT=false - - AUTHOR_TYPE=$(gh api "users/${LOGIN}" --jq '.type' 2>/dev/null || echo "Unknown") - if [ "$AUTHOR_TYPE" = "Bot" ]; then - echo "$LOGIN is a Bot account: exempt" - EXEMPT=true - fi - - if [ "$EXEMPT" = "false" ]; then - if gh api "orgs/blacklanternsecurity/members/$LOGIN" > /dev/null 2>&1; then - echo "$LOGIN is an org member: exempt" - EXEMPT=true - fi + OK=false + if [ "$OPENER_EXEMPT" = "true" ]; then + if is_named_bot "$AUTHOR" || is_member "$AUTHOR"; then OK=true; fi + elif [ "$VERIFIED" = "true" ] && [ "$COMMITTER" = "$AUTHOR" ] && is_member "$AUTHOR"; then + OK=true fi + if [ "$OK" = "true" ]; then EXEMPT="$EXEMPT$AUTHOR "; else REQUIRED="$REQUIRED$AUTHOR "; fi + done < <(GH_TOKEN="$PR_TOKEN" gh api "repos/$REPO/pulls/$PR_NUM/commits?per_page=100" \ + --jq '.[] | [(.author.login // "null"), (.committer.login // "null"), (.commit.verification.verified | tostring)] | @tsv') - if [ "$EXEMPT" = "true" ]; then - EXEMPT_LOGINS="${EXEMPT_LOGINS:+$EXEMPT_LOGINS,}$LOGIN" - else - echo "$LOGIN is not exempt: CLA required" - ALL_EXEMPT=false - fi - done + # A login is allowlisted only if none of its commits needed a CLA. + ALLOWLIST="" + while read -r LOGIN; do + [ -n "$LOGIN" ] || continue + case "$REQUIRED" in *" $LOGIN "*) continue ;; esac + echo "$LOGIN: exempt" + ALLOWLIST="${ALLOWLIST:+$ALLOWLIST,}$LOGIN" + done < <(tr ' ' '\n' <<< "$EXEMPT" | sort -u) + while read -r LOGIN; do + [ -n "$LOGIN" ] && echo "$LOGIN: CLA required" + done < <(tr ' ' '\n' <<< "$REQUIRED" | sort -u) + ALL_EXEMPT=false + if [ "$OPENER_EXEMPT" = "true" ] && [ -z "${REQUIRED// /}" ]; then ALL_EXEMPT=true; fi echo "all_exempt=$ALL_EXEMPT" >> "$GITHUB_OUTPUT" - { - echo "exempt_logins<> "$GITHUB_OUTPUT" + echo "allowlist=$ALLOWLIST" >> "$GITHUB_OUTPUT" - name: Skip CLA when all committers are exempt if: steps.cla-check.outputs.all_exempt == 'true' && github.event_name == 'pull_request_target' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | - gh api --method POST "repos/${{ github.repository }}/statuses/${{ github.event.pull_request.head.sha }}" \ + gh api --method POST "repos/$REPO/statuses/$HEAD_SHA" \ -f state=success \ -f context="CLAAssistant" \ - -f description="CLA check skipped: all committers are org members or bots" + -f description="CLA check skipped: opener and all committers are org members or named bots" - name: "CLA Assistant" - if: | - (steps.cla-check.outputs.all_exempt != 'true') && - ((github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target') + if: steps.cla-check.outputs.all_exempt != 'true' # Upstream is archived with no maintained successor; replacing it is tracked with the CLA cleanup. uses: contributor-assistant/github-action@ca4a40a7d1004f18d9960b404b97e5f30a505a08 # v2.6.1 # zizmor: ignore[archived-uses] env: @@ -102,9 +125,11 @@ jobs: PERSONAL_ACCESS_TOKEN: ${{ steps.app-token.outputs.token }} with: path-to-signatures: "signatures/version1/cla.json" - path-to-document: "https://github.com/blacklanternsecurity/CLA/blob/main/ICLA.md" - branch: "main" - allowlist: "dependabot[bot],github-actions[bot],renovate[bot],${{ steps.cla-check.outputs.exempt_logins }}" + # The ICLA text at a fixed commit: a signature refers to this exact text. + path-to-document: "https://github.com/blacklanternsecurity/CLA/blob/ee1e15fb37b72c2ddb3098d7837cfb5f5c5feba3/ICLA.md" + # Bot-only branch (rulesets signatures-no-rewrite, signatures-bot-only); main's copy is a frozen archive. + branch: "signatures" + allowlist: ${{ steps.cla-check.outputs.allowlist }} remote-organization-name: "blacklanternsecurity" remote-repository-name: "CLA" lock-pullrequest-aftermerge: "false" diff --git a/README.md b/README.md index 94b4e7b..4afd6d1 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ same release controls as the projects it publishes. | `maturin-wheels.yml`, `crates.yml`, `docker.yml` | Build and publish artifacts | | `docker-dev.yml` | `dev` channel images on GHCR (not releases) | | `publish.yml` | Creates the tag and GitHub release with the release App, after everything is published | -| `cla-reusable.yml` | CLA check. The agreement and signatures live in [`CLA`](https://github.com/blacklanternsecurity/CLA) | +| `cla-reusable.yml` | CLA check. The agreement and signatures live in [`CLA`](https://github.com/blacklanternsecurity/CLA), signatures on its bot-only `signatures` branch. Members and named bots are exempt only when the pull request's opener is one of them; otherwise only a member's signed commits are. Fails a pull request with more than 100 commits | ## Using a workflow diff --git a/pyproject.toml b/pyproject.toml index 47597af..17a404b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "release-workflows" -version = "1.2.0" +version = "1.3.0" requires-python = ">=3.11" dependencies = ["packaging", "pyyaml"] diff --git a/tests/test_cla_check.py b/tests/test_cla_check.py new file mode 100644 index 0000000..e2a1d46 --- /dev/null +++ b/tests/test_cla_check.py @@ -0,0 +1,108 @@ +"""The exemption step of cla-reusable.yml, run with a stub `gh` in place of the GitHub API. + +A commit's author is only an email, so a commit can claim to be from a member or a bot. These cases check +that such a claim exempts nobody unless the pull request's opener (verified by GitHub) is a member or a +named bot, or the commit is signed and committed by that same member. +""" + +import os +import stat +import subprocess +from pathlib import Path + +import pytest +import yaml + +WORKFLOW = Path(__file__).parent.parent / ".github" / "workflows" / "cla-reusable.yml" +STEP = next(s for s in yaml.safe_load(WORKFLOW.read_text())["jobs"]["CLAAssistant"]["steps"] if s.get("id") == "cla-check")["run"] + +GH_STUB = """#!/bin/bash +case "$2" in + orgs/blacklanternsecurity/members/*) + [ "$MEMBERS" = FAIL ] && exit 1 + case " $MEMBERS " in *" ${2##*/} "*) exit 0 ;; esac + exit 1 ;; + repos/o/r/pulls/7) printf '{"user":{"login":"%s"},"commits":%s}\\n' "$OPENER" "$N_COMMITS" ;; + repos/o/r/pulls/7/commits*) cat "$COMMITS_FILE" ;; + *) echo "unexpected: gh $*" >&2; exit 2 ;; +esac +""" + + +def run_step(tmp_path, opener, members, commits, n_commits=None): + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + gh = bin_dir / "gh" + gh.write_text(GH_STUB) + gh.chmod(gh.stat().st_mode | stat.S_IEXEC) + commits_file = tmp_path / "commits.tsv" + commits_file.write_text("".join("\t".join(c) + "\n" for c in commits)) + output = tmp_path / "out" + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "GITHUB_OUTPUT": str(output), + "REPO": "o/r", + "PR_NUM": "7", + "GH_TOKEN": "app", + "PR_TOKEN": "pr", + "OPENER": opener, + "MEMBERS": members, + "N_COMMITS": str(len(commits) if n_commits is None else n_commits), + "COMMITS_FILE": str(commits_file), + } + result = subprocess.run(["bash", "-c", STEP], cwd=tmp_path, env=env, capture_output=True, text=True) + outputs = dict(line.split("=", 1) for line in output.read_text().splitlines()) if output.exists() else {} + required = sorted( + line.removesuffix(": CLA required") for line in result.stdout.splitlines() if line.endswith(": CLA required") and "no GitHub login" not in line + ) + return result, outputs, required + + +SIGNED, UNSIGNED = "true", "false" + +CASES = [ + # (name, opener, members, commits [(author, committer, verified)], all_exempt, allowlist, required) + ("outsider PR, own commits", "out", "m1 m2", [("out", "out", UNSIGNED)], "false", "", ["out"]), + ("outsider PR, commit claims a member's email", "out", "m1", [("out", "out", UNSIGNED), ("m1", "out", UNSIGNED)], "false", "", ["m1", "out"]), + ("outsider PR, unsigned commit claims member as author and committer", "out", "m1", [("m1", "m1", UNSIGNED)], "false", "", ["m1"]), + ("outsider PR, commit claims dependabot", "out", "m1", [("dependabot[bot]", "dependabot[bot]", SIGNED)], "false", "", ["dependabot[bot]"]), + ("outsider PR, member's signed commit plus own", "out", "m1", [("m1", "m1", SIGNED), ("out", "out", UNSIGNED)], "false", "m1", ["out"]), + ("outsider PR, member's signed commit plus one claiming the member", "out", "m1", [("m1", "m1", SIGNED), ("m1", "out", UNSIGNED)], "false", "", ["m1"]), + ("outsider PR, outsider-signed commit authored as member", "out", "m1", [("m1", "out", SIGNED)], "false", "", ["m1"]), + ( + "member PR, member and dependabot commits", + "m1", + "m1 m2", + [("m2", "m2", UNSIGNED), ("dependabot[bot]", "web-flow", SIGNED)], + "true", + "dependabot[bot],m2", + [], + ), + ("member PR, outsider commit", "m1", "m1", [("m1", "m1", UNSIGNED), ("out", "out", UNSIGNED)], "false", "m1", ["out"]), + ("dependabot PR", "dependabot[bot]", "m1", [("dependabot[bot]", "web-flow", SIGNED)], "true", "dependabot[bot]", []), + ("member PR, commit without a GitHub login", "m1", "m1", [("null", "null", UNSIGNED)], "false", "", [""]), + ("membership lookup fails: not exempt", "m1", "FAIL", [("m1", "m1", SIGNED)], "false", "", ["m1"]), +] + + +@pytest.mark.parametrize("name,opener,members,commits,all_exempt,allowlist,required", CASES, ids=[c[0] for c in CASES]) +def test_exemption(tmp_path, name, opener, members, commits, all_exempt, allowlist, required): + result, outputs, got_required = run_step(tmp_path, opener, members, commits) + assert result.returncode == 0, result.stdout + result.stderr + assert outputs == {"all_exempt": all_exempt, "allowlist": allowlist} + assert got_required == required + + +def test_more_than_100_commits_fails(tmp_path): + result, outputs, _ = run_step(tmp_path, "m1", "m1", [("m1", "m1", SIGNED)], n_commits=346) + assert result.returncode != 0 + assert "only the first 100" in result.stdout + assert outputs == {} + + +def test_bracketed_login_is_not_a_glob(tmp_path): + (tmp_path / "dependabotb").write_text("") + _, outputs, required = run_step(tmp_path, "out", "m1", [("dependabot[bot]", "x", UNSIGNED)]) + assert required == ["dependabot[bot]"] + assert outputs["allowlist"] == "" diff --git a/uv.lock b/uv.lock index 5c1c6e0..4a53819 100644 --- a/uv.lock +++ b/uv.lock @@ -126,7 +126,7 @@ wheels = [ [[package]] name = "release-workflows" -version = "1.2.0" +version = "1.3.0" source = { virtual = "." } dependencies = [ { name = "packaging" }, From 489287dc91471faa003854982b74f68b690b3e1c Mon Sep 17 00:00:00 2001 From: Philip Hartlieb Date: Wed, 11 Nov 2026 22:14:15 -0500 Subject: [PATCH 2/2] CLA: on a pull request opened by an outside contributor, everyone signs On such a pull request the opener and every commit author must sign, members and bots included; the signed-commit exemption is gone, so commit signatures play no part. An outside opener with no commit in the pull request fails the check, since the action records only commit authors. --- .github/workflows/cla-reusable.yml | 31 ++++++++++------ README.md | 2 +- tests/test_cla_check.py | 57 ++++++++++++++---------------- 3 files changed, 48 insertions(+), 42 deletions(-) diff --git a/.github/workflows/cla-reusable.yml b/.github/workflows/cla-reusable.yml index d3ee5c0..4bbe858 100644 --- a/.github/workflows/cla-reusable.yml +++ b/.github/workflows/cla-reusable.yml @@ -44,10 +44,12 @@ jobs: PR_NUM: ${{ github.event.pull_request.number || github.event.issue.number }} run: | set -euo pipefail - # A commit's author is only an email, so a commit can claim to be from a member or a bot. - # The pull request's opener is verified by GitHub. Members and named bots are exempt only - # when the opener is one of them; otherwise only a member's signed (verified) commits, - # committed by that same member, are exempt. A failed membership lookup means "not exempt". + # Who must sign the CLA (sign = the sign-phrase comment; commit signatures play no part): + # - opener is an org member or a named bot: every commit author who is not a member or named bot. + # - opener is anyone else: the opener and every commit author, members and bots included. + # A commit's author is only an email, so on such a pull request no author can be trusted + # to be a member; and the action records only commit authors, so the opener must be one. + # A failed membership lookup means "not a member". NAMED_BOTS=" dependabot[bot] github-actions[bot] renovate[bot] bls-signaturebot[bot] bls-release[bot] " is_named_bot() { case "$NAMED_BOTS" in *" $1 "*) return 0 ;; esac; return 1; } is_member() { gh api "orgs/blacklanternsecurity/members/$1" > /dev/null 2>&1; } @@ -71,21 +73,28 @@ jobs: EXEMPT=" " REQUIRED=" " - while IFS=$'\t' read -r AUTHOR COMMITTER VERIFIED; do + AUTHORS=" " + while read -r AUTHOR; do if [ "$AUTHOR" = "null" ]; then echo "a commit author has no GitHub login: CLA required" REQUIRED="$REQUIRED " continue fi + AUTHORS="$AUTHORS$AUTHOR " OK=false - if [ "$OPENER_EXEMPT" = "true" ]; then - if is_named_bot "$AUTHOR" || is_member "$AUTHOR"; then OK=true; fi - elif [ "$VERIFIED" = "true" ] && [ "$COMMITTER" = "$AUTHOR" ] && is_member "$AUTHOR"; then - OK=true - fi + if [ "$OPENER_EXEMPT" = "true" ] && { is_named_bot "$AUTHOR" || is_member "$AUTHOR"; }; then OK=true; fi if [ "$OK" = "true" ]; then EXEMPT="$EXEMPT$AUTHOR "; else REQUIRED="$REQUIRED$AUTHOR "; fi done < <(GH_TOKEN="$PR_TOKEN" gh api "repos/$REPO/pulls/$PR_NUM/commits?per_page=100" \ - --jq '.[] | [(.author.login // "null"), (.committer.login // "null"), (.commit.verification.verified | tostring)] | @tsv') + --jq '.[] | .author.login // "null"') + + if [ "$OPENER_EXEMPT" = "false" ]; then + case "$AUTHORS" in + *" $OPENER "*) ;; + *) + echo "::error::The opener ($OPENER) must sign the CLA, but has no commit in this pull request, so the CLA action can't record a signature. A maintainer handles this pull request by hand." + exit 1 ;; + esac + fi # A login is allowlisted only if none of its commits needed a CLA. ALLOWLIST="" diff --git a/README.md b/README.md index 4afd6d1..a63a901 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ same release controls as the projects it publishes. | `maturin-wheels.yml`, `crates.yml`, `docker.yml` | Build and publish artifacts | | `docker-dev.yml` | `dev` channel images on GHCR (not releases) | | `publish.yml` | Creates the tag and GitHub release with the release App, after everything is published | -| `cla-reusable.yml` | CLA check. The agreement and signatures live in [`CLA`](https://github.com/blacklanternsecurity/CLA), signatures on its bot-only `signatures` branch. Members and named bots are exempt only when the pull request's opener is one of them; otherwise only a member's signed commits are. Fails a pull request with more than 100 commits | +| `cla-reusable.yml` | CLA check. The agreement and signatures live in [`CLA`](https://github.com/blacklanternsecurity/CLA), signatures on its bot-only `signatures` branch. On a pull request opened by a member or named bot, other members and named bots are exempt; on one opened by anyone else, the opener and every commit author must sign, members included. Fails a pull request with more than 100 commits, or one whose outside opener has no commit in it | ## Using a workflow diff --git a/tests/test_cla_check.py b/tests/test_cla_check.py index e2a1d46..b09bbd7 100644 --- a/tests/test_cla_check.py +++ b/tests/test_cla_check.py @@ -1,8 +1,9 @@ """The exemption step of cla-reusable.yml, run with a stub `gh` in place of the GitHub API. -A commit's author is only an email, so a commit can claim to be from a member or a bot. These cases check -that such a claim exempts nobody unless the pull request's opener (verified by GitHub) is a member or a -named bot, or the commit is signed and committed by that same member. +Signing means the CLA sign-phrase comment; commit signatures play no part. On a pull request opened by an +org member or a named bot, other members and named bots are exempt. On one opened by anyone else, the +opener and every commit author must sign, members included: a commit's author is only an email, so there +no author can be trusted to be a member. """ import os @@ -36,7 +37,7 @@ def run_step(tmp_path, opener, members, commits, n_commits=None): gh.write_text(GH_STUB) gh.chmod(gh.stat().st_mode | stat.S_IEXEC) commits_file = tmp_path / "commits.tsv" - commits_file.write_text("".join("\t".join(c) + "\n" for c in commits)) + commits_file.write_text("".join(c + "\n" for c in commits)) output = tmp_path / "out" env = { **os.environ, @@ -59,30 +60,19 @@ def run_step(tmp_path, opener, members, commits, n_commits=None): return result, outputs, required -SIGNED, UNSIGNED = "true", "false" - CASES = [ - # (name, opener, members, commits [(author, committer, verified)], all_exempt, allowlist, required) - ("outsider PR, own commits", "out", "m1 m2", [("out", "out", UNSIGNED)], "false", "", ["out"]), - ("outsider PR, commit claims a member's email", "out", "m1", [("out", "out", UNSIGNED), ("m1", "out", UNSIGNED)], "false", "", ["m1", "out"]), - ("outsider PR, unsigned commit claims member as author and committer", "out", "m1", [("m1", "m1", UNSIGNED)], "false", "", ["m1"]), - ("outsider PR, commit claims dependabot", "out", "m1", [("dependabot[bot]", "dependabot[bot]", SIGNED)], "false", "", ["dependabot[bot]"]), - ("outsider PR, member's signed commit plus own", "out", "m1", [("m1", "m1", SIGNED), ("out", "out", UNSIGNED)], "false", "m1", ["out"]), - ("outsider PR, member's signed commit plus one claiming the member", "out", "m1", [("m1", "m1", SIGNED), ("m1", "out", UNSIGNED)], "false", "", ["m1"]), - ("outsider PR, outsider-signed commit authored as member", "out", "m1", [("m1", "out", SIGNED)], "false", "", ["m1"]), - ( - "member PR, member and dependabot commits", - "m1", - "m1 m2", - [("m2", "m2", UNSIGNED), ("dependabot[bot]", "web-flow", SIGNED)], - "true", - "dependabot[bot],m2", - [], - ), - ("member PR, outsider commit", "m1", "m1", [("m1", "m1", UNSIGNED), ("out", "out", UNSIGNED)], "false", "m1", ["out"]), - ("dependabot PR", "dependabot[bot]", "m1", [("dependabot[bot]", "web-flow", SIGNED)], "true", "dependabot[bot]", []), - ("member PR, commit without a GitHub login", "m1", "m1", [("null", "null", UNSIGNED)], "false", "", [""]), - ("membership lookup fails: not exempt", "m1", "FAIL", [("m1", "m1", SIGNED)], "false", "", ["m1"]), + # (name, opener, members, commit authors, all_exempt, allowlist, required) + ("member opens PR with own commits: exempt", "m1", "m1", ["m1"], "true", "m1", []), + ("member commits on another member's PR: exempt", "m1", "m1 m2", ["m1", "m2"], "true", "m1,m2", []), + ("member PR with dependabot commit: exempt", "m1", "m1", ["m1", "dependabot[bot]"], "true", "dependabot[bot],m1", []), + ("dependabot PR: exempt", "dependabot[bot]", "m1", ["dependabot[bot]"], "true", "dependabot[bot]", []), + ("external commit on a member's PR: external signs", "m1", "m1", ["m1", "ext"], "false", "m1", ["ext"]), + ("external opens PR with own commits: external signs", "ext", "m1", ["ext"], "false", "", ["ext"]), + ("another external's commit on an external's PR: both sign", "ext", "m1", ["ext", "ext2"], "false", "", ["ext", "ext2"]), + ("member commit on an external's PR: member signs too", "ext", "m1", ["ext", "m1"], "false", "", ["ext", "m1"]), + ("external's PR with a commit claiming dependabot: must sign", "ext", "m1", ["ext", "dependabot[bot]"], "false", "", ["dependabot[bot]", "ext"]), + ("member PR, commit without a GitHub login: CLA required", "m1", "m1", ["null"], "false", "", [""]), + ("membership lookup fails: not a member", "m1", "FAIL", ["m1"], "false", "", ["m1"]), ] @@ -95,7 +85,7 @@ def test_exemption(tmp_path, name, opener, members, commits, all_exempt, allowli def test_more_than_100_commits_fails(tmp_path): - result, outputs, _ = run_step(tmp_path, "m1", "m1", [("m1", "m1", SIGNED)], n_commits=346) + result, outputs, _ = run_step(tmp_path, "m1", "m1", ["m1"], n_commits=346) assert result.returncode != 0 assert "only the first 100" in result.stdout assert outputs == {} @@ -103,6 +93,13 @@ def test_more_than_100_commits_fails(tmp_path): def test_bracketed_login_is_not_a_glob(tmp_path): (tmp_path / "dependabotb").write_text("") - _, outputs, required = run_step(tmp_path, "out", "m1", [("dependabot[bot]", "x", UNSIGNED)]) - assert required == ["dependabot[bot]"] + _, outputs, required = run_step(tmp_path, "ext", "m1", ["ext", "dependabot[bot]"]) + assert required == ["dependabot[bot]", "ext"] assert outputs["allowlist"] == "" + + +def test_external_opener_without_a_commit_fails(tmp_path): + result, outputs, _ = run_step(tmp_path, "ext", "m1", ["m1"]) + assert result.returncode != 0 + assert "The opener (ext) must sign the CLA" in result.stdout + assert outputs == {}