// 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(', ')}`); } };