Skip to content

Commit 456a0af

Browse files
os-litantclaude
andauthored
feat(pm): a scheduled backup of the board, so an account suspension destroys no record (#17404)
* feat(pm): a scheduled backup of the board, so an account suspension destroys no record (#17390) Three fleet accounts were suspended in two months and the appeals are unanswered after ten days. #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, so one suspension erases state nothing else holds; six cards are already gone, one of them a p1 security decision. `scripts/pm/board-snapshot.mjs` reads the board over repo-scoped REST with whatever token the caller supplies and writes one JSON file per number, its comment thread as JSONL, a pull request's reviews beside it, and a manifest carrying the run stamp, the `since` used, counts by state, the board's own `open_issues_count` read in the same run, the request count and a resume cursor. Runs are incremental from the previous manifest and idempotent: a re-run over an unchanged board writes nothing at all. Two properties are load-bearing and neither is visible on a clean tree. The page walk never trusts `Link: rel="next"` and ends only on a short page, and it re-anchors `since` on the last row's VALUE rather than incrementing an offset — an item updated mid-walk shifts every row behind it one slot, and a blind `page += 1` skips the row at the boundary permanently, its own `updated_at` never having moved. The other is the rate discipline: a refusal stops the run, writes the cursor and exits non-zero with the reset time, and nothing here retries, because a loop against a spent budget starves every other automated caller in the repository for the rest of the hour. `--restore <n>` prints, and only prints, the recreate payload for a destroyed card: the provenance header a rebuilt record must carry, the archived body, the labels to re-apply and the comment thread as a second block. Posting stays a seat's act, and the script has no write path to GitHub in any mode — two self-test cases read its own source and hold that structurally, which is the mechanical half of the one-board rule the archive lives under. `.github/workflows/board-snapshot.yml` is the standing caller, at the half-state patrol's cadence, running as `github-actions[bot]` into the orphan branch `board-archive`. It never writes to `main` and holds no scope that would let it write to any issue. Self-test: 70 cases across 8 batteries, offline, no network and no token, wired into the required lint job as `check:pm-board-snapshot`. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com> * fix(pm): the archiver's workflow exercises itself at PR time, writing nothing `check:pm-dispatch-gates` pins that no discovered gate family is reachable only through a schedule — every patrol in this tree carries a `pull_request` paths trigger, and a workflow whose script CI never runs at PR time reddens that gate by existing. The first draft of `board-snapshot.yml` omitted the trigger to keep PR-time cost at zero and was refused by exactly that pin, which named the remedy in its own failure text. So a pull request now runs the snapshot on a real runner — transport, flags, page walk and rendering, proven where they will actually run — into the runner's temp dir, `--dry-run`, capped at five numbers and twenty-five requests, with the archive checkout and the commit both skipped. A pull request writes to neither the board nor the archive, and the two spellings differ in where they write and in nothing else, so what a PR exercises is the path a schedule takes. Two readings from the local drill, folded back in: the PENDING count-check line no longer prints an arithmetic the run never read (a stopped run skips the board's own count, and `expected 0` read as a real zero), and `--restore` no longer re-execs itself through the proxy — it makes no request at all, so routing its transport spawned a child to prove a route that mode never uses. Claude-Session: https://claude.ai/code/session_01YKEjmbYNvYWJvWGSWx26zK Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 77c801e commit 456a0af

4 files changed

Lines changed: 1623 additions & 0 deletions

File tree

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
name: Board Snapshot
2+
3+
# The standing caller for `scripts/pm/board-snapshot.mjs` (#17390).
4+
#
5+
# ## Why a scheduled backup, and why it lands on a branch
6+
#
7+
# Three fleet accounts were suspended in two months and the appeals are
8+
# unanswered. #17374 F3 measured what a suspension destroys: every issue, pull
9+
# request and comment the account authored — while every branch and commit
10+
# survives, because those belong to the repository rather than to a user. The
11+
# board IS the fleet's state by rule (pm-dispatch SKILL.md, 全体座位的不变量:
12+
# 「GitHub 之外永不维护任何跟踪状态」), so today one suspension erases state that
13+
# nothing else holds; six cards, one of them a p1 security decision, are gone.
14+
#
15+
# The maintainer's answer, verbatim and untranslated, in chat on 2026-09-10:
16+
# 「或者用 ci 写个定时任务备份?」 — this file is that scheduled task. It runs as
17+
# `github-actions[bot]`, which is not a fleet account and cannot be suspended
18+
# with one, and it writes to an ORPHAN BRANCH of this same repository: no second
19+
# repo, no second credential, and a branch is precisely the thing a suspension
20+
# leaves standing.
21+
#
22+
# ## An archive is not a tracker — the one-board rule still holds
23+
#
24+
# ⛔ Nothing reads `board-archive` for state. Not a seat, not a patrol, not a
25+
# gate, not a query; every reading of the board still goes to GitHub. The
26+
# archive answers exactly one question, after a loss: what did the record say?
27+
# The script it runs has NO write path to GitHub in any mode — two of its
28+
# self-test cases read its own source and hold that structurally — and
29+
# `--restore` PRINTS a recreate payload for a seat to post, or for nobody to.
30+
#
31+
# ## Budget, and why a run may stop before it is finished
32+
#
33+
# `GITHUB_TOKEN` is limited to 1,000 requests per hour PER REPOSITORY, and this
34+
# job is not that budget's only caller (the half-state patrol and the closed-card
35+
# sweep share it). A first full snapshot of this board — several thousand
36+
# numbers, each with a comment thread — does not fit one run and must not try, so
37+
# the script stops at its own `--max-requests` ceiling, writes a resume cursor
38+
# into the manifest and exits 0. The next scheduled run continues from that
39+
# cursor. Four runs a day walk the backlog in a few days without ever exceeding
40+
# the budget, and a steady-state incremental run costs a few hundred requests.
41+
#
42+
# ⛔ On a real rate-limit refusal the script does NOT retry: it stops, writes the
43+
# cursor and exits non-zero with the reset time. A loop against a spent budget
44+
# starves every other automated caller in the repository for the rest of the hour.
45+
#
46+
# ## The `pull_request` trigger, and what it is allowed to do
47+
#
48+
# Every patrol in this tree exercises itself on the pull requests that change
49+
# it, and that is not a style preference: `check:pm-dispatch-gates` pins that NO
50+
# discovered gate family is reachable only through a schedule, so a workflow
51+
# whose script CI never runs at PR time reddens that gate by existing. A first
52+
# draft of this file omitted the trigger to keep PR-time cost at zero and was
53+
# refused by exactly that pin.
54+
#
55+
# So a pull_request run DOES execute the snapshot on a real runner — the
56+
# transport, the flags, the page walk and the rendering, proven where they will
57+
# actually run — and it writes NOTHING: no archive branch is checked out, the
58+
# run is `--dry-run` into the runner's temp dir, and the commit step is skipped.
59+
# It is also capped hard (`--limit`, `--max-requests` below), because the
60+
# repository's request budget is shared with the half-state patrol and the
61+
# closed-card sweep and must not be spent per push. ⛔ A pull request must never
62+
# write to the board OR to the archive.
63+
#
64+
# The offline half is held for free beside it: `check:pm-board-snapshot` runs the
65+
# script's 70-case `--self-test` inside the required `Lint & Repo Gates` job,
66+
# which has no paths filter (`check:self-test-wired` is what requires that step
67+
# to exist). This job runs the same self-test again before it touches the
68+
# archive, so a broken tool fails loudly instead of committing a broken backup.
69+
#
70+
# ## Adopting this in a sibling repo
71+
#
72+
# Copy FOUR files, unchanged: `scripts/pm/board-snapshot.mjs`,
73+
# `scripts/pm/check-half-states.mjs` (the archiver imports its proxy-re-exec plan
74+
# and its repo resolver), `scripts/invoked-as.mjs` (imported by both) and this
75+
# file. A repo that has already adopted the half-state patrol has the middle two.
76+
# ⛔ Do not shorten this list from memory — the imports decide it, not this
77+
# comment, and the patrol's own adopt list was wrong for months in exactly that
78+
# way, which installed a dead sweeper that failed before its first predicate.
79+
#
80+
# There is nothing to configure. The board archived is `github.repository`,
81+
# passed explicitly below, and the script REFUSES its resolver's default rather
82+
# than falling back to one: a copy of this file quietly archiving the repo it was
83+
# copied FROM would produce a complete, well-formed, green archive of the wrong
84+
# board.
85+
86+
on:
87+
schedule:
88+
# The half-state patrol's cadence, deliberately: four times a day, six hours
89+
# apart, at :37 past the hour — offset from the top of the hour where the
90+
# hourly triage Routine runs, so the two do not contend for the same minute
91+
# of the shared request budget. Six-hourly is the loss window this accepts:
92+
# a card created and destroyed inside one interval was never archived, and
93+
# nothing cheaper than a webhook closes that, which is a different card.
94+
- cron: '37 1,7,13,19 * * *'
95+
workflow_dispatch: {}
96+
# Changes to the archiver itself get exercised before they merge. The paths
97+
# name every file the run actually loads — the archiver, the module it imports
98+
# its proxy plan and repo resolver from, the entry-guard helper both import,
99+
# and this file. An undeclared dependency is one a change can break without
100+
# this trigger firing, which is the same defect the adopt list above warns
101+
# about, one layer along.
102+
pull_request:
103+
paths:
104+
- 'scripts/pm/board-snapshot.mjs'
105+
- 'scripts/pm/check-half-states.mjs'
106+
- 'scripts/invoked-as.mjs'
107+
- '.github/workflows/board-snapshot.yml'
108+
109+
# Least privilege. `contents: write` is for the archive BRANCH and nothing else;
110+
# the two read scopes are what the snapshot reads. ⛔ This job never writes to
111+
# `main` and never writes to any issue, pull request, label or comment — there is
112+
# no scope here that would let it, which is the mechanical half of the one-board
113+
# rule stated above.
114+
permissions:
115+
contents: write
116+
issues: read
117+
pull-requests: read
118+
119+
# One archiver at a time. Two runs racing the same branch would have the loser
120+
# push a snapshot built from a stale checkout, silently discarding the winner's.
121+
concurrency:
122+
group: board-snapshot
123+
cancel-in-progress: false
124+
125+
jobs:
126+
archive:
127+
name: Snapshot the board to the archive branch
128+
runs-on: ubuntu-latest
129+
timeout-minutes: 15
130+
steps:
131+
- name: Checkout repository
132+
uses: actions/checkout@v7
133+
134+
- name: Setup Node.js
135+
uses: actions/setup-node@v7
136+
with:
137+
node-version: '22'
138+
139+
# No `pnpm install`: the archiver imports only `node:` builtins, global
140+
# `fetch`, and two repo-local modules — no npm dependency, so installing
141+
# the workspace here would buy nothing and would give a scheduled backup a
142+
# lockfile it could fail on.
143+
- name: The tool's own cases, before it touches the archive
144+
run: node scripts/pm/board-snapshot.mjs --self-test
145+
146+
# The archive branch is an ORPHAN: it shares no history with `main`, so a
147+
# checkout of it carries the archive and nothing else. First run creates it
148+
# locally; the push below is what publishes it.
149+
- name: Check out or create the archive branch
150+
if: github.event_name != 'pull_request'
151+
run: |
152+
set -euo pipefail
153+
git fetch --depth=1 origin '+refs/heads/board-archive:refs/remotes/origin/board-archive' || true
154+
if git rev-parse --verify --quiet refs/remotes/origin/board-archive > /dev/null; then
155+
git worktree add archive -b board-archive refs/remotes/origin/board-archive
156+
echo "archive branch exists; checked out at $(git -C archive rev-parse --short HEAD)"
157+
else
158+
git worktree add --detach archive HEAD
159+
git -C archive checkout --orphan board-archive
160+
git -C archive rm -rf --quiet .
161+
# An orphan that still carries main's tree would commit the whole
162+
# repository into the archive branch on its first run, so this is
163+
# asserted rather than assumed.
164+
tracked=$(git -C archive ls-files | wc -l)
165+
if [ "$tracked" != "0" ]; then
166+
echo "::error::the new orphan branch still tracks $tracked file(s) from main; refusing to seed the archive with them."
167+
exit 1
168+
fi
169+
echo "archive branch created as an orphan (first run)"
170+
fi
171+
172+
- name: Run the snapshot
173+
id: snapshot
174+
env:
175+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
176+
# WHICH board this run archives: the repo this workflow is installed
177+
# in, always. A copy of this file cannot end up archiving the repo it
178+
# was copied FROM, and the script refuses to guess when this is unset.
179+
PM_SWEEP_REPO: ${{ github.repository }}
180+
# A pull_request run PROVES the tool on a runner and writes nothing:
181+
# the temp dir has no manifest, so it is a full read, capped to a
182+
# handful of numbers and a budget the shared quota will not notice.
183+
# ⛔ The two spellings differ in WHERE they write and in nothing else,
184+
# so what a PR exercises is the path a schedule takes.
185+
SNAPSHOT_ARGS: ${{ github.event_name == 'pull_request' && '--out=board --dry-run --limit=5 --max-requests=25' || '--out=archive/board' }}
186+
run: |
187+
set +e
188+
node scripts/pm/board-snapshot.mjs $SNAPSHOT_ARGS \
189+
> "$RUNNER_TEMP/snapshot.md" 2> "$RUNNER_TEMP/snapshot.err"
190+
code=$?
191+
set -e
192+
# Captured with NO pipe in between. `cmd | tail` reports the PIPE's
193+
# status — `tail` essentially never fails, so a clean snapshot and a
194+
# failed count check would both read as 0, and this script's exit
195+
# register (0 / 2 / 3 / 4) is the whole point of having one.
196+
echo "exit_code=$code" >> "$GITHUB_OUTPUT"
197+
echo "board-snapshot exited $code"
198+
cat "$RUNNER_TEMP/snapshot.md" || true
199+
cat "$RUNNER_TEMP/snapshot.err" >&2 || true
200+
201+
# Land the truth, THEN raise the alarm: whatever the snapshot managed to
202+
# read is committed before the run is allowed to go red, so a failing count
203+
# check never costs the records the same run archived.
204+
- name: Commit and push the archive
205+
id: commit
206+
if: github.event_name != 'pull_request'
207+
run: |
208+
set -euo pipefail
209+
if [ -z "$(git -C archive status --porcelain)" ]; then
210+
echo "committed=none" >> "$GITHUB_OUTPUT"
211+
echo "no change on the board since the last run — nothing committed."
212+
exit 0
213+
fi
214+
git -C archive add -A
215+
files=$(git -C archive diff --cached --name-only | wc -l)
216+
git -C archive \
217+
-c user.name='github-actions[bot]' \
218+
-c user.email='41898282+github-actions[bot]@users.noreply.github.com' \
219+
commit -q -m "board snapshot $(date -u +%Y-%m-%dT%H:%M:%SZ)" \
220+
-m "run ${{ github.run_id }} · ${files} file(s) changed"
221+
git -C archive push origin board-archive
222+
echo "committed=${files}" >> "$GITHUB_OUTPUT"
223+
echo "pushed ${files} changed file(s) to board-archive"
224+
225+
- name: Publish the run to the summary
226+
if: always()
227+
run: |
228+
{
229+
echo "### Board snapshot — exit ${{ steps.snapshot.outputs.exit_code }}"
230+
echo
231+
echo "Committed: ${{ steps.commit.outputs.committed || 'nothing (the run stopped before the commit step)' }}"
232+
echo
233+
echo '```'
234+
cat "$RUNNER_TEMP/snapshot.md" 2>/dev/null || echo '(no report produced)'
235+
echo '```'
236+
echo
237+
echo '<details><summary>stderr</summary>'
238+
echo
239+
echo '```'
240+
cat "$RUNNER_TEMP/snapshot.err" 2>/dev/null || true
241+
echo '```'
242+
echo
243+
echo '</details>'
244+
} >> "$GITHUB_STEP_SUMMARY"
245+
246+
- name: Fail the run if the snapshot did not read the board
247+
if: steps.snapshot.outputs.exit_code != '0'
248+
run: |
249+
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."
250+
exit 1

.github/workflows/lint.yml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1123,6 +1123,27 @@ jobs:
11231123
- name: Stamped-post helper self-test
11241124
run: pnpm check:pm-post-stamped
11251125

1126+
# Board-snapshot archiver self-test (#17390) — same family and the same
1127+
# split as every step around it. The LIVE run reads the whole board over
1128+
# REST and commits the result to an orphan branch, so it is not a thing
1129+
# CI runs on a pull request; the offline half is what CI can hold, and
1130+
# `.github/workflows/board-snapshot.yml` is the only caller of the other.
1131+
#
1132+
# What the self-test instruments is the set of rules a clean tree cannot
1133+
# exercise: the page walk's value re-anchor (blind `page += 1` skips a row
1134+
# that shifted forward mid-walk, permanently and with no symptom), the
1135+
# resume cursor an interrupted run leaves, the idempotence that keeps a
1136+
# no-op run from committing, the count check's three verdicts — including
1137+
# `pending`, which exists so "could not check" never renders as "checked
1138+
# and clean" — and the provenance sentence a rebuilt record must carry.
1139+
# Two of its cases are STRUCTURAL and read this file's own source: that
1140+
# the archiver has no write path to GitHub in any mode (the one-board
1141+
# rule, mechanically) and that it carries no retry loop against a spent
1142+
# rate limit. Weakening any of them leaves every ordinary run just as
1143+
# green, which is the shape `check:self-test-wired` requires this step for.
1144+
- name: Board-snapshot archiver self-test
1145+
run: pnpm check:pm-board-snapshot
1146+
11261147
# Changeset-deadline census self-test (#16850) — same family and the same
11271148
# split as every step around it. The LIVE census reads the shared board
11281149
# over the API and is REPORT-ONLY by ruling: #16850 takes option 1 (detect

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"check:pm-widening-tells": "node scripts/pm/check-widening-tells.mjs --self-test",
8181
"check:pm-closed-card-sweep": "node scripts/pm/sweep-closed-cards.mjs --self-test",
8282
"check:pm-post-stamped": "node scripts/pm/post-stamped.mjs --self-test",
83+
"check:pm-board-snapshot": "node scripts/pm/board-snapshot.mjs --self-test",
8384
"check:pm-changeset-deadline-census": "node scripts/pm/changeset-deadline-census.mjs --self-test",
8485
"check:pm-governed-merges": "node scripts/pm/check-governed-merges.mjs --self-test",
8586
"check:pm-governed-prose": "node scripts/pm/check-governed-prose.mjs --self-test && node scripts/pm/check-governed-prose.mjs",

0 commit comments

Comments
 (0)