206 lines
7.8 KiB
YAML
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(', ')}`);
|
|
}
|