Skip to content
 
 

Repository files navigation

Runner Fallback Action

CI CodeQL

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.

Runtime

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.

Usage

Inputs

Required

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).

Optional

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.

Outputs

Name Description
use-runner Selected runner labels as a JSON-encoded string array, ready to consume via fromJson() in runs-on.

Example

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 }}"

Versioning & releases

Consume the action via the moving major-version tag for automatic non-breaking updates:

uses: dodi-smart/runner-fallback-action@v2

Or pin to an immutable patch release if you need byte-for-byte determinism:

uses: dodi-smart/runner-fallback-action@v2.0.0

Both 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.

Contributing

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.

Development

npm ci
npm run all   # lint + format:check + typecheck + test + build

Source 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/.

Credits

About

Github action to determine the availability of self-hosted runners

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages