Skip to content

gh run: Debugging GitHub Actions Runs from the Terminal

Lesson 7 of 10Intermediate9 min readGitHub Engineering · GitHub CLIVerified: gh 2.98.0 on Ubuntu 24.04, August 2026

gh run is the fastest way to answer “why did CI fail?” — considerably faster than loading the Actions tab, finding the run, expanding the job, and scrolling to the error.

One command gets you there.

CommandDoes
listRecent runs
viewOne run, its jobs, or its logs
watchFollow a run until it finishes
rerunRun it again, optionally only failed jobs
cancelStop an in-progress run
downloadFetch artifacts
deleteDelete a run and its logs
Terminal window
gh run view --log-failed

What it doesPrints the log output of only the jobs and steps that failed in the most recent run.

Why we run itA full CI log can be tens of thousands of lines across many jobs. This filters to the failures, which is almost always the only part you need.

Expected resultLog excerpts for each failed step, prefixed with the job and step name.

Run inside a repository with no arguments, it targets the most recent run for the current branch. That single command replaces most CI debugging navigation.

Related views:

Terminal window
gh run view # interactive picker
gh run view RUN_ID # summary of jobs and their status
gh run view RUN_ID --log # complete log, all jobs
gh run view RUN_ID --job JOB_ID --log
gh run view RUN_ID --web # open in a browser when you do want the interface
Terminal window
gh run list --limit 20
gh run list --workflow build.yml
gh run list --branch main --status failure
gh run list --user "@me" --limit 10
gh run list --event pull_request --status failure --limit 20

Filters compose, which makes narrow questions easy to ask:

Terminal window
# Failures on main in the last 20 runs, as data
gh run list --branch main --status failure --limit 20 \
--json databaseId,displayTitle,createdAt,conclusion
Terminal window
gh run watch
gh run watch RUN_ID
gh run watch RUN_ID --exit-status

--exit-status is the flag that makes watch useful in scripts: it exits non-zero if the run fails, so a deployment script can wait and then react.

