From 5bfa026146e03001c0714b01646600d15acb9567 Mon Sep 17 00:00:00 2001 From: Ivan Shymko Date: Thu, 23 Jul 2026 13:36:31 +0000 Subject: [PATCH 1/4] ci: require two approvals on release-please PRs --- .github/workflows/release-pr-approvals.yml | 78 ++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/release-pr-approvals.yml diff --git a/.github/workflows/release-pr-approvals.yml b/.github/workflows/release-pr-approvals.yml new file mode 100644 index 000000000..0b3bf72f5 --- /dev/null +++ b/.github/workflows/release-pr-approvals.yml @@ -0,0 +1,78 @@ +name: release-pr-approvals + +# Requires N approvals before a release-please PR can be merged. Publishes a +# commit status (context "release-pr-approvals") so it can be added as a +# required status check on the protected branch. Normal PRs pass immediately. +# +# A PR counts as a release PR if ANY signal matches: head branch prefix, author, +# or an autorelease label. Matching on any one is deliberate — missing a real +# release PR reopens the accidental-merge gap, while over-matching only asks for +# a second approval. +# +# Uses pull_request_target (not pull_request) so the status can be written on +# PRs opened from forks; this job never checks out or executes PR code, so the +# usual pull_request_target risk does not apply. + +on: + pull_request_target: + types: [opened, reopened, synchronize] + pull_request_review: + types: [submitted, dismissed, edited] + +permissions: + contents: read + pull-requests: read + statuses: write + +concurrency: + group: release-pr-approvals-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + approvals: + runs-on: ubuntu-latest + env: + REQUIRED_APPROVALS: "2" + RELEASE_BRANCH_PREFIX: "release-please--" + RELEASE_AUTHOR: "a2a-bot" + RELEASE_LABEL_PREFIX: "autorelease:" + steps: + - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const CONTEXT = 'release-pr-approvals'; + const required = Number(process.env.REQUIRED_APPROVALS); + const pr = context.payload.pull_request; + const { owner, repo } = context.repo; + + const setStatus = (state, description) => + github.rest.repos.createCommitStatus({ + owner, repo, sha: pr.head.sha, context: CONTEXT, state, description, + }); + + const labels = (pr.labels ?? []).map((l) => l.name); + const isRelease = + pr.head.ref.startsWith(process.env.RELEASE_BRANCH_PREFIX) || + pr.user?.login === process.env.RELEASE_AUTHOR || + labels.some((n) => n.startsWith(process.env.RELEASE_LABEL_PREFIX)); + + if (!isRelease) { + await setStatus('success', 'Not a release PR'); + return; + } + + const reviews = await github.paginate(github.rest.pulls.listReviews, { + owner, repo, pull_number: pr.number, per_page: 100, + }); + + // Latest decisive review per user; COMMENTED does not change approval state. + const latest = new Map(); + for (const r of reviews) { + if (!r.user || r.state === 'COMMENTED') continue; + latest.set(r.user.id, r.state); + } + const approvals = [...latest.values()].filter((s) => s === 'APPROVED').length; + + const description = `${approvals}/${required} approvals`; + await setStatus(approvals >= required ? 'success' : 'failure', description); + core.info(description); From 4562c31394cfbd48cb164bf0b84c4708bb47e778 Mon Sep 17 00:00:00 2001 From: Ivan Shymko Date: Thu, 23 Jul 2026 13:57:56 +0000 Subject: [PATCH 2/4] chore(main): release test update --- .github/workflows/release-pr-approvals.yml | 31 ++++++++++------------ 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/.github/workflows/release-pr-approvals.yml b/.github/workflows/release-pr-approvals.yml index 0b3bf72f5..696009abb 100644 --- a/.github/workflows/release-pr-approvals.yml +++ b/.github/workflows/release-pr-approvals.yml @@ -1,17 +1,18 @@ name: release-pr-approvals -# Requires N approvals before a release-please PR can be merged. Publishes a -# commit status (context "release-pr-approvals") so it can be added as a -# required status check on the protected branch. Normal PRs pass immediately. +# Fails until N approvals are present on a release-please PR, so the job's check +# run ("release-pr-approvals / approvals") can be a required status check on the +# protected branch. Normal PRs pass immediately. # # A PR counts as a release PR if ANY signal matches: head branch prefix, author, # or an autorelease label. Matching on any one is deliberate — missing a real # release PR reopens the accidental-merge gap, while over-matching only asks for # a second approval. # -# Uses pull_request_target (not pull_request) so the status can be written on -# PRs opened from forks; this job never checks out or executes PR code, so the -# usual pull_request_target risk does not apply. +# Uses pull_request_target (not pull_request) so the check always runs and +# reports on PRs from forks without waiting for first-time-contributor run +# approval; this job never checks out or executes PR code, so the usual +# pull_request_target risk does not apply. on: pull_request_target: @@ -22,7 +23,6 @@ on: permissions: contents: read pull-requests: read - statuses: write concurrency: group: release-pr-approvals-${{ github.event.pull_request.number }} @@ -40,16 +40,10 @@ jobs: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | - const CONTEXT = 'release-pr-approvals'; const required = Number(process.env.REQUIRED_APPROVALS); const pr = context.payload.pull_request; const { owner, repo } = context.repo; - const setStatus = (state, description) => - github.rest.repos.createCommitStatus({ - owner, repo, sha: pr.head.sha, context: CONTEXT, state, description, - }); - const labels = (pr.labels ?? []).map((l) => l.name); const isRelease = pr.head.ref.startsWith(process.env.RELEASE_BRANCH_PREFIX) || @@ -57,7 +51,7 @@ jobs: labels.some((n) => n.startsWith(process.env.RELEASE_LABEL_PREFIX)); if (!isRelease) { - await setStatus('success', 'Not a release PR'); + core.info('Not a release PR'); return; } @@ -73,6 +67,9 @@ jobs: } const approvals = [...latest.values()].filter((s) => s === 'APPROVED').length; - const description = `${approvals}/${required} approvals`; - await setStatus(approvals >= required ? 'success' : 'failure', description); - core.info(description); + const summary = `${approvals}/${required} approvals`; + if (approvals < required) { + core.setFailed(`Release PR blocked: ${summary}`); + } else { + core.info(`Release PR OK: ${summary}`); + } From 311dbfa446fc4c55af598629c437495fa4a45222 Mon Sep 17 00:00:00 2001 From: Ivan Shymko Date: Thu, 23 Jul 2026 14:15:59 +0000 Subject: [PATCH 3/4] ci: update --- .github/workflows/release-pr-approvals.yml | 48 ++++++++++++---------- 1 file changed, 27 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release-pr-approvals.yml b/.github/workflows/release-pr-approvals.yml index 696009abb..69342827c 100644 --- a/.github/workflows/release-pr-approvals.yml +++ b/.github/workflows/release-pr-approvals.yml @@ -1,18 +1,14 @@ name: release-pr-approvals -# Fails until N approvals are present on a release-please PR, so the job's check -# run ("release-pr-approvals / approvals") can be a required status check on the -# protected branch. Normal PRs pass immediately. +# Requires N approvals from users with write access before a release-please PR +# can be merged. Posts a commit status (context "release-pr-approvals") so it can +# be added as a required status check on the protected branch; the job itself +# stays green. Normal PRs pass immediately. # # A PR counts as a release PR if ANY signal matches: head branch prefix, author, -# or an autorelease label. Matching on any one is deliberate — missing a real +# or an autorelease label. Matching on any one is deliberate - missing a real # release PR reopens the accidental-merge gap, while over-matching only asks for # a second approval. -# -# Uses pull_request_target (not pull_request) so the check always runs and -# reports on PRs from forks without waiting for first-time-contributor run -# approval; this job never checks out or executes PR code, so the usual -# pull_request_target risk does not apply. on: pull_request_target: @@ -23,6 +19,7 @@ on: permissions: contents: read pull-requests: read + statuses: write concurrency: group: release-pr-approvals-${{ github.event.pull_request.number }} @@ -35,23 +32,27 @@ jobs: REQUIRED_APPROVALS: "2" RELEASE_BRANCH_PREFIX: "release-please--" RELEASE_AUTHOR: "a2a-bot" - RELEASE_LABEL_PREFIX: "autorelease:" steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | + const CONTEXT = 'release-pr-approvals'; const required = Number(process.env.REQUIRED_APPROVALS); const pr = context.payload.pull_request; const { owner, repo } = context.repo; + const setStatus = (state, description) => + github.rest.repos.createCommitStatus({ + owner, repo, sha: pr.head.sha, context: CONTEXT, state, description, + }); + const labels = (pr.labels ?? []).map((l) => l.name); const isRelease = - pr.head.ref.startsWith(process.env.RELEASE_BRANCH_PREFIX) || - pr.user?.login === process.env.RELEASE_AUTHOR || - labels.some((n) => n.startsWith(process.env.RELEASE_LABEL_PREFIX)); + pr.head.ref.startsWith(process.env.RELEASE_BRANCH_PREFIX) && + pr.user?.login === process.env.RELEASE_AUTHOR; if (!isRelease) { - core.info('Not a release PR'); + await setStatus('success', 'Not a release PR'); return; } @@ -63,13 +64,18 @@ jobs: const latest = new Map(); for (const r of reviews) { if (!r.user || r.state === 'COMMENTED') continue; - latest.set(r.user.id, r.state); + latest.set(r.user.login, r.state); } - const approvals = [...latest.values()].filter((s) => s === 'APPROVED').length; - const summary = `${approvals}/${required} approvals`; - if (approvals < required) { - core.setFailed(`Release PR blocked: ${summary}`); - } else { - core.info(`Release PR OK: ${summary}`); + let approvals = 0; + for (const [login, state] of latest) { + if (state !== 'APPROVED') continue; + const { data: perm } = await github.rest.repos.getCollaboratorPermissionLevel({ + owner, repo, username: login, + }); + if (perm.permission === 'admin' || perm.permission === 'write') approvals++; } + + const description = `${approvals}/${required} approvals`; + await setStatus(approvals >= required ? 'success' : 'failure', description); + core.info(description); From 3649343508aabc8fefdcc6ffe1b8083cc67fb042 Mon Sep 17 00:00:00 2001 From: Ivan Shymko Date: Thu, 23 Jul 2026 14:40:08 +0000 Subject: [PATCH 4/4] Fix description --- .github/workflows/release-pr-approvals.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/.github/workflows/release-pr-approvals.yml b/.github/workflows/release-pr-approvals.yml index 69342827c..a53388727 100644 --- a/.github/workflows/release-pr-approvals.yml +++ b/.github/workflows/release-pr-approvals.yml @@ -5,11 +5,7 @@ name: release-pr-approvals # be added as a required status check on the protected branch; the job itself # stays green. Normal PRs pass immediately. # -# A PR counts as a release PR if ANY signal matches: head branch prefix, author, -# or an autorelease label. Matching on any one is deliberate - missing a real -# release PR reopens the accidental-merge gap, while over-matching only asks for -# a second approval. - +# A PR counts as a release PR based on the branch name and author. on: pull_request_target: types: [opened, reopened, synchronize] @@ -46,7 +42,6 @@ jobs: owner, repo, sha: pr.head.sha, context: CONTEXT, state, description, }); - const labels = (pr.labels ?? []).map((l) => l.name); const isRelease = pr.head.ref.startsWith(process.env.RELEASE_BRANCH_PREFIX) && pr.user?.login === process.env.RELEASE_AUTHOR;