feat(pm): archive the open board before the closed history, and move the snapshot cron off the patrol's hour #3
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: Board Snapshot | |
| # 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. | |
| # | |
| # ## 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 FOUR 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) and this | |
| # file. A repo that has already adopted the half-state patrol has the middle two. | |
| # ⛔ 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. | |
| on: | |
| schedule: | |
| # 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:00 rerun-safety, 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 * * *' | |
| workflow_dispatch: {} | |
| # Changes to the archiver itself get exercised before they merge. The paths | |
| # name every file the run actually loads — the archiver, the module it imports | |
| # its proxy plan and repo resolver from, the entry-guard helper both import, | |
| # and this file. An undeclared dependency is one a change can break without | |
| # this trigger firing, which is the same defect the adopt list above warns | |
| # about, one layer along. | |
| pull_request: | |
| paths: | |
| - 'scripts/pm/board-snapshot.mjs' | |
| - 'scripts/pm/check-half-states.mjs' | |
| - 'scripts/invoked-as.mjs' | |
| - '.github/workflows/board-snapshot.yml' | |
| # 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 | |
| jobs: | |
| archive: | |
| name: Snapshot the board to the archive branch | |
| runs-on: ubuntu-latest | |
| timeout-minutes: 15 | |
| steps: | |
| - name: Checkout repository | |
| uses: actions/checkout@v7 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v7 | |
| with: | |
| node-version: '22' | |
| # No `pnpm install`: the 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. | |
| - name: Check out or create the archive branch | |
| if: github.event_name != 'pull_request' | |
| run: | | |
| set -euo pipefail | |
| git fetch --depth=1 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" | |
| - 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 '```' | |
| 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" | |
| - name: Fail the run if the snapshot did not read the board | |
| 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. Whatever was read HAS been committed; see this run's summary." | |
| exit 1 |