Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions .github/workflows/block_major_releases.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# This workflow blocks release-please PRs for major versions unless they have at least 2 approvals.
name: Enforce Multiple Approvals for Major Releases

on:
pull_request_review:
types: [submitted, dismissed]
pull_request:
types: [opened, synchronize, reopened]

jobs:
check-major-approval:
runs-on: ubuntu-latest
if: github.event.pull_request.user.login == 'release-please[bot]'
permissions:
pull-requests: read
steps:
- name: Enforce Multiple Approvals for Major Releases
uses: actions/github-script@v8
with:
script: |
const prTitle = context.payload.pull_request.title;
console.log(`PR Title: ${prTitle}`);

// 1. Extract proposed version securely from the PR title
const versionMatch = prTitle.match(/\b\d+\.\d+\.\d+\b/);
if (!versionMatch) {
console.log("No version pattern found in the PR title. Skipping check.");
return;
}

const version = versionMatch[0];
console.log(`Extracted Version: ${version}`);

// 2. Identify if this is a major release (ends with .0.0)
if (!version.endsWith('.0.0')) {
console.log("This is a minor or patch version release. Skipping check.");
return;
}

console.log(`MAJOR version release detected (${version})! Verifying approvals...`);

const prNumber = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;

// 3. Fetch reviews for the PR (with pagination safety)
const { data: reviews } = await github.rest.pulls.listReviews({
owner,
repo,
pull_number: prNumber,
per_page: 100,
});

// 4. Handle potential API delay by including the triggering review if missing
if (context.eventName === 'pull_request_review' && context.payload.action === 'submitted') {
const currentReview = context.payload.review;
if (currentReview && !reviews.some(r => r.id === currentReview.id)) {
reviews.push(currentReview);
}
}

// 5. Gather unique approvals
const approvals = {};
for (const review of reviews) {
if (review.state === 'APPROVED') {
approvals[review.user.login] = true;
} else if (review.state === 'CHANGES_REQUESTED' || review.state === 'DISMISSED') {
delete approvals[review.user.login];
}
}

// 6. Verify repository write/admin permissions for each unique approver
const approvers = Object.keys(approvals);
const verifiedGooglers = [];
for (const username of approvers) {
try {
const response = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: username,
});
const permission = response.data.permission; // 'admin', 'write', 'read', or 'none'
if (['admin', 'write'].includes(permission)) {
verifiedGooglers.push(username);
} else {
console.log(`User ${username} has permission level '${permission}', which is insufficient (requires 'write' or 'admin').`);
}
} catch (e) {
console.log(`Could not verify repository permissions for ${username}:`, e.message);
}
}

const approvalCount = verifiedGooglers.length;
console.log(`Current approved reviews by authorized Googlers: ${verifiedGooglers.join(', ')} (${approvalCount})`);

if (approvalCount < 2) {
core.setFailed(`Major version releases (${version}) require at least 2 approvals from authorized maintainers. Current approval count: ${approvalCount}.`);
} else {
console.log("Approval requirements successfully met.");
}
Loading