From 153e41e0366314cbb7ec00c253c32dcab71a9b6a Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Mon, 22 Jun 2026 09:37:13 +0200 Subject: [PATCH] ci: block bot contributors from PR commit history (#21926) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. Review in cubic --- .../workflows/ci-blocked-contributors.yaml | 27 +++++ .../scripts/check-blocked-contributors.ts | 109 ++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 .github/workflows/ci-blocked-contributors.yaml create mode 100644 packages/twenty-server/scripts/check-blocked-contributors.ts diff --git a/.github/workflows/ci-blocked-contributors.yaml b/.github/workflows/ci-blocked-contributors.yaml new file mode 100644 index 00000000000..791c1fb09ef --- /dev/null +++ b/.github/workflows/ci-blocked-contributors.yaml @@ -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 }} diff --git a/packages/twenty-server/scripts/check-blocked-contributors.ts b/packages/twenty-server/scripts/check-blocked-contributors.ts new file mode 100644 index 00000000000..ea4451350e0 --- /dev/null +++ b/packages/twenty-server/scripts/check-blocked-contributors.ts @@ -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 { + 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 { + 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); +});