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
140 changes: 140 additions & 0 deletions .github/workflows/closing-target-claim-guard.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# A pull request may close a card only while that card's own thread carries a
# `Claim:` naming this pull request's head branch.
#
# Every agent here shares ONE GitHub identity, so the assignee field is a
# presence bit and the `Claim:` comment is the identity record. Two guards
# already run at pull-request time — the Duplicate Fix Guard ("no other open PR
# may claim the same issue") and the Single-Claim Path Guard — and neither asks
# the question that decides ownership. That question WAS already asked, by row
# H46 of the half-state sweep, and asked AFTER THE FACT as a report-only patrol
# input: the cloud-repo incident (one card implemented 29 minutes apart by two
# seats, the second with no claim) and an objectstack card implemented twice in
# one morning were both visible to that predicate and stopped by nothing. A
# control that detects a defect it cannot prevent is a TIMING defect in an
# existing control, not an absent one — so this workflow runs the same
# predicate at the moment the merge can still be stopped.
#
# The rule, the imported predicates, the declined-number policy, the
# branch-rename remedy and the exit contract all live in
# `scripts/check-closing-target-claim.mjs`; that header is authoritative and
# this file is the invocation.
#
# Sibling shape, deliberately copied rather than reinvented: the Part-of
# Closing-Keyword Guard and the Single-Claim Path Guard are this repo's other
# PR-scoped blocking checks, and this takes their job shape, their runtime pin
# and their `pull_request` trigger set.
name: Closing-Target Claim Guard

# THREE things are load-bearing here.
#
# `edited`: one of the two remedies is to drop the closing keyword and write
# `Part of #N` instead. The body is the frozen half of this check's input, so
# that remedy needs a fresh event rather than a re-run — exactly as in the two
# body-scoped sibling guards.
#
# `synchronize`: the head BRANCH is half the question, and a force-free re-push
# onto a differently-named branch arrives as a new pull request, but a push that
# changes what the PR contains must be re-judged on the commit that changed it.
#
# `merge_group`: and this is where this gate deliberately DIVERGES from both
# siblings, whose headers say they take no queue leg because a merge-queue event
# carries no pull request. True — and here it does not matter, because the queue
# REF NAMES its pull request (`gh-readonly-queue/<base>/pr-<N>-<sha>`). The
# script reads that number with the governed queue guard's own shipped parser
# and then reads the same PR body and the same live comment threads, so the
# queue build judges the SAME pull request by the SAME rule. That is what makes
# the card's "the check holds in the queue too" a wiring fact rather than an
# assumption.
#
# ⚠️ In a MULTI-PR group the queue ref names only the LAST pull request; the
# script header states that limit. Every member of the group passed the
# `pull_request` leg to be armed at all.
on:
pull_request:
types: [opened, edited, reopened, synchronize]
merge_group:

# ⛔ NO `paths:` filter, on either leg. A skipped job counts as SUCCESS in branch
# protection, so a path filter would hand a green check to the very PR the
# filter mis-scoped — and this gate's input is a PR BODY, which no path filter
# can see at all. (`merge_group` has no paths support either, so a filter would
# also make the two legs disagree about what they cover.)

# Read-only, and that is the whole grant. This gate reports; it never closes a
# PR, comments, edits a body, or writes a label.
#
# `contents: read` is not optional padding: naming a `permissions:` block at all
# sets every scope NOT listed to `none`, and this job checks the repo out to get
# at the script. `issues: read` is what the card comment threads need — the
# claim record is an ISSUE comment, and the pull-requests scope does not reach
# it. `pull-requests: read` is what the merge_group leg needs to read the body
# of the pull request its queue ref names.
permissions:
contents: read
issues: read
pull-requests: read

concurrency:
group: closing-target-claim-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
closing-target-claim:
# ⚠️ THIS LITERAL IS THE CHECK-RUN NAME branch protection would pin, and it
# is duplicated in `CHECK_CONTEXT_NAME` in the script — deliberately, and
# pinned in both directions: the script's `--self-test` reads THIS FILE and
# fails if the two ever disagree. Renaming a job silently detaches a
# required context.
#
# ⛔ Making it a required context is NOT this PR's step. Branch protection
# takes a `REQUIRED_CONTEXTS` row PLUS the Settings → Rulesets entry, in one
# sitting, and the settings half is the maintainer's. Either half alone is
# an outage. What the merge_group leg above buys today is that the question
# is asked on the queue build; what it buys the day someone does make this
# required is that it cannot deadlock the queue, which a workflow with no
# queue leg always does.
name: The card this PR closes must claim this branch
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout repository
uses: actions/checkout@v7

# Pinned to the same major and spelling as every other setup-node in this
# repo, for the measured reason the sibling guards record: a setup-node
# major whose package-manager-cache default is on reads `packageManager`
# out of package.json and shells out to pnpm to find its store, killing
# the job in the SETUP step with "Unable to locate executable file: pnpm"
# — before the script runs, and naming a tool the workflow source never
# mentions. This job installs no package manager on purpose: the script is
# dependency-free and imports two sibling modules.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'

