← Actions runner starvation check
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.
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.
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.
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.
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.
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.
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.
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?