Route jobs
Configure workflows
Configure a local-infrastructure route and GitHub-hosted fallback in your existing workflow.
Your workflow’s runs-on selects a job’s execution route. This guide configures local infrastructure
with GitHub-hosted fallback while keeping your build and test steps. Other jobs in the
workflow can use different providers.
For existing workflows, a setup pull request is the recommended path. Mirage writes the routing for each job you select and keeps its current runner as the fallback. Its fallback reruns only the affected job, and a manual rerun tries your machines again. Follow this guide when you want to write the route yourself.
For a direct Cloudflare route, see Cloudflare runners. To compare providers and change a job’s default through Mirage-generated pull requests, use Compare CI providers.
Change runs-on
Start with an existing build or test job. Keep its steps and replace its GitHub-hosted
runner label with the expression for the same operating system and architecture. For a job
that currently uses ubuntu-24.04:
runs-on: ${{ github.run_attempt == '1' && 'mirage-linux-x64' || 'ubuntu-24.04' }}On the first attempt, GitHub sends the job to Mirage. If Mirage needs to use fallback, it requests a second attempt of the same workflow; the expression selects GitHub’s runner for that attempt. There is no extra setup job or Mirage action to add.
Merge queues
Mirage supports GitHub merge queues on local infrastructure and Cloudflare runners. Add
the merge_group trigger to each workflow that reports a required queue check, keeping
its runner labels, routing, and test steps:
on:
pull_request:
merge_group:
types: [checks_requested]GitHub runs these checks against its temporary merge-queue branch. Checkout must use that
run's commit; the default checkout behavior does this. An explicit checkout of a PR head
or main tests a different commit from the one the queue needs. See GitHub's
merge-queue documentation.
Runner labels
Use the Mirage label and GitHub-hosted fallback for the job’s operating system and architecture. Runner labels lists each label with its resources, fallback, and limits.
runs-on: ${{ github.run_attempt == '1' && 'mirage-linux-arm64' || 'ubuntu-24.04-arm' }}runs-on: ${{ github.run_attempt == '1' && 'mirage-macos-arm64' || 'macos-26' }}Choose the architecture your tools support. On an Apple-silicon Mac, Linux arm64 runs natively and Linux x64 runs more slowly through Rosetta, which must be installed. Native macOS needs an enabled and ready macOS runner profile. The machine’s details page lists each profile and any setup still required.
Tools, secrets, and caches
Mirage’s runner image has its own toolchain; it does not include everything from GitHub’s hosted images. Use setup actions or installation steps to select the versions your project needs, and install your dependencies before building or testing. Runner labels lists what each image includes.
Jobs start in fresh environments. Use checkout to fetch your code, GitHub Actions artifacts to pass files between jobs, and cache actions for dependencies. Keep secrets in GitHub Actions and reference them as you already do. Tools and files installed in your Mac’s user account are not automatically available inside a job.
Caching selects GitHub, Cloudflare, Machine or None in Mirage settings. Existing cache and setup actions keep working without changes to workflow YAML.
Linux runners on Macs support Docker commands, service containers, job containers, and Docker container actions. Cloudflare runners have different limits and do not support those Docker features.
Parallel jobs
Each job requests CPU, memory, and disk. Machines run concurrent Linux jobs while their
allocations fit the background work budget. macOS CI and agent VMs share an allowance of
at most two macOS guests. A job waits for enough resources, and one that waits longer than the repository’s
queue timeout falls back or fails. If you submit several jobs at once, add
machines, increase the background work budget, request smaller runners in Repos, or use needs to run jobs in
sequence:
jobs:
first:
runs-on: ${{ github.run_attempt == '1' && 'mirage-linux-arm64' || 'ubuntu-24.04-arm' }}
steps:
- run: echo "First job"
second:
needs: first
runs-on: ${{ github.run_attempt == '1' && 'mirage-linux-arm64' || 'ubuntu-24.04-arm' }}
steps:
- run: echo "Second job"Each job still gets a fresh environment. A dependency controls execution order; it does not share a working directory.
How fallback works
If no machine takes the job within the repository’s queue timeout, or a runner has an infrastructure failure, Mirage cancels attempt 1 and requests one rerun of the whole workflow. Attempt 2 uses your configured fallback. GitHub keeps both attempts under the same run ID.
- A failed test or build is a CI result and does not trigger an infrastructure retry.
- A manual rerun also increments the attempt, so the expression selects GitHub-hosted runners.
- Fallback needs Mirage and GitHub to be reachable. A Mirage outage can leave a job queued.
- GitHub-hosted jobs use your GitHub Actions allowance or billing.
Queue timeout
A job waits in GitHub’s queue until one of your machines can fit its requested resources. Each repository has a queue timeout: 120 minutes unless you change it on the repository’s page in Repos. When a job has waited that long, Mirage stops waiting for a machine:
- Attempt expression: Mirage cancels the run and reruns the workflow. Attempt 2 runs the job on its GitHub-hosted runner.
- Setup pull request: Mirage cancels the run and reruns only that job, on the runner it used before.
- Literal label: the job has no fallback, so it fails. Mirage adds a failed check that says how long the job waited and what to fix, then cancels the run.
GitHub can’t end a single queued job, so Mirage cancels its run. Before it reruns a routed job or fails one, Mirage waits until no other job in the run is running or could still start on your machines, so the cancel doesn’t cut other work short. If a machine takes the job in the meantime, it runs.
While a job waits, its Mirage check says when the timeout ends and what Mirage will do then. If your Mirage operator sets one timeout for every repository, the repository’s page shows it as global.
Run only on your machines
If you want every attempt to target Mirage, use a literal label:
runs-on: mirage-linux-arm64This label never selects a GitHub-hosted runner, including on a rerun. While no eligible machine can fit its allocation, the job stays queued. If none takes it within the repository’s queue timeout, Mirage adds a failed check that explains why and cancels the run. To let jobs wait longer, raise the timeout on the repository’s page in Repos. GitHub still cancels a job that has been queued for 24 hours; see GitHub’s Actions limits.
If a machine takes the job and then fails as infrastructure, Mirage cancels the attempt and reruns the whole workflow once. The rerun comes back to your machines. If it also fails as infrastructure, Mirage cancels the run and adds a failed check that explains why. Use the attempt expression above when you want automatic hosted fallback.
When GitHub-hosted runners are blocked
GitHub does not charge for Actions usage on self-hosted runners. It blocks GitHub-hosted runners when an account without a payment method uses up its included minutes, or when a budget set to stop usage runs out. See GitHub’s Actions billing and budgets documentation.
To GitHub, a Mirage job is a self-hosted runner job, so it can still run in that state. Use a literal label rather than the attempt expression:
runs-on: mirage-linux-arm64With the attempt expression, a fallback would cancel attempt 1 and rerun the workflow on the blocked GitHub-hosted runners, and a manual rerun would select them too. Restore the expression once GitHub-hosted runners are available again.
Mirage still relies on GitHub to dispatch each job. If GitHub stops starting jobs for your account altogether, Mirage cannot run them either.