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.
The subcommands
Section titled “The subcommands”| Command | Does |
|---|---|
list | Recent runs |
view | One run, its jobs, or its logs |
watch | Follow a run until it finishes |
rerun | Run it again, optionally only failed jobs |
cancel | Stop an in-progress run |
download | Fetch artifacts |
delete | Delete a run and its logs |
The command that matters most
Section titled “The command that matters most”gh run view --log-failedWhat 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:
gh run view # interactive pickergh run view RUN_ID # summary of jobs and their statusgh run view RUN_ID --log # complete log, all jobsgh run view RUN_ID --job JOB_ID --loggh run view RUN_ID --web # open in a browser when you do want the interfaceFinding runs
Section titled “Finding runs”gh run list --limit 20gh run list --workflow build.ymlgh run list --branch main --status failuregh run list --user "@me" --limit 10gh run list --event pull_request --status failure --limit 20Filters compose, which makes narrow questions easy to ask:
# Failures on main in the last 20 runs, as datagh run list --branch main --status failure --limit 20 \ --json databaseId,displayTitle,createdAt,conclusionWatching a run
Section titled “Watching a run”gh run watchgh run watch RUN_IDgh 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.
gh workflow run deploy.yml -f environment=stagingsleep 5RUN_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 1fiThat 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.
Rerunning and cancelling
Section titled “Rerunning and cancelling”gh run rerun RUN_IDgh run rerun RUN_ID --failedgh run rerun RUN_ID --job JOB_IDgh 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.
Artifacts
Section titled “Artifacts”gh run download RUN_IDgh run download RUN_ID --name build-outputgh run download RUN_ID --pattern '*-linux-*' --dir ./artifactsgh run download RUN_ID --name coverage --dir /tmp/coverageDownloading 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.
Deleting runs
Section titled “Deleting runs”gh run delete RUN_IDDeleting 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.
Building CI reporting
Section titled “Building CI reporting”Because everything supports --json, useful reporting is a couple of lines.
Failure rate on the default branch over the last fifty runs:
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:
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 -rnThat 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.
Reading a run properly
Section titled “Reading a run properly”gh run view on its own gives a summary; the JSON gives you the structure to reason about.
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.
Finding the slow parts
Section titled “Finding the slow parts”CI duration is a cost nobody notices accumulating. Job-level timings across recent runs make it visible:
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 -rnOutput:
12.4 integration-tests 4.1 unit-tests 1.2 lint 0.8 buildThat 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.
Artifacts and logs in automation
Section titled “Artifacts and logs in automation”Downloading artifacts is how you get test reports, coverage output and build products out of CI and into something else.
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 ./coveragegh run download "$RUN_ID" --pattern 'dist-*' --dir ./artifactsTwo 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:
gh run view "$RUN_ID" --log > full.loggh run view "$RUN_ID" --job "$JOB_ID" --log | grep -A5 'BENCHMARK'A CI health report
Section titled “A CI health report”Putting the pieces together, a weekly report that is genuinely worth reading:
#!/usr/bin/env bashset -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)"'
echoecho "== 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 -5The 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.
Common mistakes
Section titled “Common mistakes”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.
Waiting correctly in a script
Section titled “Waiting correctly in a script”The naive wait — trigger, sleep, check — is wrong in two ways, and both bite in production.
# Wrong: may find the previous run, and gives up arbitrarilygh workflow run deploy.ymlsleep 10gh run list --workflow deploy.yml --limit 1The 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 bashset -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 2done
[ -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 1fiCapturing 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:
SHA=$(git rev-parse HEAD)gh run list --workflow ci.yml --json databaseId,headSha \ --jq --arg sha "$SHA" '.[] | select(.headSha == $sha) | .databaseId' | head -1Reruns, and what they cost
Section titled “Reruns, and what they cost”gh run rerun re-executes a run. Three forms, with different costs:
gh run rerun RUN_ID # every jobgh run rerun RUN_ID --failed # only the failed jobs and their dependantsgh 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.
Cancelling and cleaning up
Section titled “Cancelling and cleaning up”gh run cancel RUN_ID
# Cancel everything queued or running for a branchgh run list --branch my-branch --status in_progress --json databaseId --jq '.[].databaseId' \| while read -r id; do gh run cancel "$id"; doneCancelling 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:
gh run delete RUN_IDThe 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.
Exercise
Section titled “Exercise”- Trigger a workflow that you know will fail — a step running
exit 1is enough. - Run
gh run view --log-failedand confirm you see only the failing step. - Capture the run ID with
--json databaseId --jq '.[0].databaseId'. - Run
gh run watch "$RUN_ID" --exit-statusand check$?afterwards. - Fix the step, push, and use
gh run rerun --failedon the old run to see the difference. - Run the failure-rate query against a repository whose CI you know.
What you learned
Section titled “What you learned”gh run view --log-failedanswers “why did CI fail?” in one command.- The run identifier in JSON output is
databaseId. --exit-statusmakesgh run watchusable as a gate in scripts.--failedreruns 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.
The short version
Section titled “The short version”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.
Actions caches
Section titled “Actions caches”gh cache manages the caches Actions workflows create, and it is the tool for a problem with no other
good answer: a poisoned cache.
gh cache listgh cache list --limit 50 --sort size_in_bytes --order descgh cache delete CACHE_KEYgh cache delete --allID KEY SIZE CREATED ACCESSED482 node-modules-Linux-a3f8c21b 412.66 MB 2 days ago 1 hour ago479 node-modules-Linux-7e2d94ff 409.12 MB 1 week ago 1 week agoTwo 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.
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.