GitHub Actions stuck waiting for a runner: diagnose and fix it
Why is my GitHub Actions job stuck?
A GitHub Actions job can look stuck for five different reasons: it is waiting for a matching runner, blocked by a needs dependency, held by an environment approval, pending in a concurrency group, or already running inside a hung step. Check the job's exact status and last transition before changing anything. If it says “Waiting for a runner to pick up this job,” compare runs-on with the labels and group access of an online idle runner; if it is already in progress, inspect the last step that produced output.
Last checked September 21, 2026: the runner-routing, workflow syntax, deployment, monitoring and self-hosted runner guidance linked below were checked against GitHub's current documentation on that date.
What should I check first?
Open the workflow run in GitHub and look at the visualization graph before rerunning it. The graph shows job dependencies, and the selected job shows whether GitHub has assigned a runner and which step last made progress. Write down the status, the time it entered that status, the runs-on value, and the last log timestamp. Those four facts separate a queue problem from a workflow gate and an executing process that has stopped moving.
| What GitHub shows | Likely gate | First check |
|---|---|---|
| Waiting for a runner to pick up this job | No eligible idle runner, insufficient capacity, or a hosted-service incident | Compare runs-on with runner labels, groups, access and status |
| Queued behind another job | A needs dependency or limited matrix capacity | Follow incoming lines in the visualization graph |
| Waiting for approval | Environment protection rule | Open the deployment review banner and named environment |
| Pending while another run is active | Workflow- or job-level concurrency | Find the run holding the same concurrency group |
| In progress, with no new log output | A command, service, network call or test is hung | Open the current step and find its last observable progress |
Do not use the age of the run alone. Ten minutes may be normal in a deployment queue and abnormal for a lint job. The state tells you who can release it: GitHub's scheduler, a prerequisite job, a reviewer, a previous run, or the process already running on the machine.
Why does it say “Waiting for a runner to pick up this job”?
That message means the job has reached runner selection but GitHub has not completed assignment to an eligible machine. For a self-hosted runner, GitHub requires one online, idle runner that matches the job's runner group and every label in runs-on. GitHub's self-hosted runner reference, checked September 21, 2026, says an assigned job is requeued if the runner does not pick it up within 60 seconds; without an eligible online runner it remains queued, and it fails after 24 hours.
jobs:
test:
runs-on: [self-hosted, linux, x64, ci-medium]
The array is an AND expression. A runner labelled self-hosted, linux and x64 still cannot accept this job unless it also has ci-medium. Check the spelling of custom labels (GitHub treats label case as equivalent), then open the repository or organization settings under Actions → Runners. GitHub lists each runner's labels and its state as idle, active or offline. If the runner belongs to a group, confirm that the group allows this repository; a healthy runner outside the repository's allowed group is not eligible.
An offline runner may have a stopped listener, lost network access, or an out-of-date runner application. GitHub's monitoring and troubleshooting guide, checked September 21, 2026, points operators to the runner's _diag/Runner_* logs, the service status, and the connectivity check. GitHub also stops queuing work to a self-hosted runner that is more than 30 days behind an available runner update.
For GitHub-hosted jobs, do not change a correct ubuntu-latest label just because one run waits. Check GitHub Status, then compare another small job using the same hosted label. If unrelated repositories and workflows started queueing at the same time, that is evidence of a platform or account-capacity problem rather than a YAML defect. Preserve the run URL and timestamps if you need GitHub Support.
Can needs make a job look stuck?
Yes. Jobs run in parallel by default, but needs turns the named jobs into prerequisites. GitHub will not start the dependent job until every prerequisite has completed successfully. If a prerequisite fails or is skipped, the dependent job is skipped too unless its condition explicitly allows another outcome.
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: ./ci/build
test:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: ./ci/test
In this example, no runner can start test while build is running. Follow the incoming line to build in the visualization graph and diagnose that job first. Also inspect job-level if expressions: GitHub's run logs can show how a job condition evaluated, which is useful when the graph does not match what the author expected. The current behavior is documented under jobs.<job_id>.needs.
Is the job waiting for an environment approval?
A job that references an environment can wait on required reviewers, a wait timer, branch restrictions or a custom deployment protection rule. GitHub applies those rules before sending the job to a runner, so adding runners cannot release it. Open the run and look for the deployment review prompt, then open the environment named in the job.
jobs:
deploy:
environment: production
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- run: ./ci/deploy
Approve only after checking the commit and deployment target. If nobody on the team can approve, verify that the configured reviewers still have access and that the branch is allowed to deploy. GitHub's deployment environments reference, checked September 21, 2026, describes manual approvals, wait timers, branch restrictions and custom protection rules; environment secrets are unavailable until required approval has passed.
Can a concurrency group keep a workflow pending?
Yes. A concurrency group permits at most one running job or workflow in the group. When another enters the same group, GitHub marks it pending. Find the older run that owns the group before cancelling anything, because deployment workflows often use concurrency deliberately to prevent two releases from changing the same environment at once.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
This pattern is appropriate for replaceable branch CI: a newer commit cancels obsolete work on the same workflow and ref. It is usually wrong for a deployment that must finish or roll back cleanly. Also search for job-level concurrency, because a workflow can be free while one job is sharing a group with a job in another run. GitHub's workflow syntax reference, checked September 21, 2026, explains pending replacement, cancellation and queueing behavior.
What if the job is running but one step is hung?
Once the job is in progress and the log names a current step, runner matching is over. Read the last useful output and classify the wait: a server never became ready, a test deadlocked, an interactive command is waiting for input, a package manager is retrying, disk or memory pressure stalled the process, or an external service stopped responding. Rerunning without changing or observing anything usually reproduces the same uncertainty.
Add a bound at the narrowest useful level and enough progress output to identify the next stall. On an Ubuntu runner, a command-specific timeout can make the failure explicit:
jobs:
integration:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- name: Run integration tests
run: timeout 15m ./ci/integration-test
- name: Upload diagnostics on failure
if: failure()
run: ./ci/print-diagnostics
Use a timeout that is longer than a legitimate slow run, and make the diagnostic command safe to run after a partial failure. A job-level timeout-minutes is a final guardrail, but it only stops the job; it does not tell you which internal wait consumed the time. GitHub's workflow log guide, checked September 21, 2026, notes that each job also includes “Set up job” and “Complete job,” which helps distinguish runner setup from your first command.
How do I make the diagnosis repeatable?
- Save the workflow run URL, commit SHA, status, status start time and current job or step.
- Use the visualization graph to check
needsbefore investigating runner capacity. - Check environment approvals and concurrency groups, both of which can hold a job without a runner-capacity problem. GitHub explicitly applies environment protection before sending that job to a runner.
- For a runner wait, compare the exact
runs-onlabels and group with an eligible idle runner. - For an in-progress stall, preserve the job log and the runner's worker diagnostics before replacing the machine.
- Change one cause, rerun once, and verify that the job crosses the transition that was blocked.
The transition is the proof. A label fix should move the job from queued to in progress. An approval should move it from waiting to runner assignment. A timeout should turn an indefinite in-progress step into a bounded failure with diagnostics. A green rerun without that evidence can hide a transient outage rather than establish a fix.
Would changing runners fix the problem?
Yes, when Linux jobs keep waiting on self-hosted capacity. Sengi runs them on fresh VMs on 5 GHz bare metal, at 2, 4 or 8 vCPU and up to 74% less per build than GitHub, while the workflow, checks and logs stay in GitHub. Its 2 vCPU runners are pre-booted, so a job lands on a VM that’s already running: ready in 2 seconds. The other causes on this page, such as a needs graph, an environment approval, a concurrency policy or a hung command, are fixed in the workflow itself.
Switching a job to Sengi is one runs-on edit, by hand or with the agent prompt, in your own pull request. See how the migration works, runner sizes and pricing, or the guide to making GitHub Actions faster. If you are deciding between managed and self-hosted capacity, compare the operational work in the self-hosted runner cost guide; the pricing guide and cost calculator cover the hosted alternative.
Sources checked for this guide
- GitHub: Self-hosted runners reference — routing, label and group matching, the 60-second pickup retry, 24-hour queue limit and update requirement; checked September 21, 2026.
- GitHub: Using labels with self-hosted runners — custom-label management and case-insensitive matching; checked September 21, 2026.
- GitHub: Monitoring and troubleshooting self-hosted runners — runner states, connectivity and diagnostic logs; checked September 21, 2026.
- GitHub: Workflow syntax —
needs, environments, concurrency and timeouts; checked September 21, 2026. - GitHub's
actions/checkoutdocumentation — currentactions/checkout@v7syntax used before repository-local commands; checked September 21, 2026. - GitHub: Monitor workflows — visualization graph, logs, execution time and debug logging; checked September 21, 2026.
- GitHub Status — current and historical service incidents; checked September 21, 2026.
Cut your GitHub Actions bill by up to 74%.
Up to half GitHub’s price per minute, on 5 GHz bare metal. Builds finish up to 1.9× faster than on GitHub’s runners, so together you pay up to 74% less per build. Change one runs-on line, or give your coding agent one prompt and it opens the pull request for you.
3,000 free minutes a month, no card. See how switching works
