diff --git a/.github/workflows/README.md b/.github/workflows/README.md new file mode 100644 index 000000000000..6f1744f772f5 --- /dev/null +++ b/.github/workflows/README.md @@ -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. \ No newline at end of file diff --git a/.github/workflows/pr-approval-check.yml b/.github/workflows/pr-approval-check.yml new file mode 100644 index 000000000000..7533332b7f9f --- /dev/null +++ b/.github/workflows/pr-approval-check.yml @@ -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}`) + } +