Repository navigation
ci: wire the issue-citation verdict (blocking) and its census (report-only), plus the merged-result probe #273
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). | |
| # | |
| # ## 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 this means for a runner is nothing at all, and that is load-bearing given | |
| # this file is copied VERBATIM into sibling repos (#11217): the script re-execs | |
| # only when `HTTPS_PROXY`/`https_proxy` is set, and a runner sets neither. No | |
| # child process, no extra line, no behaviour change — pinned in the script's | |
| # `--self-test` and measured byte-identical on the probe output. | |
| # | |
| # ## 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 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 THREE files, unchanged: `scripts/pm/check-half-states.mjs`, | |
| # `scripts/invoked-as.mjs` (the sweeper imports it — see below), and this | |
| # file; | |
| # 2. 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). | |
| # | |
| # ⚠️ Step 1 said TWO files until 2026-09-03, and the sweeper has imported | |
| # `../invoked-as.mjs` since well before that — so the documented install was a | |
| # patrol that could not start. Measured on a clean two-file copy of this repo's | |
| # own files: `ERR_MODULE_NOT_FOUND … /scripts/invoked-as.mjs`, exit 1, before a | |
| # single predicate runs; the same copy with the helper added passes the | |
| # sweeper's 2,062-case `--self-test`. It fails LOUDLY rather than silently (the | |
| # job's last step turns the run red and the anchor is rewritten with "THE SWEEP | |
| # DID NOT RUN"), which is the one mercy in it — but a repo adopting this file by | |
| # following the list above installed a dead patrol. ⛔ Do not shorten this list | |
| # again from memory: the import is what decides it, not this comment. | |
| # | |
| # 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. | |
| 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. | |
| - 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. | |
| 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. Same reasoning as the adopt list | |
| # above, one layer down: an undeclared dependency is undeclared in every | |
| # place that has to name it. (The adopted objectui copy carries this row | |
| # already; upstream is catching up to its own port.) | |
| - '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' | |
| - '.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. | |
| # | |
| # `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 | |
| 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 refuse | |
| # to write rather than guess (see the "Resolve the anchor" step). 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. | |
| # | |
| # 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: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| - 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 the one | |
| # repo-local helper `../invoked-as.mjs` — no npm dependency, so installing | |
| # the workspace here would buy nothing and would give a scheduled patrol a | |
| # lockfile it could fail on. ⚠️ That repo-local import is why the adopt | |
| # list above copies THREE files; this sentence read "imports nothing but | |
| # `node:process` and global `fetch`" until 2026-09-03, which is the claim | |
| # an adopter would have checked the copy list against. | |
| - 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 }} | |
| 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 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: Sweep the closed cards | |
| id: 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. | |
| # | |
| # ⛔ Gated on the repository NAME, unlike every other step in this file: | |
| # this one is objectstack-only until a sibling has both its patrol | |
| # anchor (objectui#5986) and a copy of the script. A verbatim copy of | |
| # this workflow in a sibling repo therefore SKIPS this step rather than | |
| # failing on a missing file. | |
| # | |
| # ⛔ This step never fails the job, whatever the sweep returns. The | |
| # anchor write below is the patrol's product and must not be starved by | |
| # a step that runs before it; the alarm rides an annotation and the run | |
| # summary instead. Findings are not a failure condition here either — | |
| # the same posture the sweep step above takes. | |
| if: github.repository == 'objectstack-ai/objectstack' | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # Same wiring, and the same reason, as the live sweep above: the board | |
| # this run acts on is the repo this workflow is installed 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 below is skipped for it. That convention is this file'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, for the reason the step above | |
| # states at length: 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: Resolve the anchor issue | |
| # An install with no anchor configured has nowhere to land its report, | |
| # and the ONLY safe behaviour is to say so loudly (#11217). The two | |
| # alternatives are both the failure this file exists to prevent: | |
| # guessing a number would rewrite an unrelated card in this repo, and | |
| # skipping the write quietly would leave a patrol that runs, finds, and | |
| # tells nobody — indistinguishable from a clean board. | |
| # | |
| # 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. | |
| if: github.event_name != 'pull_request' | |
| run: | | |
| if [ -z "${ANCHOR_ISSUE//[[:space:]]/}" ]; then | |
| echo "::error::No anchor issue configured for ${{ github.repository }}. The sweep RAN (see the run summary) but has nowhere to land. 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 1 | |
| 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 "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. | |
| if: github.event_name != 'pull_request' | |
| uses: actions/github-script@v9 | |
| env: | |
| SWEEP_EXIT: ${{ steps.sweep.outputs.exit_code }} | |
| 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 | |
| fi | |
| echo '<details><summary>Rendered anchor body</summary>' | |
| echo | |
| cat "$RUNNER_TEMP/report.md" 2>/dev/null || echo '(no report produced)' | |
| echo | |
| echo '</details>' | |
| echo | |
| echo '<details><summary>stderr</summary>' | |
| echo | |
| echo '```' | |
| cat "$RUNNER_TEMP/report.err" 2>/dev/null || true | |
| echo '```' | |
| echo | |
| echo '</details>' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - 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. | |
| # | |
| # 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. | |
| # | |
| # ⛔ 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. Placed AFTER the anchor write on purpose, unlike 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: 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 | |
| - 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 |