Skip to content

Restrict who can queue merges #27

Restrict who can queue merges

Restrict who can queue merges #27

name: Restrict who can queue merges
# **What it does**:
# On a `merge_group` event, checks whether the person who put the pull
# request into the merge queue is on github/technical-content. If they
# are not, comments on the pull request saying so and fails, which
# ejects the entry from the queue.
# **Why we have it**:
# Classic branch protection used to restrict who could push to `main`,
# but that rule was swept org-wide on 2026-06-22, so today anyone with
# write access can merge. Rebuilding it means also enabling a merge
# queue on the same rule, which could collide with the merge queue on
# our ruleset and lock the branch for everyone. This does the same job
# with machinery we own outright.
# **Who does it impact**: Anyone merging to `main`.
# Two things to know before changing this:
#
# 1. `merge-queue-restriction` has to be a required status check on the ruleset targeting `refs/heads/main`, or this
# enforces nothing. Add it there only after this workflow is on `main` and reporting. The other order makes the
# check required before it has ever reported, which blocks every pull request. To turn enforcement off again,
# remove it from the ruleset. This workflow keeps running and keeps passing.
#
# 2. The `pull_request` runs do no work. They exist so the required check reports a passing context on the pull
# request itself. Drop them and the check sits pending forever and nothing can ever be enqueued.
on:
pull_request:
types: [opened, reopened, synchronize, ready_for_review]
merge_group:
permissions:
contents: read
pull-requests: write
# Keyed on head SHA rather than pull request number. Webhook delivery order is not guaranteed, and keying on the pull
# request would let a late event for an old SHA cancel the run for a newer one.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.head.sha || github.event.merge_group.head_sha }}
cancel-in-progress: true
jobs:
# The job id doubles as the check run name because this job deliberately has no `name:` key. Renaming this job renames
# the required status check, which silently stops enforcing anything.
merge-queue-restriction:
# This repository syncs a subset of files to the public github/docs, including workflows. Nothing here applies there.
if: github.repository == 'github/docs-internal'
runs-on: ubuntu-latest
steps:
- name: Check that the enqueuer is on the Technical Content team
if: github.event_name == 'merge_group'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
# Reading org team membership needs `read:org`, which GITHUB_TOKEN does not have. `merge_group` always runs in
# the base repository, so this secret is always available here, including for pull requests from forks.
github-token: ${{ secrets.DOCS_BOT_PAT_BASE }}
script: |
// Addressed by numeric ID (org github = 9919, team technical-content = 325922) because IDs survive renames
// and slugs do not. This team was called `docs` until recently and the rename broke a pile of automation.
const ORG_ID = 9919
const TEAM_ID = 325922
const TEAM = 'github/technical-content'
const MARKER = '<!-- merge-queue-restriction -->'
const EXEMPT_USERS = ['docs-bot']
const CONTENT_SLACK_CHANNEL = 'C0E9DK082'
const MAX_ATTEMPTS = 3
// `github.actor` is the person who enqueued. On a re-run it stays the original actor, unlike
// `github.triggering_actor`, so re-running cannot launder a failing check into a passing one.
const actor = context.actor
core.info(`This merge group was queued by @${actor}.`)
// A GitHub App actor always ends in `[bot]`, and `[` is not a valid character in a username, so nobody can
// impersonate one. `docs-bot` is a plain User account and has to be named explicitly.
core.info(`Checking whether @${actor} is an automation account...`)
if (actor.endsWith('[bot]') || EXEMPT_USERS.includes(actor)) {
core.info(`Checked: @${actor} is an automation account. Allowing the merge.`)
return
}
core.info(`Checked: @${actor} is a person, so they need to be on the team.`)
// Every request retries transient failures before giving up, then fails closed. Failing closed is safe
// here: github/technical-content is an `always` bypass actor on the ruleset, so a broken check stops
// non-Docs merges but never stops Docs. A 404 comes back as null data rather than as an error, because on
// both of the endpoints below it is an answer rather than a failure.
async function ask(description, route, params) {
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
core.info(`${description} (attempt ${attempt} of ${MAX_ATTEMPTS})...`)
try {
const { data } = await github.request(route, params)
return { data }
} catch (error) {
if (error.status === 404) return { data: null }
if (attempt === MAX_ATTEMPTS) return { error }
const seconds = attempt * 2
core.warning(`Asked and failed with HTTP ${error.status}. Retrying in ${seconds}s.`)
await new Promise((resolve) => setTimeout(resolve, seconds * 1000))
}
}
}
// Deliberately says nothing on the pull request. We only comment when we know the answer, and here we
// do not.
function giveUp(detail) {
core.setFailed(`${detail} Failing closed, so this stays out of the queue. Ask in #docs-content.`)
}
// Checking the parent team is enough. Every member of every child team (docs-content, docs-engineering,
// docs-localization, docs-content-systems, docs-product-managers, docs-open-source, docs-design,
// docs-content-design, copilot-docs) also resolves as a member of the parent.
const membershipResult = await ask(
`Asking the API whether @${actor} is on ${TEAM}`,
'GET /organizations/{org_id}/team/{team_id}/memberships/{username}',
{ org_id: ORG_ID, team_id: TEAM_ID, username: actor },
)
if (membershipResult.error) {
giveUp(
`Could not check ${TEAM} membership for @${actor}. ` +
`The last attempt returned HTTP ${membershipResult.error.status}.`,
)
return
}
const membership = membershipResult.data
if (membership) {
core.info(`Asked: @${actor} has membership state "${membership.state}" on ${TEAM}.`)
} else {
core.info(`Asked: the API reports no membership for @${actor} on ${TEAM} (HTTP 404).`)
// That 404 is ambiguous. It is byte for byte the same response for "not a member", "team no longer
// exists", and "the token lost visibility into the org". Read the team back before believing it,
// otherwise a deleted team or a downgraded token would blame every single person who tries to merge.
const teamResult = await ask(
`A 404 is ambiguous, so reading ${TEAM} back to confirm it is still visible`,
'GET /organizations/{org_id}/team/{team_id}',
{ org_id: ORG_ID, team_id: TEAM_ID },
)
if (teamResult.error || !teamResult.data) {
giveUp(
`Could not read ${TEAM} itself, so the 404 for @${actor} says nothing about their membership. ` +
`The last attempt returned HTTP ${teamResult.error?.status ?? 404}. ` +
'Either the team is gone or this token lost access to it.',
)
return
}
core.info(`Read it back: ${TEAM} is visible as "${teamResult.data.slug}", so the 404 is a real answer.`)
}
if (membership?.state === 'active') {
core.info(`@${actor} is an active member of ${TEAM}. Allowing the merge.`)
return
}
const reason = membership
? `@${actor} has a "${membership.state}" membership on ${TEAM} rather than an active one.`
: `@${actor} is not a member of ${TEAM}.`
core.info(`Blocking the merge. ${reason}`)
// A failed check on a `gh-readonly-queue` ref is not something anyone goes looking for, so say why on the
// pull request itself. The merge group ref is `refs/heads/gh-readonly-queue/<base>/pr-<number>-<sha>`, and
// the pull request it names is the one this actor just enqueued, so the number and the actor correspond.
const ref = context.payload.merge_group?.head_ref ?? context.ref
core.info(`Working out which pull request this merge group is for, from "${ref}"...`)
const number = Number(ref.match(/\/pr-(\d+)-[0-9a-f]+$/)?.[1])
if (!number) {
core.warning(`Worked it out: could not find a pull request number in "${ref}". Skipping the comment.`)
} else {
core.info(`Worked it out: this merge group is for #${number}.`)
// Only comment once. Someone who tries to enqueue again already has the explanation, and repeating it
// turns a useful comment into noise.
core.info(`Reading the existing comments on #${number}...`)
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
per_page: 100,
})
core.info(`Read ${comments.length} comment(s) on #${number}.`)
if (comments.some((comment) => comment.body?.includes(MARKER))) {
core.info(`#${number} already has this explanation, so not commenting again.`)
} else {
core.info(`Commenting on #${number} to explain...`)
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: number,
body: [
// The marker has to be on its own line. GitHub parses `<!--` at the start of a block as an
// HTML block that runs to the end of the line containing `-->`, so anything sharing that
// line renders as literal text: no code spans, no links.
MARKER,
[
`👋 Hi @${actor}, this pull request was removed from the merge queue.`,
'Only the GitHub Technical Content team merges to `main` in this repository.',
'Once this is reviewed and ready, ask in',
`[#docs-content](https://github.slack.com/archives/${CONTENT_SLACK_CHANNEL})`,
'and someone on the team can merge it for you.',
].join(' '),
].join('\n'),
})
core.info(`Commented on #${number}.`)
}
}
core.setFailed(
`Only ${TEAM} merges to main in this repository. ${reason} ` +
'Ask in #docs-content and someone on the team can merge this for you.',
)