How Do I Debug a Failed GitHub Actions Job?
Reproduce the failure on the runner itself with an SSH session gated on failure, then read the runner telemetry for that job beside the GitHub Actions logs.
Last verified:
Answer
Debug a failed GitHub Actions job by reproducing it on the runner that produced it, using an SSH session that opens only when the job fails, then reading the runner telemetry for that same job beside the GitHub Actions logs. Those two moves replace the re-run loop: the session gives you a shell at the moment of failure, and the telemetry says whether the machine ran out of CPU, memory, filesystem, disk I/O, or network before the step died.
The job log alone cannot separate those two answers. A log records what a process printed, and a process killed by the kernel out-of-memory killer prints nothing at all. The job page shows a step that ended with exit code 137 and no stack trace, which reads as a mystery until the memory chart for that instance is next to it.
| Instrument | Question it answers | Where it lives |
|---|---|---|
| Action Debugger SSH session | What does this machine look like right now, at the failing step | One step in the workflow, documented in the Action Debugger reference |
| CI observability | Did the machine run out of a resource while that step ran | Usage view for the instance, documented in the observability reference |
Both are part of the WarpBuild product surface, alongside snapshot runners, remote Docker builders, and an MCP server.
Work in this order on a single red run:
- Open the Usage view for the instance that ran the job and line the failing timestamp up against the five utilization charts.
- If a resource sits at its ceiling, the class is resource exhaustion and the fix is a bigger label or less work per job. Stop there.
- If every chart stays low and flat, the machine was fine, and the failure is logic, drift, or permissions.
- Add the debugger step behind a failure condition, push once, and connect when the run pauses.
That ordering matters because a shell answers questions about one machine at one moment and tells you nothing about the shape of the machine over the whole job. The long form of the procedure, with the five failure classes and the fix per class, is in the guide to debugging GitHub Actions failures.
Detail
The step that pauses only on failure
Three settings turn the debugger from a demo into something safe to leave in a workflow: the failure condition, the access restriction, and the timeout.
name: test
on:
push:
branches: [main]
pull_request:
jobs:
unit-tests:
runs-on: warp-ubuntu-latest-x64-4x
permissions:
contents: read
checks: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
- run: npm ci
- run: npm test
- name: Setup interactive ssh session
if: ${{ failure() }}
uses: Warpbuilds/[email protected]
timeout-minutes: 15
with:
limit-access-to-actor: trueif: ${{ failure() }} runs the step only when an earlier step in the job has failed, so green runs skip it and bill nothing extra. On a red run the workflow pauses at that step, an SSH session opens on the runner machine, and the URL is written to the action logs and posted as a check on the GitHub run. The job holds on that step until a user connects and exits the session.
limit-access-to-actor: true restricts the session to the GitHub user who triggered the run. Leave it off and the default depends on that account: if the triggering user has SSH keys on their GitHub account, only they can connect, and if that account has no keys, anyone who has or guesses the generated SSH URL can connect. Run logs on a public repository are public, and the URL is in them.
timeout-minutes: 15 bounds the pause. GitHub kills workflows after 6 hours by default, and runner minutes bill for the whole time a session sits open, so an abandoned session is a 6 hour bill on a machine nobody is typing into. The step key sits beside uses rather than under with.
The permissions block grants the checks scope the URL check needs. When the URL appears in the logs and no check shows up on the run, workflow permissions are the cause, and on many organizations that setting is enforced at the organization level. Detached mode, named sessions, and the full input list are covered in the answer on SSH access to a GitHub Actions runner.
The telemetry path
WarpBuild agents on the runner collect three things: utilization metrics, system logs from the machine, and the GitHub Actions logs for the job. The third is the point of the design, because it puts workflow execution and system behavior on the same timeline in one view instead of in two browser tabs.
Five metrics are recorded per instance. Read them against the timestamp of the failing step.
| Metric | Collected as | Failure it explains |
|---|---|---|
| CPU utilization | Maximum rolling average over the last 30 seconds | A step that crawls while the machine is pinned, then hits a job timeout |
| Memory utilization | Maximum | Exit code 137, Killed, a step that stops mid-output with no traceback |
| Filesystem utilization | Maximum | no space left on device during a build, an image pull, or an artifact upload |
| Disk I/O | Maximum rolling average of read plus write throughput over the last 30 seconds | A step that slows as caches, layers, and artifacts land on disk |
| Network utilization | Maximum rolling average of read plus write throughput over the last 30 seconds | A download or push step that stalls and then times out |
WarpBuild labels an instance under-provisioned when max sustained CPU, max memory utilization, or max filesystem utilization reaches 80 percent, and when max disk I/O reaches 80 percent of supported throughput. The Recommendations view aggregates those readings by repository, workflow, job, and instance type, which is where a failure that arrived with a specific merge becomes visible. The Usage view is the one to open for a named failing job.
Two collection rules decide whether there is anything to read. Observability collects metrics and logs only for jobs longer than about 1 minute, so a job that dies in 20 seconds has no charts. Collection can also be paused for an organization, in which case the Usage view shows the telemetry agent initializing with no data behind it. Telemetry is gathered with OpenTelemetry over port 33931.
The reading rule is short, and it maps directly to a catalog move.
| Reading at the failing step | Class | Move |
|---|---|---|
| Memory at the ceiling, exit code 137 | Resource | Same family, more RAM: warp-ubuntu-latest-x64-4x at 16 GB to warp-ubuntu-latest-x64-8x at 32 GB |
| Filesystem at the ceiling | Resource | Prune the workspace, or move image builds off the runner to a remote Docker builder |
| Disk I/O pinned through a long step | Resource | Size up one step and measure the same job again |
| CPU at 80 percent or higher on a job that finished late | Sizing | Size up one step; Linux x64 is priced at $0.002 per vCPU minute at every size |
| All five low and flat | Logic, drift, or permissions | Open the SSH session and reproduce by hand |
Reproduce on the platform that failed
A platform-specific failure gets reproduced on the same platform rather than guessed at from a Linux shell. Point a throwaway workflow_dispatch job at the label that failed, with the debugger step attached. Rates below come from the WarpBuild pricing page, checked on 2026-08-13.
| Platform | Label to reproduce on | vCPU | RAM | Per minute |
|---|---|---|---|---|
| Linux x64 | warp-ubuntu-latest-x64-4x | 4 | 16 GB | $0.008 |
| Linux ARM64 | warp-ubuntu-latest-arm64-4x | 4 | 16 GB | $0.006 |
| macOS | warp-macos-15-arm64-6x | 6 | 22 GB | $0.08 |
| Windows | warp-windows-latest-x64-4x | 4 | 16 GB | $0.016 |
Set fail-fast: false on any matrix you use for this. The default cancels sibling jobs on the first failure, which removes the evidence you are collecting. Two platform limits produce failures that read as missing tooling: macOS runners cannot run Docker, and Linux x64 nested virtualization is opt-in through a label, so an Android emulator job that needs /dev/kvm fails until that label is added.
Once connected, four checks cover most of what the log left out: printenv | sort against your local values, which plus --version on the tools the failing step uses, df -h and free -m for headroom at the moment of failure, and a hand run of the failing command with flags removed one at a time. Runner storage is ephemeral, so upload anything worth keeping as an artifact before you exit.
What one failure costs
Work the arithmetic on a concrete case, then substitute your own durations. A job on warp-ubuntu-latest-x64-4x at $0.008 per minute normally runs 20 minutes and now dies of memory pressure at minute 12.
- The failed run bills 12 x $0.008 = $0.096 and produces no build.
- One paused session with
timeout-minutes: 15bills at most 12 + 15 = 27 minutes, or $0.216, and returns the environment that caused it. - The memory fix moves the job to
warp-ubuntu-latest-x64-8xat $0.016 per minute. A 20 minute run costs $0.32 there against $0.16 on the 4 vCPU label. - At 500 runs per month that is 10,000 minutes: $160.00 on the 8 vCPU label against $80.00 on the 4 vCPU label, a difference of $80.00 per month.
The telemetry is what makes that $80.00 a decision rather than a guess. Reading the charts costs no runner minutes, and confirming memory at the ceiling before the resize is the difference between paying for headroom you need and paying for headroom you do not.
Full rates by runner type are on the pricing page.
For failures that move between runs rather than repeating, go to the guide to flaky GitHub Actions jobs. For the fleet-wide view of where jobs fail and how instances are utilized across every repository, see GitHub Actions observability.
Related Questions
How do I stop a failed GitHub Actions job so I can inspect the runner?
Add the Action Debugger step with the condition if: ${{ failure() }}. The job pauses at that step with the workspace, the environment, and the half-written build outputs still in place, prints an SSH URL in the logs and as a check on the run, and holds until someone connects and exits. The ordered procedure around that step is in the guide to debugging GitHub Actions failures.
Why are the runner metrics empty for the job I want to debug?
Observability collects metrics and logs only for jobs longer than about 1 minute, so a job that fails in 20 seconds has no charts. Collection can also be paused for an organization, in which case the Usage view shows the agent initializing and no data. Both cases leave the SSH session as the way in. The collection rules are in the observability documentation.
The job passes when I re-run the same commit. Is a debug session still useful?
Only for reproducing it. A shell shows one machine at one moment, and a test that fails on one run in twenty is a sampling problem. Run the same commit twice to confirm the pattern, then follow the guide to flaky GitHub Actions jobs, which covers isolation, ordering, and retry policy.
Is it safe to leave a debug session in a workflow on a public repository?
Set limit-access-to-actor to true and set timeout-minutes on the step. Without the first, an account with no SSH keys on it leaves the session reachable by anyone holding the URL from the run logs. Without the second, the runner bills until GitHub kills the workflow at 6 hours. The answer on SSH access to a GitHub Actions runner covers both inputs.
Start with $10 in free credits
Change the runner label in your workflow and keep the rest of your GitHub Actions setup. Runner time is billed per minute.