|
| 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