Skip to content

Add review checklists workflow - #899

Open
LittleHuba wants to merge 15 commits into
mainfrom
add-review-checklists-workflow
Open

Add review checklists workflow#899
LittleHuba wants to merge 15 commits into
mainfrom
add-review-checklists-workflow

Conversation

@LittleHuba

Copy link
Copy Markdown
Contributor

Adds a workflow to require acknowledgement of checklists by reviewers.

Currently no checklist is enforced.

LittleHuba and others added 13 commits August 7, 2026 10:31
Add a "Review Checklists" GitHub Actions workflow that posts checklist
findings on pull requests, tracks reviewer acknowledgements, invalidates
stale approvals on new pushes, and reports a "review-checklists" commit
status that can be required for merges.

- New composite action `actions/review_checklists` (post/check/
  dismiss_sync/dismiss_edit) implementing the checklist logic.
- New workflow `.github/workflows/review_checklists.yml` triggering on
  pull_request_target, pull_request_review_comment, pull_request_review,
  and merge_group events.
- Configuration file `.github/review_checklists.yml` with a placeholder
  example checklist to be customized for this repository's needs.
- Bazel wiring: pip.parse hub `review_checklists_dependencies` in
  MODULE.bazel, BUILD targets for the action's requirements and Python
  scripts/tests, and `actions` added to the root copyright_checker srcs.

Verified: `bazel build //actions/review_checklists/...` and
`bazel test //actions/review_checklists/...` (5/5 tests pass), plus
`//:copyright-check` and `//:format_test` show no new findings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ed apply stages

The single-stage Review Checklists workflow ran its "Check acknowledgements"
step unconditionally, including when triggered by pull_request_review and
pull_request_review_comment events raised for pull requests from forked
repositories. GitHub always forces a read-only GITHUB_TOKEN for those events
on fork PRs, regardless of the workflow's declared `permissions:` block, so
the step's PR-description update (pr.edit()) failed with
"403 Resource not accessible by integration".

Split the workflow into two stages, following GitHub's documented
"pwn request" mitigation pattern (workflow_run):

- review_checklists_trigger.yml: unprivileged, reacts to all the original
  trigger events (pull_request_target, pull_request_review_comment,
  pull_request_review, merge_group). Runs no checkout and executes no
  repository/PR-supplied code -- it only captures the raw event payload
  and uploads it as an artifact.

- review_checklists_apply.yml: privileged, triggered via workflow_run once
  the trigger stage completes. workflow_run always runs with the base
  repository's permissions regardless of where the original event
  originated, so this stage can safely hold pull-requests/statuses write
  access. It downloads the event artifact (outside the workspace), parses
  it, and runs the same post/dismiss_sync/dismiss_edit/check logic the
  single-stage workflow used to run directly.

actions/review_checklists/action.yml gains optional event-name/event-path
inputs so check_acknowledgements.py and dismiss_and_invalidate.py see the
original captured event instead of the ambient workflow_run event, with no
changes needed to the Python scripts themselves.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reduce what the unprivileged trigger stage must hand off to the
privileged apply stage, and derive as much as possible natively in
stage 2 instead of trusting artifact-supplied data:

- event_name and head_sha are now read directly from the workflow_run
  context (github.event.workflow_run.event / .head_sha), which are
  reliably correct even for fork-originated PRs. No longer parsed from
  the artifact.
- pr_number is resolved authoritatively via the GitHub API (matching
  currently-open PRs by head SHA) instead of being trusted from the
  artifact.
- dismiss_sync no longer needs before/after SHAs from the event
  payload at all: it looks up the immediately preceding
  "Review Checklists (Trigger)" run for the same head branch and uses
  that run's head SHA as "before", comparing against the current head.
- dismiss_edit (OK-comment edit/delete detection) is dropped entirely.
  It was functionally redundant: check_acknowledgements always runs
  last and already re-scans the live comment state on every
  invocation, so an edited/deleted OK comment is naturally no longer
  counted on the very next check. The associated merge-queue notice
  refresh moved into check_acknowledgements, which now runs it
  unconditionally.
- The trigger stage's artifact shrinks from a full raw webhook payload
  to a single whitelisted field ({"action": "..."}), the only piece of
  information that cannot be derived natively in stage 2.

