返回 CodeWhale
pr-gate.yml
根目录 / .github / workflows / pr-gate.yml
1 name: Contribution gate - pull requests
2
3 on:
4 pull_request_target:
5 types: [opened, reopened]
6
7 permissions:
8 contents: read
9 issues: write
10 pull-requests: write
11
12 env:
13 # Keep new gates observable first. Switch to "enforce" only after maintainers
14 # have seeded active contributors and reviewed the dry-run signal.
15 CONTRIBUTION_GATE_MODE: dry-run
16
17 jobs:
18 gate:
19 runs-on: ubuntu-latest
20 timeout-minutes: 10
21 steps:
22 - name: Gate unapproved external pull requests
23 uses: actions/github-script@v9
24 with:
25 script: |
26 const pr = context.payload.pull_request;
27 const owner = context.repo.owner;
28 const repo = context.repo.repo;
29 const privileged = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
30 const gateMode = (process.env.CONTRIBUTION_GATE_MODE || 'dry-run').trim().toLowerCase();
31 const enforceGate = gateMode === 'enforce';
32
33 if (!['dry-run', 'enforce'].includes(gateMode)) {
34 core.warning(`Unknown CONTRIBUTION_GATE_MODE "${gateMode}"; defaulting to dry-run.`);
35 }
36
37 async function label(name, color, description) {
38 try {
39 await github.rest.issues.createLabel({ owner, repo, name, color, description });
40 } catch (error) {
41 if (error.status !== 422) throw error; // 422: label already exists
42 }
43 await github.rest.issues.addLabels({
44 owner,
45 repo,
46 issue_number: pr.number,
47 labels: [name],
48 });
49 }
50
51 if (privileged.has(pr.author_association)) return;
52 // `user.type` is set by GitHub for app and bot accounts and cannot
53 // be spoofed by a login that merely ends in "[bot]".
54 if (pr.user.type === 'Bot') {
55 await label('bot-authored', 'ededed', 'Opened by a bot or app account');
56 return;
57 }
58
59 function parseAllowlist(content) {
60 return new Set(
61 content
62 .split(/\r?\n/)
63 .map(line => line.replace(/#.*/, '').trim().toLowerCase())
64 .filter(Boolean)
65 );
66 }
67
68 async function readAllowlist() {
69 try {
70 const { data } = await github.rest.repos.getContent({
71 owner,
72 repo,
73 path: '.github/APPROVED_CONTRIBUTORS',
74 ref: context.payload.repository.default_branch,
75 });
76 if (Array.isArray(data) || data.type !== 'file') return new Set();
77 return parseAllowlist(
78 Buffer.from(data.content, data.encoding || 'base64').toString('utf8')
79 );
80 } catch (error) {
81 if (error.status === 404) return new Set();
82 throw error;
83 }
84 }
85
86 const allowlist = await readAllowlist();
87 const login = pr.user.login.toLowerCase();
88 if (
89 allowlist.has(`all:${login}`) ||
90 allowlist.has(`pr:${login}`)
91 ) {
92 return;
93 }
94
95 // Labels only (founder, 2026-09-22). The label description carries
96 // the contributor-facing explanation a comment used to.
97 await label(
98 'contribution-gate',
99 'c5def5',
100 'Author not yet in .github/APPROVED_CONTRIBUTORS; a maintainer grants access with /lgtm'
101 );
102
103 if (!enforceGate) return;
104
105 await github.rest.pulls.update({
106 owner,
107 repo,
108 pull_number: pr.number,
109 state: 'closed',
110 });
111
111 lines YAML