|
| 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 |
0 commit comments