ci: Add in-house CLA check workflow (#30209)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matsu 2026-05-11 14:29:11 +03:00 committed by GitHub
parent 75646c4527
commit 410b75c3d0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 530 additions and 0 deletions

114
.github/scripts/cla/check-signatures.mjs vendored Normal file
View File

@ -0,0 +1,114 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Collects unique commit authors for the PR (or for the commits a merge
// queue is about to land) and asks the n8n CLA service whether each one
// has signed. Surfaces three buckets to subsequent steps:
// - signed : verified contributors
// - unsigned : verified non-contributors (block the merge)
// - errored : CLA lookup failed (block the merge — fail-closed so we
// never green-light an unverified contribution)
//
// Commits whose author email is not linked to a GitHub account can't be
// looked up by login; they're surfaced separately as `unlinked`.
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function checkSignatures ({ github, context, core }) {
const { owner, repo } = context.repo;
const prNumber = process.env.PR_NUMBER;
const headSha = process.env.HEAD_SHA;
const baseSha = process.env.BASE_SHA;
const isMergeGroup = process.env.IS_MERGE_GROUP === 'true';
/** @type {Set<string>} */
const authors = new Set();
/** @type {Array<{sha: string, name: string, email: string}>} */
const unlinkedCommits = [];
/**
* @param {Array<any>} commits
*/
const collect = (commits) => {
for (const c of commits) {
// Bot-authored commits don't need a CLA; skip before the linked/unlinked split
// so they don't fall through to `unlinkedCommits` and fail `all_signed`.
if (c.author && c.author.type === 'Bot') continue;
if (c.author && c.author.login) {
authors.add(c.author.login);
} else if (c.commit && c.commit.author) {
unlinkedCommits.push({
sha: c.sha,
name: c.commit.author.name,
email: c.commit.author.email,
});
}
}
};
if (isMergeGroup) {
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
owner,
repo,
basehead: `${baseSha}...${headSha}`,
});
collect(comparison.commits || []);
} else if (prNumber) {
const commits = await github.paginate(github.rest.pulls.listCommits, {
owner,
repo,
pull_number: Number(prNumber),
per_page: 100,
});
collect(commits);
}
const loginList = [...authors];
core.info(`Contributors to check: ${loginList.join(', ') || '(none)'}`);
if (unlinkedCommits.length > 0) {
core.warning(
`${unlinkedCommits.length} commit(s) have an author email not linked to a GitHub account ` +
'and cannot be verified against the CLA service.',
);
}
/** @type {string[]} */
const signed = [];
/** @type {string[]} */
const unsigned = [];
/** @type {string[]} */
const errored = [];
for (const login of loginList) {
const url = `${process.env.CLA_API}?checkContributor=${encodeURIComponent(login)}`;
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
if (data && data.isContributor === true) {
signed.push(login);
} else {
unsigned.push(login);
}
} catch (e) {
core.warning(`CLA lookup failed for @${login}: ${e instanceof Error ? e.message : String(e)}`);
errored.push(login);
}
}
const blocking = [...unsigned, ...errored];
const allSigned = blocking.length === 0 && unlinkedCommits.length === 0;
core.setOutput('signed', signed.join(','));
core.setOutput('unsigned', unsigned.join(','));
core.setOutput('errored', errored.join(','));
core.setOutput('unlinked', JSON.stringify(unlinkedCommits));
core.setOutput('all_signed', String(allSigned));
}

View File

