diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 000000000..cb9f78b92 --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,27 @@ +# Trust list for this repository. +# +# External contributors listed here are treated as trusted by the vouch +# workflow. Collaborators with write access are automatically trusted and +# do not need to be duplicated in this file. +# +# Syntax: +# github:username +# -github:username reason for denouncement +# +# Keep entries sorted alphabetically. +github:adityavardhansharma +github:binbandit +github:chuks-qua +github:cursoragent +github:gbarros-dev +github:github-actions[bot] +github:hwanseoc +github:jamesx0416 +github:jasonLaster +github:JoeEverest +github:nmggithub +github:notkainoa +github:PatrickBauer +github:realAhmedRoach +github:shiroyasha9 +github:Yash-Singh1 diff --git a/.github/workflows/pr-vouch.yml b/.github/workflows/pr-vouch.yml new file mode 100644 index 000000000..f38c93d44 --- /dev/null +++ b/.github/workflows/pr-vouch.yml @@ -0,0 +1,175 @@ +name: PR Vouch + +on: + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] + issue_comment: + types: [created] + push: + branches: + - main + paths: + - .github/VOUCHED.td + - .github/workflows/pr-vouch.yml + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + collect-targets: + name: Collect PR targets + runs-on: ubuntu-24.04 + outputs: + targets: ${{ steps.collect.outputs.targets }} + steps: + - id: collect + uses: actions/github-script@v7 + with: + script: | + if (context.eventName === "pull_request_target") { + const pr = context.payload.pull_request; + core.setOutput("targets", JSON.stringify([{ number: pr.number, user: pr.user.login }])); + return; + } + + if (context.eventName === "issue_comment") { + const issue = context.payload.issue; + const body = context.payload.comment?.body ?? ""; + if (!issue?.pull_request || !body.includes("/recheck-vouch")) { + core.setOutput("targets", "[]"); + return; + } + + core.setOutput( + "targets", + JSON.stringify([{ number: issue.number, user: issue.user.login }]), + ); + return; + } + + const pulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: "open", + per_page: 100, + }); + + const targets = pulls.map((pull) => ({ + number: pull.number, + user: pull.user.login, + })); + core.setOutput("targets", JSON.stringify(targets)); + + label: + name: Label PR ${{ matrix.target.number }} + needs: collect-targets + if: ${{ needs.collect-targets.outputs.targets != '[]' }} + runs-on: ubuntu-24.04 + concurrency: + group: pr-vouch-${{ matrix.target.number }} + cancel-in-progress: true + strategy: + fail-fast: false + matrix: + target: ${{ fromJson(needs.collect-targets.outputs.targets) }} + steps: + - id: vouch + name: Check PR author trust + uses: mitchellh/vouch/action/check-user@v1 + with: + user: ${{ matrix.target.user }} + allow-fail: true + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Sync PR labels + uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ matrix.target.number }} + VOUCH_STATUS: ${{ steps.vouch.outputs.status }} + with: + script: | + const issueNumber = Number(process.env.PR_NUMBER); + const status = process.env.VOUCH_STATUS; + const managedLabels = [ + { + name: "vouch:trusted", + color: "1f883d", + description: "PR author is trusted by repo permissions or the VOUCHED list.", + }, + { + name: "vouch:unvouched", + color: "fbca04", + description: "PR author is not yet trusted in the VOUCHED list.", + }, + { + name: "vouch:denounced", + color: "d1242f", + description: "PR author is explicitly blocked by the VOUCHED list.", + }, + ]; + + const managedLabelNames = managedLabels.map((label) => label.name); + + for (const label of managedLabels) { + try { + const { data: existing } = await github.rest.issues.getLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + }); + + if ( + existing.color !== label.color || + (existing.description ?? "") !== label.description + ) { + await github.rest.issues.updateLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } catch (error) { + if (error.status !== 404) { + throw error; + } + + await github.rest.issues.createLabel({ + owner: context.repo.owner, + repo: context.repo.repo, + name: label.name, + color: label.color, + description: label.description, + }); + } + } + + const nextLabelName = + status === "denounced" + ? "vouch:denounced" + : ["bot", "collaborator", "vouched"].includes(status) + ? "vouch:trusted" + : "vouch:unvouched"; + + const { data: currentLabels } = await github.rest.issues.listLabelsOnIssue({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + }); + + const preservedLabels = currentLabels + .map((label) => label.name) + .filter((name) => !managedLabelNames.includes(name)); + + await github.rest.issues.setLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + labels: [...preservedLabels, nextLabelName], + }); + + core.info(`PR #${issueNumber}: ${status} -> ${nextLabelName}`); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb1992fe7..2021ecdcb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,6 +8,10 @@ You can still open an issue or PR, but please do so knowing there is a high chan If that sounds annoying, that is because it is. This project is still early and we are trying to keep scope, quality, and direction under control. +PRs are automatically labeled with a `vouch:*` trust status. + +If you are an external contributor, expect `vouch:unvouched` until we explicitly add you to [.github/VOUCHED.td](.github/VOUCHED.td). + ## What We Are Most Likely To Accept Small, focused bug fixes.