Skip to content

Feat: Gate fork PR transfer on maintainer approval - #390

Merged
tykeal merged 2 commits into
lfreleng-actions:mainfrom
modeseven-lfreleng-actions:feat/fork-approval-gate
Aug 25, 2026
Merged

Feat: Gate fork PR transfer on maintainer approval#390
tykeal merged 2 commits into
lfreleng-actions:mainfrom
modeseven-lfreleng-actions:feat/fork-approval-gate

Conversation

@ModeSevenIndustrialSolutions

@ModeSevenIndustrialSolutions ModeSevenIndustrialSolutions commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Closes #383

Completes the fork-handling work started in #384 and #382. Transferring a fork
pull request pushes someone else's code into Gerrit under the tool's SSH
identity, where Gerrit CI then executes it. Nothing required a maintainer to
have looked at it first.

Scope

The gate applies to unattended runs where the tool cannot positively establish
that the head lives in the base repository.

Same-repository pull requests skip it: pushing a branch to the base repository
already implies write access, and Dependabot, pre-commit.ci and Copilot all work
that way, so automation-only consumers never meet it. That is why it defaults to
on with no new input.

Direct CLI invocation also skips it. The operator chose the pull request and is
using their own credentials, so they are already the authority the gate looks
for.

Everything else — forks, and pull requests whose provenance the tool cannot
establish — needs approval. This uses head_is_trusted rather than
is_fork_pr: the former answers the authorisation question and reports False
when provenance is unknown, which is the distinction #384 introduced.

Why reviews rather than comment directives

On a Gerrit mirror the only usable trust signal is organisation membership. The
opendaylight/mdsal collaborator list is entirely LF infrastructure staff and
bots, while project committers appear only as MEMBER — so requiring write or
admin would trust the wrong people and exclude the right ones.

But the pull request author frequently holds that same membership. Under a
comment scheme they could authorise their own change. GitHub structurally
forbids approving your own pull request
, so a review carries a guarantee a
comment cannot. That is the whole argument, and it is why the author is also
excluded locally — the structural check is doing most of the work.

What counts as approval

An approving review where every one of the following holds:

  • the reviewer's author_association is in the trusted set (shared with PR comment commands accept directives from any user #382
    via trust.py);
  • the review targets the current head commit;
  • no trusted reviewer has since requested changes; and
  • the reviewer is not the pull request author.

Binding to the head SHA is essential. GitHub keeps approvals across pushes
unless branch protection dismisses them, so an unbound approval would let a
contributor gain approval for one revision and push another. Maintainers
re-approve after each push, which is the correct trade. Absent SHA metadata on
either side withholds approval rather than skipping the comparison.

The binding survives the fetch. The gate reads the head SHA from the API,
while the workspace fetch reads the mutable refs/pull/<N>/head. The tool
records the approved commit and refuses anything else after fetching, so a push
timed against a running workflow cannot slip an unreviewed commit into Gerrit.
That refusal fails loudly, unlike an ordinary block, because it falls outside the
normal course of events.

Reviews arrive as history, not current state, so the evaluation reduces to
each author's latest position. COMMENTED and PENDING express no position and
must not displace an approval. DISMISSED must — my own test caught that it
originally did not, leaving a revoked approval still authorising the transfer.

Placement and failure behaviour

The gate runs before the tool fetches anything from the pull request and before
the Gerrit key is materialised, on both the single-PR and bulk paths.

Every failure path refuses: unreadable reviews, an unresolvable pull request, a
missing GITHUB_TOKEN, and absent SHA metadata all block. The token case
mattered — _extract_and_display_pr_info returns early without one, so placing
the gate inside it would have failed open exactly when the environment is least
well configured.

pull_request_review support

pull_request_target carries no event for a submitted review, so approving
would otherwise do nothing. Callers need:

on:
  pull_request_target:
    types: [opened, reopened, edited, synchronize, closed]
  pull_request_review:
    types: [submitted, dismissed]

The event maps to UPDATE, and a review-triggered run on an untrusted head
authorises the create-missing fallback. Both halves are needed:

  • a review changes no code, so an existing change should gain a patchset rather
    than a sibling — mapping to CREATE would raise a duplicate on every
    re-approval after a push;
  • but the review may equally be the event that first unblocks the pull
    request, when no Gerrit change exists — UPDATE alone hard-fails there, at
    core.py:1768, and only after the fork has been fetched and the key unlocked.

Scoped to untrusted heads so a review on a same-repository pull request cannot
quietly override CREATE_MISSING=false.

