-
Notifications
You must be signed in to change notification settings - Fork 781
Preventing maintainer docs from automerging if marked with "do-not-merge" label #5111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: v1.17
Are you sure you want to change the base?
Changes from all commits
71c8b9f
0514b70
15536c7
afb2faa
ff65d11
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, 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 | ||
|
|
@@ -133,6 +199,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; | ||
|
Comment on lines
+202
to
+206
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you check if this is relevant @WhitWaldo? I think the likelyhood of this one is very small.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah, I'm not too worried about it. I think it's something we can internally notate for maintainers - labels are the triggers, so if you change them while it's running, it's not going to work. Cancel first, change the label and re-trigger. |
||
| } | ||
|
|
||
| // Poll mergeability | ||
| const wait = ms => new Promise(r => setTimeout(r, ms)); | ||
| for (let i = 0; i < 12; i++) { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can you check if this is relevant @WhitWaldo?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, I think this is handled by the later check on line 81 - if mapping isn't set (and it's only set on a labeled action), the eligible flag is set to false and it terminates the action. As there isn't an unlabeled action we can trigger on, the intent is that the maintainer will add the automerge label for their repo triggering this again.