From 49dc2f10230596c162dbd899b6fd7ad1361a9e03 Mon Sep 17 00:00:00 2001 From: Parth Aggarwal Date: Wed, 21 Oct 2026 18:35:23 -0700 Subject: [PATCH 1/3] Run the two-human-reviewers gate on pull_request instead of pull_request_target GitHub starts blocking pull_request_target by default in public repositories on November 2, 2026. Move the gate and the caller template to pull_request with the same activity types, keeping the commit-status design, the pull_request_review and workflow_dispatch triggers, the permissions, and the gate logic. Pull requests from branches in the same repository still get a token that can write, so they publish the status as before, pending included, and Dependabot's runs honour the permissions key. A fork's pull request gets a read-only token, so its status is not published and it stays blocked until a maintainer runs the workflow manually with its number; that run publishes to the pull request's head commit. The warning printed when publishing fails now says so. Rewrite the comments that justified pull_request_target or were stale: a manual run does unblock a pull request (only its own check run lands on the default branch), both triggers run the pull request's own copy of the workflow, and the job no longer fails while waiting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7c537a1-1b1b-413e-9943-8d3174d1c5ce --- .../two-human-reviewers.caller.yml | 34 +++++---- .github/workflows/two-human-reviewers.yml | 69 +++++++++++-------- 2 files changed, 63 insertions(+), 40 deletions(-) diff --git a/.github/workflow-templates/two-human-reviewers.caller.yml b/.github/workflow-templates/two-human-reviewers.caller.yml index 8960faf..fb9e32b 100644 --- a/.github/workflow-templates/two-human-reviewers.caller.yml +++ b/.github/workflow-templates/two-human-reviewers.caller.yml @@ -15,23 +15,31 @@ name: Two human reviewers # # Keep BOTH triggers below. A called workflow's own triggers are ignored, so these are what re-run the # gate; drop `pull_request_review` and it evaluates once at open, finds no approvals, and never re-runs -# when someone approves - leaving the required check failing until an unrelated push happens to re-trigger -# it. +# when someone approves - leaving the required status pending until an unrelated push happens to +# re-trigger it. # -# `pull_request_target` rather than `pull_request`, because the gate publishes its verdict as a commit -# status and a pull request from a fork carries a read-only token on `pull_request`. It could never -# publish for one, and the fork's pull request would then wait forever on a status that never arrives. -# `pull_request_target` runs in the base repository's context, with a token that can write. +# `pull_request` rather than `pull_request_target`, which GitHub blocks by default in public repositories +# from November 2, 2026. The gate publishes its verdict as a commit status, which needs a token that can +# write. A pull request from a branch in this repository gets one, and so does Dependabot's, since GitHub +# honours the `permissions` below for its runs. A fork's pull request never does, so its status cannot be +# published and it stays blocked until a maintainer runs this workflow manually with its number (see +# below) - again after each push and, for a bot's pull request, whenever its reviews change. # -# That is only safe because the gate never checks out the pull request's code - it reads the API and -# nothing else. Do not add a checkout here, and do not add one to the called workflow: with -# `pull_request_target` that code would run with a writable token against the base repository. +# Both triggers take this file from the pull request, so a pull request from a branch in this repository +# can edit how it is gated. Require code owner review on `.github/workflows/**` in branch protection, so +# that such an edit is reviewed as the security change it is. +# +# The gate never checks out the pull request's code - it reads the API and nothing else. Do not add a +# checkout here, and do not add one to the called workflow: the manual run that publishes for a fork's +# pull request holds a token that can write, and anything checked out from the fork could run with it. # # Do not add a required status check for this until the file is on the default branch, or every open pull # request will wait forever on a check that cannot run. Pull requests that were already open when it landed -# get no event either, and are unstuck by reopening them, or by any push to them. Dispatching does not do -# it: a dispatch runs against the default branch, so its check run attaches to that branch's commit rather -# than to a pull request's head. It reports the verdict to the run log, and is useful for that. +# get no event either, and are unstuck by reopening them, by any push to them, or by dispatching this with +# their number; a fork's pull request only by dispatching. Start a dispatch with "Run workflow", since +# re-running one of a fork's runs keeps its read-only token. It runs the default branch's copy with a +# token that can write; its own check run attaches to that branch's commit, but the status it publishes +# goes to the pull request's head commit. on: workflow_dispatch: @@ -40,7 +48,7 @@ on: description: PR number to evaluate. type: string required: true - pull_request_target: + pull_request: types: [opened, reopened, synchronize, ready_for_review] pull_request_review: types: [submitted, dismissed] diff --git a/.github/workflows/two-human-reviewers.yml b/.github/workflows/two-human-reviewers.yml index 0124c28..4caae24 100644 --- a/.github/workflows/two-human-reviewers.yml +++ b/.github/workflows/two-human-reviewers.yml @@ -11,23 +11,36 @@ name: Two human reviewers # the caller's, so it gates the calling repository's PR. # # A called workflow's own triggers are ignored - only the caller's events run it - so a caller MUST -# declare `pull_request_review` as well as `pull_request_target`. Without it the gate evaluates once at -# open, finds no approvals, and never re-runs when humans approve, leaving the required check stuck. +# declare `pull_request_review` as well as `pull_request`. Without it the gate evaluates once at open, +# finds no approvals, and never re-runs when humans approve, leaving the required check stuck. # `.github/workflow-templates/two-human-reviewers.caller.yml` is the template to copy. # -# `pull_request_target` rather than `pull_request`, because the verdict is published as a commit status -# and a pull request from a fork carries a read-only token on `pull_request`; it could never publish for -# one, and that pull request would wait forever on a status that never arrives. `pull_request_target` -# runs in the base repository's context with a token that can write. +# `pull_request` rather than `pull_request_target`, which GitHub blocks by default in public repositories +# from November 2, 2026: +# https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target#default-policy-for-pull_request_target # -# It never checks out the pull request's code - it reads the API and nothing else - which is what makes -# `pull_request_target` safe here, and what makes running on `pull_request_review` safe too, since both -# carry a writable token. Do not add a checkout to this workflow. +# The verdict is published as a commit status, which needs a token that can write. On `pull_request` and +# `pull_request_review`, a run for a pull request from a branch in the same repository gets one, so those +# publish as they always have, `pending` included. Dependabot's runs start read-only, but GitHub honours +# the `permissions` below for them, so its pull requests publish too. A run for a fork's pull request is +# read-only whatever `permissions` asks for, so its status cannot be published and the pull request stays +# blocked. That fails safe - a missing required status never lets a merge through. A maintainer unblocks +# it by running this workflow manually with its number (see `workflow_dispatch` below), and runs it again +# after each push and, for a bot's pull request, whenever its reviews change. Fork pull requests are rare +# in the repositories this gates, so that manual step is the accepted cost. # -# Because both triggers take the definition from the base branch rather than from the pull request, a -# pull request can no longer be judged by its own edited copy of this file. Deleting or weakening it now -# takes effect only once merged. It is still security-relevant: review a diff that touches it as such, -# and requiring code owner review on `.github/workflows/**` in branch protection is what enforces that. +# It never checks out the pull request's code - it reads the API and nothing else. Keep it that way: the +# manual run that publishes for a fork's pull request holds a token that can write, as does a run from any +# caller still on `pull_request_target`, and anything checked out from the fork could run with it. +# +# `pull_request` takes this file from the pull request's merge commit, as `pull_request_review` always +# has, so a pull request from a branch in this repository that edits it is judged by its edited copy. +# Deleting it fails safe - the required status never arrives and the pull request cannot merge - but +# weakening it does not, and nor does adding another workflow that publishes the status. Review a diff +# that touches `.github/workflows/` as security-relevant; requiring code owner review on +# `.github/workflows/**` in branch protection is what enforces that. A consuming repository's pull +# request cannot alter the logic here, but editing its own caller stub is just as decisive, and the same +# rule is what covers it there. # # Why a job and not a branch rule: GitHub cannot condition an approval count on the PR author, and a # required check that never reports blocks a PR forever ("Expected - waiting for status to be @@ -53,9 +66,6 @@ name: Two human reviewers # otherwise have written and vetted, leaving one human short. A dependency bump has no missing author: # the change is a version number, and the code it pulls in was never going to be written here. The # repository's normal review rule is the control that fits it. -# -# It deliberately never checks out the PR's code, so running on `pull_request_review` (which carries a -# writable token) cannot execute anything from the branch under review. on: workflow_call: @@ -76,18 +86,22 @@ on: to it. type: string required: false - # Dispatching runs against the default branch, so the check run it produces attaches to that branch's - # commit rather than to a pull request's head, and no pull request is unblocked by it. It reports the - # verdict to the run log, which makes it useful for checking this logic against a real pull request. A - # pull request that was already open when the gate landed has no event to evaluate it with - those don't - # fire retroactively - and is unstuck by reopening it, or by any push to it. + # Dispatching runs the default branch's copy of this workflow, with a token that can write. Its own + # check run attaches to that branch's commit, but the status it publishes - the verdict branch + # protection requires - goes to the pull request's head commit, because the run looks that commit up + # from the API. That is how a maintainer unblocks a fork's pull request, whose own runs cannot publish: + # start a new run with "Run workflow", since re-running one of the fork's runs keeps its read-only + # token. The verdict holds for the head as it stands, so repeat it after each push and, for a bot's pull + # request, whenever its reviews change. A pull request that was already open when the gate landed has no + # event to evaluate it with - those don't fire retroactively - and is unstuck by dispatching this for + # it, by reopening it, or by any push to it; a fork's pull request only by dispatching. workflow_dispatch: inputs: pr: description: PR number to evaluate. type: string required: true - pull_request_target: + pull_request: types: [opened, reopened, synchronize, ready_for_review] pull_request_review: types: [submitted, dismissed] @@ -136,15 +150,16 @@ jobs: # protection treats a pending required status as blocking, which is exactly the intent. # # Publishing is best effort. It needs `statuses: write`, which a called workflow only has if its - # caller granted it, and a `pull_request` run on a fork's pull request holds a read-only token - # whatever the caller granted. Neither case may fail the run: the job's own exit code is what - # those repositories still gate on, and turning a missing permission into a red check would - # block them on the automation rather than on the review. + # caller granted it, and a `pull_request` or `pull_request_review` run on a fork's pull request + # holds a read-only token whatever the caller granted. Neither case fails the run, because + # branch protection reads the status rather than this job: the status stays missing, or at + # whatever an earlier run published for this commit, until a run that can write publishes it, + # and the warning says how to get one. publish() { if ! gh api --method POST "repos/$REPO/statuses/$head_sha" \ -f state="$1" -f context="$STATUS_CONTEXT" -f description="$2" \ -f target_url="$RUN_URL" >/dev/null 2>&1; then - echo "::warning::Could not publish the $STATUS_CONTEXT status. The run needs 'statuses: write', and a fork's pull request cannot be published to from a pull_request trigger." + echo "::warning::Could not publish the $STATUS_CONTEXT status: the run needs 'statuses: write', which pull_request and pull_request_review runs on a fork's pull request never get. A maintainer can publish it by starting this workflow with Run workflow (not Re-run) and pr=$PR, again after each push and, for a bot's pull request, whenever its reviews change." fi } From 957df313c4986e681236214dffb5de864bbabdc5 Mon Sep 17 00:00:00 2001 From: Parth Aggarwal Date: Wed, 21 Oct 2026 22:42:45 -0700 Subject: [PATCH 2/3] Condense the two-human-reviewers header comments to 50 words or fewer Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7c537a1-1b1b-413e-9943-8d3174d1c5ce --- .../two-human-reviewers.caller.yml | 35 +------ .github/workflows/two-human-reviewers.yml | 93 +++---------------- 2 files changed, 15 insertions(+), 113 deletions(-) diff --git a/.github/workflow-templates/two-human-reviewers.caller.yml b/.github/workflow-templates/two-human-reviewers.caller.yml index fb9e32b..6ef14e4 100644 --- a/.github/workflow-templates/two-human-reviewers.caller.yml +++ b/.github/workflow-templates/two-human-reviewers.caller.yml @@ -9,37 +9,10 @@ name: Two human reviewers # directly. # # END template-only -# The logic lives in c-build-tools so every repository shares one definition; see -# `.github/workflows/two-human-reviewers.yml` there for why this is a workflow rather than a branch rule, -# and for the `required-approvals` and `exempt-bots` inputs. -# -# Keep BOTH triggers below. A called workflow's own triggers are ignored, so these are what re-run the -# gate; drop `pull_request_review` and it evaluates once at open, finds no approvals, and never re-runs -# when someone approves - leaving the required status pending until an unrelated push happens to -# re-trigger it. -# -# `pull_request` rather than `pull_request_target`, which GitHub blocks by default in public repositories -# from November 2, 2026. The gate publishes its verdict as a commit status, which needs a token that can -# write. A pull request from a branch in this repository gets one, and so does Dependabot's, since GitHub -# honours the `permissions` below for its runs. A fork's pull request never does, so its status cannot be -# published and it stays blocked until a maintainer runs this workflow manually with its number (see -# below) - again after each push and, for a bot's pull request, whenever its reviews change. -# -# Both triggers take this file from the pull request, so a pull request from a branch in this repository -# can edit how it is gated. Require code owner review on `.github/workflows/**` in branch protection, so -# that such an edit is reviewed as the security change it is. -# -# The gate never checks out the pull request's code - it reads the API and nothing else. Do not add a -# checkout here, and do not add one to the called workflow: the manual run that publishes for a fork's -# pull request holds a token that can write, and anything checked out from the fork could run with it. -# -# Do not add a required status check for this until the file is on the default branch, or every open pull -# request will wait forever on a check that cannot run. Pull requests that were already open when it landed -# get no event either, and are unstuck by reopening them, by any push to them, or by dispatching this with -# their number; a fork's pull request only by dispatching. Start a dispatch with "Run workflow", since -# re-running one of a fork's runs keeps its read-only token. It runs the default branch's copy with a -# token that can write; its own check run attaches to that branch's commit, but the status it publishes -# goes to the pull request's head commit. +# Calls the shared gate in c-build-tools. Keep `pull_request_review`, or approvals never re-run it. A +# fork's PR gets a read-only token (`pull_request_target` is blocked by default), so a maintainer +# publishes its status by running this workflow manually with its number, again after each push or +# review. Never add a checkout. on: workflow_dispatch: diff --git a/.github/workflows/two-human-reviewers.yml b/.github/workflows/two-human-reviewers.yml index 4caae24..f560dc1 100644 --- a/.github/workflows/two-human-reviewers.yml +++ b/.github/workflows/two-human-reviewers.yml @@ -1,71 +1,8 @@ name: Two human reviewers -# Bot-authored pull requests need approvals from two distinct humans (see `required-approvals`). -# -# A human-authored PR already has two people in the loop: the author who wrote and vetted the change, -# and the approver. When the agent opens the PR that first human is missing, so a single approval would -# be the only human judgement applied to it. This restores the second. -# -# This is the shared definition. Other repositories call it from a thin caller workflow, so the logic -# is maintained once; it also triggers directly on this repository's own PRs, which is why it declares -# `pull_request` alongside `workflow_call`. When called, `github.repository` and the event payload are -# the caller's, so it gates the calling repository's PR. -# -# A called workflow's own triggers are ignored - only the caller's events run it - so a caller MUST -# declare `pull_request_review` as well as `pull_request`. Without it the gate evaluates once at open, -# finds no approvals, and never re-runs when humans approve, leaving the required check stuck. -# `.github/workflow-templates/two-human-reviewers.caller.yml` is the template to copy. -# -# `pull_request` rather than `pull_request_target`, which GitHub blocks by default in public repositories -# from November 2, 2026: -# https://docs.github.com/en/actions/reference/security/securely-using-pull_request_target#default-policy-for-pull_request_target -# -# The verdict is published as a commit status, which needs a token that can write. On `pull_request` and -# `pull_request_review`, a run for a pull request from a branch in the same repository gets one, so those -# publish as they always have, `pending` included. Dependabot's runs start read-only, but GitHub honours -# the `permissions` below for them, so its pull requests publish too. A run for a fork's pull request is -# read-only whatever `permissions` asks for, so its status cannot be published and the pull request stays -# blocked. That fails safe - a missing required status never lets a merge through. A maintainer unblocks -# it by running this workflow manually with its number (see `workflow_dispatch` below), and runs it again -# after each push and, for a bot's pull request, whenever its reviews change. Fork pull requests are rare -# in the repositories this gates, so that manual step is the accepted cost. -# -# It never checks out the pull request's code - it reads the API and nothing else. Keep it that way: the -# manual run that publishes for a fork's pull request holds a token that can write, as does a run from any -# caller still on `pull_request_target`, and anything checked out from the fork could run with it. -# -# `pull_request` takes this file from the pull request's merge commit, as `pull_request_review` always -# has, so a pull request from a branch in this repository that edits it is judged by its edited copy. -# Deleting it fails safe - the required status never arrives and the pull request cannot merge - but -# weakening it does not, and nor does adding another workflow that publishes the status. Review a diff -# that touches `.github/workflows/` as security-relevant; requiring code owner review on -# `.github/workflows/**` in branch protection is what enforces that. A consuming repository's pull -# request cannot alter the logic here, but editing its own caller stub is just as decisive, and the same -# rule is what covers it there. -# -# Why a job and not a branch rule: GitHub cannot condition an approval count on the PR author, and a -# required check that never reports blocks a PR forever ("Expected - waiting for status to be -# reported"). So this runs on every PR and passes immediately for human authors, and only bot-authored -# PRs are actually gated. -# -# The unit being judged is the head commit, not the pull request that triggered the run. A check run -# attaches to a commit, and branch protection reads the newest run of a given name on the pull request's -# head commit - so two pull requests that share a head commit share this check, and whichever run -# finishes last decides both. Copying a bot's branch and opening a second pull request from it is enough -# to produce that, and judging only the triggering pull request would then let the copy's "author is -# human, not gated" verdict land on the bot's pull request and clear it. Every open pull request on the -# commit is therefore evaluated together, which makes the verdict a function of the commit alone and -# leaves the order the runs finish in unable to change it. The cost is that a human's pull request -# sharing a commit with a bot's is held to the bot's requirement; that is the safe direction, and the -# approvals it collects count toward the shared commit. -# -# Gating on "the author is a bot" rather than on a list of named bots is deliberate: an app can be -# renamed, and a stale name would silently let its pull requests through under-reviewed. Exempting a bot -# is therefore an explicit act, and forgetting to update this file can only ever be too strict. -# -# `dependabot[bot]` is exempt. The gate exists because an agent writes the change a human would -# otherwise have written and vetted, leaving one human short. A dependency bump has no missing author: -# the change is a version number, and the code it pulls in was never going to be written here. The -# repository's normal review rule is the control that fits it. +# Bot-authored PRs need approvals on the head commit from two distinct humans with push access; the +# `mrbot/two-human-reviewers` status reports the verdict. Fork PRs get a read-only token, so they need a +# manual run (`pull_request_target` is blocked by default). Never check out PR code, and require code +# owner review on `.github/workflows/**`. on: workflow_call: @@ -86,15 +23,9 @@ on: to it. type: string required: false - # Dispatching runs the default branch's copy of this workflow, with a token that can write. Its own - # check run attaches to that branch's commit, but the status it publishes - the verdict branch - # protection requires - goes to the pull request's head commit, because the run looks that commit up - # from the API. That is how a maintainer unblocks a fork's pull request, whose own runs cannot publish: - # start a new run with "Run workflow", since re-running one of the fork's runs keeps its read-only - # token. The verdict holds for the head as it stands, so repeat it after each push and, for a bot's pull - # request, whenever its reviews change. A pull request that was already open when the gate landed has no - # event to evaluate it with - those don't fire retroactively - and is unstuck by dispatching this for - # it, by reopening it, or by any push to it; a fork's pull request only by dispatching. + # A manual run uses the default branch's copy with a token that can write, and publishes the status to + # the PR's head commit. Use "Run workflow", since re-running a fork PR's run keeps its read-only token, + # and repeat it after each push or review. workflow_dispatch: inputs: pr: @@ -150,16 +81,14 @@ jobs: # protection treats a pending required status as blocking, which is exactly the intent. # # Publishing is best effort. It needs `statuses: write`, which a called workflow only has if its - # caller granted it, and a `pull_request` or `pull_request_review` run on a fork's pull request - # holds a read-only token whatever the caller granted. Neither case fails the run, because - # branch protection reads the status rather than this job: the status stays missing, or at - # whatever an earlier run published for this commit, until a run that can write publishes it, - # and the warning says how to get one. + # caller granted it, and a `pull_request` run on a fork's pull request holds a read-only token + # whatever the caller granted. Neither case fails the run: branch protection reads the status, + # which stays unchanged until a run that can write publishes it. publish() { if ! gh api --method POST "repos/$REPO/statuses/$head_sha" \ -f state="$1" -f context="$STATUS_CONTEXT" -f description="$2" \ -f target_url="$RUN_URL" >/dev/null 2>&1; then - echo "::warning::Could not publish the $STATUS_CONTEXT status: the run needs 'statuses: write', which pull_request and pull_request_review runs on a fork's pull request never get. A maintainer can publish it by starting this workflow with Run workflow (not Re-run) and pr=$PR, again after each push and, for a bot's pull request, whenever its reviews change." + echo "::warning::Could not publish the $STATUS_CONTEXT status: the run needs 'statuses: write', which pull_request and pull_request_review runs on a fork's pull request never get. A maintainer can publish it with Run workflow (not Re-run) and pr=$PR, after each push or review." fi } From ac45c987943ab5aa700e8846b01578fe43c5502e Mon Sep 17 00:00:00 2001 From: Parth Aggarwal Date: Thu, 22 Oct 2026 10:15:09 -0700 Subject: [PATCH 3/3] Document the command that runs the two-human-reviewers gate manually Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: e7c537a1-1b1b-413e-9943-8d3174d1c5ce --- .github/workflow-templates/two-human-reviewers.caller.yml | 5 +++-- .github/workflows/two-human-reviewers.yml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflow-templates/two-human-reviewers.caller.yml b/.github/workflow-templates/two-human-reviewers.caller.yml index 6ef14e4..7560ea6 100644 --- a/.github/workflow-templates/two-human-reviewers.caller.yml +++ b/.github/workflow-templates/two-human-reviewers.caller.yml @@ -11,8 +11,9 @@ name: Two human reviewers # END template-only # Calls the shared gate in c-build-tools. Keep `pull_request_review`, or approvals never re-run it. A # fork's PR gets a read-only token (`pull_request_target` is blocked by default), so a maintainer -# publishes its status by running this workflow manually with its number, again after each push or -# review. Never add a checkout. +# publishes its status with "Run workflow" or the command below, again after each push or review. +# Never add a checkout. +# gh workflow run two-human-reviewers.yml -R / -f pr= on: workflow_dispatch: diff --git a/.github/workflows/two-human-reviewers.yml b/.github/workflows/two-human-reviewers.yml index f560dc1..2842fdf 100644 --- a/.github/workflows/two-human-reviewers.yml +++ b/.github/workflows/two-human-reviewers.yml @@ -24,8 +24,9 @@ on: type: string required: false # A manual run uses the default branch's copy with a token that can write, and publishes the status to - # the PR's head commit. Use "Run workflow", since re-running a fork PR's run keeps its read-only token, - # and repeat it after each push or review. + # the PR's head commit. Start it with "Run workflow" or the command below; re-running a fork PR's own + # run keeps its read-only token. Repeat it after each push or review. + # gh workflow run two-human-reviewers.yml -R / -f pr= workflow_dispatch: inputs: pr: