GitHub Action that determines the availability of self-hosted runners and falls back to a GitHub-hosted runner when the primary runners are offline or busy.
It calls the GitHub Self-Hosted Runners API to inspect runners that match a set of labels, then emits the runner label(s) to use — or the fallback runner — as a JSON-encoded string array that can be consumed via fromJson() in a downstream job's runs-on.
The API requires an access token with org:admin rights, for example a classic Personal Access Token with the org:admin scope selected.
This action runs on the node24 runtime, which becomes the GitHub Actions default on 2026-06-16. Hosted runners provide Node 24 — consumers do not need to install anything.
| Name | Description |
|---|---|
github-token |
A token with list action runners access for the given context (user repo, organization, or enterprise). |
primary-runner |
Comma-separated labels for the primary runner (e.g. self-hosted,linux). |
fallback-runner |
Name or comma-separated labels for the fallback runner (e.g. ubuntu-latest). |
Runners can be scoped at three levels: repository, organization, or enterprise. The following options switch the API surface the action queries. Only one of organization or enterprise may be supplied.
| Name | Description |
|---|---|
organization |
The name of the GitHub organization (e.g. dodi-smart). |
enterprise |
The name of the GitHub enterprise (e.g. My-Github-Ent). |
You can ask the action to fall back even when primaries are online, but busy. This is useful when self-hosted capacity is the cheap-fast path and public runners are the safety net.
| Name | Description |
|---|---|
primaries-required |
Minimum non-busy primaries required; falls back below this. |
You can also configure the action to fall back silently on any error (e.g. expired token, GitHub API outage) so CI keeps moving. Default is false.
| Name | Description |
|---|---|
fallback-on-error |
Use the fallback runner if any error occurs. |
| Name | Description |
|---|---|
use-runner |
Selected runner labels as a JSON-encoded string array, ready to consume via fromJson() in runs-on. |
jobs:
# We may have a self-hosted runner available. Use it if so.
determine-runner:
runs-on: ubuntu-latest
concurrency:
# Runner choice must happen serially for the "primaries-required" logic
# to be up to date in the context of one self-hosted runner that may be
# used for multiple workflows triggered off the same workflow event.
group: runner-determination
cancel-in-progress: false
outputs:
runner: ${{ steps.set-runner.outputs.use-runner }}
steps:
- name: Wait for possible parallel workflow run job startup lag
# After runner choice, the job that will use it has unavoidable job startup lag.
# Wait for that job start / runner state change before we choose the runner for this run.
run: sleep 15
- name: Use self-hosted runner if online and not busy, otherwise public runner
id: set-runner
uses: dodi-smart/runner-fallback-action@v2
with:
organization: 'dodi-smart'
# list of tags a runner must match to be considered a primary
primary-runner: 'self-hosted,linux'
# a single tag that will select a runner to fall back to
fallback-runner: 'ubuntu-latest'
# optional: fall back if fewer non-busy primaries are available
primaries-required: 1
# optional: fall back if the token expires or the GitHub API fails
fallback-on-error: true
# Must have org:admin permissions — GitHub's runner APIs require it.
# Note that Actions secrets and Dependabot secrets are separate.
github-token: ${{ secrets.ORG_ADMIN_TOKEN }}
another-job:
needs: determine-runner
runs-on: ${{ fromJson(needs.determine-runner.outputs.runner) }}
steps:
- name: Do something
run: echo "Doing something on ${{ needs.determine-runner.outputs.runner }}"Consume the action via the moving major-version tag for automatic non-breaking updates:
uses: dodi-smart/runner-fallback-action@v2Or pin to an immutable patch release if you need byte-for-byte determinism:
uses: dodi-smart/runner-fallback-action@v2.0.0Both forms resolve to the same set of inputs and outputs documented above; @v2 is force-updated on every v2.x.y release so you receive feat and fix updates without editing your workflow.
Releases are fully automated. Whenever a PR lands on main, a workflow runs semantic-release which reads new conventional commits, decides the bump (patch/minor/major), creates a GitHub Release with auto-generated notes, and force-updates the major-version tag. The full changelog is published on the Releases page.
PR titles must follow Conventional Commits — the CI gate enforces this, and the subject becomes the squash-merge commit message that drives release versioning:
| Prefix | Effect on the next release |
|---|---|
feat: ... |
Minor bump (v2.1.0) |
fix: ... |
Patch bump (v2.0.1) |
perf: / refactor: / build: / revert: |
Patch bump |
docs: / chore: / style: / test: / ci: |
No release |
feat!: ... / fix!: ... / footer with BREAKING CHANGE: |
Major bump (v3.0.0) |
Subjects must start with a lowercase letter (fix: handle null labels, not Fix: handle null labels). Optional scope is allowed: feat(runner): match labels case-insensitively.
npm ci
npm run all # lint + format:check + typecheck + test + buildSource lives in src/ (TypeScript, ESM). dist/index.js is the bundled action entry point and is regenerated by npm run build via @vercel/ncc. CI fails if dist/ is out of sync with src/.
- Pattern originally described by @ianpurton in community discussion #20019.
- Original action developed by @jimmygchen, maintained by @mikehardy, modernized for Node 24 + TypeScript by Asen Lekov (@azlekov), and republished under the dodi-smart org.
- Organization-level and enterprise-level runner support contributed by @O-Mutt.