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,