Synvael/.github/workflows/cla.yml

206 lines
7.8 KiB
YAML

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