ci(workspace): add CLA enforcement workflow
This commit is contained in:
parent
99c407f57e
commit
c3c60fe406
3
.github/cla-signatures.json
vendored
Normal file
3
.github/cla-signatures.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"signatures": []
|
||||
}
|
||||
205
.github/workflows/cla.yml
vendored
Normal file
205
.github/workflows/cla.yml
vendored
Normal file
|
|
@ -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(', ')}`);
|
||||
}
|
||||
Loading…
Reference in a new issue