chore: add contribution gate workflows

This commit is contained in:
Nightt
2026-06-02 10:29:46 +08:00
committed by Hunter B
parent f48e398ba5
commit 97c615ca9c
5 changed files with 377 additions and 0 deletions
+175
View File
@@ -0,0 +1,175 @@
name: Approve gated contributor
on:
issue_comment:
types: [created]
permissions:
contents: write
issues: write
pull-requests: write
jobs:
approve:
runs-on: ubuntu-latest
steps:
- name: Open allowlist update PR
uses: actions/github-script@v7
with:
script: |
const comment = context.payload.comment;
const issue = context.payload.issue;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
const command = (comment.body || '').trim().toLowerCase();
const scopeByCommand = new Map([
['/lgtm', 'pr'],
['lgtm', 'pr'],
['/lgtmi', 'issue'],
['lgtmi', 'issue'],
]);
const scope = scopeByCommand.get(command);
if (!scope) return;
if (!privileged.has(comment.author_association)) return;
if (scope === 'pr' && !issue.pull_request) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.',
});
return;
}
const path = '.github/APPROVED_CONTRIBUTORS';
const targetLogin = issue.user.login;
const normalizedLogin = targetLogin.toLowerCase();
const entry = `${scope}:${normalizedLogin}`;
const defaultContent = [
'# Scoped contribution-gate allowlist.',
'#',
'# Maintainers and collaborators bypass the gate automatically. Use this file',
'# for external contributors who are allowed through the automated front door.',
'#',
'# Supported entries:',
'# pr:username',
'# issue:username',
'# all:username',
'',
].join('\n');
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
const { data: repoData } = await github.rest.repos.get({ owner, repo });
const defaultBranch = repoData.default_branch;
const { data: baseRef } = await github.rest.git.getRef({
owner,
repo,
ref: `heads/${defaultBranch}`,
});
const baseSha = baseRef.object.sha;
const { data: baseCommit } = await github.rest.git.getCommit({
owner,
repo,
commit_sha: baseSha,
});
let content = defaultContent;
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path,
ref: defaultBranch,
});
if (!Array.isArray(data) && data.type === 'file') {
content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8');
}
} catch (error) {
if (error.status !== 404) throw error;
}
const existing = parseAllowlist(content);
if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`,
});
return;
}
const nextContent = `${content.trimEnd()}\n${entry}\n`;
const { data: blob } = await github.rest.git.createBlob({
owner,
repo,
content: nextContent,
encoding: 'utf-8',
});
const { data: tree } = await github.rest.git.createTree({
owner,
repo,
base_tree: baseCommit.tree.sha,
tree: [
{
path,
mode: '100644',
type: 'blob',
sha: blob.sha,
},
],
});
const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor';
const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`;
await github.rest.git.createRef({
owner,
repo,
ref: `refs/heads/${branchName}`,
sha: baseSha,
});
const { data: commit } = await github.rest.git.createCommit({
owner,
repo,
message: `chore: approve @${targetLogin} for ${scope} contributions`,
tree: tree.sha,
parents: [baseSha],
});
await github.rest.git.updateRef({
owner,
repo,
ref: `heads/${branchName}`,
sha: commit.sha,
});
const { data: pr } = await github.rest.pulls.create({
owner,
repo,
title: `chore: approve @${targetLogin} for ${scope} contributions`,
head: branchName,
base: defaultBranch,
body: [
`Adds \`${entry}\` to \`${path}\`.`,
'',
`Requested by @${comment.user.login} in #${issue.number}.`,
].join('\n'),
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: `Created allowlist update PR: ${pr.html_url}`,
});
+84
View File
@@ -0,0 +1,84 @@
name: Contribution gate - issues
on:
issues:
types: [opened, reopened]
permissions:
contents: read
issues: write
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Close unapproved external issues
uses: actions/github-script@v7
with:
script: |
const issue = context.payload.issue;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
if (issue.pull_request) return;
if (privileged.has(issue.author_association)) return;
if (issue.user.login === 'github-actions[bot]') return;
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
async function readAllowlist() {
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path: '.github/APPROVED_CONTRIBUTORS',
ref: context.payload.repository.default_branch,
});
if (Array.isArray(data) || data.type !== 'file') return new Set();
return parseAllowlist(
Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
);
} catch (error) {
if (error.status === 404) return new Set();
throw error;
}
}
const allowlist = await readAllowlist();
const login = issue.user.login.toLowerCase();
if (
allowlist.has(login) ||
allowlist.has(`all:${login}`) ||
allowlist.has(`issue:${login}`)
) {
return;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: [
`Thanks @${issue.user.login} for the report.`,
'',
'This repository currently uses a maintainer-managed contribution gate, so issues from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.',
'',
'Please read `CONTRIBUTING.md` for the expected issue shape. A maintainer can grant issue access by commenting `/lgtmi` on an issue.',
].join('\n'),
});
await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned',
});
+84
View File
@@ -0,0 +1,84 @@
name: Contribution gate - pull requests
on:
pull_request_target:
types: [opened, reopened]
permissions:
contents: read
issues: write
pull-requests: write
jobs:
gate:
runs-on: ubuntu-latest
steps:
- name: Close unapproved external pull requests
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const owner = context.repo.owner;
const repo = context.repo.repo;
const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
if (privileged.has(pr.author_association)) return;
if (pr.user.login === 'github-actions[bot]') return;
if ((pr.head.ref || '').startsWith('contribution-gate/')) return;
function parseAllowlist(content) {
return new Set(
content
.split(/\r?\n/)
.map(line => line.replace(/#.*/, '').trim().toLowerCase())
.filter(Boolean)
);
}
async function readAllowlist() {
try {
const { data } = await github.rest.repos.getContent({
owner,
repo,
path: '.github/APPROVED_CONTRIBUTORS',
ref: pr.base.ref,
});
if (Array.isArray(data) || data.type !== 'file') return new Set();
return parseAllowlist(
Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
);
} catch (error) {
if (error.status === 404) return new Set();
throw error;
}
}
const allowlist = await readAllowlist();
const login = pr.user.login.toLowerCase();
if (
allowlist.has(login) ||
allowlist.has(`all:${login}`) ||
allowlist.has(`pr:${login}`)
) {
return;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: pr.number,
body: [
`Thanks @${pr.user.login} for taking the time to contribute.`,
'',
'This repository currently uses a maintainer-managed contribution gate, so pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS` are closed automatically.',
'',
'Please read `CONTRIBUTING.md` for the expected contribution shape. A maintainer can grant PR access by commenting `/lgtm` on a pull request.',
].join('\n'),
});
await github.rest.pulls.update({
owner,
repo,
pull_number: pr.number,
state: 'closed',
});