ci: add workflow to dismiss unauthorized PR approvals

This commit is contained in:
Tom Boucher
2026-05-07 13:49:47 -04:00
parent d385419ac4
commit 66e686d1fd

View File

@@ -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}.`
);