@ -0,0 +1,66 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Translates the buckets emitted by check-signatures.mjs into a single
// commit status on the head SHA. The status `context` name is what a
// repository ruleset gates on; description and target_url are best-effort
// human signals.
//
// State mapping:
// - success: every contributor is signed and every commit author is linked
// - error : only failures were API lookup errors (transient)
// - failure: at least one contributor is verified unsigned, or commits
// have author emails not linked to a GitHub account
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function postFinalClaStatus({ github, context }) {
const allSigned = process.env.ALL_SIGNED === 'true';
const unsigned = (process.env.UNSIGNED ?? '').split(',').filter(Boolean);
const errored = (process.env.ERRORED ?? '').split(',').filter(Boolean);
const unlinked = JSON.parse(process.env.UNLINKED || '[]');
/** @type {'success' | 'failure' | 'error' | 'pending'} */
let state;
let description;
if (allSigned) {
state = 'success';
description = 'All contributors have signed the CLA';
} else if (errored.length > 0 && unsigned.length === 0 && unlinked.length === 0) {
state = 'error';
description = `Could not verify: ${errored.join(', ')}`;
} else {
state = 'failure';
const parts = [];
if (unsigned.length > 0) parts.push(`unsigned: ${unsigned.join(', ')}`);
if (errored.length > 0) parts.push(`errored: ${errored.join(', ')}`);
if (unlinked.length > 0) parts.push(`${unlinked.length} unlinked commit(s)`);
description = parts.join(' | ');
}
// GitHub commit status description is capped at 140 chars.
if (description.length > 140) {
description = description.slice(0, 137) + '…';
}
const prNumber = process.env.PR_NUMBER;
const target_url = prNumber
? `${context.payload.repository?.html_url}/pull/${prNumber}`
: process.env.CLA_SIGN_URL;
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: /** @type {string} */ (process.env.HEAD_SHA),
state,
context: /** @type {string} */ (process.env.STATUS_CONTEXT),
description,
target_url,
});
}

76
.github/scripts/cla/resolve-context.mjs vendored Normal file
View File

@ -0,0 +1,76 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Reads the triggering event (pull_request_target, issue_comment, or
// merge_group) and emits the head/base SHA and PR number that the rest of
// the workflow needs. For /cla-check comments, also leaves an "eyes"
// reaction so the commenter sees we picked it up.
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function resolveClaContext({ github, context, core }) {
const { owner, repo } = context.repo;
const event = context.eventName;
let prNumber = '';
let headSha = '';
let baseSha = '';
let isMergeGroup = false;
if (event === 'pull_request_target' && context.payload.pull_request) {
const pr = context.payload.pull_request;
prNumber = String(pr.number);
headSha = pr.head.sha;
baseSha = pr.base.sha;
} else if (event === 'issue_comment' && context.payload.issue) {
prNumber = String(context.payload.issue.number);
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: Number(prNumber),
});
headSha = pr.head.sha;
baseSha = pr.base.sha;
// Acknowledge the command so the commenter sees we received it.
try {
await github.rest.reactions.createForIssueComment({
owner,
repo,
comment_id: context.payload.comment?.id || -1,
content: 'eyes',
});
} catch (e) {
core.info(`Could not react to comment: ${e instanceof Error ? e.message : String(e)}`);
}
} else if (event === 'merge_group') {
isMergeGroup = true;
headSha = context.payload.merge_group.head_sha;
baseSha = context.payload.merge_group.base_sha;
} else if (event === 'workflow_dispatch') {
const input = context.payload.inputs?.pr_number;
if (!input) {
core.setFailed('workflow_dispatch requires the pr_number input');
return;
}
prNumber = String(input);
const { data: pr } = await github.rest.pulls.get({
owner,
repo,
pull_number: Number(prNumber),
});
headSha = pr.head.sha;
baseSha = pr.base.sha;
}
core.setOutput('pr_number', prNumber);
core.setOutput('head_sha', headSha);
core.setOutput('base_sha', baseSha);
core.setOutput('is_merge_group', String(isMergeGroup));
}

View File

