Add standalone CLA action (#806)

Co-authored-by: UltralyticsAssistant <[email protected]>
This commit is contained in:
Glenn Jocher
2026-07-09 23:48:44 +02:00
committed by GitHub
co-authored by UltralyticsAssistant
parent a717a806cf
commit d0a323b062
7 changed files with 705 additions and 19 deletions
+6 -18
View File
@@ -4,6 +4,8 @@
# This workflow automatically requests Pull Requests (PR) authors to sign the Ultralytics CLA before PRs can be merged
name: CLA Assistant
concurrency:
group: cla-${{ github.event.pull_request.number || github.event.issue.number }}
on:
issue_comment:
types:
@@ -16,30 +18,16 @@ on:
permissions:
actions: write
contents: write
pull-requests: write
statuses: write
jobs:
CLA:
if: github.repository == 'ultralytics/actions'
if: github.repository == 'ultralytics/actions' && (github.event_name != 'issue_comment' || github.event.issue.pull_request)
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I sign the CLA') || github.event_name == 'pull_request_target'
uses: contributor-assistant/[email protected]
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Must be repository secret PAT
PERSONAL_ACCESS_TOKEN: ${{ secrets._GITHUB_TOKEN }}
uses: ultralytics/actions/cla@main
with:
path-to-signatures: "signatures/version1/cla.json"
path-to-document: "https://docs.ultralytics.com/help/CLA" # CLA document
# Branch must not be protected
branch: cla-signatures
allowlist: dependabot[bot],github-actions,pre-commit*,bot*
remote-organization-name: ultralytics
remote-repository-name: cla
custom-pr-sign-comment: "I have read the CLA Document and I sign the CLA"
custom-allsigned-prcomment: All Contributors have signed the CLA. ✅
github-token: ${{ secrets.GITHUB_TOKEN }}
cla-token: ${{ secrets._GITHUB_TOKEN }}
+13
View File
@@ -171,6 +171,19 @@ Update GitHub Actions versions across organization repositories with cached rele
[**📖 Full Documentation →**](dependabot/README.md)
### 5. CLA Action
Check every pull request commit author against the central Ultralytics CLA signature ledger.
```yaml
- uses: ultralytics/actions/cla@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
cla-token: ${{ secrets._GITHUB_TOKEN }}
```
[**📖 Full Documentation →**](cla/README.md)
## Python Package
Install the `ultralytics-actions` package for programmatic access to action utilities, including all [requirements](https://github.com/ultralytics/actions/blob/main/pyproject.toml), in a [**Python>=3.8**](https://www.python.org/) environment.
+1 -1
View File
@@ -28,4 +28,4 @@
# ├── test_summarize_pr.py
# └── ...
__version__ = "0.2.23"
__version__ = "0.2.24"
+281
View File
@@ -0,0 +1,281 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
from __future__ import annotations
import base64
import json
import os
import time
from .utils import GITHUB_API_URL, GITHUB_GRAPHQL_URL, Action
CLA_REPOSITORY = "ultralytics/cla"
CLA_PATH = "signatures/version1/cla.json"
CLA_BRANCH = "cla-signatures"
CLA_DOCUMENT = "https://docs.ultralytics.com/help/CLA"
SIGN_COMMENT = "I have read the CLA Document and I sign the CLA"
COMMENT_MARKER = "<!-- ultralytics-cla -->"
LEGACY_MARKER = "CLA Assistant Lite bot"
BOT_LOGIN = "github-actions[bot]"
ALLOWLIST = frozenset(("dependabot[bot]", "github-actions[bot]", "pre-commit-ci[bot]"))
TRANSIENT_STATUS = (429, 500, 502, 503, 504)
COMMITS_QUERY = """
query($owner: String!, $name: String!, $number: Int!, $cursor: String) {
repository(owner: $owner, name: $name) {
pullRequest(number: $number) {
commits(first: 100, after: $cursor) {
totalCount
nodes {
commit {
author {
name
email
user { databaseId login }
}
}
}
pageInfo { endCursor hasNextPage }
}
}
}
}
"""
def _allowed(login: str) -> bool:
"""Return whether a GitHub login matches the configured bot allowlist."""
return login.casefold() in ALLOWLIST
def _read(action: Action, method: str, url: str, **kwargs):
"""Retry a read-only GitHub request on transient responses."""
for attempt in range(4):
response = getattr(action, method)(url, **kwargs)
if response.status_code not in TRANSIENT_STATUS:
response.raise_for_status()
return response
if attempt < 3:
time.sleep(float(response.headers.get("Retry-After", 2**attempt)))
response.raise_for_status()
def _paginate(action: Action, url: str) -> list[dict]:
"""Fetch every page from a GitHub REST collection."""
items = []
for page in range(1, 101):
response = _read(action, "get", url, params={"per_page": 100, "page": page})
page_items = response.json()
items.extend(page_items)
if len(page_items) < 100:
return items
raise RuntimeError(f"GitHub collection exceeded 10,000 items: {url}")
def _contributors(action: Action, number: int) -> list[dict]:
"""Return the PR opener and every unique commit author."""
contributors = {}
pr = _read(action, "get", f"{GITHUB_API_URL}/repos/{action.repository}/pulls/{number}").json()
opener = pr["user"]
if not _allowed(opener["login"]):
contributors[opener["id"]] = {"id": opener["id"], "name": opener["login"]}
owner, name = action.repository.split("/", 1)
cursor = None
count = 0
for _ in range(100):
response = _read(
action,
"post",
GITHUB_GRAPHQL_URL,
json={
"query": COMMITS_QUERY,
"variables": {"owner": owner, "name": name, "number": number, "cursor": cursor},
},
).json()
if response.get("errors"):
raise RuntimeError(f"Could not read PR commit authors: {response['errors']}")
commits = response["data"]["repository"]["pullRequest"]["commits"]
for node in commits["nodes"]:
author = node["commit"].get("author") or {}
user = author.get("user")
if user and not _allowed(user["login"]):
contributors[user["databaseId"]] = {"id": user["databaseId"], "name": user["login"]}
elif not user and author.get("name"):
key = f"unknown:{author['name']}:{author.get('email', '')}"
contributors[key] = {"id": None, "name": author["name"]}
count += len(commits["nodes"])
if not commits["pageInfo"]["hasNextPage"]:
if count != commits["totalCount"]:
raise RuntimeError(f"GitHub returned {count} of {commits['totalCount']} PR commits")
return list(contributors.values())
cursor = commits["pageInfo"]["endCursor"]
raise RuntimeError("Pull request exceeded 10,000 commits")
def _ledger(action: Action) -> tuple[dict, str]:
"""Read and validate the existing central signature ledger."""
url = f"{GITHUB_API_URL}/repos/{CLA_REPOSITORY}/contents/{CLA_PATH}"
response = _read(action, "get", url, params={"ref": CLA_BRANCH})
payload = response.json()
content = json.loads(base64.b64decode(payload["content"]))
if not isinstance(content.get("signedContributors"), list):
raise RuntimeError("CLA signature ledger has an invalid schema")
return content, payload["sha"]
def _comments(action: Action, number: int) -> list[dict]:
"""Return every PR comment."""
return _paginate(action, f"{GITHUB_API_URL}/repos/{action.repository}/issues/{number}/comments")
def _record(comment: dict, action: Action, number: int) -> dict:
"""Convert a signing comment to the established ledger schema."""
user = comment["user"]
return {
"name": user["login"],
"id": user["id"],
"comment_id": comment["id"],
"created_at": comment["created_at"],
"repoId": action.event_data["repository"]["id"],
"pullRequestNo": number,
}
def _persist(action: Action, records: list[dict], source: Action, number: int) -> None:
"""Merge new signatures into the ledger with optimistic concurrency."""
url = f"{GITHUB_API_URL}/repos/{CLA_REPOSITORY}/contents/{CLA_PATH}"
for attempt in range(4):
content, sha = _ledger(action)
signed_ids = {row["id"] for row in content["signedContributors"]}
additions = [row for row in records if row["id"] not in signed_ids]
if not additions:
return
content["signedContributors"].extend(additions)
names = ", ".join(f"@{row['name']}" for row in additions)
response = action.put(
url,
json={
"message": f"{names} signed the CLA in {source.repository}#{number}",
"content": base64.b64encode(json.dumps(content, indent=2).encode()).decode(),
"sha": sha,
"branch": CLA_BRANCH,
},
)
if response.status_code in (200, 201):
return
if response.status_code not in (409, 429, 500, 502, 503, 504):
response.raise_for_status()
if response.status_code != 409 and attempt < 3:
time.sleep(float(response.headers.get("Retry-After", 2**attempt)))
response.raise_for_status()
def _comment_body(signed: list[dict], unsigned: list[dict], unknown: list[dict]) -> str:
"""Build the single CLA status comment."""
if not unsigned and not unknown:
return f"{COMMENT_MARKER}\nAll Contributors have signed the CLA. ✅"
names = "\n".join(f"- {'✅' if user in signed else '❌'} @{user['name']}" for user in signed + unsigned)
if unknown:
names += "\n" + "\n".join(f"- ❌ {user['name']} (not linked to a GitHub account)" for user in unknown)
return f"""{COMMENT_MARKER}
Thank you for your contribution. Before it can be accepted, every contributor must sign our [Contributor License Agreement]({CLA_DOCUMENT}) by posting this exact comment:
> {SIGN_COMMENT}
{names}
"""
def _update_comment(action: Action, number: int, comments: list[dict], body: str) -> None:
"""Create or update one bot-owned CLA status comment."""
existing = [
comment
for comment in comments
if comment.get("user", {}).get("login") == BOT_LOGIN
and (COMMENT_MARKER in (comment.get("body") or "") or LEGACY_MARKER in (comment.get("body") or ""))
]
if not existing:
response = action.post(
f"{GITHUB_API_URL}/repos/{action.repository}/issues/{number}/comments",
json={"body": body},
)
existing = [
comment
for comment in _comments(action, number)
if comment.get("user", {}).get("login") == BOT_LOGIN
and (COMMENT_MARKER in (comment.get("body") or "") or LEGACY_MARKER in (comment.get("body") or ""))
]
if not existing:
response.raise_for_status()
raise RuntimeError("GitHub did not return the created CLA status comment")
action.patch(
f"{GITHUB_API_URL}/repos/{action.repository}/issues/comments/{existing[0]['id']}",
json={"body": body},
hard=True,
)
def _rerun_pr_check(action: Action, number: int) -> None:
"""Rerun the PR-head CLA workflow after a successful issue-comment signature."""
if action.event_name != "issue_comment":
return
pr = _read(action, "get", f"{GITHUB_API_URL}/repos/{action.repository}/pulls/{number}").json()
workflow_ref = os.environ["GITHUB_WORKFLOW_REF"]
workflow = workflow_ref.split(f"{action.repository}/", 1)[1].rsplit("@", 1)[0]
run = None
url = f"{GITHUB_API_URL}/repos/{action.repository}/actions/workflows/{workflow}/runs"
for page in range(1, 101):
runs = _read(
action,
"get",
url,
params={"branch": pr["head"]["ref"], "event": "pull_request_target", "per_page": 100, "page": page},
).json()["workflow_runs"]
run = next((item for item in runs if item["head_sha"] == pr["head"]["sha"]), None)
if run or len(runs) < 100:
break
if not run:
raise RuntimeError("Could not find the PR-head CLA workflow run")
if run["conclusion"] is None:
return
if run["conclusion"] != "success":
action.post(f"{GITHUB_API_URL}/repos/{action.repository}/actions/runs/{run['id']}/rerun", hard=True)
def run(action: Action, ledger_action: Action) -> None:
"""Check the PR contributors against the central CLA ledger."""
number = (action.event_data.get("pull_request") or action.event_data.get("issue"))["number"]
contributors = _contributors(action, number)
comments = _comments(action, number)
content, _ = _ledger(ledger_action)
signed_ids = {row["id"] for row in content["signedContributors"]}
contributor_ids = {user["id"] for user in contributors if user["id"] is not None}
records = {
comment["user"]["id"]: _record(comment, action, number)
for comment in comments
if comment.get("body") == SIGN_COMMENT and comment.get("user", {}).get("id") in contributor_ids - signed_ids
}
if records:
_persist(ledger_action, list(records.values()), action, number)
signed_ids.update(records)
signed = [user for user in contributors if user["id"] in signed_ids]
unsigned = [user for user in contributors if user["id"] is not None and user["id"] not in signed_ids]
unknown = [user for user in contributors if user["id"] is None]
_update_comment(action, number, comments, _comment_body(signed, unsigned, unknown))
if unsigned or unknown:
raise RuntimeError("All PR contributors must sign the CLA")
_rerun_pr_check(action, number)
def main() -> None:
"""Run the CLA check from GitHub Actions environment variables."""
token = os.environ["GITHUB_TOKEN"]
cla_token = os.environ["CLA_TOKEN"]
action = Action(token=token)
run(action, Action(token=cla_token, event_name=action.event_name, event_data=action.event_data))
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
# Ultralytics CLA Action
Checks every pull request commit author against the shared signature ledger in
`ultralytics/cla` and maintains one status comment on the pull request.
```yaml
name: CLA Assistant
concurrency:
group: cla-${{ github.event.pull_request.number || github.event.issue.number }}
on:
issue_comment:
types: [created]
pull_request_target:
types: [reopened, opened, synchronize]
permissions:
actions: write
pull-requests: write
jobs:
CLA:
if: github.event_name != 'issue_comment' || github.event.issue.pull_request
runs-on: ubuntu-latest
steps:
- name: CLA Assistant
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I sign the CLA') || github.event_name == 'pull_request_target'
uses: ultralytics/actions/cla@main
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
cla-token: ${{ secrets._GITHUB_TOKEN }}
```
The action preserves the existing `signatures/version1/cla.json` schema and
updates its `cla-signatures` branch with optimistic concurrency. Missing or
invalid ledger data, unlinked commit authors, and unsigned contributors fail
the check.
Contributor identity uses GitHub's numeric user ID, so commits from any email
address GitHub associates with the same account require only one signature.
Raw unlinked commit emails are never guessed or treated as identity.
The workflow requires `actions: write` to refresh the PR-head check after a
signature comment and `pull-requests: write` to maintain its single status
comment. It never checks out or executes pull request code.
+35
View File
@@ -0,0 +1,35 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
name: "Ultralytics CLA"
author: "Ultralytics"
description: "Check pull request contributors against the central Ultralytics CLA signature ledger"
branding:
icon: "check-circle"
color: "green"
inputs:
github-token:
description: "GitHub token for pull request comments and workflow reruns"
required: true
cla-token:
description: "Token with read/write access to the ultralytics/cla signature ledger"
required: true
runs:
using: "composite"
steps:
- name: Install requests
run: |
cd "$GITHUB_ACTION_PATH/.."
python -m venv "$RUNNER_TEMP/ultralytics-cla"
"$RUNNER_TEMP/ultralytics-cla/bin/python" -m pip install --disable-pip-version-check \
"requests==2.32.4; python_version < '3.10'" \
"requests==2.33.0; python_version >= '3.10'"
shell: bash
- name: Check CLA
env:
GITHUB_TOKEN: ${{ inputs.github-token }}
CLA_TOKEN: ${{ inputs.cla-token }}
run: |
cd "$GITHUB_ACTION_PATH/.."
"$RUNNER_TEMP/ultralytics-cla/bin/python" -m actions.cla
shell: bash
+325
View File
@@ -0,0 +1,325 @@
# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license
import base64
import json
from unittest.mock import MagicMock
import pytest
from actions import cla
def response(status=200, data=None, headers=None):
"""Create a mock HTTP response."""
result = MagicMock(status_code=status)
result.json.return_value = data
result.headers = headers or {}
return result
def action(event_name="pull_request_target"):
"""Create a mock source-repository action."""
result = MagicMock()
result.repository = "ultralytics/example"
result.event_name = event_name
result.event_data = {
"repository": {"id": 123, "full_name": result.repository},
"pull_request": {"number": 7},
}
return result
def ledger_response(rows, sha="old-sha"):
"""Create a Contents API response for the established CLA schema."""
content = base64.b64encode(json.dumps({"signedContributors": rows}).encode()).decode()
return response(data={"content": content, "sha": sha})
def commits_response(authors, total=None, has_next=False, cursor=None):
"""Create a GraphQL response containing PR commit authors."""
nodes = [{"commit": {"author": author}} for author in authors]
commits = {
"totalCount": len(nodes) if total is None else total,
"nodes": nodes,
"pageInfo": {"hasNextPage": has_next, "endCursor": cursor},
}
return response(data={"data": {"repository": {"pullRequest": {"commits": commits}}}})
def test_contributors_paginates_and_requires_verified_github_identity():
"""Collect linked authors while retaining unverified email identities as unknown."""
source = action()
authors = [
{
"email": "[email protected]",
"user": {"databaseId": 1, "login": "person"},
},
{
"email": "[email protected]",
"user": {"databaseId": 1, "login": "person"},
},
{"name": "Alias", "email": "[email protected]", "user": None},
{"name": "Unknown", "email": "[email protected]", "user": None},
{"user": {"databaseId": 3, "login": "dependabot[bot]"}},
{"user": {"databaseId": 4, "login": "other[bot]"}},
{"user": {"databaseId": 5, "login": "bot-attacker"}},
]
source.get.return_value = response(data={"user": {"id": 1, "login": "person"}})
source.post.return_value = commits_response(authors)
assert cla._contributors(source, 7) == [
{"id": 1, "name": "person"},
{"id": None, "name": "Alias"},
{"id": None, "name": "Unknown"},
{"id": 4, "name": "other[bot]"},
{"id": 5, "name": "bot-attacker"},
]
def test_ledger_preserves_existing_schema_and_never_creates_missing_file():
"""Read the established ledger directly without a replacement creation path."""
store = action()
row = {"name": "old", "id": 1, "comment_id": 2, "created_at": "date", "repoId": 3, "pullRequestNo": 4}
store.get.return_value = ledger_response([row])
assert cla._ledger(store) == ({"signedContributors": [row]}, "old-sha")
store.get.assert_called_once_with(
"https://api.github.com/repos/ultralytics/cla/contents/signatures/version1/cla.json",
params={"ref": "cla-signatures"},
)
def test_contributors_fails_when_github_truncates_commits():
"""Fail instead of silently skipping commit authors beyond an API limit."""
source = action()
source.get.return_value = response(data={"user": {"id": 1, "login": "person"}})
source.post.return_value = commits_response([{"name": "Unknown", "user": None}], total=2)
with pytest.raises(RuntimeError, match="returned 1 of 2"):
cla._contributors(source, 7)
def test_contributors_paginates_graphql_commits():
"""Follow GraphQL cursors beyond one hundred PR commits."""
source = action()
source.get.return_value = response(data={"user": {"id": 1, "login": "opener"}})
first = [{"user": {"databaseId": i, "login": f"user-{i}"}} for i in range(2, 102)]
source.post.side_effect = [
commits_response(first, total=101, has_next=True, cursor="next"),
commits_response([{"user": {"databaseId": 102, "login": "user-102"}}], total=101),
]
assert len(cla._contributors(source, 7)) == 102
assert source.post.call_args_list[1].kwargs["json"]["variables"]["cursor"] == "next"
def test_persist_rereads_and_merges_after_conflict():
"""Re-read and merge the signature ledger after a concurrent write conflict."""
source, store = action(), action()
old = {"id": 1}
new = {"name": "new", "id": 2, "comment_id": 3, "created_at": "date", "repoId": 123, "pullRequestNo": 7}
store.get.side_effect = [ledger_response([old], "sha-1"), ledger_response([old], "sha-2")]
store.put.side_effect = [response(409), response(200)]
cla._persist(store, [new], source, 7)
assert store.put.call_count == 2
written = json.loads(base64.b64decode(store.put.call_args.kwargs["json"]["content"]))
assert written["signedContributors"] == [old, new]
assert store.put.call_args.kwargs["json"]["sha"] == "sha-2"
def test_persist_honors_retry_after_for_transient_write(monkeypatch):
"""Back off before re-reading after an ambiguous transient ledger write."""
source, store = action(), action()
new = {"name": "new", "id": 2}
store.get.side_effect = [ledger_response([], "sha-1"), ledger_response([], "sha-2")]
store.put.side_effect = [response(429, headers={"Retry-After": "3"}), response(200)]
sleep = MagicMock()
monkeypatch.setattr("actions.cla.time.sleep", sleep)
cla._persist(store, [new], source, 7)
sleep.assert_called_once_with(3.0)
@pytest.mark.parametrize(
("statuses", "message"),
[([502, 502, 502, 502], "502 Bad Gateway"), ([502, 502, 502, 409], "409 Conflict")],
)
def test_persist_surfaces_final_exhausted_error(monkeypatch, statuses, message):
"""Preserve the final HTTP error when mixed write retries are exhausted."""
source, store = action(), action()
store.get.side_effect = [ledger_response([], f"sha-{i}") for i in range(4)]
failures = [response(status) for status in statuses]
failures[-1].raise_for_status.side_effect = RuntimeError(message)
store.put.side_effect = failures
monkeypatch.setattr("actions.cla.time.sleep", MagicMock())
with pytest.raises(RuntimeError, match=message):
cla._persist(store, [{"name": "new", "id": 2}], source, 7)
def test_run_records_exact_sentence_and_updates_legacy_comment():
"""Persist an exact signature and reuse the legacy action's status comment."""
source, store = action(), action()
signing = {
"id": 30,
"body": cla.SIGN_COMMENT,
"created_at": "date",
"user": {"id": 2, "login": "new", "type": "User"},
}
bot = {"id": 40, "body": "Posted by the CLA Assistant Lite bot", "user": {"login": cla.BOT_LOGIN}}
source.get.side_effect = [
response(data={"user": {"id": 2, "login": "new"}}),
response(data=[signing, bot]),
]
source.post.return_value = commits_response([{"user": {"databaseId": 2, "login": "new"}}])
store.get.side_effect = [ledger_response([]), ledger_response([])]
store.put.return_value = response(200)
cla.run(source, store)
stored = json.loads(base64.b64decode(store.put.call_args.kwargs["json"]["content"]))["signedContributors"]
assert stored == [
{"name": "new", "id": 2, "comment_id": 30, "created_at": "date", "repoId": 123, "pullRequestNo": 7}
]
assert cla.SIGN_COMMENT in cla._comment_body([], [{"id": 2, "name": "new"}], [])
source.patch.assert_called_once()
assert "All Contributors have signed" in source.patch.call_args.kwargs["json"]["body"]
@pytest.mark.parametrize("body", [f"{cla.SIGN_COMMENT}!", cla.SIGN_COMMENT.lower(), f" {cla.SIGN_COMMENT}"])
def test_run_rejects_similar_sentence_and_keeps_hard_failure(body):
"""Reject a modified signing sentence and leave the CLA gate failed."""
source, store = action(), action()
user = {"id": 2, "login": "new", "type": "User"}
source.get.side_effect = [
response(data={"user": {"id": 2, "login": "new"}}),
response(data=[{"id": 30, "body": body, "created_at": "date", "user": user}]),
response(data=[{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}]),
]
source.post.side_effect = [
commits_response([{"user": {"databaseId": 2, "login": "new"}}]),
response(201),
]
store.get.return_value = ledger_response([])
source.post.return_value = response(201)
with pytest.raises(RuntimeError, match="must sign"):
cla.run(source, store)
store.put.assert_not_called()
assert cla.SIGN_COMMENT in source.post.call_args.kwargs["json"]["body"]
def test_run_fails_unlinked_email_author():
"""Require commit authors with unlinked emails to link a GitHub account."""
source, store = action(), action()
source.get.side_effect = [
response(data={"user": {"id": 2, "login": "new"}}),
response(data=[]),
response(data=[{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}]),
]
source.post.side_effect = [
commits_response([{"name": "Unknown", "email": "[email protected]", "user": None}]),
response(201),
]
store.get.return_value = ledger_response([])
source.post.return_value = response(201)
with pytest.raises(RuntimeError, match="must sign"):
cla.run(source, store)
assert "not linked to a GitHub account" in source.post.call_args.kwargs["json"]["body"]
def test_run_requires_pr_opener_when_commit_identity_is_already_signed():
"""Require the submitter to sign even when forged commit metadata names a signer."""
source, store = action(), action()
source.get.side_effect = [
response(data={"user": {"id": 9, "login": "submitter"}}),
response(data=[]),
response(data=[{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}]),
]
source.post.side_effect = [
commits_response([{"user": {"databaseId": 1, "login": "signed-author"}}]),
response(201),
]
store.get.return_value = ledger_response([{"id": 1}])
with pytest.raises(RuntimeError, match="must sign"):
cla.run(source, store)
assert "@submitter" in source.patch.call_args.kwargs["json"]["body"]
def test_create_comment_confirms_ambiguous_write_before_failing():
"""Treat an ambiguous comment response as success only when the marker exists."""
source = action()
source.post.return_value = response(502)
source.get.return_value = response(data=[{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}])
cla._update_comment(source, 7, [], "body")
source.get.assert_called_once()
source.post.return_value.raise_for_status.assert_not_called()
source.patch.assert_called_once()
def test_update_comment_ignores_marker_from_another_bot():
"""Adopt only comments owned by the authenticated GitHub Actions bot."""
source = action()
source.post.return_value = response(201)
source.get.return_value = response(data=[{"id": 41, "body": cla.COMMENT_MARKER, "user": {"login": cla.BOT_LOGIN}}])
other = [{"id": 40, "body": cla.COMMENT_MARKER, "user": {"login": "other[bot]"}}]
cla._update_comment(source, 7, other, "body")
source.post.assert_called_once()
assert source.patch.call_args.args[0].endswith("/issues/comments/41")
def test_read_retries_transient_responses(monkeypatch):
"""Retry a transient GitHub read without changing unrelated API clients."""
source = action()
source.get.side_effect = [response(502), response(200, {"ok": True})]
monkeypatch.setattr("actions.cla.time.sleep", MagicMock())
assert cla._read(source, "get", "url").json() == {"ok": True}
assert source.get.call_count == 2
def test_rerun_uses_exact_pr_head(monkeypatch):
"""Rerun the failed CLA workflow associated with the live PR head only."""
source = action("issue_comment")
monkeypatch.setenv("GITHUB_WORKFLOW_REF", "ultralytics/example/.github/workflows/cla.yml@refs/heads/main")
source.get.side_effect = [
response(data={"head": {"ref": "feature", "sha": "exact"}}),
response(
data={
"workflow_runs": [
{"id": 1, "head_sha": "stale", "conclusion": "failure"},
{"id": 2, "head_sha": "exact", "conclusion": "failure"},
]
}
),
]
cla._rerun_pr_check(source, 7)
assert source.post.call_args.args[0].endswith("/actions/runs/2/rerun")
def test_rerun_leaves_queued_exact_head_to_complete(monkeypatch):
"""Let an incomplete exact-head check run after per-PR concurrency releases."""
source = action("issue_comment")
monkeypatch.setenv("GITHUB_WORKFLOW_REF", "ultralytics/example/.github/workflows/cla.yml@refs/heads/main")
source.get.side_effect = [
response(data={"head": {"ref": "feature", "sha": "exact"}}),
response(data={"workflow_runs": [{"id": 2, "head_sha": "exact", "conclusion": None}]}),
]
cla._rerun_pr_check(source, 7)
source.post.assert_not_called()