From 857aada9815c6c60c942146a92066f8bde01abb1 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 4 Aug 2026 01:09:52 +0200 Subject: [PATCH] ci(workspace): extract the cla check script from the workflow --- .github/scripts/cla-check.js | 174 +++++++++++++++++++++++++++++++++++ .github/workflows/cla.yml | 173 +--------------------------------- 2 files changed, 176 insertions(+), 171 deletions(-) create mode 100644 .github/scripts/cla-check.js diff --git a/.github/scripts/cla-check.js b/.github/scripts/cla-check.js new file mode 100644 index 0000000..a8d37ca --- /dev/null +++ b/.github/scripts/cla-check.js @@ -0,0 +1,174 @@ +// Enforces the Contributor License Agreement on pull requests. +// +// Invoked from .github/workflows/cla.yml through actions/github-script, which supplies the `github`, `context` and `core` helpers as arguments. Kept in a file rather than inline in the workflow so that no `${{ }}` expression is ever interpolated into executable code: this workflow runs on `pull_request_target` with write permissions, where an injected expression would execute against repository credentials. + +module.exports = async ({ github, context, core }) => { + const fs = require('fs'); + + const SIGNATURES_FILE = '.github/cla-signatures.json'; + const CLA_AGREE_PATTERN = /I have read the CLA and I agree/i; + const CLA_LINK = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/dev/CLA.md`; + const CHECK_NAME = 'CLA Signed'; + + // --- Helpers --- + + function loadSignatures() { + try { + const raw = fs.readFileSync(SIGNATURES_FILE, 'utf8'); + return JSON.parse(raw); + } catch { + return { signatures: [] }; + } + } + + function saveSignatures(data) { + fs.writeFileSync(SIGNATURES_FILE, JSON.stringify(data, null, 2) + '\n'); + } + + function hasSigned(data, username) { + return data.signatures.some( + (s) => s.github_username.toLowerCase() === username.toLowerCase() + ); + } + + async function getAuthors(prNumber) { + const commits = await github.paginate( + github.rest.pulls.listCommits, + { owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber } + ); + const authors = new Set(); + for (const c of commits) { + if (c.author && c.author.login) { + authors.add(c.author.login); + } + } + return [...authors]; + } + + async function setStatus(sha, state, description) { + await github.rest.repos.createCommitStatus({ + owner: context.repo.owner, + repo: context.repo.repo, + sha, + state, + description, + context: CHECK_NAME, + }); + } + + async function commitSignatures(username) { + // Stage, commit, and push the updated signatures file using the GitHub API (create-or-update-file-contents). + const content = fs.readFileSync(SIGNATURES_FILE, 'utf8'); + const encoded = Buffer.from(content).toString('base64'); + + let sha; + try { + const existing = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: SIGNATURES_FILE, + ref: context.payload.repository.default_branch, + }); + sha = existing.data.sha; + } catch { + // File does not exist yet; will be created. + } + + await github.rest.repos.createOrUpdateFileContents({ + owner: context.repo.owner, + repo: context.repo.repo, + path: SIGNATURES_FILE, + message: `chore(workspace): record CLA signature for @${username}`, + content: encoded, + sha, + branch: context.payload.repository.default_branch, + }); + } + + // --- Main logic --- + + let prNumber; + let headSha; + + if (context.eventName === 'pull_request_target') { + prNumber = context.payload.pull_request.number; + headSha = context.payload.pull_request.head.sha; + } else { + // issue_comment on a PR + prNumber = context.payload.issue.number; + // Fetch the PR to get the head SHA. + const pr = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + }); + headSha = pr.data.head.sha; + } + + const sigData = loadSignatures(); + const authors = await getAuthors(prNumber); + + // If this is a comment event, check if the commenter is signing. + if (context.eventName === 'issue_comment') { + const comment = context.payload.comment.body; + const commenter = context.payload.comment.user.login; + + if (CLA_AGREE_PATTERN.test(comment) && authors.includes(commenter)) { + if (!hasSigned(sigData, commenter)) { + sigData.signatures.push({ + github_username: commenter, + signed_at: new Date().toISOString(), + pull_request: `https://github.com/${context.repo.owner}/${context.repo.repo}/pull/${prNumber}`, + }); + saveSignatures(sigData); + await commitSignatures(commenter); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: `✅ @${commenter} — CLA signature recorded. Thank you!`, + }); + } + } + } + + // Re-read signatures (may have been updated above). + const currentSigs = loadSignatures(); + const unsigned = authors.filter((a) => !hasSigned(currentSigs, a)); + + if (unsigned.length === 0) { + await setStatus(headSha, 'success', 'All authors have signed the CLA.'); + core.info('All PR authors have signed the CLA.'); + } else { + await setStatus(headSha, 'pending', `CLA not signed by: ${unsigned.join(', ')}`); + + // Only post the instructions comment on PR open/reopen, not on every push or unrelated comment. + if ( + context.eventName === 'pull_request_target' && + ['opened', 'reopened'].includes(context.payload.action) + ) { + const mention = unsigned.map((u) => `@${u}`).join(', '); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: [ + `### 📝 CLA Signature Required`, + '', + `${mention} — thank you for your contribution! Before this pull request can be reviewed and merged, all commit authors must sign the [Contributor License Agreement](${CLA_LINK}).`, + '', + 'To sign, please leave a comment on this pull request containing **exactly**:', + '', + '```', + 'I have read the CLA and I agree', + '```', + '', + 'Signing is a one-time action. Once recorded, all future pull requests from the same account are accepted automatically.', + ].join('\n'), + }); + } + + core.info(`Unsigned authors: ${unsigned.join(', ')}`); + } +}; diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 2936805..214a932 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -32,174 +32,5 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | - const fs = require('fs'); - const path = require('path'); - - const SIGNATURES_FILE = '.github/cla-signatures.json'; - const CLA_AGREE_PATTERN = /I have read the CLA and I agree/i; - const CLA_LINK = 'https://github.com/${{ github.repository }}/blob/dev/CLA.md'; - const CHECK_NAME = 'CLA Signed'; - - // --- Helpers --- - - function loadSignatures() { - try { - const raw = fs.readFileSync(SIGNATURES_FILE, 'utf8'); - return JSON.parse(raw); - } catch { - return { signatures: [] }; - } - } - - function saveSignatures(data) { - fs.writeFileSync(SIGNATURES_FILE, JSON.stringify(data, null, 2) + '\n'); - } - - function hasSigned(data, username) { - return data.signatures.some( - (s) => s.github_username.toLowerCase() === username.toLowerCase() - ); - } - - async function getAuthors(prNumber) { - const commits = await github.paginate( - github.rest.pulls.listCommits, - { owner: context.repo.owner, repo: context.repo.repo, pull_number: prNumber } - ); - const authors = new Set(); - for (const c of commits) { - if (c.author && c.author.login) { - authors.add(c.author.login); - } - } - return [...authors]; - } - - async function setStatus(sha, state, description) { - await github.rest.repos.createCommitStatus({ - owner: context.repo.owner, - repo: context.repo.repo, - sha, - state, - description, - context: CHECK_NAME, - }); - } - - async function commitSignatures(username) { - // Stage, commit, and push the updated signatures file using - // the GitHub API (create-or-update-file-contents). - const content = fs.readFileSync(SIGNATURES_FILE, 'utf8'); - const encoded = Buffer.from(content).toString('base64'); - - let sha; - try { - const existing = await github.rest.repos.getContent({ - owner: context.repo.owner, - repo: context.repo.repo, - path: SIGNATURES_FILE, - ref: context.payload.repository.default_branch, - }); - sha = existing.data.sha; - } catch { - // File does not exist yet; will be created. - } - - await github.rest.repos.createOrUpdateFileContents({ - owner: context.repo.owner, - repo: context.repo.repo, - path: SIGNATURES_FILE, - message: `chore(workspace): record CLA signature for @${username}`, - content: encoded, - sha, - branch: context.payload.repository.default_branch, - }); - } - - // --- Main logic --- - - let prNumber; - let headSha; - - if (context.eventName === 'pull_request_target') { - prNumber = context.payload.pull_request.number; - headSha = context.payload.pull_request.head.sha; - } else { - // issue_comment on a PR - prNumber = context.payload.issue.number; - // Fetch the PR to get the head SHA. - const pr = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: prNumber, - }); - headSha = pr.data.head.sha; - } - - const sigData = loadSignatures(); - const authors = await getAuthors(prNumber); - - // If this is a comment event, check if the commenter is signing. - if (context.eventName === 'issue_comment') { - const comment = context.payload.comment.body; - const commenter = context.payload.comment.user.login; - - if (CLA_AGREE_PATTERN.test(comment) && authors.includes(commenter)) { - if (!hasSigned(sigData, commenter)) { - sigData.signatures.push({ - github_username: commenter, - signed_at: new Date().toISOString(), - pull_request: `https://github.com/${context.repo.owner}/${context.repo.repo}/pull/${prNumber}`, - }); - saveSignatures(sigData); - await commitSignatures(commenter); - - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: `✅ @${commenter} — CLA signature recorded. Thank you!`, - }); - } - } - } - - // Re-read signatures (may have been updated above). - const currentSigs = loadSignatures(); - const unsigned = authors.filter((a) => !hasSigned(currentSigs, a)); - - if (unsigned.length === 0) { - await setStatus(headSha, 'success', 'All authors have signed the CLA.'); - core.info('All PR authors have signed the CLA.'); - } else { - await setStatus(headSha, 'pending', `CLA not signed by: ${unsigned.join(', ')}`); - - // Only post the instructions comment on PR open/reopen, not on - // every push or unrelated comment. - if ( - context.eventName === 'pull_request_target' && - ['opened', 'reopened'].includes(context.payload.action) - ) { - const mention = unsigned.map((u) => `@${u}`).join(', '); - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: prNumber, - body: [ - `### 📝 CLA Signature Required`, - '', - `${mention} — thank you for your contribution! Before this pull request can be reviewed and merged, all commit authors must sign the [Contributor License Agreement](${CLA_LINK}).`, - '', - 'To sign, please leave a comment on this pull request containing **exactly**:', - '', - '```', - 'I have read the CLA and I agree', - '```', - '', - 'Signing is a one-time action. Once recorded, all future pull requests from the same account are accepted automatically.', - ].join('\n'), - }); - } - - core.info(`Unsigned authors: ${unsigned.join(', ')}`); - } + const claCheck = require('${{ github.workspace }}/.github/scripts/cla-check.js'); + await claCheck({ github, context, core });