Terminal window
gh workflow run deploy.yml -f environment=staging
sleep 5
RUN_ID=$(gh run list --workflow deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId')
if gh run watch "$RUN_ID" --exit-status; then
echo "deploy succeeded"
else
echo "deploy failed" >&2
gh run view "$RUN_ID" --log-failed
exit 1
fi

That pattern — trigger, capture the run, wait, report failures — is the backbone of most CI glue scripts. Note the deliberate pause and the explicit capture: taking “the latest run” immediately after triggering can return the previous one.

Terminal window
gh run rerun RUN_ID
gh run rerun RUN_ID --failed
gh run rerun RUN_ID --job JOB_ID
gh run cancel RUN_ID

--failed reruns only the jobs that failed, which on a matrix build with twenty jobs and one failure saves substantial time and runner minutes.

Terminal window
gh run download RUN_ID
gh run download RUN_ID --name build-output
gh run download RUN_ID --pattern '*-linux-*' --dir ./artifacts
gh run download RUN_ID --name coverage --dir /tmp/coverage

Downloading artifacts locally is often much easier than inspecting them through the interface — particularly test reports, coverage output and build logs that are attached rather than printed.

Artifacts expire according to the repository’s retention setting, so download on an old run may find nothing even though the run itself is still listed.

Terminal window
gh run delete RUN_ID

Deleting removes the run and its logs permanently. It is occasionally necessary — a run whose logs contain a secret that was accidentally printed, for instance — and in that case deleting the log is damage limitation rather than remediation. Rotate the credential; the log may already have been read, cached or copied.

Because everything supports --json, useful reporting is a couple of lines.

Failure rate on the default branch over the last fifty runs:

Terminal window
gh run list --branch main --limit 50 --json conclusion \
--jq '[.[] | .conclusion] | group_by(.) | map({conclusion: .[0], count: length})'

Output:

[
{ "conclusion": "failure", "count": 6 },
{ "conclusion": "success", "count": 44 }
]

Which jobs fail most often:

Terminal window
gh run list --limit 50 --status failure --json databaseId --jq '.[].databaseId' \
| while read -r id; do
gh run view "$id" --json jobs \
--jq '.jobs[] | select(.conclusion == "failure") | .name'
done | sort | uniq -c | sort -rn

That second one is worth running on any repository where CI is unreliable. The answer is usually concentrated in one or two jobs, and knowing which changes the conversation from “CI is flaky” to a specific fixable problem.

gh run view on its own gives a summary; the JSON gives you the structure to reason about.

Terminal window
gh run view RUN_ID --json displayTitle,status,conclusion,event,headBranch,createdAt,updatedAt,jobs \
--jq '{
title: .displayTitle,
event: .event,
branch: .headBranch,
conclusion: .conclusion,
minutes: (((.updatedAt | fromdateiso8601) - (.createdAt | fromdateiso8601)) / 60 | floor),
jobs: [.jobs[] | {name, conclusion, minutes: (((.completedAt // .startedAt) | fromdateiso8601) - (.startedAt | fromdateiso8601)) / 60 | floor}]
}'

Output:

{
"branch": "add-retry-handling",
"conclusion": "failure",
"event": "pull_request",
"jobs": [
{ "conclusion": "success", "minutes": 1, "name": "build" },
{ "conclusion": "failure", "minutes": 4, "name": "test" },
{ "conclusion": "skipped", "minutes": 0, "name": "deploy" }
],
"minutes": 6,
"title": "Retry idempotent requests up to three times"
}

That view answers the three questions you actually have — did it pass, which job failed, and how long it took — without opening anything.

The event field is worth noticing. A run triggered by pull_request behaves differently from one triggered by push or merge_group, particularly around permissions and available secrets, and “why did this pass on my branch and fail in the queue” is very often an event difference.

CI duration is a cost nobody notices accumulating. Job-level timings across recent runs make it visible:

Terminal window
gh run list --workflow ci.yml --limit 30 --status success --json databaseId \
--jq '.[].databaseId' \
| while read -r id; do
gh run view "$id" --json jobs \
--jq '.jobs[] | [.name, (((.completedAt | fromdateiso8601) - (.startedAt | fromdateiso8601)) / 60 | floor)] | @tsv'
done \
| awk -F'\t' '{sum[$1]+=$2; n[$1]++} END {for (j in sum) printf "%6.1f %s\n", sum[j]/n[j], j}' \
| sort -rn

Output:

12.4 integration-tests
4.1 unit-tests
1.2 lint
0.8 build

That is thirty requests, so run it occasionally rather than in a loop. The result usually shows the duration concentrated in one job — which turns “CI is slow” into a specific, fixable target.

Downloading artifacts is how you get test reports, coverage output and build products out of CI and into something else.

Terminal window
RUN_ID=$(gh run list --workflow ci.yml --branch main --status success --limit 1 \
--json databaseId --jq '.[0].databaseId')
gh run download "$RUN_ID" --name coverage --dir ./coverage
gh run download "$RUN_ID" --pattern 'dist-*' --dir ./artifacts

Two constraints worth designing around. Artifacts expire according to the repository’s retention setting, so a script fetching “the last successful build” may find nothing on an old run. And --name must match exactly, while --pattern globs — using the wrong one produces a confusing “no artifact matched” on a run that plainly has artifacts.

Logs can also be extracted, which is useful for parsing structured output a job printed:

Terminal window
gh run view "$RUN_ID" --log > full.log
gh run view "$RUN_ID" --job "$JOB_ID" --log | grep -A5 'BENCHMARK'

Putting the pieces together, a weekly report that is genuinely worth reading:

#!/usr/bin/env bash
set -euo pipefail
REPO="${GH_REPO:?set GH_REPO}"
LIMIT="${LIMIT:-50}"
echo "== outcomes over the last $LIMIT runs on main =="
gh run list --repo "$REPO" --branch main --limit "$LIMIT" --json conclusion \
--jq 'group_by(.conclusion) | map({conclusion: .[0].conclusion, count: length})
| sort_by(-.count) | .[] | "\(.count)\t\(.conclusion)"'
echo
echo "== jobs failing most often =="
gh run list --repo "$REPO" --limit "$LIMIT" --status failure --json databaseId \
--jq '.[].databaseId' \
| while read -r id; do
gh run view --repo "$REPO" "$id" --json jobs \
--jq '.jobs[] | select(.conclusion == "failure") | .name'
done | sort | uniq -c | sort -rn | head -5

The second section is the one that changes conversations. “CI is flaky” is a complaint; “the integration-tests job failed in eleven of the last fifty runs” is a ticket.

Note the request cost — one call per failed run — which is why LIMIT is a variable and why this is a scheduled report rather than something anyone runs casually. See Automating GitHub with Bash for rate-limit budgeting.

Scrolling full logs instead of --log-failed. The single biggest time saver here.

Using .id instead of .databaseId. Produces a confusing not-found error.

Assuming the latest run is the one you triggered. Capture it explicitly.

Rerunning until green. Hides real failures and wastes minutes.

Forgetting --exit-status. Without it, watch succeeds even when the run failed.

Expecting artifacts to persist. They expire on the retention setting.

Deleting a run with a leaked secret and considering it handled. Rotate the credential.

The naive wait — trigger, sleep, check — is wrong in two ways, and both bite in production.

Terminal window
# Wrong: may find the previous run, and gives up arbitrarily
gh workflow run deploy.yml
sleep 10
gh run list --workflow deploy.yml --limit 1

The problems are that the run may not exist yet when you look, and “the latest run” is not necessarily yours. Correct it by matching on something you know:

#!/usr/bin/env bash
set -euo pipefail
BEFORE=$(gh run list --workflow deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0')
gh workflow run deploy.yml -f environment=staging
# Wait for a run newer than the one that existed before we dispatched.
RUN_ID=""
for _ in $(seq 1 30); do
CANDIDATE=$(gh run list --workflow deploy.yml --limit 1 --json databaseId --jq '.[0].databaseId // 0')
if [ "$CANDIDATE" != "$BEFORE" ]; then RUN_ID="$CANDIDATE"; break; fi
sleep 2
done
[ -n "$RUN_ID" ] || { echo "run did not appear within 60s" >&2; exit 1; }
echo "watching run $RUN_ID"
if gh run watch "$RUN_ID" --exit-status; then
echo "deploy succeeded"
else
echo "deploy failed" >&2
gh run view "$RUN_ID" --log-failed
exit 1
fi

Capturing the previous run ID and waiting for a different one is the reliable version. For workflows triggered by a push rather than a dispatch, match on headSha instead:

Terminal window
SHA=$(git rev-parse HEAD)
gh run list --workflow ci.yml --json databaseId,headSha \
--jq --arg sha "$SHA" '.[] | select(.headSha == $sha) | .databaseId' | head -1

gh run rerun re-executes a run. Three forms, with different costs:

Terminal window
gh run rerun RUN_ID # every job
gh run rerun RUN_ID --failed # only the failed jobs and their dependants
gh run rerun RUN_ID --job JOB_ID # one job

--failed is the one to reach for. On a matrix build with twenty jobs where one failed, rerunning everything costs twenty times the runner minutes for the same information.

Reruns re-execute against the same commit, which is what makes them useful for diagnosing flakiness and useless for testing a fix. A fix needs a new push and therefore a new run.

Terminal window
gh run cancel RUN_ID
# Cancel everything queued or running for a branch
gh run list --branch my-branch --status in_progress --json databaseId --jq '.[].databaseId' \
| while read -r id; do gh run cancel "$id"; done

Cancelling matters more than it sounds on a busy repository: queued runs consume concurrency, and a branch that has been force-pushed five times may have four obsolete runs holding slots that current work needs.

Deleting runs removes them and their logs permanently:

Terminal window
gh run delete RUN_ID

The legitimate reasons are narrow — a log containing a leaked credential, or clearing genuinely worthless noise. Deleting failed runs to make a dashboard look better destroys the record that would have shown a pattern, and it is worth resisting.

  1. Trigger a workflow that you know will fail — a step running exit 1 is enough.
  2. Run gh run view --log-failed and confirm you see only the failing step.
  3. Capture the run ID with --json databaseId --jq '.[0].databaseId'.
  4. Run gh run watch "$RUN_ID" --exit-status and check $? afterwards.
  5. Fix the step, push, and use gh run rerun --failed on the old run to see the difference.
  6. Run the failure-rate query against a repository whose CI you know.
  • gh run view --log-failed answers “why did CI fail?” in one command.
  • The run identifier in JSON output is databaseId.
  • --exit-status makes gh run watch usable as a gate in scripts.
  • --failed reruns only failed jobs, which matters on large matrices.
  • Artifacts expire, so old runs may have none.
  • Deleting a log containing a secret is not remediation; rotation is.
  • Per-run loops make one request each and will meet rate limits at scale.

gh run view --log-failed answers “why did CI fail?” in one command, and it is the single most useful thing in this lesson.

For scripting, three details matter: the run identifier in JSON output is databaseId, not id; --exit-status is what makes gh run watch usable as a gate; and “the latest run” is not necessarily the one you triggered, so capture it explicitly by comparing against what existed before, or by matching on headSha.

Rerunning is the right response to genuine flakiness and the wrong response to a real failure. A team that reruns until green has stopped using CI as a signal.

gh cache manages the caches Actions workflows create, and it is the tool for a problem with no other good answer: a poisoned cache.

Terminal window
gh cache list
gh cache list --limit 50 --sort size_in_bytes --order desc
gh cache delete CACHE_KEY
gh cache delete --all
ID KEY SIZE CREATED ACCESSED
482 node-modules-Linux-a3f8c21b 412.66 MB 2 days ago 1 hour ago
479 node-modules-Linux-7e2d94ff 409.12 MB 1 week ago 1 week ago

Two situations where this matters.

A cache containing bad state. A dependency cache saved from a broken install will be restored by every subsequent run, so the failure persists after the underlying problem is fixed. Deleting the key is the fix, and there is no other way to do it — caches are immutable and keyed, so you cannot overwrite one.

Approaching the repository cache limit. GitHub evicts least-recently-used entries when a repository exceeds its allowance, which can silently evict something you rely on. Listing by size shows what is consuming it.

Terminal window
gh cache list --json key,sizeInBytes --jq '[.[] | .sizeInBytes] | add / 1024 / 1024 / 1024'

That prints total cache usage in gigabytes — worth checking on any repository where builds have mysteriously got slower, since a cache miss looks like a slow build rather than an error.

Professional ToolkitThe gh api recipes and the PR triage and release-notes scripts are in the Professional Toolkit.