action.yaml needs no change: PR number and PR_HEAD_REPO both resolve from
the pull_request_review payload, and _augment_pr_refs_if_needed (#384) fills
the refs GitHub leaves empty on that event.

When the gate blocks

The run finishes successfully. A pull request waiting for a human is not
broken, and a red check would suggest otherwise — this follows the existing
G2G_DISABLED precedent of exiting cleanly with a message.

One comment explains what is missing, and later runs edit it rather than adding
another. Ownership of that comment is established by attempting the edit,
which the API refuses on another user's comment — the marker alone is not proof
of authorship, since anyone may paste it. A stale approval is reported as stale,
so a maintainer who did approve is not told that nobody has.

A note on the self-test

Test GitHub Action failed on the first push of this branch, and the reason is
worth recording: pull requests to this repository come from a fork, so the gate
blocked the action's own deliberate-failure test, which then exited successfully
having transferred nothing. The gate working as designed. That assertion is now
skipped for fork pull requests, which is honest about what can be tested from
one.

Validation

  • uv run pytest tests/ — full suite passes, coverage 72.30%
  • tests/test_fork_approval_gate.py covers trusted and untrusted associations,
    head-SHA binding including the approve-then-push bypass and absent metadata,
    self-approval, the history reduction (supersession, trailing comments,
    dismissal, pending), the fail-closed paths, comment ownership against a
    planted marker, the post-fetch head comparison, per-PR recording hygiene in
    bulk runs, gate scoping, and the review-event operation mode
  • prek run --all-files, zizmor --persona auditor (no findings), aislop
    (score 100, zero findings)

Follow-up

This changes no repository's behaviour on its own: AUTOMATION_ONLY still
defaults to true and closes human pull requests before the gate is reached. A
project accepting user submissions sets that to false, at which point this
becomes the control.

Transferring a fork pull request pushes someone else's code into
Gerrit under the tool's SSH identity, where Gerrit CI then executes
it. Nothing required a maintainer to have looked at it first.

Require an approving review, checked before the tool fetches the pull
request and before it unlocks the Gerrit key, so a pull request
awaiting review reaches neither. Same-repository pull requests skip
the gate: pushing a branch to the base repository already implies
write access, and Dependabot, pre-commit.ci and Copilot all work that
way, so automation consumers never meet it.

Reviews rather than comment directives, because on a Gerrit mirror the
only usable trust signal is organisation membership, which the pull
request author frequently holds. GitHub structurally forbids approving
your own pull request; a comment scheme has no equivalent. The author
is excluded locally as well, since that structural guarantee is doing
most of the work.

Approval binds to the head SHA. GitHub keeps approvals across pushes
unless branch protection dismisses them, so without this a contributor
could gain approval for one revision and push another. Maintainers
re-approve after each push, which is the correct trade.

Reviews arrive as history rather than current state, so reduce to each
author's latest position. COMMENTED and PENDING express no position
and must not displace an approval; DISMISSED must, or a revoked
approval keeps authorising the transfer.

Map pull_request_review to UPDATE, and let a review-triggered run
authorise the create-missing fallback. A review changes no code, so an
existing change should gain a patchset rather than a sibling; but the
review may equally be the event that first unblocks the pull request,
in which case no change exists yet and UPDATE alone would hard-fail
every fork PR on its first approval.

Blocking finishes the run successfully. A pull request waiting for a
human is not broken, and a red check would say otherwise. One comment
explains what is missing and later runs edit it rather than adding
another; a stale approval is reported as stale, so a maintainer who
did approve is not told that nobody has.

Every failure path refuses: unreadable reviews, an unresolvable pull
request, and a missing token all block rather than pass.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds maintainer-approval gating before fork PRs transfer to Gerrit.

Changes:

  • Evaluates trusted reviews against the current head SHA.
  • Supports review-triggered updates and create-missing fallback.
  • Adds gate documentation and tests.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
src/github2gerrit/pr_approval.py Implements approval evaluation and notices.
src/github2gerrit/cli.py Integrates gating into transfer paths.
src/github2gerrit/core.py Handles review-triggered creation fallback.
src/github2gerrit/models.py Maps review events to updates.
src/github2gerrit/github_api.py Adds review API abstractions.
tests/test_fork_approval_gate.py Tests gate behavior.
docs/features.md Documents approval gating.
Suppressed comments (1)

src/github2gerrit/cli.py:301

  • The approval is checked against the PR API snapshot, but that SHA is not carried into the checkout. _prepare_workspace_checkout later fetches the moving refs/pull/<N>/head, so the author can push a new commit between this check and the fetch and this run will transfer code that was never approved. Carry the approved head SHA into the checkout and fail closed unless the fetched/archive commit exactly matches it (or fetch the exact approved SHA).
    status = evaluate_fork_approval(
        pr_obj, head_sha=head_sha, author_login=author
    )

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/github2gerrit/pr_approval.py
Comment thread src/github2gerrit/cli.py Outdated
Comment thread src/github2gerrit/core.py Outdated
Comment thread src/github2gerrit/cli.py Outdated
Comment thread docs/features.md Outdated
Copilot AI review requested due to automatic review settings August 25, 2026 18:48

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

1. Approval could pass with no SHA to check against. An APPROVED
   review carrying an empty commit_id, or a PR whose head SHA could
   not be read, fell through to approved. The guarantee is that the
   approval covers exactly the commit being transferred, so absent
   metadata now withholds it.

2. Unknown provenance bypassed the gate. is_fork_pr states a fact and
   reports False when it cannot tell; head_is_trusted answers the
   authorisation question and reports False in the same case. The gate
   uses the latter, so a pull request whose head could not be resolved
   is gated rather than waved through. This is the distinction drawn
   in 384 and then not used here.

3. Review-triggered create-missing applied to every pull request. A
   same-repository PR was never gated, so a review on one could
   quietly override CREATE_MISSING=false. Scoped to untrusted heads.

4. The comment marker was treated as proof of authorship. Anyone may
   paste it, which could suppress the notice or make the tool try to
   edit a stranger's comment. Ownership is now established by
   attempting the edit, which the API refuses on another user's
   comment; a failure means it was not ours and the search continues.

5. The documentation told maintainers to re-run a failed job, while
   the gate finishes successfully. Corrected.

6. The approval bound only to an API snapshot, while the workspace
   fetch reads the mutable refs/pull/<N>/head. A push timed against a
   running workflow could therefore transfer a commit nobody had
   reviewed. The gate now records the approved commit and the fetch
   refuses anything else.

   The approved commit travels with the pull request, as an explicit
   Orchestrator argument, rather than through the environment. Bulk
   runs process up to four pull requests concurrently, so a
   process-global value would let one worker clear or overwrite
   another's constraint.

   The archive fallback re-reads the pull request's current head, so
   it is checked there too, before the download rather than after:
   unlike the git path there is no commit object left to compare once
   the files have landed.

7. The create-missing notice named only two authorisation sources
   while there are now three, so the first approved fork PR was told
   a comment or flag triggered it. _should_create_missing returns the
   reason and the notice renders it, rather than restating conditions
   it cannot see.

8. An approved pull request kept displaying the block notice. The
   gate now retracts its own notice when approval arrives, using the
   same edit-to-prove-ownership rule, and creates nothing where no
   notice exists.

9. Both README caller examples registered only pull_request_target,
   so anyone following the documented setup would never get the
   approval-triggered re-run. Both now include pull_request_review.

10. A review on a same-repository pull request ran the whole pipeline
    and resubmitted an unchanged head. The trigger exists to unblock
    gated pull requests, so review-triggered runs stop early when the
    head is trusted.

11. The blocked notice asserted the pull request came from a fork,
    while the gate also covers heads whose origin could not be
    established. Reworded to say what the tool actually knows.

Also exempt direct CLI invocation. Requiring a GitHub approval there
asks the wrong question: the operator chose the pull request and is
using their own credentials, so they are already the authority. The
gate exists for the unattended path, where the tool acts on a shared
identity with nobody watching.

The action's own self-test caught the gate working: pull requests to
this repository come from a fork, so the deliberate failure case now
exits successfully having transferred nothing. That assertion is
skipped for fork pull requests, which is honest about what can be
tested from one.

The test fixture for the single-PR path omitted head.repo from its
event payload, which real pull request payloads always carry; it now
includes it rather than relying on the gate ignoring absent
provenance.

Co-authored-by: Claude <noreply@anthropic.com>
Signed-off-by: Matthew Watkins <mwatkins@linuxfoundation.org>

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/github2gerrit/models.py:163

  • Mapping every submitted review to UPDATE still resubmits approved fork PRs on non-approval reviews. After a fork head has one valid approval, COMMENTED is deliberately ignored by the history reducer, so any outsider can submit another comment-only review; the gate remains approved and the unchanged head runs through the Gerrit update pipeline again. Preserve the triggering review state and only continue the transfer for an APPROVED submission; dismissal/change-request events can still evaluate and update the gate notice, but should stop when another approval leaves the gate open.
        if self.event_name == "pull_request_review":
            return PROperationMode.UPDATE

@tykeal
tykeal merged commit 3d548cc into lfreleng-actions:main Aug 25, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Gate fork PR transfer on maintainer approval

3 participants