ci: block bot contributors from PR commit history (#21926)

## What

Adds a CI check (`Blocked Contributors Check`) that runs on every PR and
**fails** if any commit is attributed to a known bot — via the commit
author, committer, or a `Co-Authored-By:` trailer.

Goal: keep automated agents (Claude, Cursor, Copilot, …) out of Twenty's
contributor history.

## How

- On `pull_request` (`opened`, `synchronize`, `reopened`) it fetches all
PR commits via the GitHub API and matches author/committer name+email
and the full commit message (for trailers) against an editable
blocklist.
- Patterns target **bot identities** (emails / `[bot]` handles), **not**
bare first names — so a human contributor named "Claude" is *not*
flagged.
- On failure it emits `::error::` annotations naming the offending SHA +
what matched, plus remediation guidance (rebase with `--reset-author`,
strip trailers, force-push).

Current blocklist:

```
noreply@anthropic.com
@anthropic.com
cursoragent@cursor.com
copilot-swe-agent[bot]
```

Add a line to block another bot — no logic changes needed.

## Notes

- This workflow only *reports* a failed status. To actually block
merges, add **Blocked Contributors Check** as a required status check in
branch-protection rules for `main` (repo Settings → Branches).
- `@anthropic.com` also blocks any Anthropic-domain identity; narrow to
just `noreply@anthropic.com` if real Anthropic employees may contribute
under their work email.

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21926?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Charles Bochet
2026-06-22 09:37:13 +02:00
committed by GitHub
parent 1b7dc0367e
commit 153e41e036
2 changed files with 136 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
name: Blocked Contributors Check
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: read
jobs:
check-blocked-contributors:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Install dependencies
uses: ./.github/actions/yarn-install
- name: Check PR commits for blocked contributors
run: npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/check-blocked-contributors.ts
env:
GITHUB_TOKEN: ${{ github.token }}
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}

View File

@@ -0,0 +1,109 @@
// Fails a PR when any commit is attributed to a known bot (author, committer,
// or Co-Authored-By trailer). Patterns match bot identities, not human names.
// Usage: GITHUB_TOKEN=xxx GITHUB_REPOSITORY=owner/repo PR_NUMBER=123 npx nx run twenty-server:ts-node-no-deps-transpile-only -- ./scripts/check-blocked-contributors.ts
const BLOCKED_PATTERNS = [
/noreply@anthropic\.com/i,
/@anthropic\.com/i,
/cursoragent@cursor\.com/i,
/copilot-swe-agent\[bot\]/i,
];
type Commit = {
sha: string;
commit: {
message: string;
author: { name: string; email: string };
committer: { name: string; email: string };
};
};
async function fetchPrCommits(
repo: string,
prNumber: string,
token: string,
): Promise<Commit[]> {
const commits: Commit[] = [];
let page = 1;
for (;;) {
const response = await fetch(
`https://api.github.com/repos/${repo}/pulls/${prNumber}/commits?per_page=100&page=${page}`,
{
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/vnd.github+json',
},
},
);
if (!response.ok) {
throw new Error(
`GitHub API ${response.status}: ${await response.text()}`,
);
}
const batch = (await response.json()) as Commit[];
commits.push(...batch);
if (batch.length < 100) {
return commits;
}
page += 1;
}
}
function findMatches(commit: Commit): string[] {
const haystack = [
commit.commit.author.name,
commit.commit.author.email,
commit.commit.committer.name,
commit.commit.committer.email,
commit.commit.message,
].join(' ');
return BLOCKED_PATTERNS.flatMap((pattern) => {
const match = haystack.match(pattern);
return match ? [match[0]] : [];
});
}
async function main(): Promise<void> {
const token = process.env.GITHUB_TOKEN;
const repo = process.env.GITHUB_REPOSITORY;
const prNumber = process.env.PR_NUMBER;
if (!token || !repo || !prNumber) {
console.error('Error: GITHUB_TOKEN, GITHUB_REPOSITORY and PR_NUMBER are required');
process.exit(1);
}
const commits = await fetchPrCommits(repo, prNumber, token);
let violations = 0;
for (const commit of commits) {
const matches = findMatches(commit);
if (matches.length > 0) {
console.error(
`::error::Commit ${commit.sha} is attributed to a blocked contributor (matched: ${matches.join(', ')})`,
);
violations += 1;
}
}
if (violations > 0) {
console.error(`\nFound ${violations} commit(s) attributed to blocked bot contributors.`);
console.error("Rewrite the author/committer and strip Co-Authored-By trailers, then force-push:");
console.error(" git rebase -i --exec 'git commit --amend --reset-author --no-edit' origin/main");
process.exit(1);
}
console.log('No blocked contributors found in PR commits.');
}
main().catch((error) => {
console.error('Error:', error);
process.exit(1);
});