← Actions runner starvation check

Add a starvation guard step to a GitHub Actions workflow

The page-and-paste tool and the check.py CLI both answer this for one run you already have the JSON for. This page is the other case: wiring the same check into your own repository so a starved run tells you so on its own, in its job summary, instead of showing up as one more red check to chase.

Why a separate workflow, not a step in the job that might starve

A job that never gets a runner never executes a single step of its own — there is nothing inside it to add a check to. And a job that did run cannot see its siblings' final status while the run is still in progress. The fix used here is a second workflow, triggered by workflow_run, that starts only after the run it is watching has completely finished. By then every sibling job — starved or not — has a final conclusion the Actions API can report.

The workflow

This version needs no external fetch at all — it asks the Actions API directly with the gh CLI that every ubuntu-latest runner already has installed, and classifies with gh api's own --jq filter:

name: starvation-guard
on:
  workflow_run:
    workflows: ["*"]        # or name the specific workflow(s) to guard
    types: [completed]

jobs:
  guard:
    if: github.event.workflow_run.conclusion == 'failure'
    runs-on: ubuntu-latest
    permissions:
      actions: read
    steps:
      - name: Classify the run
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh api "repos/${{ github.repository }}/actions/runs/${{ github.event.workflow_run.id }}/jobs" \
            --jq '[.jobs[] | select(.conclusion == "failure")] |
              if length == 0 then "No failed jobs in this run."
              else .[] | if .runner_id == 0
                then "STARVED  \(.name) — no runner was ever assigned, not a real failure"
                else "REAL     \(.name) — ran on runner \(.runner_id), worth debugging"
                end
              end' | tee -a "$GITHUB_STEP_SUMMARY"

This is the same field the browser tool and the check.py CLI decide on — runner_id: 0 means no runner was ever assigned. Prefer check.py itself over reimplementing the filter above if your own repository can vendor it: it also separates a still-pending job from a starved one and carries the test suite that keeps the classification correct.

Reading the result

The job summary lists one line per failed job in the run: STARVED for a job whose runner_id came back 0 — not your code, nothing to debug — and REAL for one that actually ran and is worth triaging normally. The guard step above always exits success so it never becomes one more red check for a starved run to hide behind; add | grep -q STARVED && exit 1 after the pipe instead if you want the guard job itself to fail loudly the moment it finds a starved job.

What this does not need

No fine-grained personal access token, no organization setting, and no change to the workflow being guarded. The default GITHUB_TOKEN the guard job requests with permissions: actions: read is enough — the gh CLI already on the runner picks it up from the GH_TOKEN environment variable automatically, which is the same route check.py's own credential resolution prefers.

Common questions

Why a separate workflow instead of a step inside the one that might be starved?

A job cannot reliably read its own sibling jobs' final status while they are still running, and a starved job never runs a step of its own at all -- there is nothing inside it to add a step to. A workflow_run job fires after the whole run finishes, so every sibling job (starved or not) already has a final conclusion the Actions API can report.

Does the default GITHUB_TOKEN have enough access to read job data?

It needs the actions: read permission on the triggered job. Repositories with restrictive default token permissions will not grant that automatically, so the example workflow below requests it explicitly with a permissions: block rather than relying on the account-wide default.

What does the guard job actually do with the verdict?

It writes one line per failed job into the run's job summary (GITHUB_STEP_SUMMARY): STARVED for a job whose runner_id came back 0, REAL for one that actually ran. The step as written always exits success, so a starved run does not itself show up as one more red check to triage -- add a grep on STARVED afterward if you want the guard job to fail loudly instead.

← Back to the checker · Why is my check red in seconds with no output?