返回 CodeWhale
approve-contributor.yml
根目录 / .github / workflows / approve-contributor.yml
1 name: Approve gated contributor
2
3 on:
4 issue_comment:
5 types: [created]
6
7 permissions: {}
8
9 jobs:
10 approve:
11 # Cheap pre-filter before any token is minted: this workflow fires on
12 # every issue comment, but only a maintainer's lgtm/lgtmi command does
13 # anything. Expression string comparisons are case-insensitive; the
14 # script below still performs the exact trimmed-command match.
15 if: >-
16 contains(github.event.comment.body, 'lgtm')
17 && contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'), github.event.comment.author_association)
18 runs-on: ubuntu-latest
19 timeout-minutes: 10
20 permissions:
21 contents: write
22 issues: write
23 pull-requests: write
24 # Job-level so skipped runs never enter the group: a workflow-level group
25 # let any unrelated comment queue and cancel a pending approval run.
26 concurrency:
27 group: contribution-gate-approval
28 cancel-in-progress: false
29 steps:
30 - name: Open allowlist update PR
31 uses: actions/github-script@v9
32 with:
33 script: |
34 const comment = context.payload.comment;
35 const issue = context.payload.issue;
36 const owner = context.repo.owner;
37 const repo = context.repo.repo;
38 const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
39 const command = (comment.body || '').trim().toLowerCase();
40 const scopeByCommand = new Map([
41 ['/lgtm', 'pr'],
42 ['lgtm', 'pr'],
43 ['/lgtmi', 'issue'],
44 ['lgtmi', 'issue'],
45 ]);
46 const scope = scopeByCommand.get(command);
47
48 // Answer the maintainer's command with a reaction, never a comment
49 // (founder, 2026-09-22). The run log carries the detail, and the
50 // allowlist PR body links back here, so the thread still shows it.
51 async function react(content, message) {
52 core.notice(message);
53 await github.rest.reactions.createForIssueComment({
54 owner,
55 repo,
56 comment_id: comment.id,
57 content,
58 });
59 }
60
61 if (!scope) return;
62 if (!privileged.has(comment.author_association)) return;
63 if (scope === 'pr' && !issue.pull_request) {
64 await react('confused', '`/lgtm` grants PR access and must be used on a pull request. Use `/lgtmi` to grant issue access.');
65 return;
66 }
67 if (scope === 'issue' && issue.pull_request) {
68 await react('confused', '`/lgtmi` grants issue access and must be used on an issue. Use `/lgtm` to grant PR access.');
69 return;
70 }
71
72 const path = '.github/APPROVED_CONTRIBUTORS';
73 const targetLogin = issue.user.login;
74 const normalizedLogin = targetLogin.toLowerCase();
75 const entry = `${scope}:${normalizedLogin}`;
76 const branchSlug = normalizedLogin.replace(/[^a-z0-9._-]+/g, '-').replace(/^-+|-+$/g, '') || 'contributor';
77
78 const defaultContent = [
79 '# Scoped contribution-gate allowlist.',
80 '#',
81 '# Maintainers and collaborators bypass the gate automatically. Use this file',
82 '# for external contributors who are allowed through the automated front door.',
83 '# Seed active contributors here before switching the gate workflows to enforce mode.',
84 '#',
85 '# Supported entries:',
86 '# pr:username',
87 '# issue:username',
88 '# all:username',
89 '',
90 ].join('\n');
91
92 function parseAllowlist(content) {
93 return new Set(
94 content
95 .split(/\r?\n/)
96 .map(line => line.replace(/#.*/, '').trim().toLowerCase())
97 .filter(Boolean)
98 );
99 }
100
101 const { data: repoData } = await github.rest.repos.get({ owner, repo });
102 const defaultBranch = repoData.default_branch;
103 const { data: baseRef } = await github.rest.git.getRef({
104 owner,
105 repo,
106 ref: `heads/${defaultBranch}`,
107 });
108 const baseSha = baseRef.object.sha;
109 const { data: baseCommit } = await github.rest.git.getCommit({
110 owner,
111 repo,
112 commit_sha: baseSha,
113 });
114
115 let content = defaultContent;
116 try {
117 const { data } = await github.rest.repos.getContent({
118 owner,
119 repo,
120 path,
121 ref: defaultBranch,
122 });
123 if (!Array.isArray(data) && data.type === 'file') {
124 content = Buffer.from(data.content, data.encoding || 'base64').toString('utf8');
125 }
126 } catch (error) {
127 if (error.status !== 404) throw error;
128 }
129
130 const existing = parseAllowlist(content);
131 if (existing.has(entry) || existing.has(`all:${normalizedLogin}`)) {
132 await react('eyes', `@${targetLogin} is already approved for ${scope} contributions in \`${path}\`.`);
133 return;
134 }
135
136 const openPrs = [];
137 for (let page = 1; ; page++) {
138 const { data: pagePrs } = await github.rest.pulls.list({
139 owner,
140 repo,
141 state: 'open',
142 per_page: 100,
143 page,
144 });
145 openPrs.push(...pagePrs);
146 if (pagePrs.length < 100) break;
147 }
148 const repoFullName = `${owner}/${repo}`.toLowerCase();
149 const pendingPr = openPrs.find(openPr => {
150 const sameRepo = (openPr.head?.repo?.full_name || '').toLowerCase() === repoFullName;
151 const body = openPr.body || '';
152 return sameRepo && body.includes(`Adds \`${entry}\` to \`${path}\`.`);
153 });
154
155 if (pendingPr) {
156 await react('eyes', `@${targetLogin} already has a pending allowlist update PR for ${scope} contributions: ${pendingPr.html_url}`);
157 return;
158 }
159
160 const nextContent = `${content.trimEnd()}\n${entry}\n`;
161 const { data: blob } = await github.rest.git.createBlob({
162 owner,
163 repo,
164 content: nextContent,
165 encoding: 'utf-8',
166 });
167 const { data: tree } = await github.rest.git.createTree({
168 owner,
169 repo,
170 base_tree: baseCommit.tree.sha,
171 tree: [
172 {
173 path,
174 mode: '100644',
175 type: 'blob',
176 sha: blob.sha,
177 },
178 ],
179 });
180
181 const branchName = `contribution-gate/${scope}-${branchSlug}-${Date.now()}`;
182 await github.rest.git.createRef({
183 owner,
184 repo,
185 ref: `refs/heads/${branchName}`,
186 sha: baseSha,
187 });
188
189 const { data: commit } = await github.rest.git.createCommit({
190 owner,
191 repo,
192 message: `chore: approve @${targetLogin} for ${scope} contributions`,
193 tree: tree.sha,
194 parents: [baseSha],
195 });
196 await github.rest.git.updateRef({
197 owner,
198 repo,
199 ref: `heads/${branchName}`,
200 sha: commit.sha,
201 });
202
203 const { data: pr } = await github.rest.pulls.create({
204 owner,
205 repo,
206 title: `chore: approve @${targetLogin} for ${scope} contributions`,
207 head: branchName,
208 base: defaultBranch,
209 body: [
210 `Adds \`${entry}\` to \`${path}\`.`,
211 '',
212 `Requested by @${comment.user.login} in #${issue.number}.`,
213 ].join('\n'),
214 });
215
216 await react('rocket', `Created allowlist update PR: ${pr.html_url}`);
217
217 lines YAML