Skip to content
Open
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
57 changes: 57 additions & 0 deletions .github/workflows/cla-gate.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Require "CLA gate" instead of "license/cla" after deploying this workflow.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Document the new CLA gate in AGENTS.md

This introduces a repository-wide workflow intended to become a required status check, but leaves the canonical AGENTS.md CI contract unchanged. Update that contract in this PR to describe the CLA gate, including its maintainer/bot exemptions and external CLA fallback, so future CI changes and reviews account for the new required check.

AGENTS.md reference: AGENTS.md:L10-L10

Useful? React with 👍 / 👎.

name: CLA gate
on: # zizmor: ignore[dangerous-triggers] Metadata-only API calls; never checks out or executes PR code.
pull_request_target:
types: [opened, reopened, synchronize, edited, ready_for_review]
merge_group:
status:
Comment on lines +3 to +7

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 (optional) Every PR already open when CLA gate becomes required will be blocked until someone edits it, including Dependabot auto-merge PRs and signed fork PRs. Statuses are only written on new PR events or new license/cla statuses. Existing PRs got their license/cla before the workflow existed, so they never get a CLA gate status. Fix: add a workflow_dispatch trigger that runs the same evaluation for each open PR's head SHA, and run it once after the ruleset switch.

Why this was flagged

The workflow writes CLA gate only when triggered by pull_request_target (opened, reopened, synchronize, edited, ready_for_review), merge_group, or status events with context license/cla (cla-gate.yml:3-11). There is no workflow_dispatch or schedule trigger. A PR opened before merge already received its license/cla from cla-assistant, so no further status event will arrive for its head SHA. After the planned follow-up swaps the required check from license/cla to CLA gate, that PR waits on a check that never reports. On the base branch the same PR is mergeable because license/cla is already present. Affected PRs include Dependabot PRs where gh pr merge --auto is pending (dependabot-merge.yml:23) and outside contributors' signed fork PRs. Each one needs a manual title or body edit, or a push. The PR description says the gate only starts on PRs opened or updated after merge, but it does not say that existing PRs become blocked. The status-event path already lists all open PRs (cla-gate.yml:30), so a dispatch backfill fits inside this change.

Verification: cla-gate.yml:3-7 has only these triggers: pull_request_target, merge_group, and status. There is no workflow_dispatch or schedule. A PR whose opened event and license/cla status both came before the workflow existed gets no further event, so it never receives a CLA gate status. Once CLA gate is required, such a PR cannot be merged.

permissions: {}
jobs:
evaluate:
if: github.event_name != 'status' || github.event.context == 'license/cla'
runs-on: ubuntu-latest
timeout-minutes: 3
permissions:
contents: read
pull-requests: read
statuses: write
concurrency:
group: cla-gate-${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha || github.event.sha }}
cancel-in-progress: false
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo, event = context.payload;
const sha = event.merge_group?.head_sha ?? event.pull_request?.head.sha ?? event.sha;
const publish = (state, description) => github.rest.repos.createCommitStatus({ owner, repo, sha, context: "CLA gate", state, description });
if (context.eventName === "merge_group") return publish("success", "CLA enforcement handled on the pull request.");
if (event.branches?.some(b => b.name.startsWith("gh-readonly-queue/"))) return;
const prs = event.pull_request ? [event.pull_request] : await github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 });
const current = prs.filter(pr => pr.state === "open" && pr.head.sha === sha);
if (current.length === 0) return;
await publish("failure", "CLA verification incomplete. Edit PR title/body to retry.");
/** Fork author attribution does not prove who can push to the branch. */
let exempt = current.every(pr => pr.head.repo?.full_name === `${owner}/${repo}`);
Comment on lines +30 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Anyone outside the repo can turn a maintainer PR's required CLA gate red by opening a fork PR whose head is the same commit. The gate is a per-SHA status. Line 35 still requires every open PR at that SHA to have a same-repo head. Fix: decide exemption per commit rather than per PR, so a SHA that a fully exempt same-repo PR points at stays exempt whichever PR's event runs. Load all open PRs at the SHA on every event, as the status path already does.

Why this was flagged

An outsider fetches a maintainer's same-repo branch head X, pushes it to a fork branch, and opens a PR. The pull_request_target run for that fork PR publishes failure on X at line 33. Line 35 then makes exempt false because pr.head.repo.full_name is the fork. Unless cla-assistant has a license/cla success on X, it ends with "CLA missing or unavailable" on X. That is the same commit the maintainer PR needs, so its required check goes red. Editing the maintainer PR re-runs with only that PR (line 30) and turns it green. Any later status event re-evaluates both PRs at line 31 and turns it red again, and each edited or synchronize event the outsider triggers on the fork PR does the same. On the base branch the required license/cla comes from cla-assistant's evaluation of X's commit authors, which is identical for both PRs, so a duplicate fork PR cannot change it. No safeguard prevents this. The fork check is meant to stop false success, but because statuses are keyed by SHA it also lets a fork PR veto a commit pushed by someone with write access.

