Skip to content

Commit 0ed9bb2

Browse files
heiskrCopilot
andauthored
Restrict merging to main to the Technical Content team (#62790)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e69dde28-9eec-4ca7-b322-eafff4217240
1 parent 0e315c1 commit 0ed9bb2

1 file changed

Lines changed: 216 additions & 0 deletions

File tree

Lines changed: 216 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,216 @@
1+
name: Restrict who can queue merges
2+
3+
# **What it does**:
4+
# On a `merge_group` event, checks whether the person who put the pull
5+
# request into the merge queue is on github/technical-content. If they
6+
# are not, comments on the pull request saying so and fails, which
7+
# ejects the entry from the queue.
8+
# **Why we have it**:
9+
# Classic branch protection used to restrict who could push to `main`,
10+
# but that rule was swept org-wide on 2026-06-22, so today anyone with
11+
# write access can merge. Rebuilding it means also enabling a merge
12+
# queue on the same rule, which could collide with the merge queue on
13+
# our ruleset and lock the branch for everyone. This does the same job
14+
# with machinery we own outright.
15+
# **Who does it impact**: Anyone merging to `main`.
16+
17+
# Two things to know before changing this:
18+
#
19+
# 1. `merge-queue-restriction` has to be a required status check on the ruleset targeting `refs/heads/main`, or this
20+
# enforces nothing. Add it there only after this workflow is on `main` and reporting. The other order makes the
21+
# check required before it has ever reported, which blocks every pull request. To turn enforcement off again,
22+
# remove it from the ruleset. This workflow keeps running and keeps passing.
23+
#
24+
# 2. The `pull_request` runs do no work. They exist so the required check reports a passing context on the pull
25+
# request itself. Drop them and the check sits pending forever and nothing can ever be enqueued.
26+
27+
on:
28+
pull_request:
29+
types: [opened, reopened, synchronize, ready_for_review]
30+
merge_group:
31+
32+
permissions:
33+
contents: read
34+
pull-requests: write
35+
36+
# Keyed on head SHA rather than pull request number. Webhook delivery order is not guaranteed, and keying on the pull
37+
# request would let a late event for an old SHA cancel the run for a newer one.
38+
concurrency:
39+
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
40+
cancel-in-progress: true
41+
42+
jobs:
43+
# The job id doubles as the check run name because this job deliberately has no `name:` key. Renaming this job renames
44+
# the required status check, which silently stops enforcing anything.
45+
merge-queue-restriction:
46+
# This repository syncs a subset of files to the public github/docs, including workflows. Nothing here applies there.
47+
if: github.repository == 'github/docs-internal'
48+
runs-on: ubuntu-latest
49+
steps:
50+
- name: Check that the enqueuer is on the Technical Content team
51+
if: github.event_name == 'merge_group'
52+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
53+
with:
54+
# Reading org team membership needs `read:org`, which GITHUB_TOKEN does not have. `merge_group` always runs in
55+
# the base repository, so this secret is always available here, including for pull requests from forks.
56+
github-token: ${{ secrets.DOCS_BOT_PAT_BASE }}
57+
script: |
58+
// Addressed by numeric ID (org github = 9919, team technical-content = 325922) because IDs survive renames
59+
// and slugs do not. This team was called `docs` until recently and the rename broke a pile of automation.
60+
const ORG_ID = 9919
61+
const TEAM_ID = 325922
62+
const TEAM = 'github/technical-content'
63+
const MARKER = '<!-- merge-queue-restriction -->'
64+
const EXEMPT_USERS = ['docs-bot']
65+
const CONTENT_SLACK_CHANNEL = 'C0E9DK082'
66+
const MAX_ATTEMPTS = 3
67+
68+
// `github.actor` is the person who enqueued. On a re-run it stays the original actor, unlike
69+
// `github.triggering_actor`, so re-running cannot launder a failing check into a passing one.
70+
const actor = context.actor
71+
core.info(`This merge group was queued by @${actor}.`)
72+
73+
// A GitHub App actor always ends in `[bot]`, and `[` is not a valid character in a username, so nobody can
74+
// impersonate one. `docs-bot` is a plain User account and has to be named explicitly.
75+
core.info(`Checking whether @${actor} is an automation account...`)
76+
if (actor.endsWith('[bot]') || EXEMPT_USERS.includes(actor)) {
77+
core.info(`Checked: @${actor} is an automation account. Allowing the merge.`)
78+
return
79+
}
80+
core.info(`Checked: @${actor} is a person, so they need to be on the team.`)
81+
82+
// Every request retries transient failures before giving up, then fails closed. Failing closed is safe
83+
// here: github/technical-content is an `always` bypass actor on the ruleset, so a broken check stops
84+
// non-Docs merges but never stops Docs. A 404 comes back as null data rather than as an error, because on
85+
// both of the endpoints below it is an answer rather than a failure.
86+
async function ask(description, route, params) {
87+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
88+
core.info(`${description} (attempt ${attempt} of ${MAX_ATTEMPTS})...`)
89+
try {
90+
const { data } = await github.request(route, params)
91+
return { data }
92+
} catch (error) {
93+
if (error.status === 404) return { data: null }
94+
if (attempt === MAX_ATTEMPTS) return { error }
95+
96+
const seconds = attempt * 2
97+
core.warning(`Asked and failed with HTTP ${error.status}. Retrying in ${seconds}s.`)
98+
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
99+
}
100+
}
101+
}
102+
103+
// Deliberately says nothing on the pull request. We only comment when we know the answer, and here we
104+
// do not.
105+
function giveUp(detail) {
106+
core.setFailed(`${detail} Failing closed, so this stays out of the queue. Ask in #docs-content.`)
107+
}
108+
109+
// Checking the parent team is enough. Every member of every child team (docs-content, docs-engineering,
110+
// docs-localization, docs-content-systems, docs-product-managers, docs-open-source, docs-design,
111+
// docs-content-design, copilot-docs) also resolves as a member of the parent.
112+
const membershipResult = await ask(
113+
`Asking the API whether @${actor} is on ${TEAM}`,
114+
'GET /organizations/{org_id}/team/{team_id}/memberships/{username}',
115+
{ org_id: ORG_ID, team_id: TEAM_ID, username: actor },
116+
)
117+
118+
if (membershipResult.error) {
119+
giveUp(
120+
`Could not check ${TEAM} membership for @${actor}. ` +
121+
`The last attempt returned HTTP ${membershipResult.error.status}.`,
122+
)
123+
return
124+
}
125+
126+
const membership = membershipResult.data
127+
128+
if (membership) {
129+
core.info(`Asked: @${actor} has membership state "${membership.state}" on ${TEAM}.`)
130+
} else {
131+
core.info(`Asked: the API reports no membership for @${actor} on ${TEAM} (HTTP 404).`)
132+
133+
// That 404 is ambiguous. It is byte for byte the same response for "not a member", "team no longer
134+
// exists", and "the token lost visibility into the org". Read the team back before believing it,
135+
// otherwise a deleted team or a downgraded token would blame every single person who tries to merge.
136+
const teamResult = await ask(
137+
`A 404 is ambiguous, so reading ${TEAM} back to confirm it is still visible`,
138+
'GET /organizations/{org_id}/team/{team_id}',
139+
{ org_id: ORG_ID, team_id: TEAM_ID },
140+
)
141+
142+
if (teamResult.error || !teamResult.data) {
143+
giveUp(
144+
`Could not read ${TEAM} itself, so the 404 for @${actor} says nothing about their membership. ` +
145+
`The last attempt returned HTTP ${teamResult.error?.status ?? 404}. ` +
146+
'Either the team is gone or this token lost access to it.',
147+
)
148+
return
149+
}
150+
151+
core.info(`Read it back: ${TEAM} is visible as "${teamResult.data.slug}", so the 404 is a real answer.`)
152+
}
153+
154+
if (membership?.state === 'active') {
155+
core.info(`@${actor} is an active member of ${TEAM}. Allowing the merge.`)
156+
return
157+
}
158+
159+
const reason = membership
160+
? `@${actor} has a "${membership.state}" membership on ${TEAM} rather than an active one.`
161+
: `@${actor} is not a member of ${TEAM}.`
162+
core.info(`Blocking the merge. ${reason}`)
163+
164+
// A failed check on a `gh-readonly-queue` ref is not something anyone goes looking for, so say why on the
165+
// pull request itself. The merge group ref is `refs/heads/gh-readonly-queue/<base>/pr-<number>-<sha>`, and
166+
// the pull request it names is the one this actor just enqueued, so the number and the actor correspond.
167+
const ref = context.payload.merge_group?.head_ref ?? context.ref
168+
core.info(`Working out which pull request this merge group is for, from "${ref}"...`)
169+
const number = Number(ref.match(/\/pr-(\d+)-[0-9a-f]+$/)?.[1])
170+
171+
if (!number) {
172+
core.warning(`Worked it out: could not find a pull request number in "${ref}". Skipping the comment.`)
173+
} else {
174+
core.info(`Worked it out: this merge group is for #${number}.`)
175+
176+
// Only comment once. Someone who tries to enqueue again already has the explanation, and repeating it
177+
// turns a useful comment into noise.
178+
core.info(`Reading the existing comments on #${number}...`)
179+
const comments = await github.paginate(github.rest.issues.listComments, {
180+
owner: context.repo.owner,
181+
repo: context.repo.repo,
182+
issue_number: number,
183+
per_page: 100,
184+
})
185+
core.info(`Read ${comments.length} comment(s) on #${number}.`)
186+
187+
if (comments.some((comment) => comment.body?.includes(MARKER))) {
188+
core.info(`#${number} already has this explanation, so not commenting again.`)
189+
} else {
190+
core.info(`Commenting on #${number} to explain...`)
191+
await github.rest.issues.createComment({
192+
owner: context.repo.owner,
193+
repo: context.repo.repo,
194+
issue_number: number,
195+
body: [
196+
// The marker has to be on its own line. GitHub parses `<!--` at the start of a block as an
197+
// HTML block that runs to the end of the line containing `-->`, so anything sharing that
198+
// line renders as literal text: no code spans, no links.
199+
MARKER,
200+
[
201+
`👋 Hi @${actor}, this pull request was removed from the merge queue.`,
202+
'Only the GitHub Technical Content team merges to `main` in this repository.',
203+
'Once this is reviewed and ready, ask in',
204+
`[#docs-content](https://github.slack.com/archives/${CONTENT_SLACK_CHANNEL})`,
205+
'and someone on the team can merge it for you.',
206+
].join(' '),
207+
].join('\n'),
208+
})
209+
core.info(`Commented on #${number}.`)
210+
}
211+
}
212+
213+
core.setFailed(
214+
`Only ${TEAM} merges to main in this repository. ${reason} ` +
215+
'Ask in #docs-content and someone on the team can merge this for you.',
216+
)

0 commit comments

Comments
 (0)