Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 14 additions & 6 deletions .github/scripts/enforce-pr-target.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,14 @@ const { latestCodeRabbitReviewForHead } = require("./pr-quality-state.cjs");
describe("enforce-pr-target workflow", () => {
const workflowPath = path.join(__dirname, "../workflows/enforce-pr-target.yml");
const workflow = fs.readFileSync(workflowPath, "utf8");
// The triggers live in the PR gates caller since #7654; this file runs as its
// local reusable workflow.
const caller = fs.readFileSync(path.join(__dirname, "../workflows/pr-gates.yml"), "utf8");

it("uses pull_request_target without checking out PR head code", () => {
assert.match(workflow, /pull_request_target:/);
assert.match(caller, /^ pull_request_target:/m);
assert.match(caller, /uses: \.\/\.github\/workflows\/enforce-pr-target\.yml$/m);
assert.match(workflow, /^on:\n workflow_call:\n/m);
assert.doesNotMatch(
workflow,
/ref:\s*\$\{\{\s*github\.event\.pull_request\.head/,
Expand Down Expand Up @@ -50,10 +55,11 @@ describe("enforce-pr-target workflow", () => {
});

it("uses label events for GUI waivers, hygiene sponsorship, and a trusted CodeRabbit status signal", () => {
assert.doesNotMatch(caller, /^ issue_comment:/m);
assert.doesNotMatch(workflow, /^ issue_comment:/m);
assert.match(workflow, /- labeled/);
assert.match(workflow, /- unlabeled/);
assert.match(workflow, /^ status:/m);
assert.match(caller, /types: \[[^\]]*\blabeled\b[^\]]*\]/);
assert.match(caller, /types: \[[^\]]*\bunlabeled\b[^\]]*\]/);
assert.match(caller, /^ status:/m);
assert.match(workflow, /github\.event\.context == 'CodeRabbit'/);
assert.match(workflow, /github\.event\.state == 'success'/);
assert.match(workflow, /github\.event\.label\.name == 'gui-screenshot-waived'/);
Expand All @@ -72,8 +78,10 @@ describe("enforce-pr-target workflow", () => {
// the workflow definition under a write token. The findings claim runs on
// every `pull_request_target` event instead (opened/edited/synchronize/
// ready_for_review).
assert.doesNotMatch(workflow, /^ pull_request_review:/m);
assert.doesNotMatch(workflow, /^ pull_request_review_comment:/m);
for (const text of [workflow, caller]) {
assert.doesNotMatch(text, /^ pull_request_review:/m);
assert.doesNotMatch(text, /^ pull_request_review_comment:/m);
}
});

it("queries review threads and feeds them to the findings claim check", () => {
Expand Down
16 changes: 13 additions & 3 deletions .github/scripts/pr-labeler.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -259,10 +259,20 @@ describe("pr-labeler workflow", () => {
const workflowPath = path.join(__dirname, "../workflows/pr-labeler.yml");
const workflow = fs.readFileSync(workflowPath, "utf8");

// Since #7654 the trigger lives in pr-gates.yml, whose `label` job starts this
// reusable workflow only for the actions listed in its fromJSON(...) filter.
const caller = fs.readFileSync(path.join(__dirname, "../workflows/pr-gates.yml"), "utf8");

function pullRequestTargetTypes() {
const match = workflow.match(/pull_request_target:\s*\n(?:[ \t].*\n)*?[ \t]+types:\s*\[([^\]]+)\]/);
assert.ok(match, "expected pull_request_target types array in pr-labeler.yml");
return match[1].split(",").map((type) => type.trim());
const triggerMatch = caller.match(/pull_request_target:\s*\n(?:[ \t].*\n)*?[ \t]+types:\s*\[([^\]]+)\]/);
assert.ok(triggerMatch, "expected pull_request_target types array in pr-gates.yml");
const subscribed = triggerMatch[1].split(",").map((type) => type.trim());
const labelJob = caller.match(/^ label:\n((?: .*\n|\n)+?)(?=^ \S)/m);
assert.ok(labelJob, "expected a label job in pr-gates.yml");
const filter = labelJob[1].match(/fromJSON\('(\[[^']*\])'\)/);
assert.ok(filter, "expected the label job to filter github.event.action");
assert.match(labelJob[1], /uses: \.\/\.github\/workflows\/pr-labeler\.yml/);
return JSON.parse(filter[1]).filter((type) => subscribed.includes(type));
}

it("listens for labeled and unlabeled so human overrides cancel stale sync runs", () => {
Expand Down
24 changes: 10 additions & 14 deletions .github/workflows/enforce-pr-target.yml
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
name: Enforce PR target branch

# Called by pr-gates.yml (#7654) for every pull_request_target event it
# subscribes to (opened, reopened, edited, labeled, unlabeled, ready_for_review,
# synchronize) and for every `status` event. CodeRabbit publishes a legacy commit
# status named `CodeRabbit` on the reviewed head SHA; `status` and
# pull_request_target workflows are loaded only from the default branch, and this
# local reusable workflow loads from that same commit, so a PR cannot suppress or
# rewrite this signal path. The status is only a wake-up signal; resolve-pr below
# filters it and the gate re-reads live reviews before any write. The `github`
# context here is the caller's event.
on:
pull_request_target:
types:
- opened
- reopened
- edited
- labeled
- unlabeled
- ready_for_review
- synchronize
# CodeRabbit publishes a legacy commit status named `CodeRabbit` on the
# reviewed head SHA. `status` workflows are loaded only from the default
# branch, so a PR cannot suppress or rewrite this signal path. The status is
# only a wake-up signal; the gate re-reads live reviews before any write.
status:
workflow_call:

# pull-requests:write covers title/comment/label updates.
# contents:write is required for convertPullRequestToDraft /
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/issue-quality-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ on:
- ".github/workflows/issue-triage.yml"
- ".github/workflows/issue-quality-tests.yml"
- ".github/workflows/pr-hygiene.yml"
- ".github/workflows/pr-gates.yml"

permissions:
contents: read
Expand Down
58 changes: 58 additions & 0 deletions .github/workflows/pr-gates.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
name: PR gates

# One run per PR event for the three pull-request gates (#7654). Each gate keeps
# its logic in its own file and runs here as a local reusable workflow:
# label -> pr-labeler.yml (type label and title sync)
# hygiene -> pr-hygiene.yml (deterministic hygiene checks)
# enforce -> enforce-pr-target.yml (target, description and readiness gate)
# Before this, every description edit or label change started three separate
# workflow runs.
#
# Trust boundary. pull_request_target and status always load this workflow from
# the repository default branch (`dev`), and a `./` reusable workflow loads from
# the same commit as its caller, so no PR can change which gate code runs. The
# gates still check out their scripts from a fixed ref and never run PR head
# code. Only GITHUB_TOKEN is used; nothing is passed as a secret.
on:
pull_request_target:
# The union of what the three gates subscribed to. Each caller job below keeps
# its own gate to the actions it handled before.
types: [opened, reopened, edited, labeled, unlabeled, ready_for_review, synchronize]
# CodeRabbit's legacy commit status wakes the target gate; resolve-pr inside
# enforce-pr-target.yml accepts only the trusted CodeRabbit success status.
status:

# No default token. Each caller job grants exactly what its gate's jobs request;
# a callee can only lower that ceiling.
permissions: {}

# No workflow-level concurrency: each gate owns its group on its own job, so a
# job this run skips never enters a group.
jobs:
label:
if: >-
github.event_name == 'pull_request_target' &&
contains(fromJSON('["opened", "edited", "synchronize", "labeled", "unlabeled"]'), github.event.action)
permissions:
contents: read
pull-requests: write
issues: write
uses: ./.github/workflows/pr-labeler.yml

hygiene:
if: >-
github.event_name == 'pull_request_target' &&
contains(fromJSON('["opened", "reopened", "synchronize", "edited", "labeled", "unlabeled"]'), github.event.action)
permissions:
contents: read
issues: write
pull-requests: write
uses: ./.github/workflows/pr-hygiene.yml

enforce:
# No caller filter: resolve-pr decides which pull_request_target labels and
# which status events reach the write-capable gate.
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/enforce-pr-target.yml
33 changes: 17 additions & 16 deletions .github/workflows/pr-hygiene.yml
Original file line number Diff line number Diff line change
@@ -1,31 +1,32 @@
name: PR hygiene

# Called by pr-gates.yml (#7654), which owns the pull_request_target trigger and
# starts this job only for opened, reopened, synchronize, edited, labeled and
# unlabeled. `edited` is there for the carry-attribution check alone. Every other
# hygiene failure is about the diff, so only a push can clear it -- but the
# remedy this one asks for is a `Co-authored-by` trailer, which an author can
# add to the description without touching the branch. Without `edited` the fix
# would be invisible until an unrelated push, and the author would reasonably
# conclude the gate was broken. The `github` context here is the caller's event.
on:
pull_request_target:
# `edited` is here for the carry-attribution check alone. Every other
# hygiene failure is about the diff, so only a push can clear it -- but the
# remedy this one asks for is a `Co-authored-by` trailer, which an author
# can add to the description without touching the branch. Without `edited`
# the fix would be invisible until an unrelated push, and the author would
# reasonably conclude the gate was broken.
types: [opened, reopened, synchronize, edited, labeled, unlabeled]
workflow_call:

# Trusted scripts are checked out from the fixed `dev` ref, independent of the
# PR base and head. Patches are read through the GitHub API.
# Least privilege: no default permissions; the hygiene job grants only what it needs.
permissions: {}

concurrency:
# Shared with the enforce-target gate: both workflows read-modify-write the
# same consolidated gate comment, so one stable PR-number group serializes
# old-head and new-head runs as well as hygiene and gate writes.
# A newer run queues behind the in-flight one instead of cancelling it.
group: pr-gate-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false

jobs:
hygiene:
runs-on: ubuntu-latest
concurrency:
# Shared with the enforce-target job: both read-modify-write the same
# consolidated gate comment, so one stable PR-number group serializes
# old-head and new-head runs as well as hygiene and gate writes.
# A newer run queues behind the in-flight one instead of cancelling it.
# On this job rather than the caller, so it exists only when hygiene runs.
group: pr-gate-comment-${{ github.event.pull_request.number }}
cancel-in-progress: false
# contents: read for the trusted script checkout; issues/pull-requests write
# maintain the blocked label and one bot comment.
permissions:
Expand Down
27 changes: 15 additions & 12 deletions .github/workflows/pr-labeler.yml
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
name: PR Labeler

# pull_request_target always loads this workflow from the repository DEFAULT
# branch, which is `dev`. A change lands on the live labeler as soon as it is on
# `dev` — same model as enforce-issue-quality.yml.
# Called by pr-gates.yml (#7654), which owns the pull_request_target trigger and
# starts this job only for opened, edited, synchronize, labeled and unlabeled.
# pull_request_target always loads the caller from the repository DEFAULT branch,
# which is `dev`, and a local reusable workflow loads from that same commit, so a
# change lands on the live labeler as soon as it is on `dev` — same model as
# enforce-issue-quality.yml. The `github` context here is the caller's event.
on:
pull_request_target:
# labeled/unlabeled let a human type-label change enqueue a fresher run in the
# per-PR concurrency group, cancelling any in-flight title sync that started
# before the override (PR #518 race).
types: [opened, edited, synchronize, labeled, unlabeled]

concurrency:
group: pr-labeler-${{ github.event.pull_request.number }}
cancel-in-progress: true
workflow_call:

permissions:
contents: read
Expand All @@ -29,6 +24,14 @@ permissions:
jobs:
label:
runs-on: ubuntu-latest
# labeled/unlabeled let a human type-label change enqueue a fresher run in the
# per-PR concurrency group, cancelling any in-flight title sync that started
# before the override (PR #518 race). The group sits on this job, not on the
# caller: this job exists only when the caller decided to run it, so a skipped
# event can never enter (or cancel) the group.
concurrency:
group: pr-labeler-${{ github.event.pull_request.number }}
cancel-in-progress: true
steps:
- name: Checkout labeler script (default-branch trusted code)
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
Expand Down
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,8 @@ than nudged.
- Do not dispatch `lane=all` for a PR branch. Run it once for a batch on `dev`;
never re-dispatch merely because the head moved or rerun to green. Cancel your
own superseded dispatches with `gh run cancel`.
- Every description edit or label change starts three event-gate workflows. Batch those
edits, and keep the number of agents working pull requests in parallel small.
- Every description edit or label change starts a `PR gates` run with three gate jobs. Batch
those edits, and keep the number of agents working pull requests in parallel small.
- **Never** add a `dev` push trigger, or `pull_request` runs for macOS/Windows
jobs, to any workflow. PRs that change this policy will be closed.

Expand Down
Loading
Loading