Verification: In cla-gate.yml, publish posts a per-SHA "CLA gate" status (line 27), so both PRs share it on X. For the fork PR's event, line 30 sets prs = [event.pull_request], line 35 is false, and line 57 posts "CLA missing or unavailable" unless license/cla succeeded. On the base branch the required license/cla depended only on commit authors.

const checked = new Set([49699333, 41898282, 206951365, 199161495]); // Dependabot, Actions, Cursor.
for (const pr of current) {
if (!exempt) break;
const { repository: { pullRequest: live } } = await github.graphql(`
query($owner: String!, $repo: String!, $number: Int!) { repository(owner: $owner, name: $repo) { pullRequest(number: $number) {
headRefOid commits(first: 100) { totalCount nodes { commit { authors(first: 100) { totalCount nodes { user { login databaseId } } } } } }
} } }`, { owner, repo, number: pr.number });
if (live.headRefOid !== sha) return;
exempt &&= live.commits.totalCount === live.commits.nodes.length && live.commits.nodes.every(({ commit: c }) => c.authors.totalCount > 0 && c.authors.totalCount === c.authors.nodes.length);

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.

P1 Large maintainer PRs stay blocked

A same-repository PR with more than 100 commits cannot receive the maintainer exemption, even when every contributor has write access. commits(first: 100) returns only the first page, so this count comparison always makes exempt false. If CLA Assistant has not posted a successful status—the case this workflow is meant to cover—the PR stays blocked.

Fetch the remaining commit pages before deciding whether all contributors are exempt.

Prompt To Fix With AI
This is a comment left during a code review.
Path: .github/workflows/cla-gate.yml
Line: 44

Comment:
**Large maintainer PRs stay blocked**

A same-repository PR with more than 100 commits cannot receive the maintainer exemption, even when every contributor has write access. `commits(first: 100)` returns only the first page, so this count comparison always makes `exempt` false. If CLA Assistant has not posted a successful status—the case this workflow is meant to cover—the PR stays blocked.

Fetch the remaining commit pages before deciding whether all contributors are exempt.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

for (const user of [pr.user, ...live.commits.nodes.flatMap(({ commit: c }) => c.authors.nodes.map(a => a.user))]) {
if (!exempt) break;
Comment on lines +43 to +46

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 Dependabot and GitHub Actions PRs will likely never get the exemption. They would wait on license/cla exactly as they do today, which defeats the allowlist. GraphQL GitActor.user has type User and is null for commits written under a bot's noreply email, because bots are Bot objects, not users. A null user then gets the "none" permission at line 45, so exempt becomes false. Fix: when user is null, match the commit author's email or name against the allowlisted bot identities (for example 49699333+dependabot[bot]@ users.noreply.github.com). Only the PR author, pr.user, ever matches the IDs at line 36.

Why this was flagged

Trigger: a same-repo Dependabot PR, which .github/dependabot.yml produces routinely, arrives through pull_request_target. pr.user.id is 49699333, so the PR author matches the allowlist at line 44. Every commit is authored by dependabot[bot], though. The GraphQL query at line 40 asks for authors.nodes.user { login databaseId }, and GitActor.user resolves only to User accounts, so it comes back null for bot commits. At line 45 user is null, permission becomes "none" and exempt turns false. The run then falls through to the license/cla lookup and publishes failure unless cla-assistant posted success. On the base branch these PRs already wait on license/cla, so the bot exemption the PR describes (Dependabot, Actions) never applies to bot-authored commits. The same goes for any bot-authored commit pushed onto a maintainer branch. No safeguard handles this: the checked set only holds databaseIds, and a null user has none.

Verification: This is not a regression, because Dependabot PRs fall back to license/cla just as the base requires today. GitHub types GitActor.user as User, so it is null for bot commits. Diff line 53 checks undefined against the allowlist, line 54 gives "none", and line 55 sets exempt to false. The job then waits on license/cla at lines 60-63.

if (checked.has(user?.databaseId ?? user?.id)) continue;
const permission = user ? await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: user.login }).then(r => r.data.permission).catch(e => { if (e.status === 404) return "none"; throw e; }) : "none";
exempt &&= ["write", "maintain", "admin"].includes(permission);
checked.add(user?.databaseId ?? user?.id);
}
}
if (exempt) return publish("success", "All contributors exempt.");
const statuses = await github.paginate(github.rest.repos.listCommitStatusesForRef, { owner, repo, ref: sha });
const signed = statuses.find(s => s.context === "license/cla" && s.creator?.id === 2834609)?.state === "success"; // CLA Assistant's OAuth publisher.
if (signed) return publish("success", "CLA verified.");
return publish("failure", "CLA missing or unavailable. Sign, then edit PR title/body to retry.");
Loading