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