Net effect: no full (attacker-influenced) webhook payload is
transferred between stages anymore, and the one output that discerns
routing (the PR number) is now independently verified rather than
blindly trusted.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
gh api forwards its --jq value straight to jq as the filter program,
but does not support passing extra jq CLI flags like --arg alongside
it (they were being parsed as additional gh api positional arguments,
causing "accepts 1 arg(s), received 4"). Use jq's built-in `env.VAR`
accessor instead, which reads the already-exported HEAD_SHA
environment variable directly inside the filter without needing
--arg.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The trigger stage's artifact only ever carried one field: the original
event's `action` (opened/reopened/synchronize/edited), used solely to
gate the apply stage's `dismiss_sync` step to `action == 'synchronize'`.

dismiss_sync already resolves its own 'before' SHA by looking up the
previous trigger-workflow run for the same branch and diffing against
the current head. When the head SHA hasn't actually moved since that
previous run (e.g. on 'edited'/'reopened' without a new push), the diff
is empty, so no checklists are affected and the step is naturally a
no-op. This means dismiss_sync can safely run unconditionally on every
pull_request_target event without needing to know which sub-action
fired.

With that last use of the artifact gone, drop the entire artifact
transfer: the trigger workflow no longer captures/uploads any event
data, and the apply workflow no longer downloads/parses it. The trigger
job now exists solely to complete and fire the workflow_run trigger.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tion race

The 'Resolve PR number' step previously matched open PRs by
head.sha == workflow_run.head_sha. If the PR receives another push
while the apply run is queued or executing, no open PR will still have
that exact SHA as its head by the time this step runs, so pr_number
resolves empty and the whole apply run silently no-ops for that
(now-superseded) trigger.

Match on the head branch reference instead
(workflow_run.head_repository.owner.login + head_branch via
'pulls?head=owner:branch'), which stays valid across such races as
long as the PR is still open — the exact commit no longer matters for
identifying which PR this run belongs to.

Downstream steps already re-fetch the PR object and use its live
pr.head.sha for everything except the (SHA-independent) merge_group
status-setting path, so no script changes were needed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Disable the example checklist to not spam users when
the infrastructure is merged.
Actual production checklists to be merged later.
Relocates the composite action and its scripts from
actions/review_checklists/ to tools/review-checklists/, updating all
path references: MODULE.bazel's pip.parse requirements_lock, the Bazel
BUILD files' visibility/label, requirements.txt(.in)'s embedded paths,
and the apply workflow's three 'uses: ./...' steps.

Adds tools/review-checklists/README.md documenting:
- What the action/workflows do and the acknowledgement model (threaded
  'OK' replies to file-level review comment findings).
- Why the logic is split into an unprivileged trigger workflow and a
  privileged workflow_run-triggered apply workflow (pwn-request
  prevention), and that the apply workflow always reads
  .github/review_checklists.yml from the base branch, never the PR
  branch.
- A step-by-step list of what a consuming repository must additionally
  configure: vendoring/referencing the action, creating the checklist
  config, adjusting the base-branch filter, requiring the
  'review-checklists' commit status (not the workflow job) plus an
  approving-review rule in branch protection, the merge-queue notice's
  assumption that the queue's merge method is a real merge commit, the
  'reply OK in-thread' acknowledgement UX, the diff-position-1 anchor
  constraint on checklist include patterns, runner/network
  requirements, and keeping (or updating) the trigger workflow's
  filename since it's looked up by name for run-history diffing.
- The checklist YAML schema and the composite action's inputs.

