diff --git a/.github/workflows/half-state-patrol.yml b/.github/workflows/half-state-patrol.yml
index 237eaf9b44..47b3c839ab 100644
--- a/.github/workflows/half-state-patrol.yml
+++ b/.github/workflows/half-state-patrol.yml
@@ -1,168 +1,82 @@
name: Half-State Patrol
-# The standing caller for objectstack's `scripts/pm/check-half-states.mjs`.
-#
-# ## ⚠️ THE SWEEPER IS OBJECTSTACK'S, run from an objectstack checkout
-#
-# This repository holds NO copy of the sweeper (objectui#10208). The step
-# "Check out the objectstack sweeper" below places `objectstack-ai/objectstack`
-# beside this checkout, and the sweep step runs ITS
-# `scripts/pm/check-half-states.mjs` against this board (`PM_SWEEP_REPO`), with
-# this checkout named as the tree its local git reads use (`PM_SWEEP_CHECKOUT`).
-# The copy this file used to run, its port pin and the parity gate over it were
-# retired on the maintainer's ruling (objectui#10205). This file itself was
-# adopted from objectstack (PR #11294): the prose below is upstream's, and its
-# issue numbers (#9844, #4449, #9575, #4690, #7412, #11217 …) are OBJECTSTACK
-# numbers — do not read them as objectui cards.
-#
-# The checkout is NOT pinned to a sha: each run executes objectstack's `main`
-# as of that run, the same sweeper a seat runs from its own objectstack
-# checkout. The accepted cost is that an upstream change reaches this patrol on
-# its next run with no reviewed moment in this repository; the patrol is
-# report-only, and a sweep that cannot run still fails loudly (see below).
-#
-# One decision is taken in THIS file about how the sweeper is called:
-#
-# `PM_SWEEP_CLOSED_FLOOR` on the sweep step — H22's closed-card reader is
-# ON here, but judges only cards closed on/after the cutover date. It read
-# `PM_SWEEP_CLOSED_WINDOW_PAGES: 0` (reader fully OFF) until 2026-08-28; that
-# objectui-only switch was dropped with the copy (objectui#10205).
-#
-# The measurement that forced the hold, taken 2026-08-24: 815 closed cards
-# here carry `pm:dispatched`, and ~347 of the 400 issues in upstream's
-# window carry some `pm:*` residue label (~87%, against the 26% upstream
-# measured). At that density H22 reports the CONVENTION, not a defect —
-# ~347 rows that consume the whole body budget and trim every other
-# predicate out of the anchor.
-#
-# objectui#5985 recorded that as a two-way choice: either stripping is the
-# rule (and ~815 closed cards need a BACKFILL first) or the row is simply
-# not wanted here. The dated floor is the third option both readings omit.
-# `pm:*` on a card closed before the convention existed is inert history —
-# the dispatch loop reads state on OPEN cards only — so judging from the
-# cutover forward buys the row's whole value (residue produced from now
-# on, while the paired write is still a live duty) at zero backfill and
-# zero historical noise. ⛔ NO bulk label backfill of closed cards was run
-# and none is owed; the sweeper writes no label under any code path.
-#
-# The cutover is 2026-08-28, the date the strip-on-close convention was
-# written into the pm-dispatch protocol's label-discipline section. ⛔ Do
-# not move it EARLIER without re-measuring: every day it moves back pulls
-# in cards closed under no convention at all. `resolveClosureFloor` in the
-# sweeper refuses a malformed value (exit 2) rather than degrading to "no
-# floor", because a silent degrade here restores the 87% flood four times
-# a day and a flooded anchor reads exactly like a working patrol.
-#
-# ⚠️ The floor is UPSTREAM code. What is this repository's own is this
-# WIRING — upstream sets no floor, because its own board measured 26% and
-# it treats recent closed residue as a live duty.
-#
-# ## Why a workflow, and not "a seat should run it"
-#
-# The sweeper carries thirteen predicates over the dispatch protocol's
-# label/assignee/PR invariants, and for most of its life its documented consumer
-# was "a PM seat's patrol round" — which is to say, nobody's calendar. A shift
-# covering two lanes declared a queue empty from memory while eight malformed
-# claims (H2) and an unenumerated backlog sat on the board. Not one predicate had
-# fired. A healing mechanism with no scheduled caller heals only in the
-# counterfactual, and an alarm added to a script nobody runs is still silence.
-#
-# "Some seat should run it" also kept not happening for a MEASURED reason, not a
-# discipline one: the live sweep cannot run inside a PM session container at all
-# (#7412 class 1 — api.github.com refuses that egress in both directions, with and
-# without a token). The fix therefore had to move the caller somewhere the
-# transport prerequisite is actually met. A GitHub Actions runner with the
-# workflow's own `GITHUB_TOKEN` is that place — #7412 class 2, the triage Routine
-# container, is the same shape and measured reachable with 15,000 core quota.
-#
-# ## What lands where
-#
-# One pinned ANCHOR ISSUE, rewritten in place every run (`ANCHOR_ISSUE` below).
-# Never a comment per run: the board is one board, a per-run comment stream would
-# be a second tracker that nobody prunes, and GitHub's edit history is already the
-# archive this needs. The body is owned end-to-end by the generator, so no run can
-# leave half of it stale.
-#
-# The `Swept` timestamp in that body is the patrol's heartbeat and is deliberately
-# refreshed even when the findings are unchanged: a timestamp that stops advancing
-# is how a reader learns the standing caller died. That is the whole defect class
-# this workflow exists to close, so the run must not "optimize away" the no-op
-# edit that proves it is alive.
-#
-# ## Report-only, and the one thing that is NOT report-only
-#
-# Findings never fail anything. A completed sweep exits 0 whether it found 0 or 40
-# half-states, this job never writes a label, never closes a card, never fixes a
-# state, and no H-predicate is a blocking gate — the script's own header argues
-# that at length (a half-state is a fact about a live shared board, not about
-# whichever PR happens to run CI next).
-#
-# The job DOES fail when the sweep could not run, or when a CONFIGURED anchor
-# could not be written. That is not a gate on the board; it is the patrol
-# reporting its own death. A workflow that quietly does nothing because a
-# credential lapsed is the exact shape this repo keeps having to fix (#4449,
-# #9575), and it is doubly unacceptable here: silent non-delivery would leave a
-# stale anchor body that reads exactly like a clean board — the #4690 failure
-# ("could not read the input" must never look like "input is clean") with a
-# timestamp on it. Failing costs nobody a PR: this workflow gates no branch and
-# blocks no queue.
-#
-# ## NO anchor configured is a SUPPORTED configuration (objectui#8740)
-#
-# The one case deliberately carved out of the paragraph above: an install where
-# the anchor variable is UNSET delivers to the run summary, emits a `::notice::`
-# naming the variable, and exits 0. It is not a failure because there is nothing
-# to fix — this is a settled configuration here, not a lapse. objectui's
-# maintainer closed objectui#7852 and #5986 (「7852 太麻烦,直接关闭」), so the
-# anchor setup will not happen in this install, and the previous behaviour left
-# a scheduled job red four times a day forever. objectui#6596 ruled on exactly
-# that shape: A CHECK RED ON THE HEALTHY CASE TRAINS EVERYONE TO IGNORE RED —
-# permanent red is the larger defect, and it costs the sibling install its alarm
-# too, because a lane where every run is red carries no signal when a real one
-# goes red.
-#
-# ⛔ The carve-out is EMPTY-ONLY and does not widen. A configured anchor that is
-# malformed, missing, or refuses the write still fails loudly (see the "Resolve
-# the anchor" step and the guarded write below): "nobody asked for delivery" and
-# "delivery was asked for and failed" are opposite facts and must not share an
-# exit code. The ACCEPTED COST of the empty branch is written down rather than
-# discovered later: findings then live only in run summaries, which notify
-# nobody — objectui#5791 measured what that costs on this board (7 blocks whose
-# blocker had already closed, 58% of the machine-readable blocks false, one for
-# a week).
-#
-# ## Adopting this in a sibling repo (#11217)
-#
-# This file is REPO-AGNOSTIC and is meant to be copied verbatim. It was not:
-# installed in objectstack alone, it left 37 of the fleet's 59 open `pm:blocked`
-# cards outside any patrol, and a hand-run of H19's predicate over objectui's
-# blocked inventory found 7 blocks whose blocker had already closed — 58% of
-# that repo's machine-readable blocks were false, one of them for a week. The
-# same predicate had been catching objectstack's four every hour. The difference
-# was never discipline; it was that one repo had a caller.
-#
-# To adopt, in the sibling repo:
-#
-# 1. copy this file, unchanged — it checks the sweeper out of objectstack,
-# so no script is copied (objectui#10208);
-# 2. OPTIONAL since objectui#8740 — open one `tracking`-labeled anchor issue
-# there and set the repository VARIABLE `HALF_STATE_ANCHOR_ISSUE` to its
-# number (Settings → Secrets and variables → Actions → Variables). Skip it
-# and the install still sweeps and still reports, into each run's summary
-# only; the trade is stated under "NO anchor configured" above and it is a
-# real one — a summary notifies nobody.
-#
-# That is the whole install. The swept repo needs no configuration at all: it is
-# `github.repository`, so the copy reads the board it lives in — a hardcoded
-# default was how a copied file could have swept THIS repo and written the
-# findings into a sibling's anchor, a fully green report about the wrong board.
-#
-# ⛔ Each install uses its OWN `secrets.GITHUB_TOKEN` and reads its own repo. No
-# cross-repo credential, no matrix over repos, no PAT: that route was refused at
-# grading (it buys no coverage a per-repo install lacks and raises the
-# credential floor for every repo at once). The accepted consequence is that a
-# cross-repo `Blocked-by:` target stays UNJUDGED in each install — H19 says so
-# in its own row rather than reading it as a healthy block.
+# This board's standing caller of objectstack's half-state patrol: the composite
+# action `objectstack-ai/objectstack/.github/actions/half-state-patrol`, pinned
+# to an objectstack commit sha (objectui#11174, objectui's half of
+# objectstack-ai/objectstack#18471).
+#
+# ## What lives here, and what does not
+#
+# This file holds only what is THIS board's: when the patrol runs, what it may
+# touch, and the inputs whose values are decided here. Everything else — the
+# sweeper (objectstack's `scripts/pm/check-half-states.mjs`), how its findings
+# are rendered and delivered, and when a run goes red — is the action's, and
+# the action's own header is the authority on it. ⛔ Do not restate that
+# behaviour here. A copy of upstream prose describes upstream as of the day it
+# was copied and no later: objectui#10208 retired this repository's copy of the
+# sweeper for that reason, and objectui#11174 retired the copied steps and the
+# prose adopted with them.
+#
+# ## The pin
+#
+# The `uses:` ref is a 40-character objectstack commit sha, ⛔ never `@main`:
+# `@main` would adopt whatever landed upstream an hour ago, with no reviewed
+# moment in this repository and nothing to roll back to. A composite action
+# referenced at a sha makes the runner place the WHOLE objectstack repository
+# at that sha, so the pin freezes the sweeper as well as `action.yml`.
+#
+# TO BUMP: pin objectstack `main`'s tip as read at that moment, and record the
+# sha and the time it was read in the pull request. ⛔ Not the action's own last
+# commit — that would roll the sweeper back to wherever it stood when
+# `action.yml` last changed (objectui#11174, the seat's ruling on which sha to
+# pin). The pin must contain objectstack `79114850f`, where `anchor-optional`
+# was declared: an older one receives that input as an unexpected one, which
+# the runner only warns about, and every scheduled run here goes red.
+#
+# The sha spelling is a declared exception to this repository's action-ref
+# convention: `DECLARED_EXCEPTIONS` in `scripts/check-action-ref-convention.mjs`
+# carries this workflow, this action and the reason.
+#
+# ## No anchor issue, by decision (objectui#8740)
+#
+# This board has no pinned anchor issue: the maintainer declined the setup and
+# closed objectui#7852 for it. The step below says so BY
+# NAME rather than by omission — `anchor-issue` is passed empty and
+# `anchor-optional: true` declares empty to be this board's supported
+# configuration (objectstack-ai/objectstack#20793, decision A). The action then
+# sweeps, puts the rendered findings in the run summary, emits a `::notice::`,
+# skips the anchor write and passes. An issue number is only ever meaningful in
+# the repo it was minted in, which is why nothing here guesses one.
+#
+# ⚠️ The accepted cost, stated rather than rediscovered: the findings live only
+# in run summaries, which notify nobody — objectui#5791 measured what that
+# silence costs on this board. A seat reading this board's half-states reads the
+# patrol's run summaries; there is no anchor to read.
+#
+# ⛔ The opt-in is empty-only, and the action enforces that, not this file: a
+# non-numeric `anchor-issue`, or a sweep that could not run, still fails the
+# run. Delivering to an anchor is one input away (a `tracking`-labeled issue's
+# number as `anchor-issue`), but it reverses the maintainer's decline, so it is
+# the maintainer's call.
+#
+# ## The closed-card floor (objectui#5985)
+#
+# `closed-floor` holds the sweeper's closed-card reader (`pm:*` labels left on
+# cards that already closed) to cards closed on or after 2026-08-28 — the date
+# the strip-on-close convention was written into the pm-dispatch protocol's
+# label-discipline section. `pm:*` on a card closed before the convention
+# existed is inert history, since the dispatch loop reads state on OPEN cards
+# only. The measurement taken on 2026-08-24 says what an unfloored reader does
+# here: 815 closed cards carried `pm:dispatched`, and ~87% of the issues in the
+# reader's window carried some `pm:*` residue, against the 26% upstream
+# measured — rows that report the convention rather than a defect, and that
+# consume the whole body budget. Judging from the cutover forward buys the
+# row's value with no backfill: ⛔ no bulk relabelling of closed cards was run
+# and none is owed.
+#
+# ⛔ Do not move the date EARLIER without re-measuring: every day it moves back
+# pulls in cards closed under no convention at all. The floor is UPSTREAM code;
+# what is this repository's own is the value passed to it.
on:
schedule:
@@ -173,309 +87,66 @@ on:
# same minute would keep reading the board mid-heal — reporting half-states
# the healer is in the middle of pairing, i.e. manufacturing findings that
# clear themselves. :37 puts this sweep in the quiet part of the healer's
- # cycle in both directions. Four runs/day rather than hourly: H13's own
- # threshold is 2h and the incident it comes from sat ~26h, so six-hourly
- # detection is two orders of magnitude better than the status quo (never)
- # while staying cheap on the core quota this sweep shares with the loop's
- # hot path.
+ # cycle in both directions. Four runs a day rather than hourly keeps it
+ # cheap on the core API quota it shares with the dispatch loop's hot path.
- cron: '37 1,7,13,19 * * *'
workflow_dispatch: {}
- # Changes to the patrol itself get exercised before they merge — the same
- # posture as engine-split-metric.yml. On a pull_request run the sweep still
- # executes (that is the point: the transport, the flags and the rendering are
- # proven on a real runner), but the anchor write is skipped and the rendered
- # body goes to the run's step summary instead. A PR must never rewrite the
- # board's pinned view.
+ # A change to the patrol is exercised before it merges: on a pull_request run
+ # the action still sweeps, proving the pin, the inputs and the transport on a
+ # real runner, but it skips both anchor steps and writes no issue.
+ #
+ # ⚠️ That skip means a pull_request run cannot show the no-anchor opt-in at
+ # all. The first scheduled or dispatched run after a change here is the live
+ # reading of it: `configured=false`, a `::notice::`, the anchor write skipped,
+ # and the run green.
pull_request:
paths:
- # The sweeper and the helper it imports come from the objectstack
- # checkout below (objectui#10208), so this file is the only path in this
- # repository a change can break the patrol through.
+ # The action and the sweeper both arrive at the pinned sha, so this file
+ # is the only path in this repository a change can break the patrol
+ # through — and bumping the pin is an edit to it.
- '.github/workflows/half-state-patrol.yml'
-# Least privilege: this job reads the repo and writes exactly one issue BODY.
-# `issues: write` is the narrowest scope GitHub offers for that edit; the job
-# never uses it for labels, comments, assignees or state, and the sweeper it
-# calls is read-only against the API by construction.
+# What the action asks of its caller's token: `contents: read`, and
+# `issues: write` for its single anchor-body PATCH. With no anchor configured
+# here that write is never attempted, and the token is never used for labels,
+# comments, assignees or state.
permissions:
contents: read
issues: write
-# One patrol at a time. A scheduled run overlapping a manual dispatch would have
-# two runs racing to rewrite the same body, and the loser's findings would vanish
-# with no trace but an edit-history entry.
+# One patrol at a time. A scheduled run overlapping a manual dispatch would read
+# the board twice on one request budget and, were an anchor ever configured,
+# race to rewrite the same body — the loser's findings vanishing with no trace
+# but an edit-history entry.
concurrency:
group: half-state-patrol
cancel-in-progress: false
-env:
- # The pinned anchor issue whose body this workflow owns — the ONE per-repo
- # input this file takes (#11217).
- #
- # Resolution: the repository variable `HALF_STATE_ANCHOR_ISSUE` if set, else
- # this repo's own pinned number, else EMPTY — and empty makes the job report
- # to the run summary instead of guessing a number, without failing (see the
- # "Resolve the anchor" step). ⛔ Empty is the ONLY branch that does not fail;
- # a number that is present and unusable still does. The literal is guarded by
- # the repository name on purpose: an unguarded fallback is what
- # would let a verbatim copy in objectui rewrite ITS #9857 — some unrelated
- # card — with this board's findings, silently and four times a day. A number
- # is only ever meaningful in the repo it was minted in.
- #
- # TO ROTATE (here): open a new `tracking`-labeled issue, put its number below,
- # and note the handover in the OLD issue's body before closing it (its edit
- # history is the archive and does not travel).
- # TO ADOPT (a sibling repo): change NOTHING here — set the repository variable,
- # or set nothing and read the run summaries (objectui#8740).
- #
- # The anchor deliberately carries `tracking` and NO `domain:*` label: `tracking`
- # is in the sweeper's own H13_EXEMPT_LABELS, so the anchor can never appear as a
- # finding in the sweep it hosts.
- #
- # ⚠️ Folded scalar, and every continuation line sits at the SAME indent on
- # purpose: a more-indented line in a `>-` block keeps its newline literally
- # (measured on this very value), which would hand the expression parser a
- # multi-line string instead of one expression.
- ANCHOR_ISSUE: >-
- ${{ vars.HALF_STATE_ANCHOR_ISSUE
- || (github.repository == 'objectstack-ai/objectstack' && '9857')
- || '' }}
-
jobs:
patrol:
name: Live half-state sweep
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
+ # The SUBJECT of the sweep, not a source of the code that runs it: the
+ # sweeper reads this board's own workflows and tracked files from this
+ # checkout, and refuses to run without it.
- name: Checkout repository
uses: actions/checkout@v7
- # The sweeper, from objectstack (objectui#10208). A public repository, so
- # the default token reads it; `persist-credentials: false` because nothing
- # here writes to it. Sparse: `scripts/pm` in cone mode also brings the
- # files directly under `scripts/`, which is where the one helper the
- # sweeper imports (`scripts/invoked-as.mjs`) lives. The directory is
- # untracked in this checkout, so the sweeper's `git ls-files` reads of
- # this board's tree do not see it.
- - name: Check out the objectstack sweeper
- uses: actions/checkout@v7
- with:
- repository: objectstack-ai/objectstack
- path: objectstack-tooling
- sparse-checkout: scripts/pm
- persist-credentials: false
-
+ # Stays in the caller: the action holds no Node pin of its own and only
+ # refuses a runner older than the floor its own tree declares.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
- # No `pnpm install`: the sweeper imports only `node:` builtins, global
- # `fetch` and one helper from its own checkout. Installing the workspace
- # here would buy nothing and would give a scheduled patrol a lockfile it
- # could fail on.
- - name: Run the live sweep
- id: sweep
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- # WHICH board this run reads: the repo this workflow is installed in,
- # always. The sweeper would resolve the same answer on its own from
- # the runner's `GITHUB_REPOSITORY` (`resolveSweepRepo`), and it is
- # passed explicitly anyway so the wiring is visible to a reader of the
- # workflow — the two agree by construction and a copy of this file
- # cannot end up sweeping the repo it was copied FROM.
- PM_SWEEP_REPO: ${{ github.repository }}
- # WHICH checkout serves that board: this one, the workspace root. The
- # sweeper's local git reads (H17's tracked-file oracle, H57's
- # workflow classification) take it as their cwd, and a tree whose
- # `origin` is not the swept board REFUSES (exit 3) rather than
- # reading the objectstack checkout's files as this board's.
- PM_SWEEP_CHECKOUT: ${{ github.workspace }}
- # objectui#5985 — H22's closed-card reader is ON, with a DATED FLOOR:
- # only cards closed on/after this date are judged. See this file's
- # header for the measurement and the reasoning, and
- # `resolveClosureFloor` in the sweeper for the parsing and the loud
- # refusal. The closed-card window is the sweeper's own upstream
- # default; the floor is what keeps cards closed before the cutover
- # out of it.
- PM_SWEEP_CLOSED_FLOOR: '2026-08-28'
- PROVENANCE: >-
- run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
- · commit `${{ github.sha }}` · trigger `${{ github.event_name }}`
- run: |
- set +e
- node objectstack-tooling/scripts/pm/check-half-states.mjs \
- --format=markdown \
- --provenance="$PROVENANCE" \
- > "$RUNNER_TEMP/report.md" 2> "$RUNNER_TEMP/report.err"
- code=$?
- set -e
- # Captured with NO pipe in between. `cmd | tail` would report the
- # PIPE's status — `tail` essentially never fails, so a green and a red
- # sweep both read as 0, and the script's own header calls this trap out
- # by name (its exit codes are 0 / 2 / 3 and the split is the point).
- echo "exit_code=$code" >> "$GITHUB_OUTPUT"
- echo "check-half-states exited $code"
- cat "$RUNNER_TEMP/report.err" >&2 || true
-
- - name: Resolve the anchor issue
- # This step decides, ONCE, whether this install delivers to an anchor,
- # and publishes that decision as `configured` for the steps below. ⛔ Do
- # not re-derive emptiness anywhere else in this file: two spellings of
- # "no anchor" drift, and the pair that matters here would drift into a
- # run that reports "not configured" and then PATCHes issue 0.
- #
- # Three outcomes, and the distinction between the first two is the whole
- # of objectui#8740:
- #
- # EMPTY — nobody asked for anchor delivery. `::notice::`, `exit 0`,
- # and the rendered body reaches the run summary below. Not a
- # failure: the maintainer declined the anchor setup here
- # (objectui#7852 closed 「太麻烦」), so a red run every six
- # hours would report a settled configuration as a defect, and
- # objectui#6596 already ruled that a check red on the healthy
- # case trains everyone to ignore red.
- # BAD — an anchor WAS configured and cannot be used (non-numeric).
- # `::error::` and `exit 1`, unchanged. Delivery was asked for
- # and did not happen; that is the patrol reporting its own
- # death and it must never share an exit code with the branch
- # above. ⛔ objectui#8740 does not widen into "the anchor step
- # never fails".
- # NUMBER — deliver, and let the write's own failure fail the run.
- #
- # Placed AFTER the sweep so the run summary still carries the rendered
- # findings (the same "land the truth, then raise the alarm" order the
- # final step keeps), and skipped on a pull_request run, which never
- # writes an anchor at all.
- id: anchor
- if: github.event_name != 'pull_request'
- run: |
- if [ -z "${ANCHOR_ISSUE//[[:space:]]/}" ]; then
- echo "configured=false" >> "$GITHUB_OUTPUT"
- echo "::notice::No anchor issue configured for ${{ github.repository }}, which is a supported configuration here (objectui#8740). The sweep RAN and its rendered body is in this run's summary; nothing was written to any issue and this run is NOT a failure. To deliver findings to a pinned issue instead, open a \`tracking\`-labeled anchor issue in this repo and set the repository variable HALF_STATE_ANCHOR_ISSUE to its number (Settings -> Secrets and variables -> Actions -> Variables)."
- exit 0
- fi
- case "$ANCHOR_ISSUE" in
- *[!0-9]*|'') echo "::error::HALF_STATE_ANCHOR_ISSUE is '$ANCHOR_ISSUE', which is not an issue number."; exit 1 ;;
- esac
- echo "configured=true" >> "$GITHUB_OUTPUT"
- echo "anchor: #$ANCHOR_ISSUE in ${{ github.repository }}"
-
- - name: Update the pinned anchor issue
- # A pull_request run proves the sweep; it must not touch the board. And
- # an install with no anchor has no board write to attempt at all: the
- # guard reads the resolve step's OUTPUT rather than testing
- # `env.ANCHOR_ISSUE` again, so this step runs exactly when that step
- # decided delivery was asked for. Tested against `env.ANCHOR_ISSUE != ''`
- # this would disagree with the resolve step on a whitespace-only value
- # and PATCH `Number(' ')` = issue 0 (objectui#8740).
- if: github.event_name != 'pull_request' && steps.anchor.outputs.configured == 'true'
- uses: actions/github-script@v9
- env:
- SWEEP_EXIT: ${{ steps.sweep.outputs.exit_code }}
+ - name: Sweep the board
+ uses: objectstack-ai/objectstack/.github/actions/half-state-patrol@0c5a71b0942d54e56ad365f0b2a173fd84c1c5e1
with:
- # Delivery is retried, never assumed (#9575): this single PATCH is the
- # entire product of the run, and a transient answer from the issues
- # endpoint would otherwise discard a completed sweep.
- retries: 3
- script: |
- const fs = require('fs');
- const path = require('path');
- const exitCode = Number(process.env.SWEEP_EXIT);
- const anchor = Number(process.env.ANCHOR_ISSUE);
- const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
- const read = (name) => {
- try { return fs.readFileSync(path.join(process.env.RUNNER_TEMP, name), 'utf8'); }
- catch { return ''; }
- };
-
- // The composition split, deliberately: a COMPLETED sweep renders its
- // own body (in the script, where --self-test pins every property of
- // it). Only the did-not-run body is composed here, because saying
- // "my callee failed" is the caller's job and the script's classified
- // output is already the authored explanation — this wraps it, it
- // does not re-word it.
- let body;
- if (exitCode === 0) {
- body = read('report.md');
- if (!body.trim()) {
- throw new Error('the sweep exited 0 but produced an empty report — refusing to blank the anchor');
- }
- } else {
- const classified = (read('report.err') || read('report.md') || '(no output captured)').trim();
- const kind = exitCode === 3
- ? 'PREREQUISITE NOT MET — the runner could not reach the board'
- : 'SWEEP FAILED — an unclassified failure';
- body = [
- 'os-half-state-sweep — machine-findable marker for this generated view.',
- '',
- `# ⛔ THE SWEEP DID NOT RUN (exit ${exitCode})`,
- '',
- `_Attempted ${new Date().toISOString()} · [run log](${runUrl}) · ${kind}._`,
- '',
- 'Nothing below is a finding. **No issue was judged**, so this body says nothing about whether',
- 'the board carries half-states — it is not a clean board and it is not a dirty one, it is no',
- 'reading at all. A sweep that could not run must never read as a clean board.',
- '',
- 'The standing patrol is DOWN until this is fixed; the previous run\'s findings are in this',
- 'issue\'s edit history. The sweeper\'s own classified output:',
- '',
- '```',
- classified,
- '```',
- ].join('\n');
- }
-
- await github.rest.issues.update({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: anchor,
- body,
- });
- core.info(`anchor #${anchor} updated (${body.length} chars, sweep exit ${exitCode})`);
-
- - name: Publish the rendered body to the run summary
- # Always: on a PR this IS the delivery, and on a scheduled run it makes
- # the run log self-contained when someone opens it after an alert.
- if: always()
- run: |
- {
- echo "### Half-state patrol — sweep exit ${{ steps.sweep.outputs.exit_code }}"
- echo
- if [ "${{ github.event_name }}" = "pull_request" ]; then
- echo "_Anchor write skipped: a pull_request run proves the sweep without touching the board._"
- echo
- elif [ "${{ steps.anchor.outputs.configured }}" = "false" ]; then
- # ⛔ Only on the EXPLICIT not-configured reading. A malformed
- # anchor sets no output and fails the run, and must not be
- # described here as "nothing was asked for" (objectui#8740).
- echo "_No anchor issue configured (\`HALF_STATE_ANCHOR_ISSUE\` is unset): this summary is the ONLY delivery of the findings below — it notifies nobody, by accepted trade. Set that repository variable to a \`tracking\` issue's number to have them land on a pinned card instead._"
- echo
- fi
- echo 'Rendered anchor body
'
- echo
- cat "$RUNNER_TEMP/report.md" 2>/dev/null || echo '(no report produced)'
- echo
- echo ' '
- echo
- echo 'stderr
'
- echo
- echo '```'
- cat "$RUNNER_TEMP/report.err" 2>/dev/null || true
- echo '```'
- echo
- echo ' '
- } >> "$GITHUB_STEP_SUMMARY"
-
- - name: Fail the run if the sweep could not run
- # LAST, on purpose: the anchor is updated with the did-not-run report
- # BEFORE the job goes red. Land the truth, then raise the alarm — a run
- # that failed early would leave the previous body in place with its old
- # timestamp, which is precisely the stale-reads-as-clean shape above.
- #
- # Findings are NOT a failure condition and never appear here: exit 0 with
- # 40 half-states is a successful patrol.
- if: steps.sweep.outputs.exit_code != '0'
- run: |
- echo "::error::check-half-states exited ${{ steps.sweep.outputs.exit_code }} — the standing patrol did not read the board. See the anchor issue and this run's stderr."
- exit 1
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ # Empty on purpose, with the opt-in beside it: see "No anchor issue,
+ # by decision" above.
+ anchor-issue: ''
+ anchor-optional: true
+ closed-floor: '2026-08-28'
diff --git a/content/docs/guide/ci-cd-pipeline.md b/content/docs/guide/ci-cd-pipeline.md
index 604df2347c..1553d2a6f1 100644
--- a/content/docs/guide/ci-cd-pipeline.md
+++ b/content/docs/guide/ci-cd-pipeline.md
@@ -2627,73 +2627,88 @@ and calls the issues API.
**Trigger:** Four times a day at `:37` past the hour (cron `37 1,7,13,19 * * *`), manual dispatch,
or a pull request touching the workflow itself.
-Checks out `objectstack-ai/objectstack`, runs *its* `scripts/pm/check-half-states.mjs` against
-**this** repository's issue board and rewrites one pinned anchor issue's body with what it found.
-The sweeper carries a family of predicates over the dispatch protocol's label/assignee/PR
-invariants — a `pm:dispatched` card with no assignee, a card carrying both `pm:queue` and
-`pm:dispatched`, a merged PR whose card still says it is in flight, a `Blocked-by:` block whose
-blocker already closed, and so on.
+Runs objectstack's half-state patrol against **this** repository's issue board. The workflow is a
+caller and nothing more: it checks this repository out, sets up Node, and calls the composite action
+`objectstack-ai/objectstack/.github/actions/half-state-patrol`, pinned to an objectstack commit sha
+([#11174](https://github.com/objectstack-ai/objectui/issues/11174)). The action carries objectstack's
+sweeper, `scripts/pm/check-half-states.mjs` in that repository, whose predicates check the dispatch
+protocol's label/assignee/PR invariants — a `pm:dispatched` card with no assignee, a card carrying
+both `pm:queue` and `pm:dispatched`, a merged PR whose card still says it is in flight, a
+`Blocked-by:` block whose blocker already closed, and so on. How the findings are rendered and
+delivered, and when a run goes red, belong to the action, whose own header is the authority on them;
+this page does not restate them.
**Report-only, and this is a rule rather than a description.** The job never writes a label, never
-closes a card, never fixes a state, and no finding fails anything: a completed sweep exits 0 whether
-it found 0 half-states or 40. Its one write is the anchor issue's body, and `permissions:` grants
-nothing beyond `contents: read` + `issues: write`. A pull-request run proves the sweep on a real
-runner but skips the anchor write entirely, publishing the rendered body to the run summary instead.
-
-The run *does* go red when the sweep could not run, or when a **configured** anchor could not be
-written — that is the patrol reporting its own death, not a gate on the board. A workflow that
-quietly does nothing because a credential lapsed would leave a stale anchor body that reads exactly
-like a clean board. For the same reason the `Swept` timestamp is refreshed even when the findings
-are unchanged: a timestamp that stops advancing is how a reader learns the standing caller died.
-
-**The anchor is optional, and unset is a supported configuration**
-([#8740](https://github.com/objectstack-ai/objectui/issues/8740)). The anchor issue is named by the
-repository *variable* `HALF_STATE_ANCHOR_ISSUE` (Settings → Secrets and variables → Actions →
-Variables). With it unset — which is this repository's state, after the maintainer declined the
-setup on [#7852](https://github.com/objectstack-ai/objectui/issues/7852) — the job still sweeps,
-publishes the rendered body to the **run summary**, emits a `::notice::` naming the variable, and
-**exits 0**. It will not guess an issue number and rewrite an unrelated card, and it no longer goes
-red for a settled configuration: a check red on the healthy case trains everyone to ignore red
-([#6596](https://github.com/objectstack-ai/objectui/issues/6596)), and this one was red four times
-a day.
-
-⛔ That carve-out is **empty-only**. An anchor that is configured and unusable — a non-numeric
-value, an issue that is gone, a rejected write — still fails the run loudly: *nobody asked for
-delivery* and *delivery was asked for and failed* are opposite facts and do not share an exit code.
-The accepted cost of the empty branch, stated rather than discovered later, is that findings then
-live only in run summaries, which notify nobody
-([#5791](https://github.com/objectstack-ai/objectui/issues/5791) measured 58% of this board's
-machine-readable blocks false, one for a week, in exactly that silence).
-
-**The sweeper is objectstack's; this repository keeps no copy of it**
-([#10208](https://github.com/objectstack-ai/objectui/issues/10208)). The workflow checks it out of
-`objectstack-ai/objectstack` at that repository's `main` on every run — not pinned to a sha — and
-names this board with `PM_SWEEP_REPO` and this checkout with `PM_SWEEP_CHECKOUT`. What is decided
-here is how the sweeper's closed-card reader (`pm:*` labels left on cards that already closed) is
-*called*: the reader is **on**, with a dated floor. The sweep step sets
-`PM_SWEEP_CLOSED_FLOOR: '2026-08-28'`, so only cards closed on or after that cutover are judged, and
-the closed-card window is left to the sweeper's own upstream default, which it exports as
-`CLOSED_ISSUE_WINDOW_DAYS` rather than being restated here, a constant copied into prose being a
-number that rots the moment the export moves.
+closes a card, never fixes a state, and no finding fails anything: a completed sweep passes whether
+it found 0 half-states or 40. The run goes red when the sweep could not run — the patrol reporting its
+own death, not a gate on the board. `permissions:` grants nothing beyond `contents: read` +
+`issues: write`, the latter for an anchor write this repository does not ask for (below).
+
+**The pin.** The `uses:` ref is a 40-character objectstack commit sha, never `@main`: `@main` would
+adopt whatever landed upstream an hour ago, with no reviewed moment in this repository and nothing to
+roll back to. The runner places the whole objectstack repository at the referenced commit, so the pin
+freezes the sweeper as well as the action. To bump it, pin objectstack `main`'s tip as read at that
+moment and record the sha and the time it was read in the pull request — not the action's own last
+commit, which would roll the sweeper back to wherever it stood when the action last changed. The pin
+must contain objectstack `79114850f`, where `anchor-optional` was declared. The sha spelling is a
+declared exception to the Action Ref Convention above, in that gate's `DECLARED_EXCEPTIONS` table.
+
+**What this repository decides is the step's inputs** — the whole of this install's configuration:
+
+- `github-token` — this workflow's own `secrets.GITHUB_TOKEN`; each install reads and writes its own
+ board only.
+- `anchor-issue` — passed **empty**, and `anchor-optional` — passed as `true`: together they declare
+ that this board has no anchor issue by decision (next paragraph).
+- `closed-floor` — `'2026-08-28'`, the dated floor on the sweeper's closed-card reader (further below).
+
+**No anchor issue, and that is a supported configuration**
+([#8740](https://github.com/objectstack-ai/objectui/issues/8740)). The maintainer declined the anchor
+setup on [#7852](https://github.com/objectstack-ai/objectui/issues/7852), so this board has no pinned
+anchor issue, and the workflow says so by name rather than by omission: an empty `anchor-issue` beside
+`anchor-optional: true`, the opt-in objectstack declared for exactly this board
+([objectstack#20793](https://github.com/objectstack-ai/objectstack/issues/20793), decision A). On a
+scheduled or dispatched run the action then sweeps, publishes the rendered findings to the **run
+summary**, emits a `::notice::`, skips the anchor write, and **passes**. It will not guess an issue
+number and rewrite an unrelated card, and it does not go red for a settled configuration: a check red
+on the healthy case trains everyone to ignore red
+([#6596](https://github.com/objectstack-ai/objectui/issues/6596)), and before #8740 this one was red
+four times a day.
+
+⛔ The opt-in is **empty-only**: a non-numeric `anchor-issue`, or a sweep that could not run, still
+fails the run. The accepted cost, stated rather than discovered later, is that findings live only in
+run summaries, which notify nobody ([#5791](https://github.com/objectstack-ai/objectui/issues/5791)
+measured 58% of this board's machine-readable blocks false, one for a week, in exactly that silence).
+A seat reading this board's half-states reads the patrol's run summaries; there is no anchor to read.
+
+⚠️ A pull-request run cannot show any of this: it sweeps, but skips both anchor steps. The first
+scheduled or dispatched run after a change to the workflow is the live reading of the opt-in — the
+resolve step's `configured=false`, the `::notice::`, the anchor write skipped, and a green run.
+
+**The closed-card floor** ([#5985](https://github.com/objectstack-ai/objectui/issues/5985)).
+`closed-floor: '2026-08-28'` holds the sweeper's closed-card reader (`pm:*` labels left on cards that
+already closed) to cards closed on or after that cutover. The closed-card window itself is left to the
+sweeper's own default, which it exports as `CLOSED_ISSUE_WINDOW_DAYS` rather than being restated
+here, a constant copied into prose being a number that rots the moment the export moves.
**Running it by hand.** A seat runs the same sweeper from an objectstack checkout:
-`cd ../objectstack && PM_SWEEP_REPO=objectstack-ai/objectui PM_SWEEP_CHECKOUT=../objectui node scripts/pm/check-half-states.mjs`
+`cd ../objectstack && PM_SWEEP_REPO=objectstack-ai/objectui PM_SWEEP_CHECKOUT=../objectui PM_SWEEP_CLOSED_FLOOR=2026-08-28 node scripts/pm/check-half-states.mjs`
— the board comes from `PM_SWEEP_REPO`; the script takes no `--repo` flag and refuses one by name.
+That checkout's `main` may be ahead of the pin; check the pinned sha out first to reproduce a
+patrol run exactly.
**Why a floor rather than a plain "on".** Stripping `pm:*` on close only became this repo's practice
on the cutover date, and the measurement taken just before it says what an unfloored reader would do
here: 815 closed cards carry `pm:dispatched`, and ~87% of the issues in the default window carried
some `pm:*` residue — against the 26% upstream measured. That predicate would report the
*convention* rather than a defect, and its rows would consume the whole body budget and trim every
-other predicate out of the anchor. Cards closed before the cutover are inert history — the dispatch
-loop reads state on open cards only — so judging from the cutover forward buys the row's whole value
-with no backfill: no bulk relabelling of closed cards was run, none is owed, and the sweeper writes
-no label under any code path ([#5985](https://github.com/objectstack-ai/objectui/issues/5985)). A
-malformed floor is refused outright rather than degraded to "no floor", because a silent degrade
-would restore the flood four times a day and a flooded anchor reads exactly like a working patrol.
-Until the cutover this install switched the closed reader fully off through an objectui-only switch;
-that switch was dropped with the retired copy, not carried into objectstack
-([#10205](https://github.com/objectstack-ai/objectui/issues/10205)).
+other predicate out of the rendered body. Cards closed before the cutover are inert history — the
+dispatch loop reads state on open cards only — so judging from the cutover forward buys the row's
+whole value with no backfill: no bulk relabelling of closed cards was run, none is owed, and the
+sweeper writes no label under any code path ([#5985](https://github.com/objectstack-ai/objectui/issues/5985)).
+⛔ Do not move the date earlier without re-measuring: every day it moves back pulls in cards closed
+under no convention at all. Until the cutover this install switched the closed reader fully off
+through an objectui-only switch; that switch was dropped with the retired copy, not carried into
+objectstack ([#10205](https://github.com/objectstack-ai/objectui/issues/10205)).
### Board Snapshot (`board-snapshot.yml`)
diff --git a/scripts/__tests__/check-action-ref-convention.test.ts b/scripts/__tests__/check-action-ref-convention.test.ts
index 3e748d929c..3835032d7c 100644
--- a/scripts/__tests__/check-action-ref-convention.test.ts
+++ b/scripts/__tests__/check-action-ref-convention.test.ts
@@ -154,16 +154,19 @@ describe('the gate can fail (non-vacuity)', () => {
});
it('goes red on a stale exception — an entry that matches nothing', () => {
- // This case used to mutate the real `stale.yml`, whose SHA pin was the only
- // entry DECLARED_EXCEPTIONS ever held. objectui#8548 deleted that workflow
- // and the entry with it, so the table is empty and the shape has to be
- // reproduced over a synthetic one — which is stricter, not weaker: the
- // assertion no longer depends on one particular workflow surviving.
+ // This case used to mutate the real `stale.yml`, whose SHA pin was the first
+ // entry DECLARED_EXCEPTIONS held. objectui#8548 deleted that workflow and the
+ // entry with it, so the shape is reproduced over a synthetic entry — which is
+ // stricter, not weaker: the assertion no longer depends on one particular
+ // workflow surviving. The synthetic entry is APPENDED to the real table, so
+ // the tree's own declared exceptions stay declared and the only entry under
+ // test is this one, whatever the real table holds (objectui#11174 added one).
//
// Both halves are pinned, because only the pair says the entry is doing
// work. An entry that never silences anything would satisfy the second half
// on its own.
const declared = [
+ ...DECLARED_EXCEPTIONS,
{
workflow: 'control-bytes.yml',
action: 'actions/checkout',
@@ -208,7 +211,11 @@ describe('the gate can fail (non-vacuity)', () => {
});
it('a declared exception silences the offender, and only that one', () => {
+ // Appended to the real table for the reason the stale-exception case gives:
+ // the tree's own declared exceptions stay declared, so `fake` is the only
+ // entry whose effect this case reads.
const fake = [
+ ...DECLARED_EXCEPTIONS,
{ workflow: 'control-bytes.yml', action: 'actions/checkout', issue: 'objectui#1', reason: 'x'.repeat(50) },
];
// Two off-convention refs, in two different workflows, and `fake` names only
diff --git a/scripts/__tests__/ci-cd-pipeline-doc.test.ts b/scripts/__tests__/ci-cd-pipeline-doc.test.ts
index c0fffb36bf..8fb8c6d330 100644
--- a/scripts/__tests__/ci-cd-pipeline-doc.test.ts
+++ b/scripts/__tests__/ci-cd-pipeline-doc.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
+import { parse as parseYaml } from 'yaml';
// Plain-JS CI helper; its types are INFERRED from the .mjs source by
// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — re-adding one
@@ -1942,53 +1943,63 @@ describe('ci-cd-pipeline.md — live-e2e backend pin (#7689)', () => {
/**
* objectui#8043 — the Half-State Patrol section told readers the sweeper's closed-card reader was
- * "switched **off** here via `PM_SWEEP_CLOSED_WINDOW_PAGES: '0'`". The workflow stopped setting
- * that variable on 2026-08-28: the reader is ON with a dated floor (`PM_SWEEP_CLOSED_FLOOR`), the
- * page window is deliberately absent, and the retired knob survives only in the workflow's header
- * comments as history. So the page sent anyone looking for the switch to a variable nothing sets,
- * and — worse in the direction this page is read — it described a predicate as disabled while it
- * runs four times a day.
+ * "switched **off** here via `PM_SWEEP_CLOSED_WINDOW_PAGES: '0'`". The workflow had stopped setting
+ * that variable on 2026-08-28: the reader was ON with a dated floor, the page window deliberately
+ * absent, and the retired knob survived only in the workflow's header comments as history. So the
+ * page sent anyone looking for the switch to a variable nothing set, and — worse in the direction
+ * this page is read — it described a predicate as disabled while it ran four times a day.
*
* The `workflow inventory` block above cannot see this: it matches filenames in headings and in
* the inventory table's first column, so a
* false sentence *inside* a documented section is exactly the drift it is blind to (objectui#7852
* says so in as many words). This block closes that gap for the one thing on this page that names
- * the sweeper's wiring by identifier.
- *
- * ⛔ The comparison reads the workflow's `env:` KEYS, never the file as text. A whole-file grep
- * would find `PM_SWEEP_CLOSED_WINDOW_PAGES` in the workflow's header comment and accept the very
- * sentence this block exists to reject — the retired knob is *discussed* there precisely because
- * it is retired. `envKeysOf` below is unit-controlled against that shape.
+ * the patrol's wiring by identifier.
+ *
+ * objectui#11174 MOVED that wiring, and this block moved with it. The workflow no longer sets the
+ * sweeper's environment itself: it calls objectstack's composite action at a pinned sha and
+ * configures it through that one step's `with:` INPUTS, which the action maps onto the sweeper
+ * upstream. So the comparison is now the section against those inputs — the only place this
+ * install records its wiring. The `PM_SWEEP_*` names the section still carries belong to its
+ * hand-run recipe, which is the sweeper's own interface and not a setting of this workflow, and are
+ * deliberately not read here.
+ *
+ * ⛔ The workflow side is the patrol step's `with:` mapping, PARSED, never the file as text: the
+ * workflow's header argues about inputs (that is where the no-anchor opt-in is explained), and a
+ * whole-file grep would accept a section naming any of them. `patrolStepOf` below is
+ * unit-controlled against that shape.
+ *
+ * ⚠️ The page side is a heuristic, stated so it is not mistaken for a parser: the section's
+ * backticked kebab-case tokens, which is how an action input is spelled. A future backticked
+ * kebab-case word in this section that is NOT an input reads as a phantom below, and the failure
+ * says so — rephrase it, or teach `inputsNamedIn` the difference.
*/
const HALF_STATE_WORKFLOW = 'half-state-patrol.yml';
+/** The action every half-state install calls, up to the `@` that begins its ref. */
+const PATROL_ACTION = 'objectstack-ai/objectstack/.github/actions/half-state-patrol';
+
+type PatrolStep = { ref: string; inputs: Map };
+
/**
- * Every key of every `env:` mapping in a workflow — i.e. the variables the workflow actually SETS.
- *
- * Whole-line comments go first (`withoutComments`), and only children at exactly `env:`'s
- * indentation + 2 are read, so the continuation lines of a folded scalar (`PROVENANCE: >-` runs to
- * three of them here) cannot be mistaken for further keys.
+ * The step of a workflow that calls the patrol action: its ref, and its `with:` inputs as the YAML
+ * parser reads them (so `true` is a boolean and `''` an empty string, exactly as the runner gets
+ * them before it stringifies). `undefined` when no step calls the action — which every assertion
+ * below treats as a failure in its own right, never as "nothing to compare".
*/
-function envKeysOf(yaml: string): Set {
- const keys = new Set();
- const lines = withoutComments(yaml).split('\n');
-
- lines.forEach((line, index) => {
- const opener = line.match(/^(\s*)env:\s*$/);
- if (!opener) return;
- const openIndent = opener[1].length;
-
- for (const child of lines.slice(index + 1)) {
- if (child.trim() === '') continue;
- const indent = child.match(/^\s*/)![0].length;
- if (indent <= openIndent) break;
- if (indent !== openIndent + 2) continue;
- const key = child.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*:/);
- if (key) keys.add(key[1]);
+function patrolStepOf(yaml: string): PatrolStep | undefined {
+ type Step = { uses?: unknown; with?: Record | null };
+ type Job = { steps?: Step[] };
+ const parsed = parseYaml(yaml) as { jobs?: Record } | null;
+ for (const job of Object.values(parsed?.jobs ?? {})) {
+ for (const step of job?.steps ?? []) {
+ if (typeof step?.uses !== 'string' || !step.uses.startsWith(`${PATROL_ACTION}@`)) continue;
+ return {
+ ref: step.uses.slice(PATROL_ACTION.length + 1),
+ inputs: new Map(Object.entries(step.with ?? {})),
+ };
}
- });
-
- return keys;
+ }
+ return undefined;
}
/** The page section headed by ` (``)`, up to the next heading at that level or above. */
@@ -2002,103 +2013,179 @@ function sectionForWorkflow(file: string): string {
return (next === -1 ? after : after.slice(0, next)).join('\n');
}
-const SWEEP_NAME = /PM_SWEEP_[A-Z0-9_]+/g;
+/**
+ * The action inputs a piece of prose names: backticked kebab-case tokens, alone or as the key of a
+ * backticked `name: value`. Dotted file names, slash paths and `word:word` labels are not this
+ * shape, so they drop out on their own.
+ */
+function inputsNamedIn(prose: string): string[] {
+ const names = new Set();
+ for (const m of prose.matchAll(/`([a-z][a-z0-9]*(?:-[a-z0-9]+)+)(?::\s[^`]*)?`/g)) names.add(m[1]);
+ return [...names].sort();
+}
-describe('ci-cd-pipeline.md — Half-State Patrol sweeper wiring (#8043)', () => {
+describe('ci-cd-pipeline.md — Half-State Patrol wiring (#8043, objectui#11174)', () => {
const section = sectionForWorkflow(HALF_STATE_WORKFLOW);
- const set = [...envKeysOf(readWorkflow(HALF_STATE_WORKFLOW))].filter((k) => k.startsWith('PM_SWEEP_')).sort();
- const named = [...new Set([...section.matchAll(SWEEP_NAME)].map((m) => m[0]))].sort();
+ const step = patrolStepOf(readWorkflow(HALF_STATE_WORKFLOW));
+ const passed = [...(step?.inputs.keys() ?? [])].sort();
+ const named = inputsNamedIn(section);
it('has both sides to compare — neither may be empty', () => {
- // The vacuity legs. Each of the three below has a failure mode that renders the comparison
- // green while checking nothing, and each fails silently: a renamed heading empties the
- // section, a restructured `env:` empties the workflow side, and a rewrite that drops every
- // identifier leaves the page describing the wiring without naming any of it.
+ // The vacuity legs. Each has a failure mode that renders the comparisons below green while
+ // checking nothing, and each fails silently: a renamed heading empties the section, a step
+ // that stopped calling the action (or moved its inputs) empties the workflow side, and a
+ // rewrite that drops every input name leaves the page describing the wiring without naming it.
expect(
section,
`No heading on the page names \`${HALF_STATE_WORKFLOW}\`, so this block has no section to ` +
- 'read and its comparison below would pass vacuously. The `workflow inventory` block ' +
+ 'read and its comparisons below would pass vacuously. The `workflow inventory` block ' +
'requires that heading to exist; if it moved, teach `sectionForWorkflow` where it went.',
).not.toBe('');
expect(
- set,
- `${HALF_STATE_WORKFLOW} sets no \`PM_SWEEP_*\` variable in any \`env:\` block. Either the ` +
- 'wiring moved out of `env:` — in which case `envKeysOf` is reading the wrong thing and ' +
- 'every name on the page would now be reported as a phantom — or the sweeper is no longer ' +
- 'called with any of it, and this section is describing a configuration that is gone.',
+ step,
+ `${HALF_STATE_WORKFLOW} has no step whose \`uses:\` calls ${PATROL_ACTION}. Either the ` +
+ 'patrol stopped calling the action — and the section describes a configuration that is ' +
+ 'gone — or the reference changed shape and `patrolStepOf` is reading the wrong thing.',
+ ).toBeDefined();
+
+ expect(
+ passed,
+ `The patrol step in ${HALF_STATE_WORKFLOW} passes no \`with:\` input at all, so the ` +
+ "comparisons below have nothing on the workflow side. The action's `github-token` is " +
+ 'required, so a step with no inputs cannot run — this is the reader failing, not the ' +
+ 'configuration emptying.',
).not.toEqual([]);
expect(
named,
- 'The Half-State Patrol section names no `PM_SWEEP_*` variable at all. The reader needs at ' +
- 'least the closure floor: it is the one thing about this install that is not the ' +
- "sweeper's own default, and a section that omits it sends the next reader to the upstream " +
- 'script for behaviour that is decided in the workflow (objectui#8043).',
+ 'The Half-State Patrol section names no action input at all. The reader needs at least ' +
+ "the closure floor and the anchor opt-in: they are the things about this install that " +
+ "are not the action's own defaults (objectui#8043, objectui#8740).",
).not.toEqual([]);
});
- it('reads the workflow\'s env keys, not the file as text', () => {
- // The control for the paragraph above: a commented-out key is HISTORY, and a whole-file grep
- // cannot tell it from a setting. That is not hypothetical here — it is the exact shape of
- // `half-state-patrol.yml`'s header, and it is why the wrong sentence survived.
+ it("reads the patrol step's parsed inputs, not the file as text", () => {
+ // The control for the docblock above. A commented-out input is HISTORY, a folded scalar's body
+ // is a VALUE, and another step's `with:` is another action's — a text scan cannot tell any of
+ // the three from the patrol's own wiring.
const specimen = [
'jobs:',
' patrol:',
' steps:',
- ' - name: sweep',
- ' env:',
- " # PM_SWEEP_RETIRED: '0' — read this until the cutover; history, not a setting",
- " PM_SWEEP_LIVE: 'x'",
- ' FOLDED: >-',
- ' PM_SWEEP_NOT_A_KEY: still just prose',
+ ' - uses: actions/setup-node@v7',
+ ' with:',
+ " node-version: '22'",
+ ` - uses: ${PATROL_ACTION}@${'a'.repeat(40)}`,
+ ' with:',
+ " # retired-input: '0' — read until the cutover; history, not a setting",
+ " closed-floor: '2026-08-28'",
+ ' folded: >-',
+ ' not-a-key: still just prose',
'',
].join('\n');
- expect([...envKeysOf(specimen)].sort()).toEqual(['FOLDED', 'PM_SWEEP_LIVE']);
+ const read = patrolStepOf(specimen);
+ expect(read?.ref).toBe('a'.repeat(40));
+ expect([...(read?.inputs.keys() ?? [])].sort()).toEqual(['closed-floor', 'folded']);
+ expect(patrolStepOf(specimen.replace(PATROL_ACTION, 'someone/else/.github/actions/x'))).toBeUndefined();
+ // And the page-side reader: an input, an input with its value, and the three shapes that are
+ // not inputs and must drop out.
+ expect(
+ inputsNamedIn("`closed-floor` · `anchor-optional: true` · `x.yml` · `a/b-c` · `pm:dispatched`"),
+ ).toEqual(['anchor-optional', 'closed-floor']);
});
- it('names only variables the workflow actually sets', () => {
- const phantom = named.filter((name) => !set.includes(name));
+ it('names only inputs the workflow actually passes', () => {
+ const phantom = named.filter((name) => !passed.includes(name));
expect(
phantom,
- 'The Half-State Patrol section names these `PM_SWEEP_*` variables:\n' +
+ 'The Half-State Patrol section names these action inputs:\n' +
phantom.map((n) => ` - ${n}`).join('\n') +
- `\n\n…and \`${HALF_STATE_WORKFLOW}\` sets none of them. What it does set is:\n` +
- set.map((n) => ` - ${n}`).join('\n') +
- '\n\nA reader who goes looking for the knob the page names finds a variable nothing ' +
- 'assigns, and — the expensive direction — believes whatever the page says that knob is ' +
- 'doing. That is objectui#8043 verbatim: the page claimed the closed-card reader was ' +
- "switched off by `PM_SWEEP_CLOSED_WINDOW_PAGES: '0'` for the eight days after the " +
- 'workflow stopped setting it, while the reader ran four times a day. Fix the page ' +
- 'against the workflow, not the other way round: the `env:` block and the header ' +
- 'divergence list are where this install records its wiring.',
+ `\n\n…and the patrol step in \`${HALF_STATE_WORKFLOW}\` passes none of them. What it does ` +
+ 'pass is:\n' +
+ passed.map((n) => ` - ${n}`).join('\n') +
+ '\n\nA reader who goes looking for the knob the page names finds an input nothing sets, ' +
+ 'and — the expensive direction — believes whatever the page says that knob is doing. ' +
+ "That is objectui#8043's shape, one wiring later. Fix the page against the workflow: the " +
+ "patrol step's `with:` block is where this install records its wiring. (If the token is " +
+ 'a backticked kebab-case word that is not an input at all, see this block\'s docblock.)',
).toEqual([]);
});
- it('quotes the closure floor the sweep step is actually given', () => {
- const floor = withoutComments(readWorkflow(HALF_STATE_WORKFLOW)).match(
- /^\s*PM_SWEEP_CLOSED_FLOOR:\s*'([^']+)'\s*$/m,
- )?.[1];
+ it('names every input the workflow passes', () => {
+ const missing = passed.filter((name) => !named.includes(name));
expect(
- floor,
- '`PM_SWEEP_CLOSED_FLOOR` is no longer set to a quoted literal in ' +
+ missing,
+ `The patrol step in \`${HALF_STATE_WORKFLOW}\` passes these inputs and the Half-State ` +
+ 'Patrol section never names them:\n' +
+ missing.map((n) => ` - ${n}`).join('\n') +
+ "\n\nThe section presents the step's inputs as the whole of this install's configuration, " +
+ 'so an input it omits is a setting a reader cannot learn from this page exists.',
+ ).toEqual([]);
+ });
+
+ it('quotes the closure floor the step is actually given', () => {
+ const floor = step?.inputs.get('closed-floor');
+
+ expect(
+ typeof floor === 'string' && floor !== '' ? floor : undefined,
+ '`closed-floor` is no longer passed to the patrol action as a non-empty value in ' +
`${HALF_STATE_WORKFLOW}. If the floor was removed, the closed-card reader now judges the ` +
- 'whole window and the section above is wrong in the other direction; if it merely moved ' +
- 'to an expression, this assertion needs to read it from wherever the value now lives.',
+ 'whole window and the section is wrong in the other direction; if it merely moved to an ' +
+ 'expression, this assertion needs to read it from wherever the value now lives.',
).toBeDefined();
- expect(named, 'the section must keep naming the floor variable').toContain('PM_SWEEP_CLOSED_FLOOR');
+ expect(named, 'the section must keep naming the floor input').toContain('closed-floor');
expect(
section,
- `The workflow floors H22 at ${floor}, and the Half-State Patrol section does not say so. ` +
- 'The date is the whole of the divergence — it is what separates "the reader is off" from ' +
- '"the reader judges everything closed since the convention started" — so a page that ' +
- 'names the variable without its value tells a reader nothing they can check.',
- ).toContain(floor!);
+ `The workflow floors the closed-card reader at ${String(floor)}, and the Half-State Patrol ` +
+ 'section does not say so. The date is the whole of the decision — it is what separates ' +
+ '"the reader is off" from "the reader judges everything closed since the convention ' +
+ 'started" — so a page that names the input without its value tells a reader nothing they ' +
+ 'can check.',
+ ).toContain(String(floor));
+ });
+
+ it('describes the anchor configuration the step actually passes (objectui#8740)', () => {
+ // The section says this board has NO anchor issue and that this is declared, not omitted.
+ // Both halves of that declaration are read off the step: an anchor number passed here, or the
+ // opt-in dropped, makes the section's account of where the findings go false.
+ const anchor = step?.inputs.get('anchor-issue');
+ const optional = step?.inputs.get('anchor-optional');
+
+ expect(
+ { anchor, optional: String(optional) },
+ `The patrol step in ${HALF_STATE_WORKFLOW} no longer passes an empty \`anchor-issue\` ` +
+ 'beside `anchor-optional: true`. The Half-State Patrol section tells readers this board ' +
+ 'has no anchor issue by decision and that its findings live in run summaries ' +
+ '(objectui#8740, objectstack-ai/objectstack#20793 decision A). If an anchor was ' +
+ 'configured — which reverses the maintainer\'s decline on objectui#7852 — rewrite that ' +
+ 'part of the section with it; if the opt-in was dropped, every scheduled run is now red.',
+ ).toEqual({ anchor: '', optional: 'true' });
+
+ expect(section).toContain('`anchor-optional: true`');
+ expect(named).toContain('anchor-issue');
+ });
+
+ it('pins the action to a 40-character sha, as the section says — never a branch', () => {
+ // The half of the pin ruling no other check holds. The Action Ref Convention gate's
+ // exception for this reference is matched by workflow and action, not by spelling, so
+ // `@main` in place of the sha would pass that gate untouched.
+ expect(
+ step?.ref,
+ `${HALF_STATE_WORKFLOW} calls ${PATROL_ACTION} at \`@${step?.ref}\`, which is not a ` +
+ "40-character commit sha. A sibling board pins the action to a sha and never to `@main` " +
+ '(objectstack-ai/objectstack#18471): `@main` adopts every upstream change on the next ' +
+ 'run with no reviewed moment and nothing to roll back to. To bump the pin, take ' +
+ "objectstack `main`'s tip as read at that moment and record it in the pull request.",
+ ).toMatch(/^[0-9a-f]{40}$/);
+
+ expect(section).toContain('40-character objectstack commit sha');
+ expect(section).toContain('never `@main`');
});
});
@@ -3505,6 +3592,14 @@ const SWEEP_DECLARED_NON_RUN_COMMANDS = new Map([
'reproduction spelling and names the raw `--test` spelling beside it. ⛔ Whether such an ' +
'alias should ever merge is a decision about the RULE — reported, not taken here.',
],
+ [
+ 'half-state-patrol.yml: scripts/pm/check-half-states.mjs',
+ "objectstack's sweeper, in objectstack's tree — no such file exists in this one. The section " +
+ 'names it as what the pinned objectstack action runs, and again in the hand-run recipe a ' +
+ 'seat types inside an objectstack checkout. ⚠️ Dual-eligible: since objectui#11174 this ' +
+ 'job has no `run:` step at all (its work is the `uses:` of that action), so the rule reads ' +
+ 'no command on the workflow side either.',
+ ],
[
'hook-selftests.yml: scripts/dependabot-merge-gate.mjs',
'Named as what classifies this check `OPTIONAL_CONTEXTS`. ⚠️ Dual-eligible: the job drives ' +
diff --git a/scripts/check-action-ref-convention.mjs b/scripts/check-action-ref-convention.mjs
index 82f677e629..74c67eacde 100644
--- a/scripts/check-action-ref-convention.mjs
+++ b/scripts/check-action-ref-convention.mjs
@@ -106,7 +106,7 @@ export const DEFAULT_SPELLING_LABEL = 'a floating major tag (@vN)';
/**
* `workflow file + action path -> why this ref is spelled differently`.
- * **Deliberately empty — every `uses:` in the tree follows the convention.**
+ * Each entry is one deliberate deviation, argued where it was added.
*
* ⛔ Adding an entry here is the deliberate, reviewable act that objectui#8465
* found missing. Every entry needs a real reason and the issue that owns it —
@@ -114,18 +114,34 @@ export const DEFAULT_SPELLING_LABEL = 'a floating major tag (@vN)';
*
* The test below rejects an entry that no longer matches an off-convention ref,
* so this table cannot rot into a permanent skip-list. That is not theory: the
- * one entry this table ever held named `stale.yml :: actions/stale`, and when
+ * first entry this table held named `stale.yml :: actions/stale`, and when
* objectui#8548 deleted that workflow the entry stopped matching anything.
* Leaving it would have made every pull request red on a `main` nobody broke,
* so it went in the deletion's own commit — which is exactly the rule working.
*
- * ⚠️ An empty table does NOT mean this mechanism is dormant. It means the tree
- * currently has nothing to excuse, which is the state the convention is for.
- * The gate's red branches are pinned over synthetic tables in
- * `scripts/__tests__/check-action-ref-convention.test.ts`, so emptiness here
- * costs no coverage.
+ * ⚠️ An entry is matched by workflow and action, NOT by spelling: this gate
+ * cannot tell one off-convention spelling of a declared action from another, so
+ * whatever an entry's reason says about the spelling (a sha, never a branch) is
+ * held by a pin of its own or by nothing. The `half-state-patrol.yml` entry's is
+ * held in `scripts/__tests__/ci-cd-pipeline-doc.test.ts`. The gate's red
+ * branches are pinned over synthetic tables in
+ * `scripts/__tests__/check-action-ref-convention.test.ts`, so they do not depend
+ * on what this table holds.
*/
-export const DECLARED_EXCEPTIONS = [];
+export const DECLARED_EXCEPTIONS = [
+ {
+ workflow: 'half-state-patrol.yml',
+ action: 'objectstack-ai/objectstack/.github/actions/half-state-patrol',
+ issue: 'objectui#11174',
+ reason:
+ "objectstack's composite half-state patrol, pinned to a 40-character objectstack commit sha " +
+ 'and never a branch or tag, by the ruling on objectstack-ai/objectstack#18471 that every ' +
+ 'sibling board calls the action at a sha. The runner places the whole objectstack ' +
+ 'repository at the referenced commit, so the pin freezes the sweeper with the action at one ' +
+ 'reviewed moment; a floating ref would re-adopt every upstream change on the next scheduled ' +
+ 'run, with nothing in this repository to roll back to.',
+ },
+];
/**
* Non-vacuity floors. A census that collapses reports an empty offender list,