Timeouts and Runaway Job Protection
The broken run from the earlier debugging chapter eventually failed on its own — the health-check loop is hardcoded to give up after 30 tries, about a minute. That’s a convenient built-in ceiling, but it only exists because the loop happens to have been written with one. Plenty of other things a workflow might do — a hung network call, waiting for database query result, a step waiting on a condition that will genuinely never become true — have no such built-in patience, and without one, GitHub’s own hard ceiling (6 hours per job, by default) is far too generous to help you. timeout-minutes lets you set a much tighter, deliberate limit of your own.
Where timeout-minutes Applies
- Job-level — the maximum time the entire job is allowed to run, across all its steps combined.
- Step-level — the maximum time a single step is allowed to run, independent of the rest of the job.
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Some step
timeout-minutes: 3
run: ./something.shIf a step exceeds its own limit, that step (and the job) is marked failed and stops there — later steps don’t run, the same as any other failure. If the job-level timeout hits first, the whole job stops regardless of which step is currently running.
Applying It To The Deploy Pipeline
The step most worth protecting is the one that surfaced the earlier debugging chapter’s failure: “Wait for app to become healthy.” Its own retry loop already gives up after roughly a minute, which is a limit, but it’s one baked into the script rather than something the workflow visibly enforces — and other steps in the same job have no such ceiling at all. A step-level timeout-minutes makes the limit explicit and visible in the workflow file itself, and a job-level one backstops everything else:
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@v5
# ...build, stop, start steps unchanged...
- name: Wait for app to become healthy
timeout-minutes: 2
run: |
for i in $(seq 1 30); do
curl -sf http://localhost:8000/health > /dev/null && exit 0
sleep 2
done
echo "App never became healthy"
exit 1Two minutes for the health-check step is deliberately close to, but a little tighter than, the loop’s own ~1-minute internal ceiling with some margin — with this in place, a stuck health check now fails via the GitHub Actions timeout at a predictable point regardless of how the script’s own retry count is tuned, and the failure reason in the log is unambiguous: The action has timed out. The 5-minute job-level timeout is the backstop for everything else — checkout, build, and stop/start should all complete in a few seconds under normal conditions, and shouldn’t be allowed to hang indefinitely if something outside your control (a slow runner, a network hiccup during checkout) goes wrong. The healthcheck of localhost might not even need such timeouts to be explict but what matters is pattern, this is just a demo to say that aloud.
Note
Picking timeout values is a judgment call, not a formula. Look at how long the step normally takes on a healthy run, then set the limit a bit above that — tight enough to catch genuine hangs quickly, loose enough that ordinary variance doesn’t trigger false failures. Writing timeouts is not any decoration and do no good if job or step doesn’t require it. For example there is no point of timeout-minutes: 1 that just prints welcome message — you identify hiccups and place a timer, that’s it.
What A Timeout Looks Like When It Fires
Re-run the broken pipeline from the earlier debugging chapter — still pointed at the renamed APP_PORT bug — with the step timeout in place. Instead of running the full 30-try loop to its own conclusion, the job fails at the two-minute mark with:
Error: The action has timed out.Note
To actually simulate timeout, increase sleep to 5 so that 5*30=150 seconds would be enough greater than 2*60=120 seconds of wait timer timeout-minutes.
Functionally the outcome is the same as before — the deploy still failed, and you still need the if: failure() log-dumping step from the earlier debugging chapter to find the real KeyError underneath — but the failure now happens on a limit you configured on purpose, rather than one that happened to be hardcoded into the health-check script.
Tip
timeout-minutes protects against a job running too long — it says nothing about retrying. If you want a step to retry a few times before giving up (useful for genuinely transient failures, unlike this chapter’s permanently-broken example), that’s a separate concern, usually handled with a small retry loop inside the step’s own script.