ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe #274
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |