Require product eng approval for PRs (#4478)
* feat: Add PR approval check for product-eng team Co-authored-by: dustin <[email protected]> * Checkpoint before follow-up message Co-authored-by: dustin <[email protected]> * Refactor: Expand PR approval to include child teams Co-authored-by: dustin <[email protected]> * poke * pr-approval-check: refactor, fix pagination, fix approval logic (#4519) * Update README.md * use secret --------- Co-authored-by: Cursor Agent <[email protected]> Co-authored-by: Jeff W <[email protected]>
This commit is contained in:
co-authored by
Cursor Agent
Jeff W
parent
b84c3d14d1
commit
189cd9a683
@@ -0,0 +1,48 @@
|
||||
# GitHub Workflows
|
||||
|
||||
This directory contains GitHub Actions workflows for the TruffleHog repository.
|
||||
|
||||
## PR Approval Check (`pr-approval-check.yml`)
|
||||
|
||||
This workflow enforces that at least one PR approver must be an **active** member of the `@trufflesecurity/product-eng` team or any of its child teams.
|
||||
|
||||
### How it works:
|
||||
|
||||
1. **Triggers**: The workflow runs on:
|
||||
- `pull_request_review` events when a review is submitted (`submitted` type)
|
||||
- `pull_request` events when a PR is opened, reopened, or synchronized (`opened`, `reopened`, `synchronize` types)
|
||||
|
||||
2. **Approval Check Process**: The workflow:
|
||||
- Fetches all reviews for the PR using the GitHub API
|
||||
- Filters for reviews with state `APPROVED`
|
||||
- Gets all child teams of `@trufflesecurity/product-eng` using `listChildInOrg` API
|
||||
- Checks if any approver is an **active** member (not pending) of either:
|
||||
- The parent `@trufflesecurity/product-eng` team, OR
|
||||
- Any of its child teams
|
||||
- Sets a commit status accordingly
|
||||
|
||||
3. **Status Check**: Creates a commit status named `product-eng-approval` with:
|
||||
- ✅ **Success**: When at least one approver is an active member of `@trufflesecurity/product-eng` or any child team
|
||||
- ❌ **Failure**: When there are no approvals or there are approvals but none from active `@trufflesecurity/product-eng` members
|
||||
|
||||
### Error Handling
|
||||
|
||||
If there are errors listing reviews or checking team membership, the workflow reports a failure status and also fails itself.
|
||||
|
||||
### Branch Protection
|
||||
|
||||
To make this check required:
|
||||
|
||||
1. Go to Settings → Branches
|
||||
2. Add or edit a branch protection rule for your main branch
|
||||
3. Enable "Require status checks to pass before merging"
|
||||
4. Add `pr-approval-check` to the required status checks
|
||||
|
||||
### Permissions
|
||||
|
||||
The workflow uses the default `GITHUB_TOKEN` which has sufficient permissions to:
|
||||
- Read PR reviews
|
||||
- List child teams and check team membership (for public teams)
|
||||
- Create commit statuses
|
||||
|
||||
**Note**: If the `product-eng` team or its child teams are private, you may need to use a personal access token with appropriate permissions. The Github API returns 404 for non-members and for lack of permissions.
|
||||
@@ -0,0 +1,117 @@
|
||||
name: PR Approval Check
|
||||
|
||||
on:
|
||||
pull_request_review:
|
||||
types: [submitted, dismissed]
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-product-eng-approval:
|
||||
name: Check Product Eng Approval
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for Product Engineering approval
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
ORG: trufflesecurity
|
||||
APPROVER_TEAM: product-eng
|
||||
with:
|
||||
github-token: ${{ secrets.PR_APPROVAL_CHECK }}
|
||||
script: |
|
||||
const { ORG: org, APPROVER_TEAM: team} = process.env
|
||||
const prettyTeamName = `@${org}/${team}`
|
||||
|
||||
async function status(state, msg) {
|
||||
await github.rest.repos.createCommitStatus({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
sha: context.payload.pull_request.head.sha,
|
||||
state: state,
|
||||
context: 'pr-approval-check',
|
||||
description: msg,
|
||||
});
|
||||
}
|
||||
|
||||
async function fail(msg) {
|
||||
core.setOutput(msg);
|
||||
console.error(msg)
|
||||
await status('failure', msg)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function succeed(msg) {
|
||||
core.setOutput(msg);
|
||||
console.info(msg)
|
||||
await status('success', msg)
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
async function fatal(msg) {
|
||||
core.setFailed(msg);
|
||||
console.error(msg)
|
||||
await status('failure', msg)
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
await status('pending', `Waiting for approval from ${prettyTeamName} team member`)
|
||||
|
||||
// reviews maps reviewer logins to their latest review
|
||||
let reviews = new Map();
|
||||
try {
|
||||
const iter = octokit.paginate.iterator(github.rest.pulls.listReviews, {
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number,
|
||||
});
|
||||
|
||||
let pages = [];
|
||||
for await (const page of iter) {
|
||||
pages.push(page)
|
||||
}
|
||||
|
||||
const latestReview = (a, b) => new Date(a.submitted_at) > new Date(b.submitted_at) ? a : b;
|
||||
for await (const page of pages) {
|
||||
for (const review of page.data) {
|
||||
const login = review.user.login;
|
||||
reviews.set(login, reviews.has(login) ? latestReview(reviews.get(login), review) : review);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
await fatal(`⚠️ Could not get reviews: ${error.status}`);
|
||||
}
|
||||
|
||||
let approved = false;
|
||||
let changeRequesters = [];
|
||||
for (const [login, review] of reviews) {
|
||||
try {
|
||||
const membership = await github.rest.teams.getMembershipForUserInOrg({
|
||||
org: org,
|
||||
team_slug: team,
|
||||
username: login,
|
||||
});
|
||||
|
||||
if (membership.data.state === 'active') {
|
||||
if (review.state === 'APPROVED') {
|
||||
approved = true;
|
||||
} else if (review.state === 'CHANGES_REQUESTED') {
|
||||
changeRequesters.push(login);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
if (error.status != 404) {
|
||||
await fatal(`⚠️ Could not determine membership for ${login} in ${prettyTeamName}: ${error.status}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changeRequesters.length > 0) {
|
||||
await fail(`⚠️ Changes requested by: ${changeRequesters.map(login=>`@${login}`).join(", ")} on behalf of ${prettyTeamName}`);
|
||||
} else if (approved) {
|
||||
await succeed(`✅ Approved by ${prettyTeamName}`)
|
||||
} else {
|
||||
await fail(`⚠️ Requires approval from ${prettyTeamName}`)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user