# Everything reaches the script through `env:`, never through `${{ }}`
# inside the `run:` line. A PR body is arbitrary author-controlled text,
# and an expression interpolated into a shell line is substituted before
# bash ever sees it; through `env:` it is inert data. The script's
# self-test pins this spelling.
#
# On the merge_group leg every `github.event.pull_request.*` expression
# renders EMPTY and `MERGE_GROUP_HEAD_REF` carries the queue ref, which is
# what selects the leg inside the script. On the pull_request leg the
# reverse holds. Neither leg needs its own step.
#
# `PR_HEAD_REF` is not decoration: the head branch IS the question this
# gate asks, so a blank one exits NOT MEASURED rather than green.
#
# The token is required, not optional: the claim record is a comment on
# another issue, which cannot be answered from the event payload.
- name: A PR may close only a card that claims its branch
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_BODY: ${{ github.event.pull_request.body }}
PR_HEAD_REF: ${{ github.event.pull_request.head.ref }}
MERGE_GROUP_HEAD_REF: ${{ github.event.merge_group.head_ref }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: node scripts/check-closing-target-claim.mjs
27 changes: 27 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,33 @@ jobs:
- name: Single-claim path guard self-test
run: pnpm check:single-claim-paths

# Closing-target claim guard self-test (#15845). Same split as the step
# above and for the same reason: the guard is a PR-scoped blocking check
# in its own workflow, because its question is about the COMMENT THREAD of
# the card a PR closes and needs a pull request plus an API read that this
# job has neither of. What runs HERE is the half that needs no PR — the
# verdict layer, the exit-code contract, the collection policy over a fake
# transport, and the wiring.
#
# Three things make this step worth its second. (a) The ACCEPTANCE PAIR:
# a correct claim is green and a claim naming a DIFFERENT branch is red,
# because a probe exercised only on its red half has not been shown to
# reach the card at all. (b) The NEGATIVE CONTROLS: a PR that closes
# nothing and a PR carrying only `Part of` make zero API calls and emit no
# annotation, so the gate cannot become a tax on correct PRs. (c) The COST
# assertions are measured over a fake transport with a call log, so
# "bounded by the closing-keyword count" is a test rather than a claim in
# a header.
#
# It also pins the WIRING (the guard workflow still invokes the script,
# still carries the check-run name the script pins, still subscribes to
# `edited` and `synchronize`, still takes the `merge_group` leg without
# which the queue claim would be prose, and still passes the head ref and
# the token), so unwiring the gate reddens here rather than going quiet.
# Dependency-free, imports two sibling modules, reads one file; ~0.1s.
- name: Closing-target claim guard self-test
run: pnpm check:closing-target-claim

# PM half-state sweeper self-test (#8528). `scripts/pm/check-half-states.mjs`
# carried a 79-case --self-test — the H1..H7 predicates, the seat-sticker
# parser, the transport classifier and its measured container classes —
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/partof-closing-keyword-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,8 @@
# reuses; that header is authoritative, this file is the invocation.
#
# Sibling shape, deliberately copied rather than reinvented: the Duplicate Fix
# Guard is this repo's other PR-body-scoped blocking check, and it takes the
# same trigger set for the same measured reason.
# Guard is one of this repo's other PR-body-scoped blocking checks, and it takes
# the same trigger set for the same measured reason.
name: Part-of Closing-Keyword Guard

# `edited` is load-bearing, not decoration. The body is this check's whole
Expand Down
13 changes: 9 additions & 4 deletions .github/workflows/pr-automation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ name: PR Automation
# run it produced is the first measured pass in that round where the gate read
# the line.
#
# Sibling shape, deliberately copied rather than reinvented: this repo's two
# other PR-body-scoped blocking checks -- `duplicate-fix-guard.yml` and
# `partof-closing-keyword-guard.yml` -- already take `edited` and already carry
# this argument.
# Sibling shape, deliberately copied rather than reinvented: this repo's other
# PR-body-scoped blocking checks -- `duplicate-fix-guard.yml`,
# `partof-closing-keyword-guard.yml` and `closing-target-claim-guard.yml` --
# already take `edited` and already carry this argument.
#
# ⚠️ The third of those joined AFTER the measurement below was taken, which is
# why that paragraph still says "the two `edited`-subscribed workflows above":
# it is a dated reading over the two that existed on 2026-09-08, and it is left
# exactly as measured rather than re-derived from today's set.
#
# COST, measured rather than waved at (2026-09-01..09-08, this repo's own run
# counts through `GET /actions/workflows/<file>/runs?event=pull_request`):
Expand Down
5 changes: 3 additions & 2 deletions .github/workflows/single-claim-path-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@
# is authoritative and this file is the invocation.
#
# Sibling shape, deliberately copied rather than reinvented: the Part-of
# Closing-Keyword Guard is this repo's other single-script PR-scoped blocking
# check, and this takes its job shape, its runtime pin and its trigger set.
# Closing-Keyword Guard is one of this repo's other single-script PR-scoped
# blocking checks, and this takes its job shape, its runtime pin and its trigger
# set.
name: Single-Claim Path Guard

# `synchronize` is the load-bearing one here, and that is the difference from
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"check:partof-closing-keyword": "node scripts/check-partof-closing-keyword.mjs --self-test",
"check:commit-card-trailers": "node scripts/check-commit-card-trailers.mjs --self-test",
"check:single-claim-paths": "node scripts/check-single-claim-paths.mjs --self-test",
"check:closing-target-claim": "node scripts/check-closing-target-claim.mjs --self-test",
"check:pnpm-filter-targets": "node scripts/pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs --self-test && node scripts/check-pnpm-filter-targets.mjs",
"check:turbo-task-graph": "node scripts/check-turbo-task-graph.mjs --self-test && node scripts/check-turbo-task-graph.mjs",
"check:workspace-manifest-cycles": "node scripts/check-workspace-manifest-cycles.mjs --self-test && node scripts/check-workspace-manifest-cycles.mjs",
Expand Down
Loading
Loading