ci(workspace): extract the cla check script from the workflow
This commit is contained in:
parent
ccd105e0d3
commit
857aada981
174
.github/scripts/cla-check.js
vendored
Normal file
174
.github/scripts/cla-check.js
vendored
Normal file
|
|
@ -0,0 +1,174 @@
|
||||||
|
// 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(', ')}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
173
.github/workflows/cla.yml
vendored
173
.github/workflows/cla.yml
vendored
|
|
@ -32,174 +32,5 @@ jobs:
|
||||||
with:
|
with:
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
script: |
|
script: |
|
||||||
const fs = require('fs');
|
const claCheck = require('${{ github.workspace }}/.github/scripts/cla-check.js');
|
||||||
const path = require('path');
|
await claCheck({ github, context, core });
|
||||||
|
|
||||||
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