fix(runner-availability): scope detection to runner groups, fail loud#3
Conversation
The detector read a single unpaginated page of
/orgs/{org}/actions/runners. Managed-runner vendors register one
ephemeral runner per job into their own groups and never deregister
them, so that list had grown to 1015 entries for burin-labs, 1002 of
them Blacksmith ephemerals and 997 of those offline. The org's own
runners sorted to index 1002+, past the first page.
Every caller therefore saw linux=false/macos=false while 9 Linux and
4 macOS runners were online and idle, and routed onto paid
GitHub-hosted runners instead. The probe emitted no warning, because
"I could not see the fleet" and "the fleet is busy" produced the same
output. burin-code CI spend rose from $516/mo in May to a $4815/mo
run rate by mid-July with this active.
Query the declared runner groups instead of the flat org list. That
keeps the request count proportional to the number of groups rather
than to vendor fleet size; paginating the flat list would only have
deferred the same failure, since the ephemeral fleet grew by roughly
170 stale registrations per day.
Also make every fall-through loud. An unresolvable group name and a
group set containing no runners are now warnings, not silent
downgrades to the expensive path.
Verified against the live burin-labs org across 8 cases: happy path
(13 runners, linux=true macos=true), missing group, absent token,
401, vendor-group-only, whitespace and empty entries in the input,
and single-group. actionlint and shellcheck clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correction to this PR's justificationI overstated one claim and want it on the record before it propagates. I wrote that the flat-list approach "would only have deferred the same failure, since the ephemeral fleet grew by roughly 170 stale registrations per day." That growth framing is wrong. GitHub auto-removes ephemeral self-hosted runners that have not connected for more than 1 day (docs). So Blacksmith's registrations self-reap on a 24h cycle, and the fleet is bounded at roughly one day's job churn, not accumulating without bound. Measured over 45 minutes this afternoon it moved 1002 → 1017, which is live churn under load, not stale accumulation. What this changes: What it does not change: group scoping is still the better mechanism, on narrower grounds than I claimed:
The fix stands. The reasoning behind it was sloppier than the fix. Also worth recording, from a sweep of prior art: there is no maintained off-the-shelf action to adopt here, and no native GitHub feature. The one live candidate (mikehardy/runner-fallback-action) does a single unpaginated GET with a default page size of 30 and has no runner-groups support, so it fails on exactly this defect. GitHub declined native self-hosted-to-hosted fallback on the record in community discussion #20019 and has shipped nothing since that changes it. |
What broke
runner-availability.ymlread a single unpaginated page of/orgs/{org}/actions/runners.Managed-runner vendors (Blacksmith, Namespace) register one ephemeral runner per job into their own runner groups and never deregister them. That list had grown to 1015 registrations for
burin-labs, 1002 of them Blacksmith ephemerals, 997 of those offline. The org's own runners sort to index 1002+, on the last page.So the probe saw 100 vendor ephemerals, matched none, and reported:
Ground truth at that same moment, across all pages: 9 idle Linux, 4 idle macOS.
Every caller then routed onto paid GitHub-hosted runners. Nothing warned, because "I could not see the fleet" and "the fleet is busy" produced identical output.
Cost
burin-codenet Actions spend: $515.56 (May) → $1,080.82 (Jun) → $2,379.15 (Jul 1-18), with a Jul 14-17 run rate of $160.50/day, a ~$4,815/mo pace. macOS alone was $1,053 of July while a dedicated idle CI Mac sat unused.The fix
Query the declared runner groups rather than the flat org list. Request count is now proportional to the number of groups, not to vendor fleet size. Paginating the flat list would only have deferred this: the ephemeral fleet grew by roughly 170 stale registrations per day, so a paginated probe would need ~150 calls per run within three months.
Groups come from a new
runner_groupsinput (defaultDefault,burin-labs-mac-mbp), so callers declare their own capacity rather than inferring it.Second defect fixed independently: every fall-through is now loud. An unresolvable group name, an API error, and a group set containing no runners are warnings rather than silent downgrades to the expensive path. A probe that cannot see the fleet must never be indistinguishable from a busy fleet.
Verification
Executed the exact script body against the live
burin-labsorg, 8 cases:linux=true macos=truelinux=true macos=truemacos=trueonly, correctactionlintclean,shellcheckclean, YAML valid.Note: the whitespace case initially failed. jq's
subreplaces only the first match, so"^\\s+|\\s+$"left trailing whitespace and the group name did not resolve. Fixed togsuband re-verified.Rollout
Callers pin this by SHA, so merging changes nothing until
burin-codeandharn-cloudre-pin. Those follow separately so the routing change can be observed on its own.🤖 Generated with Claude Code
Need help on this PR? Tag
/codesmithwith what you need. Autofix is disabled.