@ -0,0 +1,106 @@
// Invoked from .github/workflows/ci-cla-check.yml via actions/github-script.
//
// Maintains a single CLA comment per PR, keyed by an HTML marker so the
// same comment is edited in place across re-runs instead of spammed.
// A clean PR that has never been flagged gets no comment at all — only
// PRs that needed a nudge get the eventual "thanks" follow-up.
/**
* @typedef { InstanceType<typeof import("@actions/github/lib/utils").GitHub> } GitHubInstance
* @typedef { import("@actions/github/lib/context").Context } Context
* @typedef { typeof import("@actions/core") } Core
*/
/**
* @param {{ github: GitHubInstance, context: Context, core: Core }} params
*/
export default async function updatePRComment({ github, context }) {
const { owner, repo } = context.repo;
const issue_number = Number(process.env.PR_NUMBER);
const allSigned = process.env.ALL_SIGNED === 'true';
const unsigned = (process.env.UNSIGNED ?? '').split(',').filter(Boolean);
const errored = (process.env.ERRORED ?? '').split(',').filter(Boolean);
const unlinked = JSON.parse(process.env.UNLINKED || '[]');
const MARKER = /** @type {string} */ (process.env.COMMENT_MARKER);
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number,
per_page: 100,
});
// Only adopt the comment as ours if it's bot-authored — otherwise a user
// who copies our marker into their own comment would either hijack the
// thread or make updateComment 403 with insufficient permissions.
const existing = comments.find(
(c) => c.body && c.body.includes(MARKER) && c.user && c.user.type === 'Bot',
);
let body;
if (allSigned) {
// Only leave a "thanks" trail if we already nudged once. Avoids
// pinging every clean PR with a CLA comment.
if (!existing) {
return;
}
body = [
MARKER,
'✅ **CLA Check passed.** All contributors on this PR have signed the n8n CLA — thank you!',
].join('\n');
} else {
const lines = [MARKER, '## CLA signatures required', ''];
lines.push(`Thank you for your submission! We really appreciate it.
Like many open source projects, we ask that you sign our [Contributor License Agreement](${process.env.CLA_SIGN_URL}) before we can accept your contribution.
After signing, please comment \`\`\`/cla-check\`\`\` to re-check signature status.`);
lines.push('');
if (unsigned.length > 0) {
lines.push('**Contributors who still need to sign:**');
for (const u of unsigned) {
lines.push(`- @${u}`);
}
lines.push('');
}
if (errored.length > 0) {
lines.push('**Could not verify (will retry on next push):**');
for (const u of errored) {
lines.push(`- @${u}`);
}
lines.push('');
}
if (unlinked.length > 0) {
lines.push('**Commits authored by an email not linked to a GitHub account:**');
for (const c of unlinked) {
lines.push(`- \`${c.sha.slice(0, 7)}\`${c.name} <${c.email}>`);
}
lines.push('');
lines.push(
'Add the email to your GitHub account ' +
'([instructions](https://docs.github.com/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-email-preferences/adding-an-email-address-to-your-github-account)) ' +
'or amend the commits to use a linked email, then push again.',
);
lines.push('');
}
lines.push('Once signed, comment `/cla-check` on this PR to re-run verification.');
body = lines.join('\n');
}
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body,
});
}
}

168
.github/workflows/ci-cla-check.yml vendored Normal file
View File

