A reusable, billing-aware runner router for GitHub Actions. Jobs in public repos always run on GitHub-hosted runners. Jobs in private repos run on GitHub-hosted runners while the org still has included Actions minutes, and switch to your self-hosted runners once those minutes are spent or an overage is billed.
- Public repos always build on GitHub-hosted runners (
hosted-label, defaultubuntu-latest). GitHub gives public repos unlimited free hosted minutes, so there is nothing to route. The router job itself runs hosted, no billing check is made, and the billing token is never read. A public repo must never use a self-hosted runner: anyone can open a pull request against it, and that would run their code on your machine. - Private repos go through the billing check. Hosted while included minutes remain, self-hosted once they are spent, and self-hosted on any error.
- No org-level secrets. On GitHub's free plan an org secret is available to public repos too. The billing token is a repo-level secret on each private repo only.
- The self-hosted runner group stays private-only. Leave Allow public repositories off. The router never sends a public repo there.
Only an explicit github.event.repository.private == true counts as private. If an event carries no repository payload, the router treats the repo as public and routes hosted, so a visibility it cannot read never lands on a self-hosted runner.
It ships as two entry points that share one script (scripts/route.sh):
- a reusable workflow:
.github/workflows/router.yml - a composite action:
action.yml
Both output runner, a JSON array you pass to runs-on with fromJSON. Both work from any org. By default they check the owner of the calling repo, so the same setup works in HeartFull-online, proappstore-online, or your personal account.
- On each private repo, create the repo-level secret
RUNNER_BILLING_TOKEN: a token from an org administrator that can read the org's Actions billing (scopes under Token and scopes). Never make it an org secret. Public repos need no secret. Without it a private repo still runs, and always routes to self-hosted. - Have self-hosted runners for the overflow. ProAgentStore's are the
pagsrunners its CLI installs on an agent's pinned machine (actions_runner_setup). They carry theself-hostedandpagslabels, which are this router's defaults. - Keep the runners' group private-only. A self-hosted runner group does not serve public repos by default. Leave it that way: the router routes public repos to hosted.
- Add a router job and route each job through it. The snippet is the same for public and private repos:
jobs:
router:
uses: ProAgentStore/runner-router/.github/workflows/router.yml@v1
with:
included-minutes: 2000 # your plan's included Actions minutes
safety-margin-minutes: 100 # switch a little early, to cover billing lag
secrets:
billing-token: ${{ secrets.RUNNER_BILLING_TOKEN }}
build:
needs: router
runs-on: ${{ fromJSON(needs.router.outputs.runner) }}
steps:
- uses: actions/checkout@v4
- run: make buildEvery job that should fail over takes needs: router and runs-on: ${{ fromJSON(needs.router.outputs.runner) }}. Pin @v1 (or a commit SHA); v1 moves only for compatible fixes.
- Public repo: routes to hosted and stops. Nothing below runs.
- Reads the current calendar-month Actions usage from the billing API:
GET /organizations/{org}/settings/billing/usage/summary?product=actions. - Sums
grossQuantityfor Actions Minutes items. This is minutes consumed, including the part covered by the plan. - Routes to self-hosted if either:
netAmountfor Actions is greater than 0 (a billed overage), orused + safety-margin-minutes >= included-minutes.
- Otherwise routes to hosted (
hosted-label, defaultubuntu-latest, pinnable — see below). - Fails safe to self-hosted on any error: missing token, non-200 response, unexpected JSON, missing
jq/curl, or invalid input.
Every decision is logged in the router job's log and step summary as one line with the repo's visibility, the minutes used and included (private repos), and the chosen runner:
[runner-router] visibility=public runs-on=["ubuntu-latest"] (public repo: GitHub-hosted, no billing check)
[runner-router] visibility=private runs-on=["ubuntu-latest"] (hosted: used=120 of 2000 included minutes)
[runner-router] visibility=private runs-on=["self-hosted","pags"] (included minutes spent: used=1850 margin=200 included=2000)
[runner-router] visibility=private runs-on=["self-hosted","pags"] (fail-safe: no billing token; pass the billing-token secret)
The text in parentheses is also the composite action's reason output.
GitHub moves ubuntu-latest (and windows-latest, macos-latest) to a new underlying image on
its own schedule. On 2026-10-21 it repointed ubuntu-latest from 24.04 to 26.04, which broke a
caller whose build depended on an exact 24.04 package. hosted-label defaults to ubuntu-latest,
but you can pin an exact image two ways:
-
The
hosted-labelinput, set per call:jobs: router: uses: ProAgentStore/runner-router/.github/workflows/router.yml@v1 with: hosted-label: ubuntu-24.04 included-minutes: 2000 secrets: billing-token: ${{ secrets.RUNNER_BILLING_TOKEN }}
-
A per-repo config file,
.github/runner-router.jsonin the calling repo, consulted only when the call site leaveshosted-labelat its default — so you set the pin once instead of on every job that calls the router:{ "hosted-label": "ubuntu-24.04" }The router reads this file from the calling repo over the GitHub API using the job's own ambient token (no checkout, no extra secret); a missing file is normal, not an error.
An explicit hosted-label input always wins over the config file. A label the router does not
recognize — a typo, or an image GitHub has retired — falls back to ubuntu-latest rather than
queuing a job for a runner it can never get; the decision log names the unrecognized label and the
fallback. Recognized images: ubuntu-latest/26.04/24.04/22.04, windows-latest/2025/2022,
macos-latest/15/14/13 (and the -large/-xlarge macOS variants). Self-hosted label routing
is unaffected either way.
- On private repos the router job must run on a self-hosted runner. Once hosted minutes are gone, hosted jobs are blocked, so a hosted router would stop the pipeline exactly when you need it to switch. Any self-hosted runner works, since every self-hosted runner has the
self-hostedlabel. The reusable workflow does this for you (router-label) and runs the router job onhosted-labelfor public repos. - The self-hosted runner needs
bash,curl, andjq. Both entry points check for these and fall back to self-hosted if they are missing. - The repo's checkout of this router needs network access to
github.com(the reusable workflow checks out this repo at the exact SHA of the workflow file being called).
Store the token as the repo-level secret RUNNER_BILLING_TOKEN on each private repo, and pass it as billing-token. Never store it as an org secret: on the free plan public repos can read org secrets. The token must come from an org administrator.
- Classic PAT:
admin:org. The endpoint acceptsadmin:orgorrepo, per theX-Accepted-OAuth-Scopesheader.admin:orgis the narrower choice. - Fine-grained PAT: the billing usage docs do not name a fine-grained permission for this endpoint. If you use one, confirm it with a test call before relying on it.
Without a token a private repo's router still runs, but always routes to self-hosted. Public repos never read it.
| Input | Default | Meaning |
|---|---|---|
org |
calling repo owner | Org whose billing is checked |
billing-token (secret, or input for the composite) |
none | Token from above, private repos only. Missing means fail-safe to self-hosted. Never read for public repos |
hosted-label |
ubuntu-latest |
Label for GitHub-hosted runners. Pin an exact image (e.g. ubuntu-24.04); see Pinning a GitHub-hosted OS version |
self-hosted-labels |
self-hosted,pags |
Comma-separated labels for self-hosted runners |
included-minutes |
2000 |
Minutes in your plan for the cycle. Set this to your plan's value (Free 2000, Team 3000, Enterprise 50000) |
safety-margin-minutes |
0 |
Switch this many minutes early, to cover billing lag |
router-label (reusable only) |
self-hosted |
runs-on for the router job on private repos. Must be self-hosted. Public repos use hosted-label |
The pags default label is ProAgentStore-specific. In other orgs, set self-hosted-labels to your own runner labels, or jobs will queue forever.
| Output | Reusable workflow | Composite action | Example |
|---|---|---|---|
runner |
needs.<job>.outputs.runner |
steps.<id>.outputs.runner |
["ubuntu-latest"] or ["self-hosted","pags"] |
reason |
not exposed | steps.<id>.outputs.reason |
hosted: used=120 of 2000 included minutes, public repo: GitHub-hosted, no billing check |
jobs:
route:
uses: ProAgentStore/runner-router/.github/workflows/router.yml@v1
with:
included-minutes: 3000
self-hosted-labels: self-hosted,pags
secrets:
billing-token: ${{ secrets.RUNNER_BILLING_TOKEN }}
build:
needs: route
runs-on: ${{ fromJSON(needs.route.outputs.runner) }}
steps:
- uses: actions/checkout@v4
- run: make buildOn a private repo, the job that calls the action must itself run on self-hosted. On a public repo it must not: run it on ubuntu-latest, and the action returns the hosted label.
jobs:
route:
runs-on: [self-hosted, pags]
outputs:
runner: ${{ steps.router.outputs.runner }}
steps:
- id: router
uses: ProAgentStore/runner-router@v1
with:
included-minutes: 3000
self-hosted-labels: self-hosted,pags
billing-token: ${{ secrets.RUNNER_BILLING_TOKEN }}
build:
needs: route
runs-on: ${{ fromJSON(needs.route.outputs.runner) }}
steps:
- uses: actions/checkout@v4
- run: make buildA complete, manually triggered example is in .github/workflows/example.yml.
.github/workflows/ci.yml runs:
shellcheckon the scripts,tests/route_test.sh, an offline suite of 38 cases using recorded billing responses intests/fixtures/, covering the public-repo rule, hosted, exhausted, margin, overage, each fail-safe path, the decision log line, and OS-version pinning (default, explicit, per-repo config, precedence, and an unrecognized image falling back),- an end-to-end check of both entry points, asserting that this public repo routes to hosted.
The CI cannot test a live private-repo decision, because it is public and has no billing token. The offline suite covers that logic.
- Billing usage can lag behind real usage. Use
safety-margin-minutesif you need headroom. - Minutes are billed per calendar month (UTC), and the router reads only the current month.
- Usage is reported in billed minutes. Larger runners and macOS/Windows runners consume minutes at a multiplier, which the billing data already reflects.
- The billing usage endpoint is in public preview and may change.
This project builds on prior art. The ideas below are credited; the code here was written independently.
- shawnazar/overtime: billing-aware, hosted-first
runs-onrouting, the core idea of checking billing before choosing a runner. - hutch-fail/github-runner-then-fallback: a composite action that prefers GitHub-hosted runners while included minutes remain, then falls back.
- mikehardy/runner-fallback-action: choosing self-hosted runners based on whether they are available.