Repo sync #23
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.', | |
| ) |