Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 80 additions & 46 deletions .github/workflows/cla-reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Comment on lines +78 to 81

Copy link
Copy Markdown
Contributor

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.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 set -euo pipefail can't see a failure inside < <(...). If this read fails, a pull request opened by a member or named bot comes out all exempt. Read the list into a variable first, then check the line count against N_COMMITS.


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"
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "release-workflows"
version = "1.2.0"
version = "1.3.0"
requires-python = ">=3.11"
dependencies = ["packaging", "pyyaml"]

Expand Down
105 changes: 105 additions & 0 deletions tests/test_cla_check.py
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 == {}
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading