Skip to content
Source
Sign inSign up

pr-vouch.yml

203 lines6.7 KB

1name: PR Vouch
2​
3on:
4 pull_request_target:
5 types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
6 issue_comment:
7 types: [created]
8 push:
9 branches:
10 - main
11 paths:
12 - .github/VOUCHED.td
13 - .github/workflows/pr-vouch.yml
14​
15permissions:
16 contents: read
17 issues: write
18 pull-requests: write
19​
20jobs:
21 collect-targets:
22 name: Collect PR targets
23 # Most comments are not recheck commands; skip them before a runner starts.
24 if: >-
25 github.event_name != 'issue_comment' ||
26 (github.event.issue.pull_request && contains(github.event.comment.body, '/recheck-vouch'))
27 runs-on: ubuntu-24.04
28 outputs:
29 targets: ${{ steps.collect.outputs.targets }}
30 steps:
31 - id: collect
32 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
33 with:
34 script: |
35 if (context.eventName === "pull_request_target") {
36 const pr = context.payload.pull_request;
37 core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }]));
38 return;
39 }
40​
41 if (context.eventName === "issue_comment") {
42 const issue = context.payload.issue;
43 const body = context.payload.comment?.body ?? "";
44 if (!issue?.pull_request || !body.includes("/recheck-vouch")) {
45 core.setOutput("targets", "[]");
46 return;
47 }
48​
49 core.setOutput(
50 "targets",
51 JSON.stringify([{ number: issue.number, user: issue.user.login }]),
52 );
53 return;
54 }
55​
56 const pulls = await github.paginate(github.rest.pulls.list, {
57 owner: context.repo.owner,
58 repo: context.repo.repo,
59 state: "open",
60 per_page: 100,
61 });
62​
63 const targets = pulls.map((pull) => ({
64 number: pull.number,
65 user: pull.user.login,
66 }));
67 core.setOutput("targets", JSON.stringify(targets));
68​
69 label:
70 name: Label PR ${{ matrix.target.number }}
71 needs: collect-targets
72 if: ${{ needs.collect-targets.outputs.targets != '[]' }}
73 runs-on: ubuntu-24.04
74 concurrency:
75 group: pr-vouch-${{ matrix.target.number }}
76 cancel-in-progress: true
77 strategy:
78 fail-fast: false
79 matrix:
80 target: ${{ fromJson(needs.collect-targets.outputs.targets) }}
81 steps:
82 - id: vouch
83 name: Check PR author trust
84 uses: mitchellh/vouch/action/check-user@d66fa29a64600490892131ad87597c30c91fcac4 # v1.5.0
85 with:
86 user: ${{ matrix.target.user }}
87 allow-fail: true
88 env:
89 GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
90​
91 - name: Sync PR labels
92 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
93 env:
94 PR_NUMBER: ${{ matrix.target.number }}
95 VOUCH_STATUS: ${{ steps.vouch.outputs.status }}
96 with:
97 script: |
98 const issueNumber = Number(process.env.PR_NUMBER);
99 const status = process.env.VOUCH_STATUS;
100 const managedLabels = [
101 {
102 name: "vouch:trusted",
103 color: "1f883d",
104 description: "Author is trusted by repo permissions or the VOUCHED list.",
105 },
106 {
107 name: "vouch:unvouched",
108 color: "fbca04",
109 description: "Author is not yet trusted in the VOUCHED list.",
110 },
111 {
112 name: "vouch:denounced",
113 color: "d1242f",
114 description: "Author is explicitly blocked by the VOUCHED list.",
115 },
116 ];
117​
118 const managedLabelNames = new Set(managedLabels.map((label) => label.name));
119​
120 for (const label of managedLabels) {
121 try {
122 const { data: existing } = await github.rest.issues.getLabel({
123 owner: context.repo.owner,
124 repo: context.repo.repo,
125 name: label.name,
126 });
127​
128 if (
129 existing.color !== label.color ||
130 (existing.description ?? "") !== label.description
131 ) {
132 await github.rest.issues.updateLabel({
133 owner: context.repo.owner,
134 repo: context.repo.repo,
135 name: label.name,
136 color: label.color,
137 description: label.description,
138 });
139 }
140 } catch (error) {
141 if (error.status !== 404) {
142 throw error;
143 }
144​
145 try {
146 await github.rest.issues.createLabel({
147 owner: context.repo.owner,
148 repo: context.repo.repo,
149 name: label.name,
150 color: label.color,
151 description: label.description,
152 });
153 } catch (createError) {
154 if (createError.status !== 422) {
155 throw createError;
156 }
157 }
158 }
159 }
160​
161 const nextLabelName =
162 status === "denounced"
163 ? "vouch:denounced"
164 : ["bot", "collaborator", "vouched"].includes(status)
165 ? "vouch:trusted"
166 : "vouch:unvouched";
167​
168 const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({
169 owner: context.repo.owner,
170 repo: context.repo.repo,
171 issue_number: issueNumber,
172 per_page: 100,
173 });
174​
175 for (const label of currentLabels) {
176 if (!managedLabelNames.has(label.name) || label.name === nextLabelName) {
177 continue;
178 }
179​
180 try {
181 await github.rest.issues.removeLabel({
182 owner: context.repo.owner,
183 repo: context.repo.repo,
184 issue_number: issueNumber,
185 name: label.name,
186 });
187 } catch (removeError) {
188 if (removeError.status !== 404) {
189 throw removeError;
190 }
191 }
192 }
193​
194 if (!currentLabels.some((label) => label.name === nextLabelName)) {
195 await github.rest.issues.addLabels({
196 owner: context.repo.owner,
197 repo: context.repo.repo,
198 issue_number: issueNumber,
199 labels: [nextLabelName],
200 });
201 }
202​
203 core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`);