Skip to content

Commit 086d70f

Browse files
authored
ci: require two approvers for release-please PRs (#1144)
Safety net pipeline to protect from accidental release creation which recognizes `release-please` PRs based on the `a2a-bot` author and `release-please--` branch name prefix and requires **2 approvers** with at least `write` level access. This is **an addition** on top of built-in GitHub write access check. Release PRs could never be merged by non-maintainers, but now the requirement is 2 maintainers. Published via a commit status instead of a workflow failure to avoid "spam" emails of workflow failures, this check will be added as mandatory. For non-release PRs passes immediately. Tested on fork: 1. "release" PR: ishymko#7 <img width="784" height="94" alt="image" src="https://github.com/user-attachments/assets/8fa922d5-cd04-46ca-a0ca-1de099fd7a46" /> 2. "normal" PR: ishymko#6 <img width="678" height="102" alt="image" src="https://github.com/user-attachments/assets/e7ef3242-9aa6-454c-bf53-d6d1acddec1f" />
1 parent 3e6fa6a commit 086d70f

1 file changed

Lines changed: 76 additions & 0 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
name: release-pr-approvals
2+
3+
# Requires N approvals from users with write access before a release-please PR
4+
# can be merged. Posts a commit status (context "release-pr-approvals") so it can
5+
# be added as a required status check on the protected branch; the job itself
6+
# stays green. Normal PRs pass immediately.
7+
#
8+
# A PR counts as a release PR based on the branch name and author.
9+
on:
10+
pull_request_target:
11+
types: [opened, reopened, synchronize]
12+
pull_request_review:
13+
types: [submitted, dismissed, edited]
14+
15+
permissions:
16+
contents: read
17+
pull-requests: read
18+
statuses: write
19+
20+
concurrency:
21+
group: release-pr-approvals-${{ github.event.pull_request.number }}
22+
cancel-in-progress: true
23+
24+
jobs:
25+
approvals:
26+
runs-on: ubuntu-latest
27+
env:
28+
REQUIRED_APPROVALS: "2"
29+
RELEASE_BRANCH_PREFIX: "release-please--"
30+
RELEASE_AUTHOR: "a2a-bot"
31+
steps:
32+
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
33+
with:
34+
script: |
35+
const CONTEXT = 'release-pr-approvals';
36+
const required = Number(process.env.REQUIRED_APPROVALS);
37+
const pr = context.payload.pull_request;
38+
const { owner, repo } = context.repo;
39+
40+
const setStatus = (state, description) =>
41+
github.rest.repos.createCommitStatus({
42+
owner, repo, sha: pr.head.sha, context: CONTEXT, state, description,
43+
});
44+
45+
const isRelease =
46+
pr.head.ref.startsWith(process.env.RELEASE_BRANCH_PREFIX) &&
47+
pr.user?.login === process.env.RELEASE_AUTHOR;
48+
49+
if (!isRelease) {
50+
await setStatus('success', 'Not a release PR');
51+
return;
52+
}
53+
54+
const reviews = await github.paginate(github.rest.pulls.listReviews, {
55+
owner, repo, pull_number: pr.number, per_page: 100,
56+
});
57+
58+
// Latest decisive review per user; COMMENTED does not change approval state.
59+
const latest = new Map();
60+
for (const r of reviews) {
61+
if (!r.user || r.state === 'COMMENTED') continue;
62+
latest.set(r.user.login, r.state);
63+
}
64+
65+
let approvals = 0;
66+
for (const [login, state] of latest) {
67+
if (state !== 'APPROVED') continue;
68+
const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({
69+
owner, repo, username: login,
70+
});
71+
if (perm.permission === 'admin' || perm.permission === 'write') approvals++;
72+
}
73+
74+
const description = `${approvals}/${required} approvals`;
75+
await setStatus(approvals >= required ? 'success' : 'failure', description);
76+
core.info(description);

0 commit comments

Comments
 (0)