From 07f5f7e9f3dd0a1b17475f3a310171f90aa897bf Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 16 Mar 2026 15:44:03 -0700 Subject: [PATCH] chore: replace mitchellh/vouch with hand-rolled workflows Enterprise action policy blocks third-party actions by repo owner. Revert to github-script implementations with the same behavior: bot exemption, org/collaborator bypass, VOUCHED.td format, /vouch command, 409 conflict retry, and concurrency serialization. Signed-off-by: John Myers --- .github/workflows/vouch-check.yml | 106 ++++++++++++++++++- .github/workflows/vouch-command.yml | 154 ++++++++++++++++++++++++++-- 2 files changed, 244 insertions(+), 16 deletions(-) diff --git a/.github/workflows/vouch-check.yml b/.github/workflows/vouch-check.yml index fde01296..08d9e829 100644 --- a/.github/workflows/vouch-check.yml +++ b/.github/workflows/vouch-check.yml @@ -13,9 +13,105 @@ jobs: if: github.repository_owner == 'NVIDIA' runs-on: ubuntu-latest steps: - - uses: mitchellh/vouch/action/check-pr@f44860978966ace98fb11aaaa20f2b27d7543e13 # v1 + - name: Check if contributor is vouched + uses: actions/github-script@v7 with: - pr-number: ${{ github.event.pull_request.number }} - auto-close: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + script: | + const author = context.payload.pull_request.user.login; + const authorType = context.payload.pull_request.user.type; + + // Skip bots (dependabot, renovate, github-actions, etc.). + if (authorType === 'Bot') { + console.log(`${author} is a bot. Skipping vouch check.`); + return; + } + + // Check org membership — members bypass the vouch gate. + try { + const { status } = await github.rest.orgs.checkMembershipForUser({ + org: context.repo.owner, + username: author, + }); + if (status === 204 || status === 302) { + console.log(`${author} is an org member. Skipping vouch check.`); + return; + } + } catch (e) { + if (e.status !== 404) { + console.log(`Org membership check error: ${e.message}`); + } + } + + // Check collaborator status — direct collaborators bypass. + try { + const { status } = await github.rest.repos.checkCollaborator({ + owner: context.repo.owner, + repo: context.repo.repo, + username: author, + }); + if (status === 204) { + console.log(`${author} is a collaborator. Skipping vouch check.`); + return; + } + } catch (e) { + if (e.status !== 404) { + console.log(`Collaborator check error: ${e.message}`); + } + } + + // Check the VOUCHED.td file. Read from the default branch, NOT the + // PR branch — the PR author could add themselves in their fork. + let vouched = false; + try { + const { data } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/VOUCHED.td', + ref: context.payload.repository.default_branch, + }); + const content = Buffer.from(data.content, 'base64').toString('utf-8'); + const usernames = content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#') && !line.startsWith('-')); + vouched = usernames.some( + name => name.toLowerCase() === author.toLowerCase() + ); + } catch (e) { + console.log(`Could not read VOUCHED.td: ${e.message}`); + } + + if (vouched) { + console.log(`${author} is in VOUCHED.td. Approved.`); + return; + } + + // Not vouched — close the PR with an explanation. + console.log(`${author} is not vouched. Closing PR.`); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.payload.pull_request.number, + body: [ + `Thank you for your interest in contributing to OpenShell, @${author}.`, + '', + 'This project uses a **vouch system** for first-time contributors. Before submitting a pull request, you need to be vouched by a maintainer.', + '', + '**To get vouched:**', + '1. Open a [Vouch Request](https://github.com/NVIDIA/OpenShell/discussions/new?category=vouch-request) discussion.', + '2. Describe what you want to change and why.', + '3. Write in your own words — do not have an AI generate the request.', + '4. A maintainer will comment `/vouch` if approved.', + '5. Once vouched, open a new PR (preferred) or reopen this one after a few minutes.', + '', + 'See [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md#first-time-contributors) for details.', + ].join('\n'), + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: context.payload.pull_request.number, + state: 'closed', + }); diff --git a/.github/workflows/vouch-command.yml b/.github/workflows/vouch-command.yml index 8f8ffd6b..b1aa3bfe 100644 --- a/.github/workflows/vouch-command.yml +++ b/.github/workflows/vouch-command.yml @@ -14,17 +14,149 @@ permissions: jobs: process-vouch: - if: github.repository_owner == 'NVIDIA' + if: >- + github.repository_owner == 'NVIDIA' && + github.event.comment.body == '/vouch' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - - uses: mitchellh/vouch/action/manage-by-discussion@f44860978966ace98fb11aaaa20f2b27d7543e13 # v1 + - name: Process /vouch command + uses: actions/github-script@v7 with: - discussion-number: ${{ github.event.discussion.number }} - comment-node-id: ${{ github.event.comment.node_id }} - vouch-keyword: "/vouch" - denounce-keyword: "/denounce" - unvouch-keyword: "/unvouch" - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + script: | + const commenter = context.payload.comment.user.login; + const discussionAuthor = context.payload.discussion.user.login; + const discussionNumber = context.payload.discussion.number; + + // --- Helpers --- + + async function getDiscussionId() { + const query = `query($owner: String!, $repo: String!, $number: Int!) { + repository(owner: $owner, name: $repo) { + discussion(number: $number) { id } + } + }`; + const { repository } = await github.graphql(query, { + owner: context.repo.owner, + repo: context.repo.repo, + number: discussionNumber, + }); + return repository.discussion.id; + } + + async function postDiscussionComment(body) { + const discussionId = await getDiscussionId(); + const mutation = `mutation($discussionId: ID!, $body: String!) { + addDiscussionComment(input: {discussionId: $discussionId, body: $body}) { + comment { id } + } + }`; + await github.graphql(mutation, { discussionId, body }); + } + + // --- Authorization --- + + let isMaintainer = false; + try { + const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner: context.repo.owner, + repo: context.repo.repo, + username: commenter, + }); + isMaintainer = ['admin', 'maintain', 'write'].includes(data.permission); + } catch (e) { + console.log(`Permission check failed: ${e.message}`); + } + + if (!isMaintainer) { + console.log(`${commenter} does not have maintainer permissions. Ignoring.`); + return; + } + + // --- Read VOUCHED.td --- + + const filePath = '.github/VOUCHED.td'; + const branch = context.payload.repository.default_branch; + + let currentContent = ''; + let sha = ''; + try { + const { data } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: filePath, + ref: branch, + }); + currentContent = Buffer.from(data.content, 'base64').toString('utf-8'); + sha = data.sha; + } catch (e) { + console.log(`Could not read VOUCHED.td: ${e.message}`); + return; + } + + // --- Parse .td format --- + + function isVouched(content, username) { + return content + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#') && !line.startsWith('-')) + .some(name => name.toLowerCase() === username.toLowerCase()); + } + + if (isVouched(currentContent, discussionAuthor)) { + console.log(`${discussionAuthor} is already vouched.`); + await postDiscussionComment( + `@${discussionAuthor} is already vouched. They can submit pull requests.` + ); + return; + } + + // --- Append username and commit --- + + async function commitVouch(content, fileSha) { + const updatedContent = content.trimEnd() + '\n' + discussionAuthor + '\n'; + await github.rest.repos.createOrUpdateFileContents({ + owner: context.repo.owner, + repo: context.repo.repo, + path: filePath, + message: `chore: vouch ${discussionAuthor}`, + content: Buffer.from(updatedContent).toString('base64'), + sha: fileSha, + branch, + }); + } + + try { + await commitVouch(currentContent, sha); + } catch (e) { + if (e.status === 409) { + // Concurrent write — re-read and retry once. + console.log('409 conflict. Re-reading VOUCHED.td and retrying.'); + const { data: fresh } = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: filePath, + ref: branch, + }); + const freshContent = Buffer.from(fresh.content, 'base64').toString('utf-8'); + if (isVouched(freshContent, discussionAuthor)) { + console.log(`${discussionAuthor} was vouched by a concurrent operation.`); + } else { + await commitVouch(freshContent, fresh.sha); + } + } else { + throw e; + } + } + + // --- Confirm --- + + await postDiscussionComment([ + `@${discussionAuthor} has been vouched by @${commenter}.`, + '', + 'You can now submit pull requests to OpenShell. Welcome aboard.', + '', + 'Please read [CONTRIBUTING.md](https://github.com/NVIDIA/OpenShell/blob/main/CONTRIBUTING.md) before submitting.', + ].join('\n')); + + console.log(`Vouched ${discussionAuthor} (approved by ${commenter}).`);