Skip to content

ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe #274

ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe

ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe #274

name: Half-State Patrol
# The standing caller for `scripts/pm/check-half-states.mjs` (#9844).
#
# Since #18471 this file is a CALLER and nothing else: the patrol's body lives
# in `.github/actions/half-state-patrol`, a composite action, and that action's
# header is the authority on how the patrol works. What stays here is what is
# NOT repo-agnostic — when it runs, what it may touch, the board's own anchor,
# and the one step that writes to this repo's cards.
#
# ## 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: in every container class measured at the time, the live sweep
# could not run 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.
#
# ⚠️ CORRECTED (#13544, measured 2026-08-31): "cannot run inside an agent
# container at all" is no longer true of every such container, and the claim
# above is kept only as the history that put this workflow here. A proxied agent
# container reaches api.github.com fully — `/rate_limit`, `/user` AND
# `GET /repos/{owner}/{repo}` all 200 with `server: github.com` — once node's
# fetch is routed through the session proxy, which the script now does for
# itself. What had actually failed was the ROUTE: node's `fetch` ignores
# `HTTPS_PROXY`, so the sweeper sent the proxy's placeholder token straight to
# GitHub, earned a 401, and reported that refusal as the container's verdict.
#
# ⛔ That does NOT retire this workflow, and the fix deliberately did not touch
# it. The #9844 reason stands on its own and is not a transport reason: an alarm
# whose only caller is "a seat should remember" is silence, whoever CAN run it.
# The on-demand path is restored BESIDE the schedule — a lane that needs the
# board read right now (the 4x/day body trims its own rows, and says so) can now
# get it — never instead of the schedule.
#
# ## 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 its report could not be
# delivered. 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.
#
# ## Adopting the patrol in a sibling repo (#11217, #18471)
#
# ⛔ Copy NOTHING. A sibling board installs the patrol by CALLING the action,
# pinned to a sha of this repository, and passing its own constants:
#
# jobs:
# patrol:
# runs-on: ubuntu-latest
# steps:
# - uses: actions/checkout@v7
# - uses: actions/setup-node@v7
# with:
# node-version: '22'
# - uses: objectstack-ai/objectstack/.github/actions/half-state-patrol@<sha>
# with:
# github-token: ${{ secrets.GITHUB_TOKEN }}
# anchor-issue: ${{ vars.HALF_STATE_ANCHOR_ISSUE }}
#
# plus one `tracking`-labeled anchor issue opened in that repo, whose number is
# what `anchor-issue` carries. ⛔ A sha, never `@main`: `@main` is the drift this
# change removes, re-entered from the other side.
#
# ⚠️ The paragraph this replaces was a LIST of files to copy, and it is worth
# recording why a list is not the fix. It said TWO files until 2026-09-03 while
# the sweeper had imported a third since well before that — so the documented
# install was a patrol that could not start; a clean two-file copy died with
# `ERR_MODULE_NOT_FOUND … /scripts/invoked-as.mjs`, exit 1, before a single
# predicate ran. It was corrected, and two weeks later it was short by four
# again. A hand-kept mirror of an import graph goes stale the way a hand-kept
# copy of a script goes stale, which is to say: by arithmetic, not by
# carelessness. The list is deleted rather than lengthened — the runner now
# places this whole repository at the pinned sha for the action, so the import
# graph travels with the code and the action asserts its own sources are there.
#
# ⛔ Each install still 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). Calling a PUBLIC repo's action from a
# private one needs no credential at all, so nothing here reopens it. The
# accepted consequence is unchanged: 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.
on:
schedule:
# Four times a day, six hours apart, at :37 past the hour.
#
# The minute is offset ON PURPOSE. The triage Routine that heals these same
# states fires hourly near the top of the hour, and a patrol landing at the
# 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.
#
# The cadence is per-board and stays in the caller: a sibling with a
# different healer cycle picks its own minute here, not in the action.
- 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.
#
# This repo is the patrol's FIRST CONSUMER and calls the action by its local
# path, so a PR editing the action runs against the edit rather than against
# a pinned copy of what is already on `main`. That is the whole value of being
# the first consumer, and it is why the action's own directory is a trigger
# path below.
pull_request:
paths:
- 'scripts/pm/check-half-states.mjs'
# The sweeper imports this helper, so a change to it can break the patrol
# without touching either file beside it — and the PR-time proof this
# trigger exists to give would not run. An undeclared dependency is
# undeclared in every place that has to name it.
- 'scripts/invoked-as.mjs'
# The closed-card sweep this workflow also calls (#16005). Same reasoning
# as the row above, one file along: a step whose script can change without
# this trigger firing is a step whose PR-time proof is a coincidence.
- 'scripts/pm/sweep-closed-cards.mjs'
# The citation census this workflow also calls (#18224). Same reasoning as
# the two rows above, one file along: a step whose script can change
# without this trigger firing is a step whose PR-time proof is a
# coincidence.
- 'scripts/check-issue-citations.mjs'
# The patrol's body. A glob, not the one file: anything added to that
# directory is part of what runs here.
- '.github/actions/half-state-patrol/**'
- '.github/workflows/half-state-patrol.yml'
# Least privilege: this job reads the repo and writes exactly one issue BODY,
# plus the labels and comment the closed-card sweep is ruled to write. `issues:
# write` is the narrowest scope GitHub offers for either edit; the sweeper the
# patrol calls is read-only against the API by construction.
#
# `pull-requests: read` is READ-ONLY and buys one thing, for the citation
# census step only — MEASURED on run 35495222460, this workflow's own
# pull_request run WITHOUT this row: the census reported 3,628 unresolvable
# citation sites where a full-scope read of the same tree reported 2,168, and
# the difference is 1,460 — EXACTLY the `resolves-as-pull-request` tally.
# `GET /repos/{owner}/{repo}/issues` answers with the pull requests omitted
# unless this scope is held, so every citation naming a PR number was reported
# as a number the board never had. A report-only reading that is wrong by 67%
# is still a machine-readable surface telling a lie. ⛔ Do not drop this row
# as tidying, and ⛔ do not widen it to `write`: nothing here writes a PR.
permissions:
contents: read
issues: write
pull-requests: read
# 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.
concurrency:
group: half-state-patrol
cancel-in-progress: false
jobs:
patrol:
name: Live half-state sweep
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# The patrol reads the SWEPT repo's own checkout for the passes that
# classify its workflows and its tracked files, so the checkout is the
# subject of the sweep rather than a source of the code that runs it.
- name: Checkout repository
uses: actions/checkout@v7
# Stays in the caller on purpose: `scripts/check-node-version.mjs` scans
# `.github/workflows/*.yml` only and reports how many setup-node steps it
# audited, so a step moved into the composite action would drop out of
# that census while the gate still printed OK. `.github/actions/setup-pnpm`
# records the same reasoning for the same gate.
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: '22'
# No `pnpm install`: the sweeper imports only `node:` builtins
# (`process`, `child_process`, `fs`, `url`), global `fetch`, and one
# repo-local helper — no npm dependency, so installing the workspace here
# would buy nothing and would give a scheduled patrol a lockfile it could
# fail on.
#
# `./` — the LOCAL action, deliberately, and this is the one consumer that
# should not pin a sha. A sibling pins one because it wants a reviewed
# moment between upstream landing a change and its board adopting it; this
# repo wants the opposite, because a PR editing the action must be proven
# by this job before it merges. Pinning here would exercise `main`'s copy
# of the thing under review.
- name: Sweep the board and update the anchor
uses: ./.github/actions/half-state-patrol
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
# The pinned anchor issue whose body the patrol owns — this board's
# own number, and the one constant this file exists to carry.
#
# Resolution: the repository variable `HALF_STATE_ANCHOR_ISSUE` if
# set, else this repo's own pinned number, else EMPTY — and empty
# makes the action refuse to write rather than guess. The literal is
# guarded by the repository name even though this file no longer
# travels: the cheapest way to adopt the patrol is still to copy this
# caller, and an unguarded fallback is what would let such a copy
# rewrite some unrelated card in a sibling 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 — pass your own
# number to the action from your own caller.
#
# 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')
|| '' }}
- name: Sweep the closed cards
# #16005 — the pm-loop state labels are CLAIMS that work is in flight,
# and GitHub leaves every label in place when a merged `Fixes` pull
# request closes a card. The seat was paying a hand round trip per
# landing to remove them (eighteen identical ones in one measured
# shift). This step is that stroke, mechanized; the script's header
# carries the ruling it obeys and the window that keeps it to
# close-time hygiene rather than the backfill the 2026-08-31 maintainer
# ruling refused.
#
# ⛔ NOT in the composite action, for two reasons that both say the same
# thing. It is the one step of the old file that was never
# repo-agnostic: it WRITES to cards under a ruling this repo's board
# took, and no sibling has taken it. And this workflow is the only place
# in CI that runs `scripts/pm/sweep-closed-cards.mjs --self-test`, which
# is how `scripts/check-self-test-wired.mjs` knows that self-test is
# run at all — that gate builds its population from `.github/workflows/`
# and nowhere else, so moving this step would leave it green while
# auditing one script fewer.
#
# ⛔ This step never fails the job, whatever the sweep returns; the
# alarm rides an annotation and the run summary instead. Findings are
# not a failure condition here either — the same posture the patrol
# takes.
#
# It runs AFTER the patrol rather than before it, which is the one
# ordering change #18471 made. The anchor's content is unaffected: the
# half-state sweep still reads the board before this step touches it.
# What changes is that the patrol's product — the anchor write — can no
# longer be starved by anything this step does, which is what the old
# placement's own comment asked for and did not get. `!cancelled()`
# rather than an implicit `success()`: a patrol run that went red
# because it could not reach the board says nothing about whether this
# repo's closed cards still carry stale labels.
if: ${{ !cancelled() && github.repository == 'objectstack-ai/objectstack' }}
id: closed-cards
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The board this run acts on is the repo this workflow lives in.
PM_SWEEP_REPO: ${{ github.repository }}
PROVENANCE: >-
posted by half-state-patrol [run ${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
· trigger `${{ github.event_name }}`
# A pull_request run PROVES the step — the transport, the flags and
# the rendering on a real runner — and writes nothing, exactly as the
# anchor write is skipped for it. That convention is this patrol's and
# it is load-bearing: a PR must never write to the board.
SWEEP_MODE: ${{ github.event_name == 'pull_request' && '--dry-run' || '--write' }}
run: |
# The judge's own cases first, and the sweep only if they hold: this
# step WRITES to other people's cards, and a broken predicate that
# still runs is the one failure mode that cannot be undone by the next
# run. `check:pm-closed-card-sweep` is the same command under a dev
# -facing name; this is the invocation CI holds.
set +e
node scripts/pm/sweep-closed-cards.mjs --self-test > "$RUNNER_TEMP/closed-cards-selftest.log" 2>&1
selftest=$?
set -e
cat "$RUNNER_TEMP/closed-cards-selftest.log"
if [ "$selftest" != "0" ]; then
echo "exit_code=$selftest" >> "$GITHUB_OUTPUT"
{
echo "### Closed-card sweep — SKIPPED: its own self-test failed (exit $selftest)"
echo
echo '```'
cat "$RUNNER_TEMP/closed-cards-selftest.log"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
echo "::error::closed-card sweep self-test failed (exit $selftest) — the sweep did NOT run and wrote nothing. Nothing here is a reading about the board."
exit 0
fi
set +e
node scripts/pm/sweep-closed-cards.mjs "$SWEEP_MODE" --provenance="$PROVENANCE" \
> "$RUNNER_TEMP/closed-cards.md" 2> "$RUNNER_TEMP/closed-cards.err"
code=$?
set -e
# Captured with NO pipe in between: piped, `$?` is the pipe's status
# and a red run and a green one read the same.
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
{
echo "### Closed-card sweep — exit $code (\`$SWEEP_MODE\`)"
echo
echo '```'
cat "$RUNNER_TEMP/closed-cards.md" 2>/dev/null || echo '(no report produced)'
echo '```'
echo
echo '<details><summary>stderr</summary>'
echo
echo '```'
cat "$RUNNER_TEMP/closed-cards.err" 2>/dev/null || true
echo '```'
echo
echo '</details>'
} >> "$GITHUB_STEP_SUMMARY"
cat "$RUNNER_TEMP/closed-cards.err" >&2 || true
if [ "$code" = "3" ]; then
echo "::error::closed-card sweep exited 3 — it could NOT read the board, so it says nothing about whether residue is accumulating. See this run's summary."
elif [ "$code" != "0" ]; then
echo "::warning::closed-card sweep exited $code — at least one card was left UNJUDGED. An unjudged card is not a clean card; see this run's summary."
fi
- name: Census the repo's issue citations
# #17512's gate, wired here by #18224 — the REPORT-ONLY half, and the
# posture is a ruling, not a preference. `--census` judges every
# citation in the gate's declared surfaces (the published release pages
# and package source docblocks), which is ~2,785 unresolvable sites on a
# tree nobody touched, and its verdict is NOT a function of this tree:
# #16783, #16786 and #16787 were measured RESOLVING on 2026-09-10 and
# 404 on 2026-09-14 with no change to this repository. That is exactly
# the shape this workflow exists for — a fact about a live shared board,
# not about whichever change happens to run CI next — so it belongs on
# the patrol lane and ⛔ NEVER on a blocking one. The DIFF-scoped half of
# the same gate is the blocking one and lives in `lint.yml`; the two
# postures are opposite on purpose and ⛔ neither moves to the other's
# lane.
#
# ⛔ NOT in the composite action this job `uses:` above — it is a step of
# the CALLER, for the same reason the closed-card sweep one step up
# states, plus two that are specific to a census of a TREE. All three
# are measurable on this repo rather than argued:
#
# - It is not repo-agnostic. `scripts/check-issue-citations.mjs` is
# objectstack-only, so the action's own "Locate the patrol sources"
# step would have to either name it — and then REFUSE to run in
# every sibling that adopted the action, which is the one thing that
# action exists to make possible — or not name it, and leave a step
# inside the action failing on a missing file in that sibling. The
# repo-name gate below is what answers both, and it belongs where
# the repository is the caller's own.
# - The action runs its scripts from `steps.sources.outputs.root`, the
# tree the ACTION ships from, while the board's checkout is
# `github.workspace`; that split is deliberate and the action's own
# comments carry it. This census's subject is the WORKSPACE tree's
# declared surfaces, so from inside the action a sibling's run would
# census objectstack's own release pages and report the count under
# the sibling's name — a machine-readable surface telling a lie, the
# failure this step's `permissions:` note above is about. In the
# caller the two directories are the same one, on every install.
# - `pull-requests: read` is granted by the `permissions:` block of
# this file. A composite action declares no permissions and the
# action's input documentation names only `issues: write` and
# `contents: read`, so a census inside it would silently depend on a
# scope no adopting caller was told to grant — measured above as a
# reading wrong by 67%. A scope and its one consumer stay in one
# file.
#
# WHERE THE REPORT GOES: this run's step summary and job log, plus one
# `::warning::` carrying the site count. ⛔ NOT the anchor issue — that
# body is owned end-to-end by `check-half-states.mjs`'s generator, and a
# second writer is how half of a generated body goes stale.
# HOW OFTEN: on this workflow's schedule — four times a day, six hours
# apart — plus any `workflow_dispatch`, plus the `pull_request` runs the
# paths filter above admits.
# WHAT IT COSTS AND WHO PAYS: the census enumerates the whole board once
# (159 requests on this repo at the time of writing, cursor-paginated —
# the alternative is one request per distinct number). It is paid by
# THIS repository's own `secrets.GITHUB_TOKEN` core quota, the same
# 5,000/hour this job already draws the live sweep from: ~636
# requests/day at four runs, under half a percent of a single hour's
# allowance. ⛔ No PAT, no cross-repo credential — the file's own rule.
#
# ⛔ Gated on the repository NAME, for the reason the closed-card sweep
# above states: this script is objectstack-only until a sibling has a
# copy, and a verbatim copy of this workflow elsewhere must SKIP rather
# than fail on a missing file.
#
# ⛔ `!cancelled()` rather than an implicit `success()`, and this
# placement has to SPELL that rather than inherit it. While the patrol's
# steps were written inline in this file, this step sat ABOVE the one
# step that fails the job, so it ran whatever the sweep returned. The
# patrol is now a single `uses:` step that goes red itself when the
# sweep could not read the board, so a default `success()` here would
# skip the census on exactly the runs where the patrol is down. This
# step reads neither the sweep's exit code nor its files: a patrol that
# could not reach the board says nothing about whether the citations in
# this tree resolve.
#
# ⛔ This step never fails the job, whatever the census returns —
# findings are not a failure condition here, and neither is a census
# that could not read the board: that is an alarm (`::error::`), not a
# gate. LAST on purpose, after the patrol's own anchor write and after
# the closed-card sweep: the anchor is this patrol's product, this job
# has a 15-minute timeout, and a report-only reading must never be able
# to starve the thing the workflow exists to deliver.
if: ${{ !cancelled() && github.repository == 'objectstack-ai/objectstack' }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set +e
node scripts/check-issue-citations.mjs --census \
> "$RUNNER_TEMP/issue-citations.md" 2> "$RUNNER_TEMP/issue-citations.err"
code=$?
set -e
# Captured with NO pipe in between, for the reason the two steps above
# state at length: piped, `$?` is the pipe's status and a red run and a
# green one read the same.
{
echo "### Issue-citation census — exit $code (report-only)"
echo
echo '```'
cat "$RUNNER_TEMP/issue-citations.md" 2>/dev/null || echo '(no report produced)'
echo '```'
echo
echo '<details><summary>stderr</summary>'
echo
echo '```'
cat "$RUNNER_TEMP/issue-citations.err" 2>/dev/null || true
echo '```'
echo
echo '</details>'
} >> "$GITHUB_STEP_SUMMARY"
cat "$RUNNER_TEMP/issue-citations.err" >&2 || true
if [ "$code" != "0" ]; then
echo "::error::issue-citation census exited $code — the board was NOT read, so this run says nothing about whether unresolvable citations are accumulating. A census that could not run is not a clean census. See this run's summary."
else
sites=$(sed -n 's/^.*census: \([0-9][0-9]*\) unresolvable citation site(s).*$/\1/p' "$RUNNER_TEMP/issue-citations.md" | tail -1)
echo "::warning::issue-citation census: ${sites:-unknown} unresolvable citation site(s) in the declared surfaces. Report-only — the blocking half judges only what a change ADDS."
fi