Repository navigation
ci: fold the four daily patrols into one fleet-patrols.yml workflow (1 of 3) #1
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: Fleet Patrols | |
| # Four daily patrols, one file — ruling: batch #397 item 2 · letter A, the | |
| # maintainer's 「同意」, recorded on the decision card that measured the fleet's | |
| # scheduled workflows. Each job below is one retired workflow moved here whole: | |
| # its steps, its `permissions:` grant, its `concurrency:` group and its `env:` | |
| # are the retired file's, so the job's failure signal, its least-privilege | |
| # grant and its budget are still its own, and nothing is widened to the | |
| # workflow level. | |
| # | |
| # job formerly cron | |
| # required-set-patrol required-set-patrol.yml 23 4,16 * * * | |
| # platform-checklist-watchdog platform-checklist-watchdog.yml 51 2 * * * | |
| # release-coverage-patrol release-coverage-patrol.yml 19 4 * * * | |
| # board-snapshot board-snapshot.yml 7 2,8,14,20 * * * | |
| # | |
| # ## One file, four clocks | |
| # | |
| # A `schedule` event carries the cron expression that fired it | |
| # (`github.event.schedule`), and every job's `if:` compares against its own, so | |
| # a scheduled run executes exactly one job and the other three read as skipped. | |
| # ⛔ A cron string under `schedule:` and the same string in its job's `if:` are | |
| # ONE fact spelled twice: change both in the same edit. A cron no `if:` names | |
| # fires a run that does nothing, and a job whose `if:` names a cron the | |
| # schedule does not declare never runs — neither shape is red anywhere. | |
| # | |
| # `workflow_dispatch` takes one input, `patrol`: `all` (the default) runs every | |
| # job, a job id runs that one alone — the manual fire and the smoke test each | |
| # retired workflow offered, without spending the board-snapshot's request budget | |
| # to smoke-test the watchdog. | |
| # | |
| # ## The `pull_request` trigger, and what it is allowed to do | |
| # | |
| # Every patrol in this tree exercises itself on the pull requests that change it | |
| # (`scripts/pm/dispatch-gates.mjs` pins tree-wide that no discovered gate family | |
| # is reachable only through a schedule), so this file's `pull_request:` trigger | |
| # is `paths`-filtered to the UNION of what the four retired files declared: this | |
| # file, plus each job's own inputs. A matching pull request runs all four jobs. | |
| # A `pull_request` run proves transport, invocation and rendering on a real | |
| # runner and WRITES NOTHING — no job files, refreshes or archives anything on a | |
| # pull_request event: every board write below carries that guard in its own | |
| # `if:`, and `scripts/check-platform-checklist-watchdog.mjs` refuses one that | |
| # does not. That pin also freezes this path list: widen it by one entry and the | |
| # pin goes red, so the checklist gate cannot drift onto the per-PR path by | |
| # accident. The standing decision that keeps `check:platform-checklist` out of | |
| # per-PR CI — "an unrelated PR is never blocked by checklist drift" — holds in | |
| # substance: no job here publishes a required context, and the watchdog job | |
| # never reds on drift. | |
| # | |
| # ⛔ NO `merge_group:` and ⛔ NO `pull_request_target:`, ever — refused outright | |
| # by the same pin; `scripts/check-required-contexts.mjs` asserts from its side | |
| # that this file declares no `merge_group:` (a workflow without one deadlocks | |
| # the queue if it is ever required-ized) and that no REQUIRED_CONTEXTS row | |
| # names it. | |
| # | |
| # ## Where the retired files' history went | |
| # | |
| # GitHub keeps the runs of a deleted workflow file: the Actions sidebar lists | |
| # each retired workflow by name while runs of it exist, and the workflow runs | |
| # API still answers for the retired file (its workflow reads `state: deleted`). | |
| # New history lands under "Fleet Patrols", one job per patrol — open a run and | |
| # read its job list, or filter the run list by job name, to follow one patrol. | |
| on: | |
| schedule: | |
| # ── `required-set-patrol` ───────────────────────────────────────────────────────── | |
| # Twice a day, twelve hours apart, at :23 past the hour. | |
| # | |
| # The live required set is changed by hand, by the maintainer, a few times a | |
| # month at most — detection latency of half a day is already two orders of | |
| # magnitude better than the status quo (never), and the #5617 incident it | |
| # guards against sat undetected for days. The minute is offset off the top | |
| # of the hour on purpose: scheduled workflows queue behind everyone else's | |
| # :00 cron, and this sweep shares the core API quota with the loop's hot | |
| # path. | |
| - cron: '23 4,16 * * *' | |
| # ── `platform-checklist-watchdog` ───────────────────────────────────────────────────────── | |
| # Daily. The gate is a ~1s offline read, so cadence is bounded by how long a | |
| # red may sit unseen, not by cost; a day is the same order the sibling | |
| # patrols settled on and is two orders better than the status quo (never). | |
| # | |
| # The minute is offset off the top of the hour ON PURPOSE — scheduled | |
| # workflows queue behind everyone else's `:00` cron — and off the three | |
| # sibling patrols' minutes, so two patrols never contend for the same runner | |
| # minute. | |
| # | |
| # ⚠️ Two readers, one fact. This value is restated as `PATROL_SCHEDULE` in | |
| # the `env:` block below, where the report step computes the deadline the | |
| # card's own `Swept` line is judged against (#17720). Change the cron and | |
| # change that env in the same edit, or the card publishes a deadline nobody | |
| # schedules. | |
| - cron: '51 2 * * *' | |
| # ── `release-coverage-patrol` ───────────────────────────────────────────────────────── | |
| # Nightly. #10268's acceptance criterion is "visible within a day of a minor | |
| # publishing", so the cadence is a day, not a week. Offset from | |
| # half-state-patrol.yml's `37 1,7,13,19` so two patrols never contend for the | |
| # same runner minute. | |
| # | |
| # ⚠️ Two readers, one fact. This value is also handed to the renderer as | |
| # `PATROL_SCHEDULE` (the "Render the report" step below) so the anchor body | |
| # can state the deadline its own heartbeat line is judged against (#17720). | |
| # Change the cron and change that env in the same edit, or the anchor | |
| # publishes a deadline nobody schedules. | |
| - cron: '19 4 * * *' | |
| # ── `board-snapshot` ───────────────────────────────────────────────────────── | |
| # Four times a day, six hours apart, at :07 past the hour. The HOURS are | |
| # chosen against the other standing spenders listed above, not for tidiness. | |
| # | |
| # This job first shipped at `37 1,7,13,19` — the half-state patrol's cron to | |
| # the minute. Both are scheduled board readers, both authenticate as this | |
| # repository's `GITHUB_TOKEN`, and that budget is per repository per hour, so | |
| # a snapshot run spending its full 800 leaves the patrol the remainder of the | |
| # same window. Measured on the day this moved: the 13:37Z patrol run and the | |
| # 13:41Z snapshot run were BOTH green, so nothing had been starved yet — the | |
| # offset is PREVENTION, taken while the archive was still small enough that | |
| # no run had yet spent its whole cap against the patrol's window. | |
| # | |
| # Of the five hour sets that share no hour with the patrol's 1,7,13,19, this | |
| # is the least contended on this repository's cron inventory: 08, 14 and 20 | |
| # UTC carry no other scheduled workflow at all, and 02 UTC carries only the | |
| # platform-checklist watchdog at :51 — 44 minutes after this run starts — | |
| # plus CodeQL on Mondays. `4,10,16,22` was the obvious alternative and is | |
| # worse on the same measurement: it drops an 800-request run into the busiest | |
| # scheduled hour on this board (04:19 release-coverage patrol, 04:23 | |
| # required-set patrol, 04:37 branch reaper on Mondays, 04:41 create-smoke) | |
| # and shares hour 16 with the required-set patrol. | |
| # | |
| # The minute is off the top of the hour on purpose, as in every patrol here: | |
| # scheduled workflows queue behind everyone else's :00 cron. | |
| # | |
| # Six-hourly is the loss window this accepts: a card created and destroyed | |
| # inside one interval was never archived, and nothing cheaper than a webhook | |
| # closes that, which is a different card. | |
| - cron: '7 2,8,14,20 * * *' | |
| # The manual fire, and the smoke test. One input: `all` runs every job, a job | |
| # id runs that one alone (each job's `if:` reads it). | |
| workflow_dispatch: | |
| inputs: | |
| patrol: | |
| description: 'Which patrol to run by hand: all, or one job id' | |
| type: choice | |
| default: all | |
| options: | |
| - all | |
| - required-set-patrol | |
| - platform-checklist-watchdog | |
| - release-coverage-patrol | |
| - board-snapshot | |
| # Changes to a patrol, or to the sweep it calls, get exercised before they | |
| # merge: the transport, the flags, the exit-code split and the rendering are | |
| # proven on a real runner rather than argued about. No job publishes a | |
| # required context, so a red here blocks nothing — and a pull_request run | |
| # never writes the board or the archive (see the header). | |
| # | |
| # ⛔ Do NOT widen this list by hand. It is the union of the four retired | |
| # files' own filters, frozen as a SET in | |
| # `scripts/check-platform-checklist-watchdog.mjs`: a new entry is a reviewed | |
| # edit to that pin in the same PR, never a quiet addition here. | |
| pull_request: | |
| paths: | |
| - '.github/workflows/fleet-patrols.yml' | |
| # `required-set-patrol` | |
| - 'scripts/check-required-contexts.mjs' | |
| # `release-coverage-patrol` | |
| - 'scripts/check-release-section-coverage.mjs' | |
| # `board-snapshot` — every file the run actually loads: the archiver, the | |
| # module it imports its proxy plan and repo resolver from, and the | |
| # entry-guard helper both import. An undeclared dependency is one a change | |
| # can break without this trigger firing. | |
| - 'scripts/pm/board-snapshot.mjs' | |
| - 'scripts/pm/check-half-states.mjs' | |
| - 'scripts/invoked-as.mjs' | |
| # `platform-checklist-watchdog` names no path beyond this file: its gate | |
| # runs through `pnpm check:platform-checklist`, which stays OFF the per-PR | |
| # path by a standing maintainer decision. | |
| jobs: | |
| # ══ `required-set-patrol` — formerly `.github/workflows/required-set-patrol.yml` ══ | |
| # | |
| # The standing caller for | |
| # `node scripts/check-required-contexts.mjs --verify-required-set` (#9678). | |
| # | |
| # ## Why a workflow, and not "a seat should run it" | |
| # | |
| # `--verify-required-set` (#9642) diffs this repo's REQUIRED_CONTEXTS registry | |
| # against the LIVE ruleset's required set, in both directions: | |
| # | |
| # A. a registry row whose context is absent from the live set — the gate | |
| # family that row names is advisory RIGHT NOW, with no signal anywhere. | |
| # #5617 verbatim: PR #5584 merged with its gate-carrying job red for 19 | |
| # minutes because that job was not in the required set at all. | |
| # B. a live required context with no registry row — renaming that job detaches | |
| # its gate silently, which is the defect check-required-contexts exists for. | |
| # | |
| # It shipped with no standing caller, so the only thing CI ran was its OFFLINE | |
| # self-test: the mechanism existed and measured nothing. A sweep whose value is | |
| # realized only when something runs it on a schedule, with nothing on a | |
| # schedule, is the `check-half-states.mjs` shape verbatim — an alarm added to a | |
| # script nobody runs is still silence, and half-state-patrol.yml exists because | |
| # that cost a shift's worth of unnoticed findings. | |
| # | |
| # "Some seat should run it" also keeps not happening for a MEASURED reason | |
| # rather than a discipline one. Re-measured 2026-08-19 from a dev session | |
| # container, both spellings the script documents: | |
| # | |
| # node scripts/check-required-contexts.mjs --verify-required-set | |
| # -> NOT VERIFIED, GET /repos/... answered HTTP 401 (exit 2) | |
| # NODE_OPTIONS=--use-env-proxy node ... --verify-required-set | |
| # -> NOT VERIFIED, GET /repos/... answered HTTP 403 (exit 2) | |
| # | |
| # i.e. the documented proxy remedy is not enough: the seat's egress policy | |
| # refuses api.github.com outright. The seats that would notice drift are exactly | |
| # the ones that cannot measure it, so the caller had to move somewhere the | |
| # transport prerequisite is actually met. A GitHub Actions runner reaches the | |
| # API directly with the workflow's own GITHUB_TOKEN — no proxy env, no | |
| # `--use-env-proxy`; the read's whole price is `metadata=read`, which every | |
| # token carries and none can give up. | |
| # | |
| # ## Where the report lands, and why nowhere else | |
| # | |
| # The run log and the run's STEP SUMMARY, and nothing else. Deliberately not an | |
| # issue: | |
| # | |
| # - a NEW tracker is refused outright — this repo has one board, and a per-run | |
| # comment stream or a fresh anchor is a second one that nobody prunes; | |
| # - the EXISTING anchor (#9857) is owned end-to-end by half-state-patrol.yml, | |
| # whose generator rewrites that body whole every run. A second writer would | |
| # make each patrol silently discard the other's findings. | |
| # | |
| # What that costs is honestly stated: a completed sweep that FINDS drift exits 0 | |
| # and shows a green check in the Actions list, so the drift is one click deep. | |
| # The `::warning::` annotation below is what keeps it from being invisible — | |
| # annotations render on the run list itself. Routing findings to a durable | |
| # surface (the PM round report, a rotating anchor) is a governed-surface change | |
| # and is not this workflow's to make. | |
| # | |
| # ## Report-only, and the one thing that is NOT report-only | |
| # | |
| # ⛔ This workflow is NEVER a required context and must never become one. | |
| # That is structural, not squeamish: the settings half of any required-set | |
| # change is maintainer-only and lands AFTER the merge (the #9325 two-step), so a | |
| # blocking version would be red on precisely the PR carrying the repo half and | |
| # could not go green before merging — it would deadlock the sitting it claims to | |
| # protect. The script's own header argues this at length, and | |
| # check-required-contexts' self-test pins it from this side too: it asserts that | |
| # this is the ONLY workflow running the flag, that this file declares no | |
| # `merge_group:` trigger (a workflow without one deadlocks the queue the moment | |
| # it is required-ized — assertion 6 of the pin), and that no REQUIRED_CONTEXTS | |
| # row names this file. | |
| # | |
| # So FINDINGS never fail anything: a completed sweep exits 0 whether it found 0 | |
| # or 40 disagreements, and this job never writes a label, an issue or a state. | |
| # | |
| # 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 repo; it is the patrol reporting its | |
| # own death. `NOT VERIFIED` is not a pass and not a failure of the tree (#4690): | |
| # exit 2 classifies the ENVIRONMENT, and a patrol that quietly stops reading | |
| # would leave a run history of green checks that reads exactly like a clean | |
| # required set. Failing costs nobody a PR — this workflow gates no branch and | |
| # blocks no queue. | |
| required-set-patrol: | |
| # ⛔ This name is NOT a required context and must never be added to the | |
| # ruleset — see the header. It is deliberately not registered in | |
| # REQUIRED_CONTEXTS, and check-required-contexts' self-test asserts so. | |
| name: Live required-set sweep | |
| # One file, four clocks: a `schedule` event carries the cron that fired it, so | |
| # each job runs only on its own cron; `workflow_dispatch` runs the one the input | |
| # names (default: all four); a `pull_request` run exercises every job. | |
| if: >- | |
| (github.event_name != 'schedule' || github.event.schedule == '23 4,16 * * *') | |
| && (github.event_name != 'workflow_dispatch' || inputs.patrol == 'all' || inputs.patrol == 'required-set-patrol') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| # Least privilege. The sweep is read-only against the API by construction and | |
| # this job writes no issue, no label and no comment, so `contents: read` is the | |
| # whole grant — the ruleset read's documented price is `metadata=read`, which is | |
| # below anything a workflow can toggle. | |
| permissions: | |
| contents: read | |
| # One patrol at a time; a scheduled run overlapping a manual dispatch would burn | |
| # two readings of the same set for one report. | |
| concurrency: | |
| group: required-set-patrol-${{ github.event.pull_request.number || github.ref }} | |
| cancel-in-progress: false | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version-file: .nvmrc | |
| # No `pnpm install`: the live mode imports nothing outside node: builtins | |
| # and global fetch. Installing the workspace would buy nothing and would | |
| # give a scheduled patrol a lockfile it could fail on. | |
| # | |
| # No NODE_OPTIONS=--use-env-proxy either, and that is not an omission: a | |
| # runner reaches api.github.com directly. The flag exists for agent | |
| # session containers, where it is necessary but (measured, #9678) still | |
| # not sufficient — that is why this caller lives here and not there. | |
| - name: Run the live required-set sweep | |
| id: sweep | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| run: | | |
| set +e | |
| node scripts/check-required-contexts.mjs --verify-required-set \ | |
| > "$RUNNER_TEMP/required-set.md" 2> "$RUNNER_TEMP/required-set.err" | |
| code=$? | |
| set -e | |
| # Captured with NO pipe in between. `cmd | tail` would report the | |
| # PIPE's status — `tail` essentially never fails, so a swept run and | |
| # an unreadable one both read as 0, and the exit split (0 swept / 2 | |
| # environment) is the entire classification this patrol delivers. | |
| echo "exit_code=$code" >> "$GITHUB_OUTPUT" | |
| echo "check-required-contexts --verify-required-set exited $code" | |
| cat "$RUNNER_TEMP/required-set.err" >&2 || true | |
| - name: Publish the sweep to the run summary | |
| # Always: this IS the delivery, on every trigger. A run whose summary is | |
| # empty because an earlier step died is itself the signal. | |
| if: always() | |
| run: | | |
| { | |
| echo "### Required-set patrol — sweep exit ${{ steps.sweep.outputs.exit_code }}" | |
| echo | |
| echo '```' | |
| cat "$RUNNER_TEMP/required-set.md" 2>/dev/null || echo '(no report produced)' | |
| cat "$RUNNER_TEMP/required-set.err" 2>/dev/null || true | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: Annotate a completed sweep that found drift | |
| # A finding is NOT a failure (see the header) — but a green check that | |
| # hides one is the #4690 shape with a tick on it, so a completed sweep | |
| # that did not render its clean mark gets an annotation on the run list. | |
| # | |
| # The marker is the script's own `✅ the live required set and this | |
| # registry agree in both directions.` line, and its ABSENCE is what is | |
| # read: every non-agreeing reading the script can produce (direction A, | |
| # direction B, a dry-run ruleset, no enforcing ruleset at all) withholds | |
| # that line, and check-required-contexts' self-test pins both directions | |
| # of that rendering — including this workflow's dependence on it by | |
| # name. Erring this way is deliberate: a rendering change costs a FALSE | |
| # ALARM, never a false all-clear. | |
| if: steps.sweep.outputs.exit_code == '0' | |
| run: | | |
| if grep -qF '✅' "$RUNNER_TEMP/required-set.md"; then | |
| echo "::notice::required-set sweep: the live required set and REQUIRED_CONTEXTS agree in both directions." | |
| else | |
| echo "::warning::required-set sweep found a registry-vs-live DISAGREEMENT. Nothing is blocked and nothing is judged — read this run's summary. Direction A (a registry row that is not live) is the maintainer's to resolve; direction B (a live context with no row) is a dev's." | |
| fi | |
| - name: Fail the run if the sweep could not run | |
| # LAST, on purpose: the summary carries the classified output BEFORE the | |
| # job goes red, so the run that raised the alarm also holds the reason. | |
| # | |
| # Findings are NOT a failure condition and never reach here: exit 0 with | |
| # 40 disagreements is a successful patrol. | |
| if: steps.sweep.outputs.exit_code != '0' | |
| run: | | |
| echo "::error::check-required-contexts --verify-required-set exited ${{ steps.sweep.outputs.exit_code }} — the standing patrol did not read the live required set. NOT VERIFIED is not a clean reading (#4690); see this run's summary." | |
| exit 1 | |
| # ══ `platform-checklist-watchdog` — formerly `.github/workflows/platform-checklist-watchdog.yml` ══ | |
| # | |
| # The standing caller for `pnpm check:platform-checklist` (#11730). | |
| # | |
| # ## What this changes, and the one thing it deliberately does NOT | |
| # | |
| # The gate stays OUT of per-PR CI. That is a recorded maintainer decision, kept | |
| # verbatim in the `lint.yml` NOTE beside the checklist gates and in | |
| # `docs/qa/platform-checklist/README.md` under "Operating cadence": the | |
| # checklist is a QA ledger, not a code gate, so an unrelated PR is never blocked | |
| # by checklist drift. ⛔ This workflow does not touch that. Only the REPORTING | |
| # CHANNEL changes — which is the whole of what was ruled. | |
| # | |
| # So this job's triggers are this file's: `schedule:` (its own cron, read by | |
| # its `if:`), `workflow_dispatch:`, and a `pull_request:` trigger | |
| # `paths`-filtered to THIS FILE AND THE SIBLING JOBS' OWN INPUTS, nothing | |
| # else — the list is frozen as a set in | |
| # `scripts/check-platform-checklist-watchdog.mjs` — the posture | |
| # `half-state-patrol.yml` and every job in this file keep, so a change to the | |
| # patrol is exercised before it merges. ⛔ NO `merge_group:` and ⛔ NO | |
| # `pull_request_target:`, ever. | |
| # | |
| # The filter is what makes that trigger compatible with the standing decision | |
| # rather than a back door through it. The decision's own words, in | |
| # `.github/workflows/lint.yml`: "keeping it out of the per-PR path means an | |
| # unrelated PR is never blocked by checklist drift." A trigger that fires only | |
| # on a pull request editing this workflow or a sibling patrol's own script | |
| # blocks no unrelated pull request at all — this job publishes no required | |
| # context and never reds on checklist drift — so it preserves the decision's | |
| # purpose while changing its letter, which is exactly what the maintainer | |
| # ruled on 2026-09-06. | |
| # | |
| # ⛔ A `pull_request` run NEVER writes to the board. It runs the gate, publishes | |
| # the output to the run summary, and stops there. | |
| # | |
| # ## Why a workflow, and not "a seat should run it" | |
| # | |
| # Measured, twice, not supposed. The gate went RED on `main` (a coverage.json / | |
| # manifest UNCLASSIFIED reading) and NO lane turned red, because there was no | |
| # channel: the red was found by a dev who happened to run the gate. Two further | |
| # checklist cards had their devs run the gate on their own initiative and record | |
| # the verdict honestly. Three for three is good discipline and says nothing at | |
| # all about the mechanism — the gate's correctness was resting on somebody | |
| # remembering, which is the shape a gate exists to replace. An alarm added to a | |
| # script nobody runs is still silence. | |
| # | |
| # ## What lands where — and why this one is NOT a pinned anchor | |
| # | |
| # The other three patrols own ONE pinned anchor issue and rewrite its body every | |
| # run, heartbeat included. This one files or refreshes an issue only when the | |
| # gate is RED, and does nothing whatsoever on green. That is the ruled | |
| # behaviour, and the difference is a real trade, stated plainly in both | |
| # directions: | |
| # | |
| # * Their heartbeat is what tells a reader the patrol is still alive — a | |
| # `Swept` timestamp that stops advancing past the cadence the same line | |
| # states is how a dead caller is noticed (#17720: the cadence is rendered | |
| # because 「stalled」 is undecidable without it, and the four patrols here | |
| # differ by 4×). ON GREEN this workflow has no such tell at all, because it | |
| # has no card, so a silent death on a green tree reads exactly like a clean | |
| # checklist. What stands in for it: this job goes RED (and the Actions run | |
| # list shows it) whenever the gate could not run or the report could not be | |
| # delivered, and the run history itself is the record. A card that DOES | |
| # stand carries the heartbeat and its deadline like the other three — with | |
| # the one ambiguity this workflow's shape implies, stated in the card | |
| # itself: a stalled `Swept` there means the caller died OR the gate went | |
| # green, and nothing closes the card either way. | |
| # * In exchange, a green tree has no standing issue to prune, and the card the | |
| # devx seat grades is minted only when there is something to grade. | |
| # | |
| # ⛔ On green this job files nothing, edits nothing and CLOSES nothing — an open | |
| # watchdog issue that has gone green is a triage judgement for the devx seat | |
| # (which owns the filed cards), not a state for an unattended run to clear. | |
| # | |
| # ## De-duplication — read off `merge-queue-triage.yml`, whose lesson is negative | |
| # | |
| # The anchor lookup keys on an IDENTITY LABEL that is never graded off, plus a | |
| # stable body marker, plus the exact title as a third way in. Each of those three | |
| # is there because one of the others fails on its own: | |
| # | |
| # * ⛔ NOT `pm:queue` and ⛔ NOT `tooling`. Those are TRIAGE-STATE labels: | |
| # first-touch grading takes `pm:queue` off by definition, and a lookup keyed | |
| # on it stops seeing the anchor the moment somebody triages it — turning | |
| # every further red into a fresh duplicate. `merge-queue-triage.yml` paid | |
| # for that reading already and its header records it; this file inherits the | |
| # fix rather than re-deriving it. | |
| # * The TITLE is stable across refreshes — it names no failing item, no count | |
| # and no date. A title carrying the first failing item would change with the | |
| # failure and make the anchor unfindable by title the moment the checklist | |
| # drifted somewhere else. | |
| # * The BODY MARKER is plain text, ⛔ never an HTML comment: this platform's | |
| # body sanitizer is measured to eat short angle-bracket fragments, and a | |
| # de-dup key that can be silently swallowed is a de-dup key that files a | |
| # duplicate a day. `half-state-patrol.yml`'s generated body uses the same | |
| # plain-text marker idiom for the same reason. | |
| # | |
| # A CLOSED anchor is evidence too, and is not reopened: the gate going red again | |
| # after the card was answered is a REGRESSION, so a new issue is filed and it | |
| # names the one it regressed from. Reopening would bury that distinction. | |
| platform-checklist-watchdog: | |
| # ⛔ This job is NOT a required context and must never become one. It runs on | |
| # no pull request at all, so a ruleset naming it would block every PR | |
| # forever on a check that cannot report. | |
| name: Platform checklist gate sweep | |
| # One file, four clocks: a `schedule` event carries the cron that fired it, so | |
| # each job runs only on its own cron; `workflow_dispatch` runs the one the input | |
| # names (default: all four); a `pull_request` run exercises every job. | |
| if: >- | |
| (github.event_name != 'schedule' || github.event.schedule == '51 2 * * *') | |
| && (github.event_name != 'workflow_dispatch' || inputs.patrol == 'all' || inputs.patrol == 'platform-checklist-watchdog') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Least privilege. The gate is read-only against the filesystem by construction | |
| # (no socket, no token), and this job writes issues and nothing else — no label | |
| # on anybody's PR, no state, no comment stream. | |
| permissions: | |
| contents: read | |
| issues: write | |
| # One watchdog at a time. A scheduled run overlapping a manual dispatch would | |
| # have two runs racing to file the same anchor, and the loser would mint a | |
| # duplicate. | |
| concurrency: | |
| group: platform-checklist-watchdog | |
| cancel-in-progress: false | |
| env: | |
| # The anchor's IDENTITY — all three ways in, single-sourced here so the | |
| # workflow and its static pin read the same strings. | |
| # | |
| # ⛔ The identity label is the machine's and is never graded off. It is NOT a | |
| # triage-state label; see the header. | |
| ANCHOR_IDENTITY_LABEL: platform-checklist-watchdog | |
| ANCHOR_TITLE: 'check:platform-checklist is red on main' | |
| ANCHOR_MARKER: os-platform-checklist-watchdog | |
| # Applied on CREATE only, beside the identity label: the ruled routing for the | |
| # cards this watchdog mints. Never rewritten on a refresh — grading is the | |
| # devx seat's and a refresh must not undo it. | |
| ANCHOR_ROUTING_LABELS: 'pm:queue,tooling' | |
| # This workflow's own `cron:`, restated here so the report step can state the | |
| # cadence that makes the card's `Swept` line decidable (#17720). The three | |
| # sibling patrol anchors carry the same heartbeat reading and one of them | |
| # fires every six hours, so a reader who carries a cadence over from a | |
| # neighbour is wrong by 4× — which is why each anchor states its own rather | |
| # than leaving the reader to go and read a workflow file. | |
| # | |
| # ⛔ A literal, not a parse of this file: the report body is inline | |
| # `github-script` that no test reaches, so the cheapest honest route is the | |
| # one with the least inline logic. The cost is the restatement above, and the | |
| # comment at the `cron:` is what pays it. | |
| PATROL_SCHEDULE: '51 2 * * *' | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| # `pnpm` itself is the requirement, NOT the workspace. The gate is invoked | |
| # through its PACKAGE SCRIPT (`pnpm check:platform-checklist`), which is a | |
| # pinned property of this file — an inlined `node scripts/...` copy would | |
| # drift from the script the maintainer's cadence documentation names, and | |
| # would silently drop the `checklist-select --self-test` leg the alias runs | |
| # first. | |
| - name: Setup workspace | |
| uses: ./.github/actions/setup-workspace | |
| with: | |
| install: 'false' | |
| # No `pnpm install`. Both halves of the alias import `node:` builtins and | |
| # repo-local modules only — no npm dependency — so installing the | |
| # workspace would buy nothing and would give an unattended daily run a | |
| # lockfile it could fail on. | |
| - name: Run the platform checklist gate | |
| id: gate | |
| run: | | |
| set +e | |
| pnpm check:platform-checklist \ | |
| > "$RUNNER_TEMP/gate.out" 2> "$RUNNER_TEMP/gate.err" | |
| code=$? | |
| set -e | |
| # Captured with NO pipe in between. `cmd | tail` reports the PIPE's | |
| # status — `tail` essentially never fails — so a red gate and a green | |
| # one would both read as 0, and this exit code is the entire input to | |
| # the decision below. | |
| echo "exit_code=$code" >> "$GITHUB_OUTPUT" | |
| echo "pnpm check:platform-checklist exited $code" | |
| - name: Publish the gate output to the run summary | |
| # Always: on a green run this IS the whole product, and on a red one it | |
| # makes the run log self-contained for whoever opens it from the issue. | |
| if: always() | |
| run: | | |
| { | |
| echo "### Platform-checklist watchdog — gate exit ${{ steps.gate.outputs.exit_code }}" | |
| echo | |
| if [ "${{ github.event_name }}" = "pull_request" ]; then | |
| echo "_Board write skipped: a pull_request run proves the sweep without filing or refreshing anything._" | |
| echo | |
| fi | |
| echo '```' | |
| cat "$RUNNER_TEMP/gate.out" 2>/dev/null || echo '(no stdout captured)' | |
| cat "$RUNNER_TEMP/gate.err" 2>/dev/null || true | |
| echo '```' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| - name: File or refresh the watchdog issue | |
| # RED only. On green this step does not run, and nothing else in this | |
| # job writes anything — that is the ruled behaviour. | |
| # | |
| # ⛔ And NEVER on a `pull_request` run, whatever the gate said. A pull | |
| # request proves the transport, the invocation and the rendering on a | |
| # real runner; it must not touch the board. That is the posture every | |
| # patrol in this repo keeps, and | |
| # `scripts/check-platform-checklist-watchdog.mjs` refuses a board write | |
| # whose `if:` lacks this guard. | |
| if: steps.gate.outputs.exit_code != '0' && github.event_name != 'pull_request' | |
| uses: actions/github-script@v9 | |
| env: | |
| GATE_EXIT: ${{ steps.gate.outputs.exit_code }} | |
| with: | |
| # Delivery is retried, never assumed: this write is the entire product | |
| # of a red 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 owner = context.repo.owner; | |
| const repo = context.repo.repo; | |
| const marker = process.env.ANCHOR_MARKER; | |
| const title = process.env.ANCHOR_TITLE; | |
| const identityLabel = process.env.ANCHOR_IDENTITY_LABEL; | |
| const routingLabels = process.env.ANCHOR_ROUTING_LABELS | |
| .split(',').map((s) => s.trim()).filter(Boolean); | |
| const gateExit = process.env.GATE_EXIT; | |
| const runUrl = `${process.env.GITHUB_SERVER_URL}/${owner}/${repo}/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 gate's OWN output, verbatim, both streams — the ruling asks | |
| // for the gate's output and this workflow does not re-word it. | |
| const output = [read('gate.out'), read('gate.err')] | |
| .filter((s) => s.trim()).join('\n').trimEnd(); | |
| if (!output.trim()) { | |
| // A red gate that printed nothing is not a finding anybody can | |
| // act on, and filing it would mint an empty card that reads like | |
| // a judged one. Fail instead: the last step turns the run red. | |
| throw new Error(`the gate exited ${gateExit} but produced no output on either stream — refusing to file an empty card`); | |
| } | |
| // ── Anchor lookup ──────────────────────────────────────────────── | |
| // Three ways in, none of them sufficient alone; see this file's | |
| // header. `pull_request` excludes PRs, which the issues endpoint | |
| // returns alongside issues. | |
| const isThisAnchor = (i) => !i.pull_request | |
| && (String(i.body ?? '').includes(marker) || i.title === title); | |
| const MAX_PAGES = 3; | |
| const candidates = []; | |
| let scanComplete = true; | |
| for (let page = 1; page <= MAX_PAGES; page += 1) { | |
| const res = await github.rest.issues.listForRepo({ | |
| owner, repo, state: 'all', labels: identityLabel, | |
| sort: 'created', direction: 'desc', per_page: 100, page, | |
| }); | |
| candidates.push(...res.data.filter(isThisAnchor)); | |
| if (res.data.length < 100) break; | |
| if (page === MAX_PAGES) scanComplete = false; | |
| } | |
| if (!scanComplete) { | |
| // A truncated scan cannot establish ABSENCE, and filing on an | |
| // unestablished absence is how a watchdog mints a duplicate a | |
| // day. Refusing is the safe direction: the finding is still in | |
| // this run's summary and the next run retries. | |
| throw new Error(`the anchor scan hit its ${MAX_PAGES}-page bound without completing — absence is NOT established, so nothing was filed. The gate output is in this run's summary.`); | |
| } | |
| // Resolution order: an OPEN anchor wins, and the OLDEST open one | |
| // wins among those — that is the issue any duplicates were closed | |
| // against. | |
| const open = candidates.filter((i) => i.state !== 'closed') | |
| .sort((a, b) => a.number - b.number); | |
| const closed = candidates.filter((i) => i.state === 'closed') | |
| .sort((a, b) => b.number - a.number); | |
| const existing = open[0] ?? null; | |
| // A closed anchor that was not closed as a duplicate is an ANSWERED | |
| // one: the gate is red again, which is a regression rather than a | |
| // continuation, and the new card has to say which issue it | |
| // regressed from or the previous answer is lost. | |
| const priorAnchor = !existing | |
| ? (closed.find((i) => i.state_reason !== 'duplicate') ?? null) | |
| : null; | |
| // The cadence that makes the `Swept` line below decidable (#17720). | |
| // `PATROL_SCHEDULE` is this workflow's own `cron:`, restated in the | |
| // `env:` block beside the other single-sourced card strings. | |
| // ⛔ Only the daily `M H * * *` form is computed; anything else says | |
| // so in the body rather than publishing a deadline derived from a | |
| // schedule this did not read. | |
| const sweptAt = new Date(); | |
| const schedule = String(process.env.PATROL_SCHEDULE ?? '').trim(); | |
| const cronFields = /^(\d{1,2}) (\d{1,2}) \* \* \*$/.exec(schedule); | |
| let expectation = `⚠️ expected cadence UNSTATED — PATROL_SCHEDULE is \`${schedule}\`, which is not the daily \`M H * * *\` form this step computes a deadline from`; | |
| if (cronFields && Number(cronFields[1]) <= 59 && Number(cronFields[2]) <= 23) { | |
| const next = new Date(sweptAt); | |
| next.setUTCHours(Number(cronFields[2]), Number(cronFields[1]), 0, 0); | |
| if (next <= sweptAt) next.setUTCDate(next.getUTCDate() + 1); | |
| expectation = `expected every 24h while this card stands (cron \`${schedule}\` UTC) · next by ${next.toISOString().slice(0, 16)}Z`; | |
| } | |
| const body = [ | |
| `${marker} — machine-findable marker for this generated view. ⛔ Do not delete this line: it is how the watchdog finds this card instead of filing a new one every day.`, | |
| '', | |
| `# \`check:platform-checklist\` is RED on \`main\``, | |
| '', | |
| `_Swept ${sweptAt.toISOString()} · ${expectation} · [run log](${runUrl}) · commit \`${process.env.GITHUB_SHA}\` · trigger \`${context.eventName}\` · gate exit ${gateExit}._`, | |
| '', | |
| 'The `Swept` line above is this watchdog\'s heartbeat, and it states the cadence that makes', | |
| '「stalled」 decidable: while the gate stays red this card is refreshed on that schedule, so a', | |
| 'timestamp still sitting there past the `next by` deadline means either the standing caller', | |
| 'died or the gate went green — this watchdog files nothing and closes nothing on green, so', | |
| 'both readings end at this card. ⛔ Do not carry a cadence over from a sibling patrol anchor:', | |
| 'they differ by up to 4× and each states its own.', | |
| '', | |
| 'The platform test checklist gate is red. It is **not** wired into per-PR CI (a standing', | |
| 'maintainer decision — the checklist is a QA ledger, not a code gate), so this card is the', | |
| 'channel that sees the red. Nothing is blocked by it.', | |
| '', | |
| '⛔ The remedy is **never** to edit the checklist data to make the gate green. Read the', | |
| 'output below, fix what it names, and re-run `pnpm check:platform-checklist` locally.', | |
| '', | |
| ...(priorAnchor | |
| ? [ | |
| `⚠️ The previous watchdog card #${priorAnchor.number} was closed on its own merits (not as a`, | |
| 'duplicate) and the gate is red again ⇒ this is a **regression**; the earlier conclusion is', | |
| 'in that issue.', | |
| '', | |
| ] | |
| : []), | |
| "The gate's own output, verbatim:", | |
| '', | |
| '```', | |
| output, | |
| '```', | |
| ].join('\n'); | |
| if (existing) { | |
| // Rewritten in place, never a comment per run: a per-run comment | |
| // stream is a second tracker nobody prunes, and this platform's | |
| // edit history is already the archive. ⛔ Labels are NOT rewritten | |
| // — grading is the devx seat's and a refresh must not undo it. | |
| await github.rest.issues.update({ | |
| owner, repo, issue_number: existing.number, body, | |
| }); | |
| core.info(`refreshed watchdog anchor #${existing.number} (${body.length} chars, gate exit ${gateExit})`); | |
| core.notice(`check:platform-checklist is red on main — watchdog card #${existing.number} refreshed.`); | |
| return; | |
| } | |
| const created = await github.rest.issues.create({ | |
| owner, repo, title, body, | |
| // Additive on create; ⛔ nothing here ever replaces a whole label | |
| // set (`check:whole-set-label-write` refuses that verb outright). | |
| labels: [identityLabel, ...routingLabels], | |
| }); | |
| core.info(`filed watchdog anchor #${created.data.number} (${body.length} chars, gate exit ${gateExit})`); | |
| core.notice(`check:platform-checklist is red on main — watchdog card #${created.data.number} filed.`); | |
| - name: Fail the run if the sweep left no reading | |
| # LAST, on purpose: on a red gate the card is filed or refreshed BEFORE | |
| # this runs, so the run that raises an alarm has already landed the | |
| # truth. | |
| # | |
| # ⛔ A RED GATE is not a failure condition and never reaches here — a | |
| # red gate is a SUCCESSFUL patrol, and a job that went red every day the | |
| # checklist was red would teach everyone to skim it, which is the | |
| # failure this whole card is about. Delivery failure needs no step | |
| # either: the github-script above throws, and a throw fails that step. | |
| # | |
| # What is left is the one reading that must never look green: a sweep | |
| # step that finished without recording an exit code at all. Nothing | |
| # downstream could tell that apart from `exit_code=0`, and "the gate was | |
| # not read" must never render as "the checklist is clean". | |
| if: always() && steps.gate.outputs.exit_code == '' | |
| run: | | |
| echo "the gate step recorded no exit code, so this run is NOT a reading about the checklist -- it is neither a clean one nor a red one. See this run's summary." | |
| exit 1 | |
| # ══ `release-coverage-patrol` — formerly `.github/workflows/release-coverage-patrol.yml` ══ | |
| # | |
| # The standing caller for `scripts/check-release-section-coverage.mjs` (#10803). | |
| # | |
| # ## Why a workflow, when the gate ALREADY runs on every PR | |
| # | |
| # Unlike `half-state-patrol.yml` and `required-set-patrol.yml`, this check does | |
| # not need a runner for TRANSPORT reasons. It is fully offline — it reads | |
| # `packages/spec/CHANGELOG.md` and `content/docs/releases/**` straight out of the | |
| # tree, opens no socket, and runs inside `lint.yml` on every pull request today. | |
| # Measured, not assumed: a network-shaped scan of the script (`fetch(`, `http`, | |
| # `api.github.com`, `execSync`, `child_process`, `octokit`, `GITHUB_TOKEN`) | |
| # matches ZERO lines, against 4 matches each in those two patrols' workflows. | |
| # | |
| # So the reason for a standing caller here is AUDIENCE, not reachability, and it | |
| # was measured on a live gap rather than argued (#10803): | |
| # | |
| # 17.2.0's CHANGELOG heading landed 2026-08-23T06:29:35Z. Over the next 40 | |
| # hours, 262 pull requests landed on `main`. Every one of their `lint.yml` | |
| # runs emitted the two advisory `::warning::` annotations. Commits touching | |
| # `content/docs/releases/` in that window: ZERO. Open PRs authoring the | |
| # section: ZERO. #10268's acceptance criterion is "visible within a day of a | |
| # minor publishing" — already exceeded, with the advisory in place and firing. | |
| # | |
| # Three structural reasons that reading is not bad luck, all verified against a | |
| # real PR head sha through the check-runs API: | |
| # | |
| # 1. The annotation is registered at `path=.github`, line 19/20 — the WORKFLOW | |
| # file, because the gate emits `::warning::` with no `file=` parameter. It | |
| # therefore cannot appear on `content/docs/releases/v17.mdx` in the | |
| # Files-changed tab, which is the only place a reviewer reads by default. | |
| # 2. It rides a GREEN required check. That PR head carried 31 check runs, 0 of | |
| # them failing, which GitHub collapses to "All checks have passed". It is | |
| # step 74 of 106 in that job. | |
| # 3. It is addressed to whoever opened the next PR. That author did not create | |
| # the debt and does not own the remedy. A finding every PR author sees and | |
| # no PR author owns is the shape that stays unfixed for 25 days. | |
| # | |
| # Volume is NOT the problem and this file does not pretend otherwise: those two | |
| # annotations were the only `##[warning]` lines in a 4004-line job log, and 2 of | |
| # only 5 annotations across all 31 checks. The advisory is quiet and correct. It | |
| # is simply pointed at the wrong reader, and no amount of shouting on a PR fixes | |
| # that. This workflow adds the reader; it does not replace the advisory. | |
| # | |
| # ## Why this calls `--strict`, and why it still cannot go red | |
| # | |
| # `--strict` promotes findings to exit 1 "for a caller that OWNS the remedy". It | |
| # had no standing caller at all, which is the defect #10803 records. It has one | |
| # here — as a PREDICATE, never as a verdict. | |
| # | |
| # The two runs below are not redundant, and the order is load-bearing. The gate | |
| # deliberately returns 1 for BOTH "findings, under --strict" and "the instrument | |
| # is broken", so `--strict` alone is ambiguous and could not be branched on: | |
| # | |
| # run 1, ADVISORY — exit 0 means the instrument READ the corpus (findings or | |
| # not); non-zero means it could not, and that is fatal in | |
| # every mode by the gate's own design. This run also | |
| # produces the authored, human-readable finding text. | |
| # run 2, --STRICT — with the instrument already proven healthy by run 1, | |
| # exit 1 now means exactly one thing: findings exist. | |
| # | |
| # That is what lets the patrol branch on a machine-readable boolean instead of | |
| # grepping prose, while the JOB stays green whenever findings exist. A `--strict` | |
| # run stays red for as long as the prose is unwritten — 25 days, in the 16.1.0 | |
| # case — and a chronically red scheduled job is how people learn to skim red, | |
| # which the retired `prerelease-pin-watch.yml`'s header warned about directly. Findings land | |
| # in the anchor issue; they never colour this job. | |
| # | |
| # ## What lands where | |
| # | |
| # One pinned ANCHOR ISSUE, rewritten in place every run (`ANCHOR_ISSUE` below) — | |
| # `half-state-patrol.yml`'s shape and its argument, which applies verbatim here: | |
| # never a comment per run, because a per-run comment stream is a second tracker | |
| # nobody prunes and GitHub's edit history is already the archive this needs. | |
| # | |
| # It fits this finding particularly well. The population is tiny — 5 in-scope GA | |
| # minors exist in total (16.0.0, 16.1.0, 17.0.0, 17.1.0, 17.2.0) — so the anchor | |
| # is quiet almost always, and the remedy is owner-shaped: one person writes one | |
| # curated section. An issue can be assigned. An annotation cannot. | |
| # | |
| # The `Swept` timestamp is the patrol's heartbeat and is refreshed even when the | |
| # findings are unchanged: a timestamp that stops advancing is how a reader learns | |
| # the standing caller died. The run must not "optimize away" that no-op edit. | |
| # | |
| # ## Report-only, and the one thing that is NOT report-only | |
| # | |
| # Findings never fail anything. This job never writes release prose — it cannot, | |
| # and the gate's header argues why at length: a curated section is a judgement | |
| # about what is user-facing, read out of 69 package changelogs, and this gate | |
| # GENERATES NOTHING. | |
| # | |
| # 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 release pages; it is the patrol reporting | |
| # its own death. Silent non-delivery would leave a stale anchor body that reads | |
| # exactly like a clean corpus — "could not read the input" must never look like | |
| # "input is clean" (#4690), with a heartbeat on it. Failing costs nobody a PR: | |
| # this workflow gates no branch and blocks no queue. | |
| release-coverage-patrol: | |
| name: Release section-coverage sweep | |
| # One file, four clocks: a `schedule` event carries the cron that fired it, so | |
| # each job runs only on its own cron; `workflow_dispatch` runs the one the input | |
| # names (default: all four); a `pull_request` run exercises every job. | |
| if: >- | |
| (github.event_name != 'schedule' || github.event.schedule == '19 4 * * *') | |
| && (github.event_name != 'workflow_dispatch' || inputs.patrol == 'all' || inputs.patrol == 'release-coverage-patrol') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 10 | |
| # 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 gate it calls | |
| # is read-only against the filesystem by construction. | |
| permissions: | |
| contents: read | |
| issues: write | |
| # One patrol at a time. A scheduled run overlapping a manual dispatch would have | |
| # two runs racing to rewrite the same body, and the loser's findings would vanish | |
| # with no trace but an edit-history entry. | |
| concurrency: | |
| group: release-coverage-patrol | |
| cancel-in-progress: false | |
| env: | |
| # The pinned anchor issue whose body this workflow owns. | |
| # | |
| # Resolution: the repository variable `RELEASE_COVERAGE_ANCHOR_ISSUE` if set, | |
| # else this repo's own pinned number, else EMPTY — and empty makes the job | |
| # refuse to write rather than guess. The literal is guarded by the repository | |
| # name on purpose: an unguarded fallback would let a verbatim copy in a sibling | |
| # repo rewrite ITS #11879 — some unrelated card — with this repo's findings, | |
| # silently, every night. A number is only 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. | |
| # | |
| # ⚠️ Folded scalar, and every continuation line sits at the SAME indent on | |
| # purpose: a more-indented line in a `>-` block keeps its newline literally, | |
| # which would hand the expression parser a multi-line string instead of one | |
| # expression. | |
| ANCHOR_ISSUE: >- | |
| ${{ vars.RELEASE_COVERAGE_ANCHOR_ISSUE | |
| || (github.repository == 'objectstack-ai/objectstack' && '11879') | |
| || '' }} | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version-file: .nvmrc | |
| # No `pnpm install`: the gate imports `node:fs` and one local module. | |
| # Installing the workspace here would buy nothing and would give a | |
| # scheduled patrol a lockfile it could fail on. | |
| # | |
| # No `--self-test` either. lint.yml runs it on every pull request, so this | |
| # job would be re-proving a property already proven on the commit it just | |
| # checked out — and two node invocations in one `run:` block is precisely | |
| # the shape `check:step-collector` exists to refuse, because the first | |
| # one's failure is masked by the second. | |
| - name: Sweep the corpus | |
| id: sweep | |
| run: | | |
| # ── run 1: ADVISORY. Exit code answers "did the instrument READ the | |
| # corpus", which is the only thing that may colour this job. | |
| set +e | |
| node scripts/check-release-section-coverage.mjs \ | |
| > "$RUNNER_TEMP/report.txt" 2> "$RUNNER_TEMP/report.err" | |
| advisory_code=$? | |
| set -e | |
| # Captured with NO pipe in between. `cmd | tail` would report the | |
| # PIPE's status — `tail` essentially never fails, so a healthy and a | |
| # broken instrument would both read as 0. | |
| echo "advisory_code=$advisory_code" >> "$GITHUB_OUTPUT" | |
| echo "advisory run exited $advisory_code" | |
| # ── run 2: --STRICT, used ONLY as a findings predicate. Meaningful | |
| # solely because run 1 has already established the instrument is | |
| # healthy; the gate returns 1 for a broken instrument too. | |
| strict_code=0 | |
| if [ "$advisory_code" -eq 0 ]; then | |
| set +e | |
| node scripts/check-release-section-coverage.mjs --strict \ | |
| > "$RUNNER_TEMP/strict.txt" 2> "$RUNNER_TEMP/strict.err" | |
| strict_code=$? | |
| set -e | |
| echo "strict run exited $strict_code" | |
| else | |
| echo "instrument is broken — skipping the --strict predicate" | |
| fi | |
| echo "strict_code=$strict_code" >> "$GITHUB_OUTPUT" | |
| cat "$RUNNER_TEMP/report.err" >&2 || true | |
| - 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. 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 corpus. | |
| # | |
| # Placed AFTER the sweep so the run summary still carries the findings, | |
| # and skipped on a pull_request run, which never writes an anchor. | |
| 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 RELEASE_COVERAGE_ANCHOR_ISSUE to its number (Settings -> Secrets and variables -> Actions -> Variables)." | |
| exit 1 | |
| fi | |
| case "$ANCHOR_ISSUE" in | |
| *[!0-9]*|'') echo "::error::RELEASE_COVERAGE_ANCHOR_ISSUE is '$ANCHOR_ISSUE', which is not an issue number."; exit 1 ;; | |
| esac | |
| echo "anchor: #$ANCHOR_ISSUE in ${{ github.repository }}" | |
| - name: Render the report | |
| id: render | |
| env: | |
| ADVISORY_CODE: ${{ steps.sweep.outputs.advisory_code }} | |
| STRICT_CODE: ${{ steps.sweep.outputs.strict_code }} | |
| # This workflow's own `cron:`, handed to the renderer so the anchor | |
| # body states the cadence that makes its heartbeat line decidable | |
| # (#17720). It lives HERE rather than in the script because the | |
| # schedule is declared here: a second copy in the renderer would be a | |
| # second place to drift from, and a drifted deadline reads exactly | |
| # like a correct one. ⛔ Unset, the renderer says so in the body | |
| # rather than assuming a period — it never guesses. | |
| PATROL_SCHEDULE: '19 4 * * *' | |
| PROVENANCE: >- | |
| run [${{ github.run_id }}](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) | |
| · commit `${{ github.sha }}` · trigger `${{ github.event_name }}` | |
| # Runs its own `--self-test` first (#11150). That leg is not ceremony | |
| # here: the renderer's assertions pin `verdict()` — the mapping from the | |
| # sweep's two exit codes onto did-not-run / findings / clean — and a | |
| # broken mapping still renders a body. The live run below would stay | |
| # exit 0 and publish a WRONG report, including the one reading #4690 | |
| # forbids, a broken instrument rendered as a clean corpus. Only the live | |
| # run is redirected; the self-test's own verdict goes to the step log. | |
| run: | | |
| node scripts/render-release-coverage-anchor.mjs --self-test | |
| node scripts/render-release-coverage-anchor.mjs > "$RUNNER_TEMP/body.md" | |
| # The rendered body is the product of the run, so it goes to the step | |
| # summary on EVERY trigger — including the pull_request runs that must not | |
| # touch the anchor. A reader of this workflow's own PR sees exactly what | |
| # would have landed. | |
| - name: Publish the report to the run summary | |
| run: cat "$RUNNER_TEMP/body.md" >> "$GITHUB_STEP_SUMMARY" | |
| - 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 | |
| with: | |
| # Delivery is retried, never assumed: 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 anchor = Number(process.env.ANCHOR_ISSUE); | |
| const body = fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'body.md'), 'utf8'); | |
| if (!body.trim()) { | |
| throw new Error('the renderer produced an empty body — refusing to blank the anchor'); | |
| } | |
| 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)`); | |
| # LAST, so a completed sweep always lands its report before the patrol | |
| # raises the alarm about itself — "land the truth, then raise the alarm". | |
| # This is the ONLY step that may redden the job, and findings can never | |
| # reach it: it reads the instrument's verdict, not the corpus's. | |
| - name: Fail if the sweep could not run | |
| if: steps.sweep.outputs.advisory_code != '0' | |
| run: | | |
| echo "::error::check-release-section-coverage could not read the corpus (exit ${{ steps.sweep.outputs.advisory_code }}). The patrol is DOWN — findings, if any, were NOT judged this run. A sweep that could not run must never read as a clean corpus." | |
| exit 1 | |
| # ══ `board-snapshot` — formerly `.github/workflows/board-snapshot.yml` ══ | |
| # | |
| # The standing caller for `scripts/pm/board-snapshot.mjs` (#17390). | |
| # | |
| # ## Why a scheduled backup, and why it lands on a branch | |
| # | |
| # Three fleet accounts were suspended in two months and the appeals are | |
| # unanswered. #17374 F3 measured what a suspension destroys: every issue, pull | |
| # request and comment the account authored — while every branch and commit | |
| # survives, because those belong to the repository rather than to a user. The | |
| # board IS the fleet's state by rule (pm-dispatch SKILL.md, 全体座位的不变量: | |
| # 「GitHub 之外永不维护任何跟踪状态」), so today one suspension erases state that | |
| # nothing else holds; six cards, one of them a p1 security decision, are gone. | |
| # | |
| # The maintainer's answer, verbatim and untranslated, in chat on 2026-09-10: | |
| # 「或者用 ci 写个定时任务备份?」 — this file is that scheduled task. It runs as | |
| # `github-actions[bot]`, which is not a fleet account and cannot be suspended | |
| # with one, and it writes to an ORPHAN BRANCH of this same repository: no second | |
| # repo, no second credential, and a branch is precisely the thing a suspension | |
| # leaves standing. | |
| # | |
| # ## An archive is not a tracker — the one-board rule still holds | |
| # | |
| # ⛔ Nothing reads `board-archive` for state. Not a seat, not a patrol, not a | |
| # gate, not a query; every reading of the board still goes to GitHub. The | |
| # archive answers exactly one question, after a loss: what did the record say? | |
| # The script it runs has NO write path to GitHub in any mode — two of its | |
| # self-test cases read its own source and hold that structurally — and | |
| # `--restore` PRINTS a recreate payload for a seat to post, or for nobody to. | |
| # | |
| # ## The budget this run is capped against, and who else spends it | |
| # | |
| # `GITHUB_TOKEN` is limited to 1,000 requests per hour PER REPOSITORY. That is | |
| # ONE pool for every workflow in this repo that calls the API — not a per-workflow | |
| # allowance — and this job is far from its only caller: | |
| # | |
| # this archiver 800 per run (the `--max-requests` cap below), 4/day | |
| # half-state patrol a board sweep, 4/day at :37 on hours 1,7,13,19 | |
| # required-set patrol 2/day at :23 on hours 4,16 | |
| # release-coverage patrol daily at 04:19; platform-checklist watchdog at 02:51 | |
| # merged-branch reaper Mondays 04:37, an API sweep over branches and PRs | |
| # every CI, lint and smoke run | |
| # a handful of requests each, hourly and per push | |
| # | |
| # 800 is chosen against that pool rather than against this job's appetite: it is | |
| # the largest cap that still leaves a fifth of the hour to whatever else lands in | |
| # the same window, and the schedule below keeps the two heavy board readers out | |
| # of each other's hours entirely. ⛔ Raising the cap is not a tuning decision, | |
| # ⛔ there is no retry loop, and ⛔ there is no second token — a run that wants | |
| # more requests waits for the next slot, which is what the resume cursor is for. | |
| # | |
| # ## Why a run may stop before it is finished — and what it reads FIRST | |
| # | |
| # A first full snapshot of this board — several thousand numbers, each with a | |
| # comment thread — does not fit one run and must not try, so the script stops at | |
| # its own `--max-requests` ceiling, writes its phase cursor into the manifest and | |
| # exits 0. The next scheduled run continues from that cursor. Four runs a day | |
| # walk the backlog in a few days without ever exceeding the budget, and a | |
| # steady-state incremental run costs a few hundred requests. | |
| # | |
| # WHICH records it reads first is the part that matters, and it is measured | |
| # rather than assumed. The first scheduled run of this workflow walked | |
| # `state=all` oldest-first and spent all 800 requests on 137 closed issues and | |
| # 268 closed pull requests from February — ZERO open cards, with days of runs | |
| # still to go. The open board is exactly what a suspension destroys (#17374 F3), | |
| # so it was the half being archived last. A first walk now runs the OPEN phase to | |
| # completion — issues and pull requests, one listing — before the closed history | |
| # starts, and a run that runs out of budget inside the open set resumes inside | |
| # it. The manifest's `walk_phase` says which phase a run is in, so "open set | |
| # complete, history resuming" is never read as "still inside the open set". | |
| # | |
| # ⛔ On a real rate-limit refusal the script does NOT retry: it stops, writes the | |
| # cursor and exits non-zero with the reset time. A loop against a spent budget | |
| # starves every other automated caller in the repository for the rest of the hour. | |
| # | |
| # ## A stale archive is not a green run | |
| # | |
| # A run that stops on its own budget is a planned pause and exits 0. A run whose | |
| # archive has not been CONFIRMED against the live board for longer than a day is | |
| # a different thing, and until #18137 it exited 0 as well: NINETEEN consecutive | |
| # runs of this workflow reported `count_check: pending` between 2026-09-10T20:23Z | |
| # and 2026-09-14T20:24Z and every one of them is `success` in this workflow's own | |
| # run history — including 2026-09-14T02:34Z, whose delta cursor was two days | |
| # behind the live board on the morning an account suspension destroyed cards the | |
| # archive did not hold. The script now carries a `freshness` row and exits 5 when | |
| # that streak passes a day; its header is the authority on the anchor and the | |
| # window, and ⛔ the row prescribes no remedy: the cadence above and the | |
| # 800-request cap are the maintainer's decisions, not this row's. | |
| # | |
| # The ORDER of the steps below is what makes that safe, and it is deliberate: the | |
| # commit and the R2 mirror run BEFORE the step that reads the exit code, so a red | |
| # freshness row never costs the records the same run archived. ⛔ Do not move the | |
| # failing step above them — a stale archive is better than none. | |
| # | |
| # ## The `pull_request` trigger, and what it is allowed to do | |
| # | |
| # Every patrol in this tree exercises itself on the pull requests that change | |
| # it, and that is not a style preference: `check:pm-dispatch-gates` pins that NO | |
| # discovered gate family is reachable only through a schedule, so a workflow | |
| # whose script CI never runs at PR time reddens that gate by existing. A first | |
| # draft of this file omitted the trigger to keep PR-time cost at zero and was | |
| # refused by exactly that pin. | |
| # | |
| # So a pull_request run DOES execute the snapshot on a real runner — the | |
| # transport, the flags, the page walk and the rendering, proven where they will | |
| # actually run — and it writes NOTHING: no archive branch is checked out, the | |
| # run is `--dry-run` into the runner's temp dir, and the commit step is skipped. | |
| # It is also capped hard (`--limit`, `--max-requests` below), because the | |
| # repository's request budget is shared with the half-state patrol and the | |
| # closed-card sweep and must not be spent per push. ⛔ A pull request must never | |
| # write to the board OR to the archive. | |
| # | |
| # The offline half is held for free beside it: `check:pm-board-snapshot` runs the | |
| # script's 70-case `--self-test` inside the required `Lint & Repo Gates` job, | |
| # which has no paths filter (`check:self-test-wired` is what requires that step | |
| # to exist). This job runs the same self-test again before it touches the | |
| # archive, so a broken tool fails loudly instead of committing a broken backup. | |
| # | |
| # ## Adopting this in a sibling repo | |
| # | |
| # Copy FIVE files, unchanged: `scripts/pm/board-snapshot.mjs`, | |
| # `scripts/pm/check-half-states.mjs` (the archiver imports its proxy-re-exec plan | |
| # and its repo resolver), `scripts/invoked-as.mjs` (imported by both), | |
| # `scripts/ci/board-snapshot-r2-upload.sh` (the R2 step runs it) and this | |
| # file — the `board-snapshot` job of it; a repo that wants only the archiver | |
| # deletes the three sibling jobs and their crons. A repo that has already | |
| # adopted the half-state patrol has the second and third. | |
| # ⛔ Do not shorten this list from memory — the imports decide it, not this | |
| # comment, and the patrol's own adopt list was wrong for months in exactly that | |
| # way, which installed a dead sweeper that failed before its first predicate. | |
| # | |
| # There is nothing to configure. The board archived is `github.repository`, | |
| # passed explicitly below, and the script REFUSES its resolver's default rather | |
| # than falling back to one: a copy of this file quietly archiving the repo it was | |
| # copied FROM would produce a complete, well-formed, green archive of the wrong | |
| # board. | |
| board-snapshot: | |
| name: Snapshot the board to the archive branch | |
| # One file, four clocks: a `schedule` event carries the cron that fired it, so | |
| # each job runs only on its own cron; `workflow_dispatch` runs the one the input | |
| # names (default: all four); a `pull_request` run exercises every job. | |
| if: >- | |
| (github.event_name != 'schedule' || github.event.schedule == '7 2,8,14,20 * * *') | |
| && (github.event_name != 'workflow_dispatch' || inputs.patrol == 'all' || inputs.patrol == 'board-snapshot') | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| # Least privilege. `contents: write` is for the archive BRANCH and nothing else; | |
| # the two read scopes are what the snapshot reads. ⛔ This job never writes to | |
| # `main` and never writes to any issue, pull request, label or comment — there is | |
| # no scope here that would let it, which is the mechanical half of the one-board | |
| # rule stated above. | |
| permissions: | |
| contents: write | |
| issues: read | |
| pull-requests: read | |
| # One archiver at a time. Two runs racing the same branch would have the loser | |
| # push a snapshot built from a stale checkout, silently discarding the winner's. | |
| concurrency: | |
| group: board-snapshot | |
| cancel-in-progress: false | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version-file: .nvmrc | |
| # No `pnpm install`: the archiver imports only `node:` builtins, global | |
| # `fetch`, and two repo-local modules — no npm dependency, so installing | |
| # the workspace here would buy nothing and would give a scheduled backup a | |
| # lockfile it could fail on. | |
| - name: The tool's own cases, before it touches the archive | |
| run: node scripts/pm/board-snapshot.mjs --self-test | |
| # The archive branch is an ORPHAN: it shares no history with `main`, so a | |
| # checkout of it carries the archive and nothing else. First run creates it | |
| # locally; the push below is what publishes it. | |
| # | |
| # It is fetched with its WHOLE history but without file contents | |
| # (`--filter=blob:none`), because the R2 step reads each record's | |
| # last-change time out of that history; see "Only changed records are | |
| # uploaded" there. The worktree checkout then fetches the tip's blobs in | |
| # one batch, which is the same content a `--depth=1` fetch downloaded, so | |
| # the history adds only commits and trees: ~7 MB for 127 commits over | |
| # 41,047 files, measured 2026-10-12. ⛔ Do not put `--depth` back: a | |
| # truncated history makes the R2 step re-upload every record older than | |
| # the cut on every run (safe, but the cost that step exists to avoid). | |
| - name: Check out or create the archive branch | |
| if: github.event_name != 'pull_request' | |
| run: | | |
| set -euo pipefail | |
| git fetch --filter=blob:none origin '+refs/heads/board-archive:refs/remotes/origin/board-archive' || true | |
| if git rev-parse --verify --quiet refs/remotes/origin/board-archive > /dev/null; then | |
| git worktree add archive -b board-archive refs/remotes/origin/board-archive | |
| echo "archive branch exists; checked out at $(git -C archive rev-parse --short HEAD)" | |
| else | |
| git worktree add --detach archive HEAD | |
| git -C archive checkout --orphan board-archive | |
| git -C archive rm -rf --quiet . | |
| # An orphan that still carries main's tree would commit the whole | |
| # repository into the archive branch on its first run, so this is | |
| # asserted rather than assumed. | |
| tracked=$(git -C archive ls-files | wc -l) | |
| if [ "$tracked" != "0" ]; then | |
| echo "::error::the new orphan branch still tracks $tracked file(s) from main; refusing to seed the archive with them." | |
| exit 1 | |
| fi | |
| echo "archive branch created as an orphan (first run)" | |
| fi | |
| - name: Run the snapshot | |
| id: snapshot | |
| env: | |
| GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} | |
| # WHICH board this run archives: the repo this workflow is installed | |
| # in, always. A copy of this file cannot end up archiving the repo it | |
| # was copied FROM, and the script refuses to guess when this is unset. | |
| PM_SWEEP_REPO: ${{ github.repository }} | |
| # A pull_request run PROVES the tool on a runner and writes nothing: | |
| # the temp dir has no manifest, so it is a full read, capped to a | |
| # handful of numbers and a budget the shared quota will not notice. | |
| # ⛔ The two spellings differ in WHERE they write and in nothing else, | |
| # so what a PR exercises is the path a schedule takes. | |
| SNAPSHOT_ARGS: ${{ github.event_name == 'pull_request' && '--out=board --dry-run --limit=5 --max-requests=25' || '--out=archive/board' }} | |
| run: | | |
| set +e | |
| node scripts/pm/board-snapshot.mjs $SNAPSHOT_ARGS \ | |
| > "$RUNNER_TEMP/snapshot.md" 2> "$RUNNER_TEMP/snapshot.err" | |
| code=$? | |
| set -e | |
| # Captured with NO pipe in between. `cmd | tail` reports the PIPE's | |
| # status — `tail` essentially never fails, so a clean snapshot and a | |
| # failed count check would both read as 0, and this script's exit | |
| # register (0 / 2 / 3 / 4) is the whole point of having one. | |
| echo "exit_code=$code" >> "$GITHUB_OUTPUT" | |
| echo "board-snapshot exited $code" | |
| cat "$RUNNER_TEMP/snapshot.md" || true | |
| cat "$RUNNER_TEMP/snapshot.err" >&2 || true | |
| # Land the truth, THEN raise the alarm: whatever the snapshot managed to | |
| # read is committed before the run is allowed to go red, so a failing count | |
| # check never costs the records the same run archived. | |
| - name: Commit and push the archive | |
| id: commit | |
| if: github.event_name != 'pull_request' | |
| run: | | |
| set -euo pipefail | |
| if [ -z "$(git -C archive status --porcelain)" ]; then | |
| echo "committed=none" >> "$GITHUB_OUTPUT" | |
| echo "no change on the board since the last run — nothing committed." | |
| exit 0 | |
| fi | |
| git -C archive add -A | |
| files=$(git -C archive diff --cached --name-only | wc -l) | |
| git -C archive \ | |
| -c user.name='github-actions[bot]' \ | |
| -c user.email='41898282+github-actions[bot]@users.noreply.github.com' \ | |
| commit -q -m "board snapshot $(date -u +%Y-%m-%dT%H:%M:%SZ)" \ | |
| -m "run ${{ github.run_id }} · ${files} file(s) changed" | |
| git -C archive push origin board-archive | |
| echo "committed=${files}" >> "$GITHUB_OUTPUT" | |
| echo "pushed ${files} changed file(s) to board-archive" | |
| # ## The copy that outlives the platform, not just the account | |
| # | |
| # The archive branch above survives an ACCOUNT suspension, because a | |
| # branch belongs to the repository rather than to a user. It does not | |
| # survive an action against the organisation or the repository itself, | |
| # and that is the remaining hole. The maintainer's answer, verbatim and | |
| # untranslated, in chat on 2026-09-13: | |
| # | |
| # 「那如果推 s3 呢?」 「Cloudflare R2」 | |
| # | |
| # So this step mirrors the archive CHECKOUT — the same tree the commit | |
| # above just pushed — into one R2 prefix. Two things follow from R2 | |
| # having NO bucket versioning: | |
| # | |
| # - `--delete` makes the prefix equal to the branch tip and nothing | |
| # else, so a restore never reads a file the board no longer has; | |
| # - a mirror therefore keeps no HISTORY at all, so once per UTC day the | |
| # tree is also written as `snapshots/YYYY-MM-DD.tar.gz`. That key | |
| # lives OUTSIDE the mirrored prefix on purpose: inside it, the very | |
| # next `--delete` sync would remove it as an object with no local | |
| # counterpart, and the history would be exactly one day long. | |
| # | |
| # The daily tarball is decided by asking the bucket for today's key | |
| # rather than by the clock, so a missed 02:07 run, a re-run and a | |
| # `workflow_dispatch` all converge on one tarball per day. | |
| # | |
| # ## Only changed records are uploaded | |
| # | |
| # `aws s3 sync` uploads a file when its size differs from the object's, | |
| # or when the file's mtime is newer than the object's LastModified | |
| # (aws-cli `SizeAndLastModifiedSync`). A fresh checkout stamps every file | |
| # with the checkout time, so each run re-PUT all ~41,000 objects: | |
| # measured on the bucket, 1.2M PutObject in 11 days (~110k a day, ~$10 a | |
| # month of R2 Class A operations against a 1M-a-month free tier). | |
| # | |
| # So before syncing, every file's mtime is set to the committer time of | |
| # the last commit on the archive branch that changed it. That makes the | |
| # comparison mean what sync assumes it means: | |
| # | |
| # - an object's LastModified is the time it was last uploaded, and every | |
| # upload happens AFTER the commit whose content it carries (the commit | |
| # step runs before this one), so an object that is current has a | |
| # LastModified newer than its file's mtime and is skipped; | |
| # - a file changed by a commit made after its object was last uploaded | |
| # has the newer mtime and is uploaded. That covers this run's changes | |
| # AND anything an earlier run failed to upload, with no state kept | |
| # between runs; | |
| # - a file the archive no longer has is still removed by `--delete`. | |
| # | |
| # ⛔ Not `--size-only`: a record edit can keep its byte count (every | |
| # `updated_at` move does, and so does a count gaining no digit), and a | |
| # size-only comparison would leave the stale copy in R2 for good. | |
| # | |
| # This relies on two things being true. The archive branch is written | |
| # only by this workflow, so its committer times follow real time. And | |
| # nothing else writes under the mirror prefix: an out-of-band write | |
| # with the same size and a newer LastModified would not be overwritten, | |
| # where the old full re-upload replaced it within six hours. If the | |
| # history cannot be read, the step does not guess. It leaves the | |
| # checkout times in place, which re-uploads everything, and says so in | |
| # a warning and in the run's R2 status line: more Class A operations, | |
| # never a weaker copy. | |
| # | |
| # ## Unset secrets are a notice, not a failure | |
| # | |
| # The bucket and its scoped API token are the maintainer's to create. | |
| # Until all four secrets exist this step prints one notice and exits 0, | |
| # so this workflow keeps archiving to the branch and nothing here has to | |
| # be merged in the same hour as the bucket. A CONFIGURED upload that then | |
| # fails is the opposite case and goes red: a backup that silently stops | |
| # copying is the failure mode this whole file exists to prevent. | |
| # | |
| # ⛔ The four secrets are read, never echoed, and never written to the | |
| # summary; the run below also never touches the board, `main`, or the | |
| # archive branch — it only reads the checkout that is already on disk. | |
| - name: Upload the archive to R2 | |
| id: r2 | |
| if: github.event_name != 'pull_request' | |
| env: | |
| AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} | |
| AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} | |
| # R2 is S3-compatible with one fixed pseudo-region. | |
| AWS_DEFAULT_REGION: auto | |
| # S3-compatible endpoints other than S3 itself are the documented | |
| # case for this setting: the CLI then sends an integrity checksum | |
| # only where the API requires one, instead of on every request. | |
| AWS_REQUEST_CHECKSUM_CALCULATION: when_required | |
| R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }} | |
| R2_BUCKET: ${{ secrets.R2_BUCKET }} | |
| # The body lives in scripts/ci/board-snapshot-r2-upload.sh (#26086); its | |
| # offline self-test is scripts/ci/board-snapshot-r2-upload.selftest.sh. | |
| run: bash scripts/ci/board-snapshot-r2-upload.sh | |
| - name: Publish the run to the summary | |
| if: always() | |
| run: | | |
| { | |
| echo "### Board snapshot — exit ${{ steps.snapshot.outputs.exit_code }}" | |
| echo | |
| echo "Committed: ${{ steps.commit.outputs.committed || 'nothing (the run stopped before the commit step)' }}" | |
| echo | |
| echo "R2: ${{ steps.r2.outputs.status || 'not attempted (a pull_request run, or the job stopped before the upload step)' }}" | |
| echo | |
| echo '```' | |
| cat "$RUNNER_TEMP/snapshot.md" 2>/dev/null || echo '(no report produced)' | |
| echo '```' | |
| echo | |
| echo '<details><summary>stderr</summary>' | |
| echo | |
| echo '```' | |
| cat "$RUNNER_TEMP/snapshot.err" 2>/dev/null || true | |
| echo '```' | |
| echo | |
| echo '</details>' | |
| } >> "$GITHUB_STEP_SUMMARY" | |
| # LAST on purpose: every step that lands the records is above it, so this | |
| # one only ever decides the colour of the run. See the header. | |
| - name: Fail the run if the snapshot did not read the board, or the archive is behind it | |
| if: steps.snapshot.outputs.exit_code != '0' | |
| run: | | |
| echo "::error::board-snapshot exited ${{ steps.snapshot.outputs.exit_code }} — 2 is a failed count check (the archive and the board disagree about how many open issues exist), 3 is a prerequisite the runner did not meet, 4 is a rate-limit stop with a resume cursor written, 5 is a STALE archive (no run has confirmed it against the live board for longer than a day; the run summary names the streak and what clears it). Whatever was read HAS been committed; see this run's summary." | |
| exit 1 |