ci(nightly-next-tick): unstick reads as the cmux-bot App; fewer request-job reads
The repository's workflow GITHUB_TOKEN pool (5,000 calls an hour, shared by every workflow) ran dry every hour on 2026-11-04. The token-budget sample (14:01-15:01Z) put nightly-next-tick first among single spenders: the unstick job made about 8.6 REST calls per job and the request job about 6.9, at about 67 jobs an hour each.
Stacked on #35062 (git ls-remote for branch refs). Its two commits show in this diff until it lands; this one merges after it, with main merged in.
What changes
-
unstick reads go to the cmux-bot App. The job mints a cmux-bot installation token with the credentials the repository already stores (
vars.CMUX_BOT_APP_ID,secrets.CMUX_BOT_APP_KEY, the same pattern as ci-queue-janitor and ci-owned-pool-rescue), scoped toactions: readandcontents: read. It then installsscripts/ci/gh_app_token.shin a new--reads-onlymode:- a gh call that only reads goes out as the App: a
gh apiGET with no request fields, a GraphQL query, orgh run|workflow|pr|issue|release list|view; - when the App is refused, the same read is sent again with github.token;
- every other call (the cancels, force-cancels and re-runs, and anything the wrapper cannot classify) runs once as github.token, untouched.
GH_APP_TOKEN_ATTEMPTS=1sits next to the existingGH_BUDGET_WAIT_ATTEMPTS=1, so neither wrapper adds a hidden backoff; - with no App token (a failed mint, or no variable), nothing is installed and the job runs exactly as before.
Without
--reads-only, gh_app_token.sh behaves as before (the nightly publish path). - a gh call that only reads goes out as the App: a
-
The unstick job's runner is bounded to the linux_2 pool's members. A job that holds an App key may not run where a variable can move it (tests/test_ci_fork_runner_routing.py). Its runs-on now names
blacksmith-2vcpu-ubuntu-2404andblacksmith-4vcpu-ubuntu-2404(the members in.github/runners.json). That matches every value the RUNNER_POOLS reconciler writes, and today's value isblacksmith-4vcpu-ubuntu-2404. -
The request job keeps only its GITHUB_TOKEN. CI_CONTROL_RUNNER may move it to a reused machine, so by design it holds no secret and no App token (tests/test_ci_cloud_overflow_switch.py). Its routing is unchanged. It spends fewer reads instead:
- the rolling rule reads the gap from nightly-next's head to the tip from the tick's own 150-commit checkout of feat-cmux-next (
git_oldest_after), not the compare API. The compare API is still the fallback when the head is not in the checkout (a train commit, or a head past the shallow history); - when the tip is already on nightly-next (at or behind its head), the rule now reads nothing more and returns
go=false. Before, it listed the runs and the promotions, then requested, and the request walk stopped at the tip with "already on nightly-next". No commit that could be promoted is skipped.
- the rolling rule reads the gap from nightly-next's head to the tip from the tick's own 150-commit checkout of feat-cmux-next (
-
Neither job starts for a cmux-next or cmux-tui artifacts run on nightly-next. A promotion's push starts those runs (about 7 an hour in the sample). They make no feat-cmux-next commit buildable and are not a finished nightly build. Landings, finished
Nightly macOS buildruns, dispatches and the 5-minute cron all start the tick as before, so no promotion waits longer.
All writes stay on the workflow token: the promotion and backfill dispatches (main's promote job admits the tick's request by its actor), cancels and re-runs.
Performance
| before (main, measured) | after (estimate) | |
|---|---|---|
| GITHUB_TOKEN REST calls/h, unstick | ~576 (8.6 x 67) | ~0 (writes only: cancels and re-runs, a few a day, plus reads the App refused) |
| GITHUB_TOKEN REST calls/h, request | ~462 (6.9 x 67) | ~295 or less ((6.9 - 1 ref read [#35062] - 1 compare) x ~60 jobs), lower still by the ticks that find the tip already promoted (not measured) |
| GITHUB_TOKEN REST calls/h, tick total | ~1,040 | ~295 (estimate) |
| cmux-bot App calls/h | 0 | ~515 ((8.6 - 1) x ~60 jobs, plus about 1 token revoke per job) (estimate) |
| tick jobs/h, each job | ~67 | ~60 (7 nightly-next cmux-tui/cmux-next completions no longer start one) |
cmux-bot's installation budget had about 6k of 12.5k used, so the ~515 calls/h fit.
Tests
tests/test_gh_app_token.py:--reads-onlysends reads as the App and every write once as github.token (a refused write is not retried and nothing waits). It falls back to github.token for a refused read without waiting. The mode is off without the flag. A wiring test checks that unstick mints withCMUX_BOT_APP_ID/CMUX_BOT_APP_KEYatactions: read, contents: read, installs before the rate-limit wait, and keeps every GH_TOKEN step on github.token with single-shot attempts. It also checks that the request job holds no App key.tests/test_nightly_next_rolling.py:git_oldest_afteris tested against a real git history (the oldest commit after the head and its landing time; nothing waits at or behind the head; a diverged head is unknown; a head outside the checkout falls back). When the checkout answers, evaluate reads no compare. A tip already on nightly-next reads nothing.- Ran: test_gh_app_token, test_gh_budget_wait, test_nightly_next_rolling, test_nightly_next_stuck_run, test_nightly_next_cancelled_tip, test_ci_cloud_overflow_switch, test_ci_fork_runner_routing, test_ci_runner_set, test_ci_workflow_run_api_budget, test_ci_nightly_off_blacksmith, test_ci_pr_runner_pool, and the workflow-wide test files; actionlint is clean.
Follow-up, not here: the request job's remaining reads (two run listings and one jobs read in the rolling rule, plus request-nightly-next.sh's per-commit reads) could move to one GraphQL query, or to an App token once the request job runs on an ephemeral label.
Agent session: agent session
🤖 Generated with Claude Code
![Autofix with [code]smith](https://pr-comments-assets.blacksmith.sh/codesmith/autofix-with-codesmith-dark-3x.png)
Open in [code]smith · Need help on this PR? Tag @codesmith-bot with what you need.
Summary by cubic
Cuts the nightly-next-tick workflow's GITHUB_TOKEN usage by about 70% (from ~1,040 to ~295 REST calls per hour) so it no longer exhausts the shared 5,000-calls-per-hour workflow budget, which ran dry hourly on 2026-11-04.
Changes
- The unstick job's reads now go out as a cmux-bot App token through a new
--reads-onlymode ofgh_app_token.sh; its writes (cancels, re-runs) still usegithub.token, and refused reads fall back to it. - The unstick job's runner is bounded to the linux_2 pool's named members, since a job holding an App key must not run where a variable could move it.
- The request job gets the nightly-next head from
git ls-remote(no API budget) and the gap to the tip from the tick's own checkout, picked by committer date as the compare API does; the compare API is the fallback only when the head is not in the checkout or a shallow cut misses part of the range. A tip already on nightly-next reads nothing at all. - Both jobs no longer start for cmux-next or cmux-tui artifacts runs pushed on nightly-next (a promotion's push), which build nothing and aren't finished builds.
- The notarization preflight turns nightly-next sources away before any API read and waits through rate limits with
gh_budget_wait.sh.
All writes stay on the workflow token, so the promotion's actor check is unaffected.
Summary by CodeRabbit
- Chores
- Refined nightly-next workflow eligibility so only the named macOS build is excluded from the specified push events.
- Restricted the unstick job to designated Linux runners, with an existing fallback.
- Recognized read-only operations can use an App token when available; writes continue using the standard GitHub token.
- Nightly rolling checks use local commit history when available, fall back to the compare API when needed, and avoid extra lookups when no gap exists.
- Documentation
- Clarified how nightly rolling checks look up the current head and commit gap.
- Tests
- Added coverage for token handling, workflow configuration, and local-history checks.