From ded4c85fff2ce2ae163508fc02216858cc3b4c49 Mon Sep 17 00:00:00 2001 From: lidge-jun <243035832+lidge-jun@users.noreply.github.com> Date: Wed, 11 Nov 2026 19:56:35 +0900 Subject: [PATCH] ci: run the three PR gates as one pull_request_target workflow Every PR event used to start three separate workflow runs (Enforce PR target branch, PR hygiene, PR Labeler). pr-gates.yml now owns the pull_request_target and status triggers and calls the three existing files as local reusable workflows, so one event starts one "PR gates" run (#7654). - Callees are workflow_call-only; their job logic is unchanged. - label and hygiene start only for the actions they handled before; enforce always starts and resolve-pr keeps its trusted status/label filter. - permissions: {} at the top, per-job ceilings equal to each callee's requests. Local ./ reusable workflows load from the caller's default-branch commit, so the trust boundary is the same. - Concurrency stays on the callee jobs (pr-labeler-N cancel on label, pr-gate-comment-N on hygiene and enforce-target), so a job the run skips never enters a group. The CI policy is unchanged: no dev push trigger, no macOS/Windows PR jobs. Closes #7654 --- .github/scripts/enforce-pr-target.test.cjs | 20 +++- .github/scripts/pr-labeler.test.cjs | 16 ++- .github/workflows/enforce-pr-target.yml | 24 ++-- .github/workflows/issue-quality-tests.yml | 1 + .github/workflows/pr-gates.yml | 58 ++++++++++ .github/workflows/pr-hygiene.yml | 33 +++--- .github/workflows/pr-labeler.yml | 27 +++-- AGENTS.md | 4 +- scripts/test-layout/layout.json | 2 +- structure/ops/docs-and-release.md | 5 +- tests/ci-workflows/ci-workflows.test.ts | 28 +++-- tests/ci-workflows/pr-gates-workflow.test.ts | 107 ++++++++++++++++++ ...-coderabbit-readiness-revalidation.test.ts | 12 +- tests/fixtures/test-layout-expected.json | 2 +- 14 files changed, 270 insertions(+), 69 deletions(-) create mode 100644 .github/workflows/pr-gates.yml create mode 100644 tests/ci-workflows/pr-gates-workflow.test.ts diff --git a/.github/scripts/enforce-pr-target.test.cjs b/.github/scripts/enforce-pr-target.test.cjs index 9756869eb91..b3919f692d4 100644 --- a/.github/scripts/enforce-pr-target.test.cjs +++ b/.github/scripts/enforce-pr-target.test.cjs @@ -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/, @@ -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'/); @@ -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", () => { diff --git a/.github/scripts/pr-labeler.test.cjs b/.github/scripts/pr-labeler.test.cjs index 103a396a2cd..80f233b82c5 100644 --- a/.github/scripts/pr-labeler.test.cjs +++ b/.github/scripts/pr-labeler.test.cjs @@ -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", () => { diff --git a/.github/workflows/enforce-pr-target.yml b/.github/workflows/enforce-pr-target.yml index d2a9674595b..e0ed985e69f 100644 --- a/.github/workflows/enforce-pr-target.yml +++ b/.github/workflows/enforce-pr-target.yml @@ -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 / diff --git a/.github/workflows/issue-quality-tests.yml b/.github/workflows/issue-quality-tests.yml index 3917fda15df..dc96578031e 100644 --- a/.github/workflows/issue-quality-tests.yml +++ b/.github/workflows/issue-quality-tests.yml @@ -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 diff --git a/.github/workflows/pr-gates.yml b/.github/workflows/pr-gates.yml new file mode 100644 index 00000000000..5b44e77d9af --- /dev/null +++ b/.github/workflows/pr-gates.yml @@ -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 diff --git a/.github/workflows/pr-hygiene.yml b/.github/workflows/pr-hygiene.yml index 4466981e0be..42b11eaaaab 100644 --- a/.github/workflows/pr-hygiene.yml +++ b/.github/workflows/pr-hygiene.yml @@ -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: diff --git a/.github/workflows/pr-labeler.yml b/.github/workflows/pr-labeler.yml index f7c2b6fa63e..011c6a7cb1a 100644 --- a/.github/workflows/pr-labeler.yml +++ b/.github/workflows/pr-labeler.yml @@ -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 @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 285ca030e6b..3b484931915 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8cc1d592a19..e413e2845ac 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -2,7 +2,7 @@ "version": 1, "root": "tests", "explicitShards": ["layout-file-identity.json", "layout-clients.json", "layout-codex-collision.json", "layout-agent-assertion.json", "layout-chat.json", "layout-replay.json", "layout-adapters.json", "layout-jev.json", "layout-workbuddy.json", "layout-combos.json", "layout-credit-renewal.json", "layout-server-inbound.json", "layout-providers.json", "layout-responses.json", "layout-exec-boundary.json", "layout-persistent-dashboard.json", "layout-start-ownership.json", "layout-config.json", "layout-kiro-usage.json", "layout-usage.json", "layout-docs-guides.json"], - "explicit": { "kimi-monthly-quota.test.ts": "providers", "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", + "explicit": { "pr-gates-workflow.test.ts": "ci-workflows", "kimi-monthly-quota.test.ts": "providers", "sync-fanout.test.ts": "cli", "sync-command-fanout.test.ts": "cli", "link-pinned-sync-argv.test.ts": "clients", "sync-link-status.test.ts": "cli", "sync-fanout-process.test.ts": "cli", "codex-installation-acl.test.ts": "codex-integration", "codex-installation-id.test.ts": "codex-integration", "codex-installation-wire.test.ts": "codex-integration", "codex-installation-failover.test.ts": "responses", "service-desktop-recovery.test.ts": "service", "catalog-omit-disabled-native.test.ts": "codex-integration", "provider-input-modalities.test.ts": "providers", "combo-description.test.ts": "routing", "combo-description-catalog.test.ts": "codex-integration", "codex-provider-routing.test.ts": "codex-integration", "config-native-reasoning-retention.test.ts": "config", "server-native-reasoning-retention-merge.test.ts": "server", "server-native-reasoning-retention.test.ts": "server", "config-workflow-budget.test.ts": "config", "server-workflow-budget-settings.test.ts": "server", "responses-retained-reasoning-stream-recovery.test.ts": "responses", "openai-native-hosted-search-history.test.ts": "adapters/openai", "responses-native-compact-search-history.test.ts": "responses", "config-hosted-search-projection.test.ts": "config", "gcp-credential-boundary.test.ts": "adapters/google", "gcp-sa-marker.test.ts": "adapters/google", "gcp-sa-adapter.test.ts": "adapters/google", "management-gcp-credential-input.test.ts": "server", "codex-preserve-signed-in.test.ts": "codex-integration", "cli-account-preservation.test.ts": "cli", "cli-provider-list-active.test.ts": "cli", "service-state-isolation.test.ts": "service", "compaction-history-framing.test.ts": "responses", "catalog-native-max-context.test.ts": "codex-integration", "protocol-argon-thought-boundary.test.ts": "responses", "service-log.test.ts": "service", "messages-native-empty-inline.test.ts": "claude-integration", "catalog-auto-review-override.test.ts": "codex-integration", "management-auto-review-settings.test.ts": "server", "management-auto-review-auth.test.ts": "server", "prefer-model-alias-ids.test.ts": "server", "responses-mimo-envelope.test.ts": "responses", "responses-call-id-linkage.test.ts": "responses", "bridge-terminal-refusal.test.ts": "adapters", "loop-terminal-refusal.test.ts": "images", "web-search-terminal-refusal.test.ts": "web-search", "combo-terminal-refusal.test.ts": "routing", "server-terminal-refusal-wiring.test.ts": "server", "incomplete-reason-normalization.test.ts": "responses", "advisor-retention.test.ts": "advisor", "responses-native-key-cooldown.test.ts": "responses", "responses-run-turn-key-readmission.test.ts": "responses", "qoder-spawn-readmission.test.ts": "adapters", "api-key-selection-cooldown.test.ts": "providers", "api-key-auto-pause-distribution.test.ts": "providers", "explicit-key-pause-oauth-boundary.test.ts": "providers", "server-chat-native-key-selection.test.ts": "server", "compact-key-selection.test.ts": "responses", "messages-native-key-selection.test.ts": "claude-integration", "coding-agent-windows-dispatch.test.ts": "adapters", "coding-agent-native-empty-argv.test.ts": "windows", "stop-restore-history-ownership.test.ts": "service", "cli-stop-refusal-transport.test.ts": "cli", "service-proxy-env.test.ts": "service", "openai-chat-standalone-bare-block.test.ts": "adapters/openai", diff --git a/structure/ops/docs-and-release.md b/structure/ops/docs-and-release.md index 49e072682bf..11c7210a4ed 100644 --- a/structure/ops/docs-and-release.md +++ b/structure/ops/docs-and-release.md @@ -225,11 +225,12 @@ cannot supply a write-job PR number. If no unique local current-head candidate r | `.github/workflows/deploy-docs.yml` | Manual dispatch from `dev` | Builds a published stable release at its resolved commit and deploys to Pages; the PR build gate is `docs-site-build`. | | `.github/workflows/service-lifecycle.yml` | Filtered pull requests and manual dispatch | Linux, macOS and Windows service checks; manual dispatch validates an exact `dev` ancestor source SHA. | | `.github/workflows/catalog-async-contracts.yml` | Filtered pull requests and manual dispatch | Read-only catalog contracts; PRs run Ubuntu and manual dispatch from `dev` runs all three platforms. | -| `.github/workflows/enforce-pr-target.yml` | `pull_request_target` (opened, reopened, edited, labeled, unlabeled, ready_for_review, synchronize) plus default-branch `status` events filtered to successful `CodeRabbit` statuses | The `enforce-target` gate: rejects pull requests whose base is not `dev` (stacked children excepted), rejects empty or malformed descriptions, requires a GUI screenshot when the title/body mentions `gui` (immediately waivable with the maintainer-controlled `gui-screenshot-waived` label; legacy maintainer comments remain compatibility evidence on later PR events), keeps contributor PRs in draft until a four-box readiness checklist is complete, verifies the CI / latest-dev / Codex+CodeRabbit-findings claims (review threads plus current-head CodeRabbit review-body findings outside the diff range), and adds a `review-ready` status label at the ready moment. CodeRabbit status SHAs must resolve to exactly one open current-head PR before writes. Stacked child PRs targeting another open PR's head skip the wrong-base gate. `labeled`/`unlabeled` wake the gate only for `gui-screenshot-waived` and the `HYGIENE_GATE_LABELS` exported by `.github/scripts/pr-hygiene.cjs`; `tests/ci-workflows/ci-workflows.test.ts` pins the two lists together. Head-specific exception labels (`test-exception-approved`, `suppression-approved`, `generated-change-approved`, `dependency-change-approved`, `attribution-approved`) count only while the head and head-rewrite count that `pr-hygiene.yml` last recorded in its comment section are unchanged; both gates read that record and only the hygiene workflow writes it or removes the labels. | +| `.github/workflows/pr-gates.yml` | `pull_request_target` (opened, reopened, edited, labeled, unlabeled, ready_for_review, synchronize) plus default-branch `status` events | One run per PR event for the three PR gates (#7654): jobs `label`, `hygiene` and `enforce` call `pr-labeler.yml`, `pr-hygiene.yml` and `enforce-pr-target.yml` as local `./` reusable workflows, which load from the same default-branch commit as the caller. `label` and `hygiene` start only for the actions those gates handle; `enforce` always starts and its `resolve-pr` job filters. `permissions: {}` at the top and per-job ceilings equal to each callee's requests; each gate keeps its concurrency group on its own job, so a skipped job never enters one. `tests/ci-workflows/pr-gates-workflow.test.ts` pins the shape. | +| `.github/workflows/enforce-pr-target.yml` | `workflow_call` from `pr-gates.yml` (every subscribed `pull_request_target` action and every `status` event; `resolve-pr` keeps only successful `CodeRabbit` statuses) | The `enforce-target` gate: rejects pull requests whose base is not `dev` (stacked children excepted), rejects empty or malformed descriptions, requires a GUI screenshot when the title/body mentions `gui` (immediately waivable with the maintainer-controlled `gui-screenshot-waived` label; legacy maintainer comments remain compatibility evidence on later PR events), keeps contributor PRs in draft until a four-box readiness checklist is complete, verifies the CI / latest-dev / Codex+CodeRabbit-findings claims (review threads plus current-head CodeRabbit review-body findings outside the diff range), and adds a `review-ready` status label at the ready moment. CodeRabbit status SHAs must resolve to exactly one open current-head PR before writes. Stacked child PRs targeting another open PR's head skip the wrong-base gate. `labeled`/`unlabeled` wake the gate only for `gui-screenshot-waived` and the `HYGIENE_GATE_LABELS` exported by `.github/scripts/pr-hygiene.cjs`; `tests/ci-workflows/ci-workflows.test.ts` pins the two lists together. Head-specific exception labels (`test-exception-approved`, `suppression-approved`, `generated-change-approved`, `dependency-change-approved`, `attribution-approved`) count only while the head and head-rewrite count that `pr-hygiene.yml` last recorded in its comment section are unchanged; both gates read that record and only the hygiene gate writes it or removes the labels. | | `.github/workflows/enforce-issue-quality.yml` | `issues` (opened, edited, reopened), `issue_comment` (created, edited), or manual dispatch with an issue number | Issue-template compliance gate. | | `.github/workflows/issue-quality-tests.yml` | `pull_request` filtered on the issue/PR automation scripts, templates, and their workflows | Tests the issue and PR automation scripts themselves, so the gates cannot rot silently. | | `.github/workflows/issue-triage.yml` | `issues` (opened) | Duplicate detection and triage labeling for new issues. | -| `.github/workflows/pr-labeler.yml` | `pull_request_target` (opened, edited, synchronize, labeled, unlabeled) | Type and path labeling plus title sync; `labeled`/`unlabeled` let a human override enqueue a fresher run in the per-PR concurrency group. | +| `.github/workflows/pr-labeler.yml` | `workflow_call` from `pr-gates.yml` (opened, edited, synchronize, labeled, unlabeled) | Type and path labeling plus title sync; `labeled`/`unlabeled` let a human override enqueue a fresher run in the per-PR concurrency group on the `label` job. | | `.github/workflows/react-doctor.yml` | `pull_request` (opened, synchronize, reopened, ready_for_review) touching `gui/**` or the workflow | React-focused static review. Findings fail the job; write-scoped outputs stay disabled, a contract pinned by `tests/ci-workflows/ci-workflows.test.ts`. | | `.github/workflows/stale-needs-info.yml` | `schedule` only (daily 06:15 UTC); deliberately no manual dispatch | Closes issues left in needs-info past the grace period. Manual dispatch is omitted so a branch-selected run cannot execute that branch's body with issue write scope. | diff --git a/tests/ci-workflows/ci-workflows.test.ts b/tests/ci-workflows/ci-workflows.test.ts index 0648b5f497d..9f06ea5e1ad 100644 --- a/tests/ci-workflows/ci-workflows.test.ts +++ b/tests/ci-workflows/ci-workflows.test.ts @@ -907,6 +907,7 @@ describe("GitHub Actions hardening", () => { async function readEnforcePrTarget(): Promise<{ workflow: WorkflowShape; + triggers: WorkflowShape["on"]; jobs: [string, WorkflowJob][]; steps: WorkflowStep[]; allSteps: WorkflowStep[]; @@ -914,6 +915,11 @@ describe("GitHub Actions hardening", () => { }> { const text = await readText(".github/workflows/enforce-pr-target.yml"); const workflow = Bun.YAML.parse(text) as WorkflowShape; + // Since #7654 the gate runs as a local reusable workflow of pr-gates.yml, which + // owns its pull_request_target and status triggers; the gate itself is + // workflow_call-only. pr-gates-workflow.test.ts pins the caller's shape. + expect(Object.keys(workflow.on ?? {})).toEqual(["workflow_call"]); + const caller = Bun.YAML.parse(await readText(".github/workflows/pr-gates.yml")) as WorkflowShape; const jobs = Object.entries(workflow.jobs ?? {}); const steps = workflow.jobs?.["enforce-target"]?.steps; expect(Array.isArray(steps)).toBe(true); @@ -924,7 +930,7 @@ describe("GitHub Actions hardening", () => { const scriptStep = steps!.find(step => typeof step.with?.script === "string"); expect(scriptStep).toBeDefined(); const script = stripComments(String(scriptStep!.with!.script)); - return { workflow, jobs, steps: steps!, allSteps, script }; + return { workflow, triggers: caller.on, jobs, steps: steps!, allSteps, script }; } const SCRIPT_LOAD = [ @@ -1060,7 +1066,7 @@ describe("GitHub Actions hardening", () => { * characterisation test on a privileged workflow actually needs. */ test("PR target enforcement's structure is an exact allowlist, not a deny-list", async () => { - const { workflow, jobs, steps } = await readEnforcePrTarget(); + const { workflow, triggers, jobs, steps } = await readEnforcePrTarget(); // Top level: concurrency lives on the write job after read-only PR resolution. expect(Object.keys(workflow).sort()).toEqual([ @@ -1080,7 +1086,7 @@ describe("GitHub Actions hardening", () => { // `status` is the only extra trigger. CodeRabbit publishes a legacy // commit status; this privileged workflow is loaded from the default branch // and re-reads live review evidence before any mutation. - expect(Object.keys(workflow.on ?? {}).sort()).toEqual([ + expect(Object.keys(triggers ?? {}).sort()).toEqual([ "pull_request_target", "status", ]); @@ -1094,8 +1100,8 @@ describe("GitHub Actions hardening", () => { // particular files change, which on a docs-only PR means never. Both are // additive, both look like ordinary scoping in a diff, and neither failed a // single assertion. - expect(Object.keys(workflow.on?.pull_request_target ?? {})).toEqual(["types"]); - expect(Object.prototype.hasOwnProperty.call(workflow.on ?? {}, "status")).toBe(true); + expect(Object.keys(triggers?.pull_request_target ?? {})).toEqual(["types"]); + expect(Object.prototype.hasOwnProperty.call(triggers ?? {}, "status")).toBe(true); // Exactly the scopes this gate needs. `pull-requests: write` covers title and // comment updates. `contents: write` is required for the draft GraphQL @@ -1139,13 +1145,16 @@ describe("GitHub Actions hardening", () => { expect(new Set(wakeLabels).size).toBe(wakeLabels.length); expect([...wakeLabels].sort()).toEqual([...HYGIENE_GATE_LABELS, "gui-screenshot-waived"].sort()); + // The shared comment lock sits on the hygiene job (#7654): it exists only when + // pr-gates.yml runs hygiene, so a skipped event never enters the group. const hygieneWorkflow = Bun.YAML.parse( await readText(".github/workflows/pr-hygiene.yml"), - ) as { concurrency?: { group?: string; "cancel-in-progress"?: boolean } }; - expect(hygieneWorkflow.concurrency?.group).toBe( + ) as { concurrency?: unknown; jobs?: { hygiene?: { concurrency?: { group?: string; "cancel-in-progress"?: boolean } } } }; + expect(hygieneWorkflow.concurrency).toBeUndefined(); + expect(hygieneWorkflow.jobs?.hygiene?.concurrency?.group).toBe( "pr-gate-comment-${{ github.event.pull_request.number }}", ); - expect(hygieneWorkflow.concurrency?.["cancel-in-progress"]).toBe(false); + expect(hygieneWorkflow.jobs?.hygiene?.concurrency?.["cancel-in-progress"]).toBe(false); expect(jobs.map(([name]) => name)).toEqual(["resolve-pr", "enforce-target"]); @@ -1233,7 +1242,8 @@ describe("GitHub Actions hardening", () => { }); test("PR target enforcement reacts to the events that can change the verdict", async () => { - const { workflow, script } = await readEnforcePrTarget(); + const { triggers: on, script } = await readEnforcePrTarget(); + const workflow = { on }; // `edited` is what catches a retarget; `ready_for_review` is what re-applies // the draft when someone undoes it by hand. Dropping either silently makes diff --git a/tests/ci-workflows/pr-gates-workflow.test.ts b/tests/ci-workflows/pr-gates-workflow.test.ts new file mode 100644 index 00000000000..203558a5ac8 --- /dev/null +++ b/tests/ci-workflows/pr-gates-workflow.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { repoPath } from "../helpers/repo-root"; + +/** + * #7654: one "PR gates" pull_request_target/status run per event instead of three. The three + * gates keep their logic in their own files and run as local reusable workflows. These checks pin + * the consolidation's security-relevant shape: where triggers live, which commit the callees load + * from, the GITHUB_TOKEN ceilings, and which jobs own which concurrency group. + */ + +type Job = Record & { + if?: string; uses?: string; permissions?: Record; steps?: unknown[]; + concurrency?: { group?: string; "cancel-in-progress"?: boolean }; +}; +type Workflow = Record & { + on?: Record & { pull_request_target?: { types?: string[] } }; + permissions?: Record; + concurrency?: unknown; + jobs?: Record; +}; + +const read = (name: string) => Bun.YAML.parse(readFileSync(repoPath(`.github/workflows/${name}`), "utf8")) as Workflow; +const LEVEL: Record = { none: 0, read: 1, write: 2 }; + +const LABELER_TYPES = ["edited", "labeled", "opened", "synchronize", "unlabeled"]; +const HYGIENE_TYPES = ["edited", "labeled", "opened", "reopened", "synchronize", "unlabeled"]; +const GATE_TYPES = ["edited", "labeled", "opened", "ready_for_review", "reopened", "synchronize", "unlabeled"]; +const CALLEES = { label: "pr-labeler.yml", hygiene: "pr-hygiene.yml", enforce: "enforce-pr-target.yml" } as const; + +describe("PR gates caller (#7654)", () => { + const caller = read("pr-gates.yml"); + + test("owns the only pull_request_target and status triggers, as a bare types list", () => { + expect(Object.keys(caller.on ?? {}).sort()).toEqual(["pull_request_target", "status"]); + // A branches: or paths: filter would let a PR skip every gate (see ci-workflows.test.ts). + expect(Object.keys(caller.on?.pull_request_target ?? {})).toEqual(["types"]); + expect([...(caller.on?.pull_request_target?.types ?? [])].sort()).toEqual(GATE_TYPES); + }); + + test("grants no default token and holds no workflow-level concurrency group", () => { + expect(caller.permissions).toEqual({}); + expect(caller.concurrency).toBeUndefined(); + }); + + test("calls each gate as a local reusable workflow from the caller's own commit", () => { + expect(Object.keys(caller.jobs ?? {})).toEqual(["label", "hygiene", "enforce"]); + for (const [job, file] of Object.entries(CALLEES)) { + const spec = caller.jobs![job]!; + // ./ resolves from the caller's commit, which for pull_request_target and status is the + // default branch. An @ref or a remote path would load code from somewhere else. + expect(spec.uses).toBe(`./.github/workflows/${file}`); + expect(Object.keys(spec).every(key => ["if", "uses", "permissions", "name"].includes(key))).toBe(true); + expect(spec.steps).toBeUndefined(); + } + }); + + test("starts the labeler and hygiene only for the pull_request_target actions they used to subscribe to", () => { + const actions = (condition: string | undefined) => { + const list = /fromJSON\('(\[[^']*\])'\)/.exec(String(condition ?? "")); + expect(list).not.toBeNull(); + return (JSON.parse(list![1]!) as string[]).sort(); + }; + for (const [job, types] of [["label", LABELER_TYPES], ["hygiene", HYGIENE_TYPES]] as const) { + const condition = caller.jobs![job]!.if; + expect(String(condition)).toContain("github.event_name == 'pull_request_target'"); + expect(String(condition)).toContain("github.event.action"); + expect(actions(condition)).toEqual([...types]); + } + // The gate decides for itself: resolve-pr keeps the trusted status / label filter. + expect(caller.jobs!.enforce!.if).toBeUndefined(); + }); + + test("caps each callee token at exactly what its jobs request", () => { + for (const [job, file] of Object.entries(CALLEES)) { + const cap = caller.jobs![job]!.permissions ?? {}; + const callee = read(file); + const requested = [callee.permissions ?? {}, ...Object.values(callee.jobs ?? {}).map(spec => spec.permissions ?? {})]; + const needed: Record = {}; + for (const scopes of requested) { + for (const [scope, level] of Object.entries(scopes)) { + if ((LEVEL[level] ?? 0) > (LEVEL[needed[scope] ?? "none"] ?? 0)) needed[scope] = level; + } + } + expect(cap).toEqual(needed); + } + }); +}); + +describe("PR gate callees (#7654)", () => { + test("run only as reusable workflows, so no second run starts per event", () => { + for (const file of Object.values(CALLEES)) { + const callee = read(file); + expect(Object.keys(callee.on ?? {})).toEqual(["workflow_call"]); + expect(callee.concurrency).toBeUndefined(); + } + }); + + test("keep their concurrency groups on their own jobs, which exist only when the caller job runs", () => { + const label = read("pr-labeler.yml").jobs!.label!; + expect(label.concurrency).toEqual({ group: "pr-labeler-${{ github.event.pull_request.number }}", "cancel-in-progress": true }); + const hygiene = read("pr-hygiene.yml").jobs!.hygiene!; + expect(hygiene.concurrency).toEqual({ group: "pr-gate-comment-${{ github.event.pull_request.number }}", "cancel-in-progress": false }); + const gate = read("enforce-pr-target.yml").jobs!["enforce-target"]!; + expect(gate.concurrency).toEqual({ group: "pr-gate-comment-${{ needs.resolve-pr.outputs.pull-number }}", "cancel-in-progress": false }); + }); +}); diff --git a/tests/ci-workflows/zz-pr-coderabbit-readiness-revalidation.test.ts b/tests/ci-workflows/zz-pr-coderabbit-readiness-revalidation.test.ts index 172d7b6c0ca..c04bd1e5557 100644 --- a/tests/ci-workflows/zz-pr-coderabbit-readiness-revalidation.test.ts +++ b/tests/ci-workflows/zz-pr-coderabbit-readiness-revalidation.test.ts @@ -98,7 +98,13 @@ describe("workflow comment-spam hardening", () => { const text = await Bun.file( new URL("../../.github/workflows/enforce-pr-target.yml", import.meta.url), ).text(); - const workflow = Bun.YAML.parse(text) as Workflow; + const gate = Bun.YAML.parse(text) as Workflow; + // The gate runs as a local reusable workflow of pr-gates.yml (#7654), which owns + // the triggers; both load from the default branch. + expect(Object.keys(gate.on ?? {})).toEqual(["workflow_call"]); + const workflow = Bun.YAML.parse(await Bun.file( + new URL("../../.github/workflows/pr-gates.yml", import.meta.url), + ).text()) as Workflow; expect(workflow.on?.issue_comment).toBeUndefined(); expect(workflow.on?.pull_request_review).toBeUndefined(); @@ -112,7 +118,7 @@ describe("workflow comment-spam hardening", () => { "unlabeled", ])); - const resolver = workflow.jobs?.["resolve-pr"]; + const resolver = gate.jobs?.["resolve-pr"]; const resolverIf = (resolver?.if ?? "").replace(/\s+/g, " ").trim(); for (const guard of [ "github.event_name == 'status'", @@ -127,7 +133,7 @@ describe("workflow comment-spam hardening", () => { expect(resolverIf).toContain(guard); } - const job = workflow.jobs?.["enforce-target"]; + const job = gate.jobs?.["enforce-target"]; expect(job?.if).toBe("needs.resolve-pr.outputs.pull-number != ''"); const checkoutStep = job?.steps?.find( diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b60b176ff3e..7e6101b6bb2 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -1195,7 +1195,7 @@ "plan.test.ts": "images", "platform-dialog-guard.test.ts": "gui", "policy-execution.test.ts": "routing", "pool-kernel-generic-sweep.test.ts": "oauth", "port-reclaim.test.ts": "server", "ports.test.ts": "server", - "pr-readiness-reattest.test.ts": "ci-workflows", "prime-client.test.ts": "clients", + "pr-gates-workflow.test.ts": "ci-workflows", "pr-readiness-reattest.test.ts": "ci-workflows", "prime-client.test.ts": "clients", "private-sqlite-path.test.ts": "lib", "privacy-mask-account.test.ts": "lib", "privacy-scan-asset-names.test.ts": "ci-workflows", "privacy-scan-meta-key.test.ts": "ci-workflows", "privacy-scan-ssh-endpoint.test.ts": "ci-workflows", "probe-lease-dispatch-wiring.test.ts": "routing", "probe-lease.test.ts": "routing",