|
| 1 | +name: Half-State Patrol |
| 2 | + |
| 3 | +# The standing caller for `scripts/pm/check-half-states.mjs` (#9844). |
| 4 | +# |
| 5 | +# ## Why a workflow, and not "a seat should run it" |
| 6 | +# |
| 7 | +# The sweeper carries thirteen predicates over the dispatch protocol's |
| 8 | +# label/assignee/PR invariants, and for most of its life its documented consumer |
| 9 | +# was "a PM seat's patrol round" — which is to say, nobody's calendar. A shift |
| 10 | +# covering two lanes declared a queue empty from memory while eight malformed |
| 11 | +# claims (H2) and an unenumerated backlog sat on the board. Not one predicate had |
| 12 | +# fired. A healing mechanism with no scheduled caller heals only in the |
| 13 | +# counterfactual, and an alarm added to a script nobody runs is still silence. |
| 14 | +# |
| 15 | +# "Some seat should run it" also kept not happening for a MEASURED reason, not a |
| 16 | +# discipline one: the live sweep cannot run inside a PM session container at all |
| 17 | +# (#7412 class 1 — api.github.com refuses that egress in both directions, with and |
| 18 | +# without a token). The fix therefore had to move the caller somewhere the |
| 19 | +# transport prerequisite is actually met. A GitHub Actions runner with the |
| 20 | +# workflow's own `GITHUB_TOKEN` is that place — #7412 class 2, the triage Routine |
| 21 | +# container, is the same shape and measured reachable with 15,000 core quota. |
| 22 | +# |
| 23 | +# ## What lands where |
| 24 | +# |
| 25 | +# One pinned ANCHOR ISSUE, rewritten in place every run (`ANCHOR_ISSUE` below). |
| 26 | +# Never a comment per run: the board is one board, a per-run comment stream would |
| 27 | +# be a second tracker that nobody prunes, and GitHub's edit history is already the |
| 28 | +# archive this needs. The body is owned end-to-end by the generator, so no run can |
| 29 | +# leave half of it stale. |
| 30 | +# |
| 31 | +# The `Swept` timestamp in that body is the patrol's heartbeat and is deliberately |
| 32 | +# refreshed even when the findings are unchanged: a timestamp that stops advancing |
| 33 | +# is how a reader learns the standing caller died. That is the whole defect class |
| 34 | +# this workflow exists to close, so the run must not "optimize away" the no-op |
| 35 | +# edit that proves it is alive. |
| 36 | +# |
| 37 | +# ## Report-only, and the one thing that is NOT report-only |
| 38 | +# |
| 39 | +# Findings never fail anything. A completed sweep exits 0 whether it found 0 or 40 |
| 40 | +# half-states, this job never writes a label, never closes a card, never fixes a |
| 41 | +# state, and no H-predicate is a blocking gate — the script's own header argues |
| 42 | +# that at length (a half-state is a fact about a live shared board, not about |
| 43 | +# whichever PR happens to run CI next). |
| 44 | +# |
| 45 | +# The job DOES fail when the sweep could not run, or when its report could not be |
| 46 | +# delivered. That is not a gate on the board; it is the patrol reporting its own |
| 47 | +# death. A workflow that quietly does nothing because a credential lapsed is the |
| 48 | +# exact shape this repo keeps having to fix (#4449, #9575), and it is doubly |
| 49 | +# unacceptable here: silent non-delivery would leave a stale anchor body that |
| 50 | +# reads exactly like a clean board — the #4690 failure ("could not read the input" |
| 51 | +# must never look like "input is clean") with a timestamp on it. Failing costs |
| 52 | +# nobody a PR: this workflow gates no branch and blocks no queue. |
| 53 | + |
| 54 | +on: |
| 55 | + schedule: |
| 56 | + # Four times a day, six hours apart, at :37 past the hour. |
| 57 | + # |
| 58 | + # The minute is offset ON PURPOSE. The triage Routine that heals these same |
| 59 | + # states fires hourly near the top of the hour, and a patrol landing at the |
| 60 | + # same minute would keep reading the board mid-heal — reporting half-states |
| 61 | + # the healer is in the middle of pairing, i.e. manufacturing findings that |
| 62 | + # clear themselves. :37 puts this sweep in the quiet part of the healer's |
| 63 | + # cycle in both directions. Four runs/day rather than hourly: H13's own |
| 64 | + # threshold is 2h and the incident it comes from sat ~26h, so six-hourly |
| 65 | + # detection is two orders of magnitude better than the status quo (never) |
| 66 | + # while staying cheap on the core quota this sweep shares with the loop's |
| 67 | + # hot path. |
| 68 | + - cron: '37 1,7,13,19 * * *' |
| 69 | + workflow_dispatch: {} |
| 70 | + # Changes to the patrol itself get exercised before they merge — the same |
| 71 | + # posture as engine-split-metric.yml. On a pull_request run the sweep still |
| 72 | + # executes (that is the point: the transport, the flags and the rendering are |
| 73 | + # proven on a real runner), but the anchor write is skipped and the rendered |
| 74 | + # body goes to the run's step summary instead. A PR must never rewrite the |
| 75 | + # board's pinned view. |
| 76 | + pull_request: |
| 77 | + paths: |
| 78 | + - 'scripts/pm/check-half-states.mjs' |
| 79 | + - '.github/workflows/half-state-patrol.yml' |
| 80 | + |
| 81 | +# Least privilege: this job reads the repo and writes exactly one issue BODY. |
| 82 | +# `issues: write` is the narrowest scope GitHub offers for that edit; the job |
| 83 | +# never uses it for labels, comments, assignees or state, and the sweeper it |
| 84 | +# calls is read-only against the API by construction. |
| 85 | +permissions: |
| 86 | + contents: read |
| 87 | + issues: write |
| 88 | + |
| 89 | +# One patrol at a time. A scheduled run overlapping a manual dispatch would have |
| 90 | +# two runs racing to rewrite the same body, and the loser's findings would vanish |
| 91 | +# with no trace but an edit-history entry. |
| 92 | +concurrency: |
| 93 | + group: half-state-patrol |
| 94 | + cancel-in-progress: false |
| 95 | + |
| 96 | +env: |
| 97 | + # The pinned anchor issue whose body this workflow owns. |
| 98 | + # |
| 99 | + # TO ROTATE: open a new `tracking`-labeled issue, put its number here, and note |
| 100 | + # the handover in the OLD issue's body before closing it (its edit history is |
| 101 | + # the archive and does not travel). Nothing else reads this number, so the |
| 102 | + # rotation is this one line. |
| 103 | + # |
| 104 | + # The anchor deliberately carries `tracking` and NO `domain:*` label: `tracking` |
| 105 | + # is in the sweeper's own H13_EXEMPT_LABELS, so the anchor can never appear as a |
| 106 | + # finding in the sweep it hosts. |
| 107 | + ANCHOR_ISSUE: '9857' |
| 108 | + |
| 109 | +jobs: |
| 110 | + patrol: |
| 111 | + name: Live half-state sweep |
| 112 | + runs-on: ubuntu-latest |
| 113 | + timeout-minutes: 15 |
| 114 | + steps: |
| 115 | + - name: Checkout repository |
| 116 | + uses: actions/checkout@v7 |
| 117 | + |
| 118 | + - name: Setup Node.js |
| 119 | + uses: actions/setup-node@v7 |
| 120 | + with: |
| 121 | + node-version: '22' |
| 122 | + |
| 123 | + # No `pnpm install`: the sweeper imports nothing but `node:process` and |
| 124 | + # global `fetch`. Installing the workspace here would buy nothing and would |
| 125 | + # give a scheduled patrol a lockfile it could fail on. |
| 126 | + - name: Run the live sweep |
| 127 | + id: sweep |
| 128 | + env: |
| 129 | + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 130 | + PROVENANCE: >- |
| 131 | + run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) |
| 132 | + · commit `${{ github.sha }}` · trigger `${{ github.event_name }}` |
| 133 | + run: | |
| 134 | + set +e |
| 135 | + node scripts/pm/check-half-states.mjs \ |
| 136 | + --format=markdown \ |
| 137 | + --provenance="$PROVENANCE" \ |
| 138 | + > "$RUNNER_TEMP/report.md" 2> "$RUNNER_TEMP/report.err" |
| 139 | + code=$? |
| 140 | + set -e |
| 141 | + # Captured with NO pipe in between. `cmd | tail` would report the |
| 142 | + # PIPE's status — `tail` essentially never fails, so a green and a red |
| 143 | + # sweep both read as 0, and the script's own header calls this trap out |
| 144 | + # by name (its exit codes are 0 / 2 / 3 and the split is the point). |
| 145 | + echo "exit_code=$code" >> "$GITHUB_OUTPUT" |
| 146 | + echo "check-half-states exited $code" |
| 147 | + cat "$RUNNER_TEMP/report.err" >&2 || true |
| 148 | +
|
| 149 | + - name: Update the pinned anchor issue |
| 150 | + # A pull_request run proves the sweep; it must not touch the board. |
| 151 | + if: github.event_name != 'pull_request' |
| 152 | + uses: actions/github-script@v9 |
| 153 | + env: |
| 154 | + SWEEP_EXIT: ${{ steps.sweep.outputs.exit_code }} |
| 155 | + with: |
| 156 | + # Delivery is retried, never assumed (#9575): this single PATCH is the |
| 157 | + # entire product of the run, and a transient answer from the issues |
| 158 | + # endpoint would otherwise discard a completed sweep. |
| 159 | + retries: 3 |
| 160 | + script: | |
| 161 | + const fs = require('fs'); |
| 162 | + const path = require('path'); |
| 163 | + const exitCode = Number(process.env.SWEEP_EXIT); |
| 164 | + const anchor = Number(process.env.ANCHOR_ISSUE); |
| 165 | + const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; |
| 166 | + const read = (name) => { |
| 167 | + try { return fs.readFileSync(path.join(process.env.RUNNER_TEMP, name), 'utf8'); } |
| 168 | + catch { return ''; } |
| 169 | + }; |
| 170 | +
|
| 171 | + // The composition split, deliberately: a COMPLETED sweep renders its |
| 172 | + // own body (in the script, where --self-test pins every property of |
| 173 | + // it). Only the did-not-run body is composed here, because saying |
| 174 | + // "my callee failed" is the caller's job and the script's classified |
| 175 | + // output is already the authored explanation — this wraps it, it |
| 176 | + // does not re-word it. |
| 177 | + let body; |
| 178 | + if (exitCode === 0) { |
| 179 | + body = read('report.md'); |
| 180 | + if (!body.trim()) { |
| 181 | + throw new Error('the sweep exited 0 but produced an empty report — refusing to blank the anchor'); |
| 182 | + } |
| 183 | + } else { |
| 184 | + const classified = (read('report.err') || read('report.md') || '(no output captured)').trim(); |
| 185 | + const kind = exitCode === 3 |
| 186 | + ? 'PREREQUISITE NOT MET — the runner could not reach the board' |
| 187 | + : 'SWEEP FAILED — an unclassified failure'; |
| 188 | + body = [ |
| 189 | + 'os-half-state-sweep — machine-findable marker for this generated view.', |
| 190 | + '', |
| 191 | + `# ⛔ THE SWEEP DID NOT RUN (exit ${exitCode})`, |
| 192 | + '', |
| 193 | + `_Attempted ${new Date().toISOString()} · [run log](${runUrl}) · ${kind}._`, |
| 194 | + '', |
| 195 | + 'Nothing below is a finding. **No issue was judged**, so this body says nothing about whether', |
| 196 | + 'the board carries half-states — it is not a clean board and it is not a dirty one, it is no', |
| 197 | + 'reading at all. A sweep that could not run must never read as a clean board.', |
| 198 | + '', |
| 199 | + 'The standing patrol is DOWN until this is fixed; the previous run\'s findings are in this', |
| 200 | + 'issue\'s edit history. The sweeper\'s own classified output:', |
| 201 | + '', |
| 202 | + '```', |
| 203 | + classified, |
| 204 | + '```', |
| 205 | + ].join('\n'); |
| 206 | + } |
| 207 | +
|
| 208 | + await github.rest.issues.update({ |
| 209 | + owner: context.repo.owner, |
| 210 | + repo: context.repo.repo, |
| 211 | + issue_number: anchor, |
| 212 | + body, |
| 213 | + }); |
| 214 | + core.info(`anchor #${anchor} updated (${body.length} chars, sweep exit ${exitCode})`); |
| 215 | +
|
| 216 | + - name: Publish the rendered body to the run summary |
| 217 | + # Always: on a PR this IS the delivery, and on a scheduled run it makes |
| 218 | + # the run log self-contained when someone opens it after an alert. |
| 219 | + if: always() |
| 220 | + run: | |
| 221 | + { |
| 222 | + echo "### Half-state patrol — sweep exit ${{ steps.sweep.outputs.exit_code }}" |
| 223 | + echo |
| 224 | + if [ "${{ github.event_name }}" = "pull_request" ]; then |
| 225 | + echo "_Anchor write skipped: a pull_request run proves the sweep without touching the board._" |
| 226 | + echo |
| 227 | + fi |
| 228 | + echo '<details><summary>Rendered anchor body</summary>' |
| 229 | + echo |
| 230 | + cat "$RUNNER_TEMP/report.md" 2>/dev/null || echo '(no report produced)' |
| 231 | + echo |
| 232 | + echo '</details>' |
| 233 | + echo |
| 234 | + echo '<details><summary>stderr</summary>' |
| 235 | + echo |
| 236 | + echo '```' |
| 237 | + cat "$RUNNER_TEMP/report.err" 2>/dev/null || true |
| 238 | + echo '```' |
| 239 | + echo |
| 240 | + echo '</details>' |
| 241 | + } >> "$GITHUB_STEP_SUMMARY" |
| 242 | +
|
| 243 | + - name: Fail the run if the sweep could not run |
| 244 | + # LAST, on purpose: the anchor is updated with the did-not-run report |
| 245 | + # BEFORE the job goes red. Land the truth, then raise the alarm — a run |
| 246 | + # that failed early would leave the previous body in place with its old |
| 247 | + # timestamp, which is precisely the stale-reads-as-clean shape above. |
| 248 | + # |
| 249 | + # Findings are NOT a failure condition and never appear here: exit 0 with |
| 250 | + # 40 half-states is a successful patrol. |
| 251 | + if: steps.sweep.outputs.exit_code != '0' |
| 252 | + run: | |
| 253 | + 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." |
| 254 | + exit 1 |
0 commit comments