From 66e686d1fd6e1d216762c0f54ae267940fef3055 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 7 May 2026 13:49:47 -0400 Subject: [PATCH] ci: add workflow to dismiss unauthorized PR approvals --- .../dismiss-unauthorized-pr-approvals.yml | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 .github/workflows/dismiss-unauthorized-pr-approvals.yml diff --git a/.github/workflows/dismiss-unauthorized-pr-approvals.yml b/.github/workflows/dismiss-unauthorized-pr-approvals.yml new file mode 100644 index 000000000..fb53f61df --- /dev/null +++ b/.github/workflows/dismiss-unauthorized-pr-approvals.yml @@ -0,0 +1,165 @@ +name: Dismiss Unauthorized PR Approvals + +on: + pull_request_review: + types: [submitted] + workflow_dispatch: + inputs: + blocked_reviewer: + description: Reviewer login to retroactively sweep + required: false + default: ari4ka + schedule: + - cron: '17 4 * * *' + +permissions: + contents: read + pull-requests: write + +concurrency: + group: dismiss-unauthorized-pr-approvals + cancel-in-progress: false + +jobs: + dismiss-unauthorized-approval: + if: github.event_name != 'pull_request_review' || github.event.review.state == 'approved' + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Dismiss blocked/non-collaborator approvals + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + + // Any login here is always blocked, even if they are a collaborator. + const blockedReviewers = new Set(['ari4ka']); + + // Any collaborator role is allowed unless explicitly blocked above. + const allowedRoles = new Set(['read', 'triage', 'write', 'maintain', 'admin']); + + function norm(v) { + return (v || '').trim().toLowerCase(); + } + + async function resolveRole(login) { + try { + const resp = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, + repo, + username: login, + }); + return norm(resp.data.role_name) || 'unknown'; + } catch (error) { + if (error.status === 404) return 'none'; // not a collaborator + core.warning(`Could not resolve reviewer role for ${login}: ${error.message}`); + return 'unknown'; + } + } + + function shouldDismissReviewer(login, roleName) { + const reviewer = norm(login); + const isBlocked = blockedReviewers.has(reviewer); + const isAllowedRole = allowedRoles.has(roleName); + + const reasons = []; + if (isBlocked) { + reasons.push(`reviewer is blocked (${reviewer})`); + } + if (!isAllowedRole) { + reasons.push(`reviewer is not a collaborator or higher (detected role: ${roleName})`); + } + + return { + dismiss: isBlocked || !isAllowedRole, + message: `Auto-dismissed approval: ${reasons.join('; ')}.`, + }; + } + + async function dismissReview(pull_number, review_id, reviewer, message) { + try { + await github.rest.pulls.dismissReview({ + owner, + repo, + pull_number, + review_id, + message, + }); + core.info(`Dismissed approval from ${reviewer} on PR #${pull_number}. Reason: ${message}`); + return true; + } catch (error) { + if (error.status === 422) { + core.warning(`Could not dismiss review ${review_id} on PR #${pull_number}: ${error.message}`); + return false; + } + throw error; + } + } + + // Future approvals: act immediately when review submitted + if (context.eventName === 'pull_request_review') { + const review = context.payload.review; + const reviewer = review.user.login; + const roleName = await resolveRole(reviewer); + const verdict = shouldDismissReviewer(reviewer, roleName); + + if (!verdict.dismiss) { + core.info(`Approval from ${reviewer} kept (role: ${roleName}).`); + return; + } + + await dismissReview( + context.payload.pull_request.number, + review.id, + reviewer, + verdict.message + ); + return; + } + + // Retroactive sweep: dismiss prior approvals on all PRs + const blockedReviewer = norm(core.getInput('blocked_reviewer') || 'ari4ka'); + + const pulls = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: 'all', + per_page: 100, + }); + + let scannedPrs = 0; + let dismissedCount = 0; + + for (const pr of pulls) { + scannedPrs += 1; + + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, + repo, + pull_number: pr.number, + per_page: 100, + }); + + for (const review of reviews) { + const reviewer = norm(review.user?.login); + const state = norm(review.state); + + if (reviewer !== blockedReviewer) continue; + if (state !== 'approved') continue; + + const ok = await dismissReview( + pr.number, + review.id, + reviewer, + `Auto-dismissed approval during retroactive sweep: reviewer is blocked (${blockedReviewer}).` + ); + + if (ok) dismissedCount += 1; + } + } + + core.info( + `Retroactive sweep complete. Scanned ${scannedPrs} PR(s); dismissed ${dismissedCount} approval(s) from ${blockedReviewer}.` + );