@ -0,0 +1,168 @@
name: 'CI: CLA Check'
# In-house replacement for the GitHub App "CLA Bot".
#
# Triggers
# - pull_request_target (opened/synchronize/reopened): re-checks signatures
# whenever a PR is opened or new commits are pushed.
# - issue_comment (`/cla-check` on a PR): manual re-check after a contributor
# signs the CLA, without needing a push.
# - merge_group: re-checks at merge-queue time so a ruleset can hard-block
# unsigned merges even if the PR check went stale.
#
# Output
# - A commit status named "CLA Check" on the head SHA. Add this name to a
# ruleset's required-checks list to gate merges on it.
# - A single, edited-in-place PR comment listing unsigned contributors.
#
# Implementation
# The heavy lifting lives in .github/scripts/cla/*.mjs. Each step below
# loads its corresponding module and invokes its default export.
on:
pull_request_target:
types: [opened, synchronize, reopened]
issue_comment:
types: [created]
merge_group:
workflow_dispatch:
inputs:
pr_number:
description: 'Pull request number to re-verify'
required: true
type: string
permissions:
contents: read
pull-requests: write
issues: write
statuses: write
concurrency:
group: cla-check-${{ github.event.pull_request.number || github.event.issue.number || github.event.merge_group.head_sha || github.event.inputs.pr_number || github.ref }}
cancel-in-progress: true
env:
STATUS_CONTEXT: 'CLA Check'
CLA_API: 'https://cla-bot-prod.users.n8n.cloud/webhook/cla/check'
CLA_SIGN_URL: 'https://cla-bot-prod.users.n8n.cloud/webhook/cla'
COMMENT_MARKER: '<!-- n8n-cla-check -->'
jobs:
cla-check:
name: Verify CLA signatures
# Skip issue_comment unless it's on a PR and the body starts with /cla-check.
if: >-
github.event_name != 'issue_comment' ||
(github.event.issue.pull_request != null &&
startsWith(github.event.comment.body, '/cla-check'))
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Generate GitHub App Token
id: generate-token
uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2.2.1
with:
app-id: ${{ secrets.N8N_ASSISTANT_APP_ID }}
private-key: ${{ secrets.N8N_ASSISTANT_PRIVATE_KEY }}
- name: Checkout CLA scripts
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
sparse-checkout: .github/scripts/cla
sparse-checkout-cone-mode: false
- name: Resolve PR context
id: context
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const mod = await import('${{ github.workspace }}/.github/scripts/cla/resolve-context.mjs');
await mod.default({ github, context, core });
- name: Post pending commit status
if: steps.context.outputs.head_sha != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
HEAD_SHA: ${{ steps.context.outputs.head_sha }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
await github.rest.repos.createCommitStatus({
owner: context.repo.owner,
repo: context.repo.repo,
sha: process.env.HEAD_SHA,
state: 'pending',
context: process.env.STATUS_CONTEXT,
description: 'Verifying CLA signatures…',
});
- name: Check CLA signatures
id: check
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
HEAD_SHA: ${{ steps.context.outputs.head_sha }}
BASE_SHA: ${{ steps.context.outputs.base_sha }}
IS_MERGE_GROUP: ${{ steps.context.outputs.is_merge_group }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const mod = await import('${{ github.workspace }}/.github/scripts/cla/check-signatures.mjs');
await mod.default({ github, context, core });
- name: Post final commit status
if: always() && steps.context.outputs.head_sha != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
HEAD_SHA: ${{ steps.context.outputs.head_sha }}
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
ALL_SIGNED: ${{ steps.check.outputs.all_signed }}
UNSIGNED: ${{ steps.check.outputs.unsigned }}
ERRORED: ${{ steps.check.outputs.errored }}
UNLINKED: ${{ steps.check.outputs.unlinked }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const mod = await import('${{ github.workspace }}/.github/scripts/cla/post-final-status.mjs');
await mod.default({ github, context, core });
- name: Update PR comment
# Don't comment from merge_group (no PR context) or when the check
# failed to produce a result.
if: >-
always() &&
steps.context.outputs.pr_number != '' &&
steps.check.outputs.all_signed != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
PR_NUMBER: ${{ steps.context.outputs.pr_number }}
ALL_SIGNED: ${{ steps.check.outputs.all_signed }}
UNSIGNED: ${{ steps.check.outputs.unsigned }}
ERRORED: ${{ steps.check.outputs.errored }}
UNLINKED: ${{ steps.check.outputs.unlinked }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
const mod = await import('${{ github.workspace }}/.github/scripts/cla/update-pr-comment.mjs');
await mod.default({ github, context, core });
- name: React to /cla-check comment
if: always() && github.event_name == 'issue_comment' && steps.check.outputs.all_signed != ''
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
env:
ALL_SIGNED: ${{ steps.check.outputs.all_signed }}
with:
github-token: ${{ steps.generate-token.outputs.token }}
script: |
try {
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: process.env.ALL_SIGNED === 'true' ? '+1' : '-1',
});
} catch (e) {
core.info(`Could not react to comment: ${e.message}`);
}