Knowledgebase

Timeouts, concurrency, and continue-on-error in GitHub Actions

The three keys that stop a workflow burning your money, queueing over itself, or falling over on something that does not matter. The six-hour default, three concurrency recipes, and what letting a failure slide really hides.

Three keys nobody writes until something goes wrong: the limit that kills a hung job, the queue that stops two runs racing, and the flag that lets a failure slide.

⏱️ timeout-minutes

Nothing stops a hung job on its own. timeout-minutes is how long GitHub waits before killing it, and you pay for every minute up to that point.

  • Reach for it when you write a job. All of them, and again on any step that talks to something outside your repository, since that is what actually hangs.
  • Lives on a job beside runs-on, and on a step, as whole minutes. A job's limit covers all of its steps; a step's covers that step alone.

What you get by leaving it out:

Where Nothing written means Which is
A job six hours GitHub's own ceiling on a hosted runner, and five days on a self-hosted one
A step whatever the job has left so a step inherits the job's limit and never gets one of its own

What a sensible number looks like:

The job Start from Because
A build 10 to 15 minutes it is either quick or it is stuck, and rarely in between
A test suite 20 to 30 minutes a cold cache and a slow runner can honestly double it
An agent run 30 to 60 minutes a model going round in a loop looks exactly like a model working
name: Build
on: push
permissions:
  contents: read
jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v6
      - run: npm ci
      - name: Publish the release
        timeout-minutes: 5
        run: ./publish.sh

Gets you in trouble when

  • You leave it off an agent job. A model in a loop reads as a job that is working, and it holds the runner for the full six hours before anybody looks. The editor warns as you type: Job "build" has no timeout-minutes, so a hung run holds a runner for the default six hours.
  • You set it too tight. A cold cache or a slow runner makes an honest build take twice as long, and a killed job reads as a broken one to whoever opens it.
  • You expect to catch the timeout. A job GitHub kills is cancelled, not failed, so if: failure() never runs afterwards and only if: always() does.
  • You write it on a job that calls a reusable workflow. GitHub does not read it there at all; the jobs inside the called workflow carry their own limits.
  • You count on it to cap the bill. It caps one job, so a matrix of twenty is twenty of those limits, and a workflow of five jobs is five more.

In the editor the job's Give up after field, whose hint reads: empty means GitHub's own limit, six hours. Every step has one of its own, leaving only the job's.

🚦 concurrency

Two runs of the same thing can start at once and step on each other. A concurrency group is how you say only one of these at a time.

  • Reach for it when two runs would collide: a deploy to one place, a cache they both write, an agent that must not answer the same issue twice.
  • Lives on the whole workflow, or on a single job, as a group and a cancel-in-progress flag. Two runs sharing a group never run together.

A group is a string you make up. GitHub never reads any meaning into it; it only compares it to the groups of the runs already going, and that is the whole mechanism.

So the group is usually an expression, and one workflow makes many groups: ${{ github.workflow }}-${{ github.ref }} is one group per branch.

The three you will actually write:

Recipe The group cancel-in-progress
One run per branch, newest wins ${{ github.workflow }}-${{ github.ref }} true
One deploy at a time, never cancelled deploy-production false
One agent per issue agent-${{ github.event.issue.number }} false

cancel-in-progress is the whole difference between them:

  • true on a pull request build. A second push makes the run already going pointless, so cancel it and keep the minutes.
  • false on anything that writes. A deploy cancelled halfway leaves half a thing deployed, and the next run starts from a state nobody designed.
name: Deploy
on:
  push:
    branches: [main]
permissions:
  contents: read
concurrency:
  group: deploy-production
  cancel-in-progress: false
jobs:
  deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 30
    steps:
      - uses: actions/checkout@v6
      - run: ./deploy.sh

Gets you in trouble when

  • You cancel a deploy. Half a deploy is worse than a slow one, and cancel-in-progress: true on anything that writes to production is how you find that out.
  • You expect the waiting runs to queue up. Only one run waits per group, and a newer one takes its place, cancelling the run that was waiting.
  • You leave the branch out of the group. ${{ github.workflow }} on its own puts every branch in one queue, so a push to a feature branch cancels the run on main.
  • You put a group on the workflow when one job needed it. The whole run waits, tests and all, when only the deploy job ever had anything to collide with.
  • You reach for it to stop the same commit building twice. It queues and it cancels; it does not deduplicate, and the second run still starts.

In the editor the Queues with field, on the workflow card and on every job, with the usual answers as buttons and Cancel whatever is already running in that group below it.

🙈 continue-on-error

Something failed and you would rather the run carried on regardless. continue-on-error is that, and it is a much bigger hammer than it looks.

  • Reach for it when what failed genuinely decides nothing: an optional linter, a coverage upload, a notification that nobody blocks a release on.
  • Lives on a step or on a job, as true or false. The failure still happened and still shows in the log; it just stops deciding the outcome.
on: pull_request
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@v6
      - run: npm test
      - name: Upload the coverage report
        continue-on-error: true
        run: ./upload-coverage.sh

What it hides, which is the reason to think twice:

  • The run's conclusion says success. A badge, a required check, a merge button, and a notification all read green, and only somebody opening the log sees otherwise.
  • On a job, every job that needs it carries on as well, because as far as the run is concerned that job passed. That is almost never what the person wanted.
  • A green run that did nothing is worse than a red one. A red run gets fixed the same afternoon; a green one that quietly skipped its work gets trusted for months.

Gets you in trouble when

  • You put it on the step that does the work. The test suite fails, the deploy after it runs anyway, and you ship the thing the tests were about to stop.
  • You use it to quiet a flaky test. The flake is still there and now nothing tells a flake apart from a real break, which is the only useful thing a red build does.
  • You meant if: always(). You wanted the later step to run whatever happened above it, not the failure to stop counting; those are two different asks.
  • You forget what it does to a matrix. A combination marked this way never trips fail-fast, so the rest keep going on the strength of a job that failed.
  • You leave it on after the emergency. It was for one bad afternoon, it reads as deliberate a week later, and nobody ever takes it out again.

In the editor the Let the workflow pass even when this job fails tick on a job card, and Let the job carry on even when this step fails on a step.

📚 Straight from GitHub