diff --git a/.github/scripts/cla/check-signatures.mjs b/.github/scripts/cla/check-signatures.mjs new file mode 100644 index 00000000000..e87cea88e96 --- /dev/null +++ b/.github/scripts/cla/check-signatures.mjs @@ -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 } 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} */ + const authors = new Set(); + /** @type {Array<{sha: string, name: string, email: string}>} */ + const unlinkedCommits = []; + + /** + * @param {Array} 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)); +} diff --git a/.github/scripts/cla/post-final-status.mjs b/.github/scripts/cla/post-final-status.mjs new file mode 100644 index 00000000000..24b8c3bbb91 --- /dev/null +++ b/.github/scripts/cla/post-final-status.mjs @@ -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 } 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, + }); +} diff --git a/.github/scripts/cla/resolve-context.mjs b/.github/scripts/cla/resolve-context.mjs new file mode 100644 index 00000000000..b0266cf6f37 --- /dev/null +++ b/.github/scripts/cla/resolve-context.mjs @@ -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 } 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)); +} diff --git a/.github/scripts/cla/update-pr-comment.mjs b/.github/scripts/cla/update-pr-comment.mjs new file mode 100644 index 00000000000..50b87da0b2c --- /dev/null +++ b/.github/scripts/cla/update-pr-comment.mjs @@ -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 } 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, + }); + } +} diff --git a/.github/workflows/ci-cla-check.yml b/.github/workflows/ci-cla-check.yml new file mode 100644 index 00000000000..9d233d038d1 --- /dev/null +++ b/.github/workflows/ci-cla-check.yml @@ -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: '' + +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}`); + }