Skip to content

Commit 20dab0b

Browse files
authored
chore: replace mitchellh/vouch with hand-rolled workflows (#378)
1 parent c95a954 commit 20dab0b

File tree

2 files changed

+244
-16
lines changed

2 files changed

+244
-16
lines changed

.github/workflows/vouch-check.yml

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,105 @@ jobs:
1313
if: github.repository_owner == 'NVIDIA'
1414
runs-on: ubuntu-latest
1515
steps:
16-
- uses: mitchellh/vouch/action/check-pr@f44860978966ace98fb11aaaa20f2b27d7543e13 # v1
16+
- name: Check if contributor is vouched
17+
uses: actions/github-script@v7
1718
with:
18-
pr-number: ${{ github.event.pull_request.number }}
19-
auto-close: true
20-
env:
21-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
19+
script: |
20+
const author = context.payload.pull_request.user.login;
21+
const authorType = context.payload.pull_request.user.type;
22+
23+
// Skip bots (dependabot, renovate, github-actions, etc.).
24+
if (authorType === 'Bot') {
25+
console.log(`${author} is a bot. Skipping vouch check.`);
26+
return;
27+
}
28+
29+
// Check org membership — members bypass the vouch gate.
30+
try {
31+
const { status } = await github.rest.orgs.checkMembershipForUser({
32+
org: context.repo.owner,
33+
username: author,
34+
});
35+
if (status === 204 || status === 302) {
36+
console.log(`${author} is an org member. Skipping vouch check.`);
37+
return;
38+
}
39+
} catch (e) {
40+
if (e.status !== 404) {
41+
console.log(`Org membership check error: ${e.message}`);
42+
}
43+
}
44+
45+
// Check collaborator status — direct collaborators bypass.
46+
try {
47+
const { status } = await github.rest.repos.checkCollaborator({
48+
owner: context.repo.owner,
49+
repo: context.repo.repo,
50+
username: author,
51+
});
52+
if (status === 204) {
53+
console.log(`${author} is a collaborator. Skipping vouch check.`);
54+
return;
55+
}
56+
} catch (e) {
57+
if (e.status !== 404) {
58+
console.log(`Collaborator check error: ${e.message}`);
59+
}
60+
}
61+
62+
// Check the VOUCHED.td file. Read from the default branch, NOT the
63+
// PR branch — the PR author could add themselves in their fork.
64+
let vouched = false;
65+
try {
66+
const { data } = await github.rest.repos.getContent({
67+
owner: context.repo.owner,
68+
repo: context.repo.repo,
69+
path: '.github/VOUCHED.td',
70+
ref: context.payload.repository.default_branch,
71+
});
72+
const content = Buffer.from(data.content, 'base64').toString('utf-8');
73+
const usernames = content
74+
.split('\n')
75+
.map(line => line.trim())
76+
.filter(line => line && !line.startsWith('#') && !line.startsWith('-'));
77+
vouched = usernames.some(
78+
name => name.toLowerCase() === author.toLowerCase()
79+
);
80+
} catch (e) {
81+
console.log(`Could not read VOUCHED.td: ${e.message}`);
82+
}
83+
84+
if (vouched) {
85+
console.log(`${author} is in VOUCHED.td. Approved.`);
86+
return;
87+
}
88+
89+
// Not vouched — close the PR with an explanation.
90+
console.log(`${author} is not vouched. Closing PR.`);
91+
92+
await github.rest.issues.createComment({
93+
owner: context.repo.owner,
94+
repo: context.repo.repo,
95+
issue_number: context.payload.pull_request.number,
96+
body: [
97+
`Thank you for your interest in contributing to OpenShell, @${author}.`,
98+
'',
99+
'This project uses a **vouch system** for first-time contributors. Before submitting a pull request, you need to be vouched by a maintainer.',
100+
'',
101+
'**To get vouched:**',
102+
'1. Open a [Vouch Request](https://github.com/NVIDIA/OpenShell/discussions/new?category=vouch-request) discussion.',
103+
'2. Describe what you want to change and why.',
104+
'3. Write in your own words — do not have an AI generate the request.',
105+
'4. A maintainer will comment `/vouch` if approved.',
106+
'5. Once vouched, open a new PR (preferred) or reopen this one after a few minutes.',
107+
'',
108+
'See [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#first-time-contributors) for details.',
109+
].join('\n'),
110+
});
111+
112+
await github.rest.pulls.update({
113+
owner: context.repo.owner,
114+
repo: context.repo.repo,
115+
pull_number: context.payload.pull_request.number,
116+
state: 'closed',
117+
});

.github/workflows/vouch-command.yml

Lines changed: 143 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,17 +14,149 @@ permissions:
1414

1515
jobs:
1616
process-vouch:
17-
if: github.repository_owner == 'NVIDIA'
17+
if: >-
18+
github.repository_owner == 'NVIDIA' &&
19+
github.event.comment.body == '/vouch'
1820
runs-on: ubuntu-latest
1921
steps:
20-
- uses: actions/checkout@v4
21-
22-
- uses: mitchellh/vouch/action/manage-by-discussion@f44860978966ace98fb11aaaa20f2b27d7543e13 # v1
22+
- name: Process /vouch command
23+
uses: actions/github-script@v7
2324
with:
24-
discussion-number: ${{ github.event.discussion.number }}
25-
comment-node-id: ${{ github.event.comment.node_id }}
26-
vouch-keyword: "/vouch"
27-
denounce-keyword: "/denounce"
28-
unvouch-keyword: "/unvouch"
29-
env:
30-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
25+
script: |
26+
const commenter = context.payload.comment.user.login;
27+
const discussionAuthor = context.payload.discussion.user.login;
28+
const discussionNumber = context.payload.discussion.number;
29+
30+
// --- Helpers ---
31+
32+
async function getDiscussionId() {
33+
const query = `query($owner: String!, $repo: String!, $number: Int!) {
34+
repository(owner: $owner, name: $repo) {
35+
discussion(number: $number) { id }
36+
}
37+
}`;
38+
const { repository } = await github.graphql(query, {
39+
owner: context.repo.owner,
40+
repo: context.repo.repo,
41+
number: discussionNumber,
42+
});
43+
return repository.discussion.id;
44+
}
45+
46+
async function postDiscussionComment(body) {
47+
const discussionId = await getDiscussionId();
48+
const mutation = `mutation($discussionId: ID!, $body: String!) {
49+
addDiscussionComment(input: {discussionId: $discussionId, body: $body}) {
50+
comment { id }
51+
}
52+
}`;
53+
await github.graphql(mutation, { discussionId, body });
54+
}
55+
56+
// --- Authorization ---
57+
58+
let isMaintainer = false;
59+
try {
60+
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
61+
owner: context.repo.owner,
62+
repo: context.repo.repo,
63+
username: commenter,
64+
});
65+
isMaintainer = ['admin', 'maintain', 'write'].includes(data.permission);
66+
} catch (e) {
67+
console.log(`Permission check failed: ${e.message}`);
68+
}
69+
70+
if (!isMaintainer) {
71+
console.log(`${commenter} does not have maintainer permissions. Ignoring.`);
72+
return;
73+
}
74+
75+
// --- Read VOUCHED.td ---
76+
77+
const filePath = '.github/VOUCHED.td';
78+
const branch = context.payload.repository.default_branch;
79+
80+
let currentContent = '';
81+
let sha = '';
82+
try {
83+
const { data } = await github.rest.repos.getContent({
84+
owner: context.repo.owner,
85+
repo: context.repo.repo,
86+
path: filePath,
87+
ref: branch,
88+
});
89+
currentContent = Buffer.from(data.content, 'base64').toString('utf-8');
90+
sha = data.sha;
91+
} catch (e) {
92+
console.log(`Could not read VOUCHED.td: ${e.message}`);
93+
return;
94+
}
95+
96+
// --- Parse .td format ---
97+
98+
function isVouched(content, username) {
99+
return content
100+
.split('\n')
101+
.map(line => line.trim())
102+
.filter(line => line && !line.startsWith('#') && !line.startsWith('-'))
103+
.some(name => name.toLowerCase() === username.toLowerCase());
104+
}
105+
106+
if (isVouched(currentContent, discussionAuthor)) {
107+
console.log(`${discussionAuthor} is already vouched.`);
108+
await postDiscussionComment(
109+
`@${discussionAuthor} is already vouched. They can submit pull requests.`
110+
);
111+
return;
112+
}
113+
114+
// --- Append username and commit ---
115+
116+
async function commitVouch(content, fileSha) {
117+
const updatedContent = content.trimEnd() + '\n' + discussionAuthor + '\n';
118+
await github.rest.repos.createOrUpdateFileContents({
119+
owner: context.repo.owner,
120+
repo: context.repo.repo,
121+
path: filePath,
122+
message: `chore: vouch ${discussionAuthor}`,
123+
content: Buffer.from(updatedContent).toString('base64'),
124+
sha: fileSha,
125+
branch,
126+
});
127+
}
128+
129+
try {
130+
await commitVouch(currentContent, sha);
131+
} catch (e) {
132+
if (e.status === 409) {
133+
// Concurrent write — re-read and retry once.
134+
console.log('409 conflict. Re-reading VOUCHED.td and retrying.');
135+
const { data: fresh } = await github.rest.repos.getContent({
136+
owner: context.repo.owner,
137+
repo: context.repo.repo,
138+
path: filePath,
139+
ref: branch,
140+
});
141+
const freshContent = Buffer.from(fresh.content, 'base64').toString('utf-8');
142+
if (isVouched(freshContent, discussionAuthor)) {
143+
console.log(`${discussionAuthor} was vouched by a concurrent operation.`);
144+
} else {
145+
await commitVouch(freshContent, fresh.sha);
146+
}
147+
} else {
148+
throw e;
149+
}
150+
}
151+
152+
// --- Confirm ---
153+
154+
await postDiscussionComment([
155+
`@${discussionAuthor} has been vouched by @${commenter}.`,
156+
'',
157+
'You can now submit pull requests to OpenShell. Welcome aboard.',
158+
'',
159+
'Please read [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md) before submitting.',
160+
].join('\n'));
161+
162+
console.log(`Vouched ${discussionAuthor} (approved by ${commenter}).`);

0 commit comments

Comments
 (0)