From 71c8b9f23a17f7fb972966243c9ada1baeb256e7 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Sun, 12 Apr 2026 13:48:02 -0500 Subject: [PATCH 1/2] Adding guard to prevent PRs from merging that would otherwise be acceptable when they're marked with the "do-not-merge" label. Automatically runs whenever a label is removed in case the do-not-merge is removed (prompting immediate merge if conditions are acceptable) Signed-off-by: Whit Waldo --- .github/workflows/dapr-maintainer-merge.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/dapr-maintainer-merge.yml b/.github/workflows/dapr-maintainer-merge.yml index b6b683fd567..6902e80125a 100644 --- a/.github/workflows/dapr-maintainer-merge.yml +++ b/.github/workflows/dapr-maintainer-merge.yml @@ -4,7 +4,7 @@ # We DO NOT check out PR code; we only read PR metadata via the API. on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, edited] + types: [opened, synchronize, reopened, ready_for_review, edited, unlabeled] paths: - 'sdkdocs/**' @@ -133,6 +133,13 @@ jobs: core.warning(`Failed to create review: ${e.message}`); } + // Block if "do-not-merge" label is present + const currentLabels = context.payload.pull_request.labels.map(l => l.name.toLowerCase()); + if (currentLabels.includes('do-not-merge')) { + core.info('PR has "do-not-merge" label; skipping merge until it is removed.'); + return; + } + // Poll mergeability const wait = ms => new Promise(r => setTimeout(r, ms)); for (let i = 0; i < 12; i++) { From 0514b70aa5129285e0de52f981823dfd9801fc70 Mon Sep 17 00:00:00 2001 From: Whit Waldo Date: Sun, 12 Apr 2026 14:05:04 -0500 Subject: [PATCH 2/2] Adding additional triggers and guards: If an "automerge: " label is applied and the changes in the PR don't correspond to the directory of the applied label, the label will be removed and the automerge will not happen. If an SDK maintainer comes along and applies an "automerge: " label for an SDK they're not a maintainer of, the label will be removed and the automerge will not happen If anyone at all comes along and applies an "automerge: " label and they're not part of the maintainer group corresponding with the label, the label will be removed and the automerge will not happen. Signed-off-by: Whit Waldo --- .github/workflows/dapr-maintainer-merge.yml | 150 ++++++++++++++------ 1 file changed, 108 insertions(+), 42 deletions(-) diff --git a/.github/workflows/dapr-maintainer-merge.yml b/.github/workflows/dapr-maintainer-merge.yml index 6902e80125a..f587c1853bc 100644 --- a/.github/workflows/dapr-maintainer-merge.yml +++ b/.github/workflows/dapr-maintainer-merge.yml @@ -4,7 +4,7 @@ # We DO NOT check out PR code; we only read PR metadata via the API. on: pull_request_target: - types: [opened, synchronize, reopened, ready_for_review, edited, unlabeled] + types: [opened, synchronize, reopened, ready_for_review, edited, labeled, unlabeled] paths: - 'sdkdocs/**' @@ -43,59 +43,125 @@ jobs: { label: 'automerge: rust', teamSlug: 'maintainers-rust-sdk', prefixes: ['sdkdocs/rust/content/en/'] }, ]; - const username = pr.user.login; + const action = context.payload.action; - // 1) List changed files - const files = await github.paginate( - github.rest.pulls.listFiles, - { owner, repo, pull_number: number, per_page: 100 } - ); - - if (files.length === 0) { - core.info('No files changed in PR; skipping.'); - core.setOutput('eligible', 'false'); - return; + // Helper: verify a user is an active member of a team + async function checkMembership(teamSlug, username) { + const membership = await github.rest.teams.getMembershipForUserInOrg({ + org: owner, team_slug: teamSlug, username + }); + return membership.data.state === 'active'; } - // 2) Determine which single SDK mapping the PR targets - let currentMapping = null, ineligible = false; - for (const f of files) { - const matched = MAPPINGS.find(m => m.prefixes.some(p => f.filename.startsWith(p))); - if (!matched) { ineligible = true; break; } - if (!currentMapping) currentMapping = matched; - else if (currentMapping !== matched) { ineligible = true; break; } + // Helper: verify all PR files fall within a single mapping's prefixes + async function resolveMapping(number) { + const files = await github.paginate( + github.rest.pulls.listFiles, + { owner, repo, pull_number: number, per_page: 100 } + ); + if (files.length === 0) return null; + let mapping = null; + for (const f of files) { + const matched = MAPPINGS.find(m => m.prefixes.some(p => f.filename.startsWith(p))); + if (!matched) return null; + if (!mapping) mapping = matched; + else if (mapping !== matched) return null; + } + return mapping; } - if (ineligible || !currentMapping) { - core.info('PR is not eligible: outside mapped paths or touches multiple SDK directories.'); - core.setOutput('eligible', 'false'); - return; - } + if (action === 'labeled') { + // --- Maintainer-approved flow --- + // A maintainer adds an automerge label to a PR they didn't necessarily author. + // Validate: label is a known automerge label, adder is in that team, PR only + // touches that SDK's directory. + const addedLabel = context.payload.label.name; + const mapping = MAPPINGS.find(m => m.label === addedLabel); - // 3) Verify author is active in the corresponding team (org-scoped token) - try { - const membership = await github.rest.teams.getMembershipForUserInOrg({ - org: owner, - team_slug: currentMapping.teamSlug, - username - }); - if (membership.data.state !== 'active') { - core.info(`User ${username} is not active in team ${currentMapping.teamSlug}.`); + if (!mapping) { + core.info(`Label "${addedLabel}" is not a recognized automerge label; skipping.`); core.setOutput('eligible', 'false'); return; } - } catch (err) { - core.info(`Membership check failed or user not in team ${currentMapping.teamSlug}: ${err.status} ${err.message}`); - core.setOutput('eligible', 'false'); - return; + + const labelAdder = context.payload.sender.login; + try { + const active = await checkMembership(mapping.teamSlug, labelAdder); + if (!active) { + core.info(`User ${labelAdder} is not active in team ${mapping.teamSlug}; removing label.`); + core.setOutput('eligible', 'false'); + core.setOutput('remove_label', addedLabel); + return; + } + } catch (err) { + core.info(`Membership check failed for ${labelAdder} in team ${mapping.teamSlug}: ${err.status} ${err.message}`); + core.setOutput('eligible', 'false'); + core.setOutput('remove_label', addedLabel); + return; + } + + const resolvedMapping = await resolveMapping(number); + if (!resolvedMapping || resolvedMapping !== mapping) { + core.info('PR touches files outside the labeled SDK directory or multiple directories; removing label.'); + core.setOutput('eligible', 'false'); + core.setOutput('remove_label', addedLabel); + return; + } + + core.info(`Maintainer ${labelAdder} approved merge via label for ${mapping.label}.`); + core.setOutput('eligible', 'true'); + core.setOutput('label', mapping.label); + core.setOutput('teamSlug', mapping.teamSlug); + core.setOutput('lang', (mapping.label.split(': ')[1] || 'sdk')); + + } else { + // --- Author-is-maintainer flow --- + // PR author must themselves be a maintainer of the single SDK directory the PR touches. + const username = pr.user.login; + + const currentMapping = await resolveMapping(number); + if (!currentMapping) { + core.info('PR is not eligible: no changed files, outside mapped paths, or touches multiple SDK directories.'); + core.setOutput('eligible', 'false'); + return; + } + + try { + const active = await checkMembership(currentMapping.teamSlug, username); + if (!active) { + core.info(`User ${username} is not active in team ${currentMapping.teamSlug}.`); + core.setOutput('eligible', 'false'); + return; + } + } catch (err) { + core.info(`Membership check failed or user not in team ${currentMapping.teamSlug}: ${err.status} ${err.message}`); + core.setOutput('eligible', 'false'); + return; + } + + core.setOutput('eligible', 'true'); + core.setOutput('label', currentMapping.label); + core.setOutput('teamSlug', currentMapping.teamSlug); + core.setOutput('lang', (currentMapping.label.split(': ')[1] || 'sdk')); } - core.setOutput('eligible', 'true'); - core.setOutput('label', currentMapping.label); - core.setOutput('teamSlug', currentMapping.teamSlug); - core.setOutput('lang', (currentMapping.label.split(': ')[1] || 'sdk')); + # 2) Remove unauthorized automerge labels (uses default repo-scoped token) + - name: Remove unauthorized automerge label + if: steps.teamcheck.outputs.remove_label != '' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const number = context.payload.pull_request.number; + const labelName = '${{ steps.teamcheck.outputs.remove_label }}'; + try { + await github.rest.issues.removeLabel({ owner, repo, issue_number: number, name: labelName }); + core.info(`Removed unauthorized label "${labelName}" from PR #${number}.`); + } catch (e) { + if (e.status !== 404) throw e; // 404 = already removed, ignore + } - # 2) If eligible, label, approve and merge with the default repo-scoped token + # 3) If eligible, label, approve and merge with the default repo-scoped token - name: Label, auto-approve & merge if: steps.teamcheck.outputs.eligible == 'true' uses: actions/github-script@v7