A job hits a timeout, or hangs until GitHub kills it
A GitHub Actions job that times out is either genuinely too slow for its bound, or it is stuck waiting on something that never arrives, a fixed sleep, an unready container, a queue backlog. Read the run's own timing and its log tail before assuming the job itself is at fault.
AI agents find the cause
StarSling agents run this diagnosis against your workflows and run history, identify which cause is yours, and open the fix as a reviewable PR.
Symptoms
Every GitHub Actions job carries a timeout: an explicit timeout-minutes you set, or the platform default of 360 minutes if you never set one. When a job is cancelled for exceeding it, the run log shows a distinct line, the job running on the runner has exceeded the maximum execution time, which tells you the limit fired but not why the job was still running. That distinction matters because a job hits the wall for two very different reasons: it is doing real, unbounded work that legitimately grew past whatever bound was set, or it is not doing work at all, it is blocked on a fixed sleep, an unhealthy service container, or a queue slot behind an earlier, now-obsolete run of the same workflow. The order below checks the cheapest, most common causes first: whether a timeout is set at all and at which level, whether the job is waiting on time instead of on a real signal, whether a container's readiness gate is missing, whether the job's own work has grown too large for one runner, and only then whether the run was ever going to finish before something newer superseded it.
The run log ends with "The job running on runner ... has exceeded the maximum execution time" and the job is marked cancelled, not failed.
A job that used to finish in minutes now runs for hours before GitHub cuts it off.
The job's log shows long stretches with no new output, then a burst of activity right before the timeout, or none at all.
You never set timeout-minutes on this job, and it is running close to or past 360 minutes when it gets killed.
Several runs of the same workflow are queued or running at once for the same branch, and the oldest one is still going when a newer push lands.
How to diagnose it
Open the timed-out run in the Actions UI and read the last log line: "The job running on runner ... has exceeded the maximum execution time" confirms the platform's own timeout fired rather than a step failing on its own.
Check whether the job was actually running or still queued when it hit the wall, the Actions UI shows queued time separately from execution time; a long queued time points at the concurrency cause, not the job's own work.
grep -n 'timeout-minutes' .github/workflows/*.ymlto see whether this job has an explicit bound, and at what value, versus running on the 360-minute default. Do not window the search aroundruns-on:timeout-minutessits among the job's other keys and is routinely further than a line or two away, so a windowed grep reports it absent on jobs that do set it.Scan the log for the last step that produced output before the gap. A step that goes silent for minutes with no further lines is very likely blocked on a fixed sleep or an unready dependency, not doing real work.
If a
services:block backs the job, check itsoptionsfor--health-cmd; its absence combined with asleepstep right after is the container-healthcheck cause.Compare this job's step timings across the last 10 to 20 runs in the Actions UI. A steady climb toward the timeout value across runs is the long-running-job cause; a flat history until one anomalous run is more likely a hang.
Likely causes
Ordered by how often each one turns out to be the answer. Confirm a cause with its check before you change anything.
No timeout-minutes set, so the job runs to GitHub's 360-minute default
If a job definition has no timeout-minutes key, GitHub applies a default of 360 minutes before cancelling it, per the workflow syntax reference. That default exists at the job level; an individual step has no default timeout of its own and can run for the remainder of the job's budget unless you cap it separately with a step-level timeout-minutes (also capped at 360). A job with no explicit bound looks fine on every normal run and only reveals the problem when something hangs, at which point it occupies a runner for up to six hours before anyone finds out.
Fix: Job timeoutsConfirm it
grep -c 'timeout-minutes' .github/workflows/*.ymlagainst the number of jobs in each file; a job with notimeout-minuteskey of its own is running on the 360-minute default. Count over the whole file rather than a window aroundruns-on- the key is not required to sit within a line or two of it, so a windowed count returns zero for almost every repository, correctly bounded ones included, and would confirm this cause everywhere.A step is waiting on a fixed sleep instead of a real readiness signal
A fixed
sleep N,page.waitForTimeout,cy.wait(ms), or a hand-rolled polling loop with no exit condition all wait on the clock, not on the thing actually becoming ready. When the dependency comes up faster than the sleep, time is wasted every run; when it comes up slower, or never, on a bad run, the step blocks past its wait and the job keeps going with nothing to show for it until the job timeout eventually cuts it off. This is the most common shape of a job that looks like it hung: no error, no progress, just silence until the platform kills it.Fix: Polling waits, not fixed sleepsConfirm it
grep -rn 'sleep [0-9]\|waitForTimeout(\|cy\.wait([0-9]' .github/workflows/ tests/ e2e/ --include='*.yml' --include='*.ts' --include='*.js'; a match on the step that stalls confirms this cause.A service container has no healthcheck, so the workflow waits on a fixed sleep for it
A job that starts a database or cache as a
services:container but gives it nooptions: --health-cmdhas no way to know when that container can accept connections, so workflows commonly paper over this with asleep 10before the first query. On a slow or contended runner the container is not actually ready when the sleep ends, and the step that depends on it either fails or, if it retries in a loop with no bound, hangs until the job's own timeout fires.Fix: Container healthchecksConfirm it
grep -A10 'services:' .github/workflows/*.yml | grep -c 'health-cmd'against the number ofservices:blocks; a service container block with no--health-cmdin itsoptionsis running unhealthchecked.One job is doing more work than fits comfortably inside its timeout
A single job that runs an entire test suite, or a full build plus every integration test, sequentially in one runner has no ceiling on how much its own workload can grow as the codebase does. It can also show up as one specific step or the cache-save Post step stretching far past what the rest of the job takes, both symptoms of work concentrated in one place instead of spread across the job graph. Either way the job's real duration creeps toward its timeout over months, until an ordinary slow day is enough to tip it over.
Fix: Long-running jobs splitConfirm it
Open the job's step timings in the Actions UI for several recent runs and compare the slowest step's duration to the job's timeout-minutes; a step whose duration or variance is climbing toward that ceiling, or a Post step alone taking real minutes, means the job's workload needs to be split rather than the timeout raised.
Superseded runs are never cancelled, so old runs queue behind each other and time out waiting for a runner
A workflow with no top-level
concurrency:block, or one withcancel-in-progress: false, lets every push start its own run without stopping the run for the commit it just replaced. On a branch with several pushes in quick succession this backs up: earlier runs keep occupying or queuing for runners well after their result stopped mattering, and a run that is queued rather than executing can sit long enough to look identical to a hung job in the Actions UI, even though nothing in the job itself is broken.Fix: Superseded runs cancelledConfirm it
grep -L 'concurrency:' .github/workflows/*.ymlfor workflows that trigger onpushorpull_request, and separatelygrep -rn 'cancel-in-progress: *false' .github/workflows/; either match on a workflow with multiple runs per branch confirms this cause.
Hand it to an agent
Hand this prompt to your coding agent (Claude Code, Cursor, and the like) to run this diagnosis against your repository and report which cause it found.
Diagnose why a job in this repository's GitHub Actions workflows times out or hangs until GitHub cancels it. Do not change anything yet; report findings first.
1. Read the job's `timeout-minutes` at job and step level. If neither is set, it runs on GitHub's 360-minute default; note this as a candidate.
2. Search the workflow and its test sources for fixed waits: `sleep N`, `waitForTimeout(`, `cy.wait(ms)`, or an unbounded polling loop. A match on the step where the run appears to stall is a strong candidate.
3. If the job starts a `services:` container, check its `options` for `--health-cmd`. No healthcheck plus a `sleep` step right after is the same class of cause.
4. Pull the job's step timings for the last 10-20 runs. A step whose duration or variance climbs toward the timeout means the job's workload has outgrown one runner.
5. Check for a top-level `concurrency:` block with `cancel-in-progress: true`. Its absence on a workflow with multiple runs per branch means old runs may be queuing, not hanging.
Report which cause you found, quoting the relevant log line or YAML, before proposing or making any change.Go further
Find the cause, fix them all, or keep them fixed.
You have the prompt that works out which cause applies. Here is how much further you can take it, each step doing more for you than the last.
Find the cause
Copy the diagnosis prompt above
Hand it to your coding agent to confirm which cause applies in your repo. It reports back what it found and changes nothing.
Fix them all, once
Install the ci-speedup skill
One prompt audits your whole repo against all 73 ci-speedup patterns (these 8 plus 65 more) and hands your agent every fix at once. Open source, MIT, runs locally.
Keep it fixed, forever
Install the StarSling GitHub App
Connect GitHub and the fixes stay applied as your CI evolves, with agents that keep inspecting your workflows and opening optimization PRs you review.
Sources
1GitHub Actions workflow syntax: timeout-minutes (opens in new tab)
2GitHub Actions: usage limits (job execution time) (opens in new tab)
3GitHub Actions: control workflow concurrency (opens in new tab)
4GitHub Actions: create Redis service containers (healthcheck options) (opens in new tab)
Last updated 2026-08-31