GitHub quietly broke our free CI overflow
Giving a self-hosted runner the ubuntu-latest label used to let it share jobs with GitHub's hosted runners, so when the Actions minutes ran out our own machines picked up the slack. That stopped working in mid-2025, without an announcement. The workarounds left all cost something: a token, a billed minute per run, a cluster to run, or a bigger bill.

Technical 9 min read

For a long time we had a trick in our CI that cost nothing, needed no clever YAML, and kept builds moving when we ran out of GitHub Actions minutes.
Then, sometime in mid-2025, it stopped working. There was nothing on GitHub's changelog and no deprecation warning, and the jobs just stopped arriving.
There are still ways to make your own machines pick up the slack when the hosted minutes run out, but none of them is as simple as the trick was.
Short version: registering a self-hosted runner with the
ubuntu-latestlabel used to make it compete for the same jobs as GitHub's hosted runners. That stopped working around May–June 2025. Now a job is effectively either hosted or self-hosted, and getting "self-hosted if available, hosted otherwise" back takes a pre-flight job, an API token and some compromises.
Labelling a self-hosted runner ubuntu-latest
A GitHub Actions job picks a runner with runs-on:
jobs:
test:
runs-on: ubuntu-latestSelf-hosted runners have labels too. By default they get self-hosted, the OS and the architecture, but you can add any custom label when you register one:
./config.sh --url https://github.com/your-org \
--token "$RUNNER_TOKEN" \
--labels ubuntu-latestSo we gave a few spare machines the ubuntu-latest label. As far as runs-on: ubuntu-latest was concerned, those machines and GitHub's hosted fleet were interchangeable, and in practice our runners picked up a job whenever they were free.
That gave us exactly what we wanted:
- On a quiet day our own machines took most of the jobs, and we used very few hosted minutes.
- On a busy day GitHub's hosted runners took the overflow.
- When the month's included minutes ran out, the hosted runners stopped and our machines carried on with everything.
It needed no workflow changes, no extra jobs and no tokens, because every workflow in every repo just said ubuntu-latest.
Around May 2025, the jobs stopped arriving
Around May 2025, our self-hosted runners with the ubuntu-latest label stopped getting ubuntu-latest jobs. They sat online and idle while every job went to a GitHub-hosted runner, and once the minutes ran out, jobs failed to start instead of falling back to our machines.
We weren't the only ones. In this GitHub Community discussion from 9 June 2025, a user described the same thing:
The
ubuntu-latestlabel used to allow prioritise running jobs on self hosted runners, recently (within the last 2/3 weeks) this behaviour has seemingly changed and jobs are no longer being sent to self hosted runners.
At the time of writing the thread is still marked unanswered, and nobody from GitHub has replied.
To be fair to GitHub, this behaviour was never documented. The docs on choosing a runner explain hosted labels and self-hosted labels separately and say nothing about what happens when they overlap, so we were relying on an implementation detail, and those can change.
What bothers me is that it changed silently. It saved real money for anyone with spare hardware, and it disappeared with no changelog entry, no warning and no explanation. I can't prove why it changed, but the old behaviour let people avoid paying for extra Actions minutes and the new behaviour doesn't. It also came in the same year GitHub announced, and then postponed, a per-minute platform fee for self-hosted runners.
A job is now either hosted or self-hosted
Today a job is one of two things:
- Hosted (
runs-on: ubuntu-latest). It runs on GitHub's machines and uses your minutes, and it stops when they run out (or starts costing money, if you've allowed that). - Self-hosted (
runs-on: [self-hosted, linux]). It waits for one of your machines and never uses the hosted fleet, even if all of yours are busy or offline.
There's no runs-on: "self-hosted if you can, hosted if you can't". If you pick self-hosted and your runners are down, the job waits in the queue (for up to 24 hours) instead of overflowing.
Workaround 1: pick the runner in a pre-flight job
runs-on can take an expression, so a first job can decide where the rest of the workflow runs:
jobs:
pick-runner:
runs-on: ubuntu-latest
outputs:
runner: ${{ steps.pick.outputs.runner }}
steps:
- id: pick
env:
GH_TOKEN: ${{ secrets.RUNNER_STATUS_TOKEN }}
run: |
online=$(gh api "/orgs/${{ github.repository_owner }}/actions/runners" \
--jq '[.runners[] | select(.status == "online" and .busy == false)] | length')
if [ "$online" -gt 0 ]; then
echo 'runner=["self-hosted","linux"]' >> "$GITHUB_OUTPUT"
else
echo 'runner="ubuntu-latest"' >> "$GITHUB_OUTPUT"
fi
test:
needs: pick-runner
runs-on: ${{ fromJSON(needs.pick-runner.outputs.runner) }}
steps:
- uses: actions/checkout@v4
- run: npm testThat lists your organisation's runners. If your runners are registered to a single repo, ask /repos/${{ github.repository }}/actions/runners instead.
We tried it on a test repo with one self-hosted runner, and it works:
- Runner idle: the job ran on the self-hosted runner.
- Runner busy with another job: the job went to
ubuntu-latest. - Runner offline: the job went to
ubuntu-latest. - Three workflows started at once: two of them saw the runner idle and both picked it. One ran straight away, and the other waited in the queue for nearly two minutes instead of overflowing. The third saw the runner busy and went to a hosted runner.
It also brings four costs:
- A token. The default
GITHUB_TOKENcan't list runners (in our test it got a403, even for the repo's own runners), so you need a separate token with permission to read them, stored as a secret in every repo that uses this. - A hosted job anyway. The check has to run somewhere guaranteed to exist, which means a hosted runner and a billed minute per workflow run. Once the minutes are gone, the check itself can't start.
- A race. "Online and not busy" is only true at the moment you ask. Workflows that start together can all see the same idle runner and all pick it, as two of ours did, and then all but one of them queue behind it instead of overflowing.
- Copy and paste. Every workflow in every repo needs the extra job and the
needs:wiring.
There was a community action that wrapped this up, jimmygchen/runner-fallback-action, but it was archived in November 2025. There are forks such as mikehardy/runner-fallback-action, which use the same idea and have the same compromises.
Workaround 2: autoscale your own runners
The other route is to stop overflowing to GitHub altogether and make your self-hosted pool big enough on its own. Tools such as Actions Runner Controller start runners on demand in Kubernetes when jobs queue up.
That solves the problem of busy runners properly, but it's a much bigger commitment. You're now running a cluster, scaling it, patching runner images and paying the cloud bill for it, all to replace something that used to be a single label.
Workaround 3: just pay
Which, presumably, is the point.
If your team's time is worth more than the minutes, buying more minutes or using GitHub's larger hosted runners takes the least effort. Set a spending limit so a runaway workflow can't surprise you at the end of the month.
Split jobs between the two pools
Split your workflows by how much they matter when the minutes run out:
- Heavy, frequent jobs (full test suites, Docker builds) go on
runs-on: [self-hosted, linux]outright. They're usually the bulk of your minutes, and your own runners can take them. - Light, occasional jobs (linting, labelling, release notes) stay on
ubuntu-latest, because they hardly use any minutes. - Only use the pre-flight job if you really need the fallback, because the token, the race and the extra YAML in every repo add up.
It's not as nice as what we had. The old setup gave every job both pools for free, and now each job lives in one pool that you choose by hand.
What we build
These runners build and test Procaptcha and Prosopo Protect. Procaptcha is a drop-in replacement for reCAPTCHA and hCaptcha that is cookieless by default and free for the first 10,000 verifications a month, and Protect stops bots at the edge, before they reach your site. If bots are hitting your signup or login forms, start free or get in touch.
TL;DR
- A self-hosted runner labelled
ubuntu-latestused to shareubuntu-latestjobs with GitHub's hosted runners, which gave a free overflow when minutes ran out. - Since around May–June 2025 that no longer happens, and jobs labelled
ubuntu-latestgo to hosted runners only. GitHub hasn't announced or explained the change. - There's no built-in "self-hosted, else hosted" fallback.
- You can rebuild one with a pre-flight job and an API token, at the cost of a billed minute per run, a race on busy runners and extra YAML everywhere.
- The alternatives are to autoscale your own runners or to pay for the minutes.
If you've found a cleaner way to get the old behaviour back, I'd love to hear about it.
Questions
Something else? Ask an engineer.
Can a self-hosted runner still pick up jobs that use runs-on: ubuntu-latest?
Not in our experience, and not in other users' reports since around May 2025. Jobs labelled ubuntu-latest now go to GitHub-hosted runners even when a self-hosted runner with that label is online and idle. GitHub hasn't announced or documented the change.
How do I fall back from self-hosted runners to GitHub-hosted runners?
There's no built-in fallback. The usual workaround is a small first job that asks the GitHub API whether a self-hosted runner is online, then sets runs-on for the real jobs from its output. It needs a token that can read your runners, it costs a hosted-runner minute for the check, and it can't tell whether an online runner is free.
Did GitHub announce this change?
Not that we could find. We searched the GitHub changelog and docs and found no mention. The only record is user reports, such as a GitHub Community discussion from June 2025 that GitHub staff haven't answered.
Related reading
Technical · How to deploy Procaptcha on your website or app How to switch from Google's reCAPTCHA to Prosopo Procaptcha and protect your website or app from bots for free.
CAPTCHA · TypeScript: Mapped Type Magic TypeScript mapped types can make Cat === Dog — a weird paradox. How to brand identical structural types so the compiler can tell them apart.
CAPTCHA · Vite: How to handle .node files Vite can't bundle .node native binaries — it treats them as raw JS and blows up. Our custom rollup plugin that copies .node files through the build.
Technical · Using Vite To Rebuild Local Dependencies in an NPM Workspace Vite ignores local workspace packages because they sit outside its module graph — the config trick that forces Vite to rebuild sibling npm packages.
CAPTCHA · TypeScript: Branded Types How to brand TypeScript types for norminal typing, providing an automated and easy-to-use way of making a type nominal.
CAPTCHA · actions/cache@v3 restore: GitHub Actions Cache Fix Fix GitHub Actions caching with actions/cache@v3 and actions/cache/restore: share caches across branches and evict old caches faster.Ready to ditch Google reCAPTCHA?
Procaptcha is free for 10K requests a month. No credit card, no cookies.