All 5 existing Bazel tests (including the requirements lock check)
pass unchanged at the new location.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Switch from create_review(comments=[{path, position: 1, body}]) to the
single-comment create_review_comment(..., subject_type="file") API.
File-level review comments are anchored to a file as a whole rather
than a specific diff position, so checklist include patterns can now
match binary files (images, archives, etc.) without failing to post.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This makes glob builds more reliable on different platforms.
Previously, the merge_group handler in check_acknowledgements.py just
set the commit status to success unconditionally ("checklists assumed
OK"), trusting that the required status check had already validated
the evidence before the PR entered the queue. This left a window where
stale evidence (e.g. dismissed reviews, invalidated acks after the
initial check went green) would not be caught at merge-queue time.

Now the merge_group handler resolves the originating PR (parsed from
the merge-queue's synthetic gh-readonly-queue/<base>/pr-<n>-<sha> head
ref) and re-runs the same acknowledgement validation against its
current live state (changed files, checklist findings, OK replies,
approving reviewers), setting success/pending/failure on the merge
commit SHA accordingly. It performs no PR writes (no evidence/notice
updates), only a read-only re-validation.

Refactored the shared decision logic (approvers vs. acks -> state)
into _acknowledgement_status() so both the live-PR flow and the
merge_group validation path use identical logic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the apply stage's own PR-number resolution (matching the
workflow_run head branch, or a fragile commit-SHA search for
pull_request_review*/fork events) with a value read directly from the
trusted event payload in the trigger stage and forwarded as a build
artifact.

This fixes a real bug: for pull_request_review and
pull_request_review_comment events, github.event.workflow_run's
head_repository/head_branch always reflect the base repository, never
the fork the review was actually submitted on, so the previous
owner:branch lookup could never resolve a PR number for fork PRs on
those events. The PR number is not sensitive/attacker-steerable data
(worst case a wrong number just points at the wrong already-public,
same-repo PR), so carrying it across the trust boundary this way does
not reintroduce the pwn-request risk the two-stage split exists to
avoid.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
GITHUB_EVENT_NAME is a GitHub Actions reserved variable name: setting it
via a step's env: block is silently ignored for the subprocess, which
always sees the runner's real ambient value instead (here,
"workflow_run", since that's what actually triggers
review_checklists_apply.yml). This meant check_acknowledgements.py's
`if event_name == "merge_group"` branch could never be reached when
invoked from the apply workflow — confirmed live: for merge_group runs
the script instead executed the normal PR-head-sha flow, posting the
merge-queue notice and setting the commit status on pr.head.sha instead
of the merge queue's synthetic HEAD_SHA, and never actually re-running
_validate_checklist_evidence().

Fix: use a non-reserved variable name (CHECKLISTS_EVENT_NAME) to pass
the original triggering event name into the script, so a step's own
env: can actually override it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…yle)

Python's fnmatch treats '*' as matching across '/' with no distinction
between recursive and single-segment wildcards, so patterns that look
like standard glob syntax behave unintuitively:
  - "**/*.md" requires a literal '/' in the matched path, so it silently
    fails to match root-level files (discovered while live-testing a
    multi-checklist merge-queue scenario).
  - There was no way to anchor a pattern to the repo root at all.

Switch include/exclude matching to the pathspec library's gitwildmatch
style, i.e. the same pattern semantics as .gitignore:
  - Unanchored patterns ("*.md") match at any depth.
  - A leading '/' anchors a pattern to the repo root ("/*.md").
  - "**" explicitly matches zero or more path segments, and correctly
    matches at the root too ("**/BUILD" now matches a root-level BUILD
    file, unlike the previous fnmatch-based implementation).

Added pathspec as a new dependency (requirements.txt.in/BUILD), and
added tests covering root-anchored and unanchored matching. Documented
the new gitignore-style syntax in the README. All 5/5 existing tests
continue to pass unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

from __future__ import annotations

import os
import pytest
import yaml

import helpers
import pytest
import yaml

import helpers
candidate = r.Rlocation(f"_main/{config_relpath}")
if candidate and os.path.isfile(candidate):
return candidate
except (ImportError, Exception):
monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo")
monkeypatch.setenv("PR_NUMBER", "42")
gh = MagicMock()
repo, pr = get_repo_and_pr(gh)
monkeypatch.setenv("GITHUB_REPOSITORY", "org/repo")
monkeypatch.setenv("PR_NUMBER", "42")
gh = MagicMock()
repo, pr = get_repo_and_pr(gh)
@LittleHuba
LittleHuba force-pushed the add-review-checklists-workflow branch from e6fb9d8 to 6f2b4c9 Compare August 11, 2026 12:39
Public visibility is actually not required for these tools.
Recude it to necessary levels.
@LittleHuba
LittleHuba marked this pull request as ready for review August 11, 2026 13:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

1 participant