The CI REST Budget

A satellite's CI spends one shared budget. Every gh api, every REST curl, every urllib call a job makes with ${{ github.token }} is charged to the CALLING repository's GITHUB_TOKEN budget. GitHub documents it as 1,000 requests per hour per repository (15,000 only for Enterprise Cloud; this organisation is on the Team plan). Every workflow of the repository draws on it — the pull-request CI, the stage-advance listener, the retry steward, the arm and supersede helpers — and the reusable node-repo-* lanes core ships draw on the CALLER's budget, not core's, because they run with the caller's token.

When it runs out, the refusal is the same for every call — "API rate limit exceeded for installation" — and it lands wherever the next call happens to be. On MeshWeaver.Plugins on 2026-10-07 11:18–11:25Z that was: the closing-keyword gate, the content-CI path guard and the manifest checker failing to fetch their script; the stage gate holding a run as "unreadable"; platform-requirement.py dying on an HTTPError 403; and the runner watchdog polling the jobs listing every 20 s through the whole window, reading a refusal each time. None of those is the defect. The defect is what spent the budget.

What spent it — measured on MeshWeaver.Plugins, 2026-10-07 10:20–11:20Z

The hour before the window, read from the Actions runs listing (REST, read-only):

workflow runs in the hour REST calls per run (floor)
Plugin Catalog CI (pull_request 33, push 3) 36, 1,790 job executions incl. re-run attempts 20–46 platform-file fetches counted (a floor) + the stage gate's 6 + resolver/requirement scripts
Stage advance (pull_request_review_comment 60, pull_request_review 39, other 5) 104 ~10 (9 counted with a shim around gh, plus the script fetch)
Retry known transients / Merged tree current 30 / 33 0 when skipped; jobs + annotations listings on a failure
Arm auto-merge, Paired core change, Supersede 29 / 33 / 16 0–3

The 2026-10-05 22:20–23:20Z window had the same shape: 39 CI pull-request runs, 52 + 40 stage-advance review events, 45 retry runs. (The 2026-10-06 ~21:45Z refusals in #6215 / #6216 are a DIFFERENT budget — the systemorph-com App installation the portal's PR steward uses; Plugins CI was quiet then, 80 runs in the hour.)

How the per-run numbers were taken:

The fixes, at the root

1. The lanes read the platform's files over git, never through REST. Core is a PUBLIC repository, so the same bytes are readable over anonymous git, which spends no REST budget. .github/actions/core-file installs $RUNNER_TEMP/mw-core-file (source: .github/scripts/mw-core-file.sh) as the first step of every job that reads a platform file, and every read is

"$RUNNER_TEMP/mw-core-file" "${SCRIPTS_REF}" ".github/scripts/check-covers.py" > "$RUNNER_TEMP/check-covers.py" \
  || { echo "::error::could not fetch …"; exit 1; }

One shallow, blob-less fetch per (job, ref); every later read in the job is local, the blobs are fetched on demand. Two properties the REST reads did not have: one commit per job (a moving main can no longer hand two steps of one job two different trees), and an abbreviated sha is refused by shape (7–39 hex, before any fetch) rather than resolved — git would otherwise take a same-named branch or tag for it; a hash-like NAMED ref is spelled refs/heads/… / refs/tags/…. CiRestBudgetGuard holds every node-repo-*.yml at zero contents-API reads, requires the install before the first call in each job, and drives the reader against a local repository (branch, tag, full sha, a moving branch, an abbreviated sha even with a same-named branch present, a missing path) and the budget mode against a stubbed curl (a normal reading, the 80 % warning, an unanswered request); its negative control proves both detectors fire on the old shapes.

A refused read is named as infrastructure, never as an old pin (#6633). The reader exits 1 when the ref cannot be FETCHED, and its line says which of two causes it was. When the server answers that it has no such ref (git's couldn't find remote ref / not our ref: a mistyped or deleted branch or tag, a full sha that is not in the repository) it says REF NOT FOUND — … This IS the caller's pin. Every other fetch failure (a network failure, GitHub refusing or throttling the read) says INFRASTRUCTURE — … not a fault in the caller's code or its pin. It exits 3 only when the ref WAS fetched and the path is absent at it — the one case a lane may read as "a pin older than the file". The lanes' own ::error:: lines no longer guess: every caller in the lane workflows points at the reader's line. Before this, a throttled read surfaced as "a pin older than core #3761" and PR owners chased a code fault that did not exist. PlatformFileReaderGuard asserts all three outcomes, and that neither the absent path nor the absent ref is ever called infrastructure.

2. The stage-advance listener answers pull_request_review only. The comment trigger was a pure duplicate (above); dropping it removes ~60 of ~100 runs an hour at ~10 calls each. The caller owns its triggers (stage-advance.yml in each satellite); the lane's header names the rule.

The instrument

The install step prints the token's budget once per job — GET /rate_limit, which GitHub does not count against the budget:

REST budget of this job's token: 412 of 1000 used, resets 11:42:07Z

and turns it into a ::warning title=REST budget nearly spent:: at 80 %. A saturated hour is therefore visible in the log of every job that reads a platform file BEFORE it reds anything, with the reset time beside it. (A job that reads no platform file does not install the reader and prints no reading.)

What was NOT measured