From c3c60fe40647c9dcbc058eae19445c3b2edfd0bc Mon Sep 17 00:00:00 2001 From: Serkyo Date: Thu, 9 Jul 2026 16:05:40 +0200 Subject: [PATCH] ci(workspace): add CLA enforcement workflow --- .github/cla-signatures.json | 3 + .github/workflows/cla.yml | 205 ++++++++++++++++++++++++++++++++++++ 2 files changed, 208 insertions(+) create mode 100644 .github/cla-signatures.json create mode 100644 .github/workflows/cla.yml diff --git a/.github/cla-signatures.json b/.github/cla-signatures.json new file mode 100644 index 0000000..bfb1be5 --- /dev/null +++ b/.github/cla-signatures.json @@ -0,0 +1,3 @@ +{ + "signatures": [] +} diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml new file mode 100644 index 0000000..2936805 --- /dev/null +++ b/.github/workflows/cla.yml @@ -0,0 +1,205 @@ +name: CLA Check + +on: + pull_request_target: + types: [opened, reopened, synchronize] + issue_comment: + types: [created] + +permissions: + contents: write + pull-requests: write + statuses: write + +jobs: + cla-check: + # Run on PR events, or on issue comments that are on PRs (not plain issues). + if: > + github.event_name == 'pull_request_target' || + (github.event_name == 'issue_comment' && github.event.issue.pull_request) + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + # pull_request_target runs on the base branch; check out the base + # so the signatures file is from the canonical source. + ref: ${{ github.event.pull_request.base.ref || github.event.repository.default_branch }} + token: ${{ secrets.GITHUB_TOKEN }} + + - name: CLA enforcement + uses: actions/github-script@v7 + 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(', ')}`); + }