Repository navigation
Release 1.3.0 #19
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stable
Are you sure you want to change the base?
Release 1.3.0 #19
Changes from all commits
dbe617e
489287d
d41dd9b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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,83 +35,110 @@ 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 | ||
| # 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; } | ||
|
|
||
| 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=" " | ||
| AUTHORS=" " | ||
| while read -r AUTHOR; do | ||
| if [ "$AUTHOR" = "null" ]; then | ||
| echo "a commit author has no GitHub login: CLA required" | ||
| REQUIRED="$REQUIRED<none> " | ||
| continue | ||
| fi | ||
| AUTHORS="$AUTHORS$AUTHOR " | ||
| OK=false | ||
| 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"') | ||
|
Comment on lines
+87
to
+88
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 |
||
|
|
||
| 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 | ||
| fi | ||
| 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 | ||
|
|
||
| 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<<EOF" | ||
| echo "$EXEMPT_LOGINS" | ||
| echo "EOF" | ||
| } >> "$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: | ||
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| 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" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| """The exemption step of cla-reusable.yml, run with a stub `gh` in place of the GitHub API. | ||
|
|
||
| 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 | ||
| 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(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 | ||
|
|
||
|
|
||
| CASES = [ | ||
| # (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", "", ["<none>"]), | ||
| ("membership lookup fails: not a member", "m1", "FAIL", ["m1"], "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"], 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, "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 == {} |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔴 The action credits a commit with no author login to its committer, who may be allowlisted or may already have signed. Exit 1 here for a maintainer to handle, as line 94 does.