How to Debug Failed GitHub Actions Jobs
Debug a failed GitHub Actions job by classifying the failure, reading per-instance runner metrics, then opening an SSH session on the runner itself.
Last verified:
A failed GitHub Actions job is debugged fastest by classifying the failure before changing any code, then reproducing it on the runner itself rather than through a loop of re-runs. WarpBuild supplies two tools for that: the Action Debugger, which pauses a workflow and opens an SSH session on the running runner after you add one line to the job, and CI observability, which shows per-instance CPU, memory, filesystem, disk I/O, and network metrics beside the GitHub Actions logs for the same job.
This guide covers the five failure classes worth separating, how to read the Observability Usage view to tell a resource failure from a logic failure, the exact workflow change that opens a debug session, the GitHub setting that has to be on first, and a worked time and cost model for the interactive session against the re-run-and-guess loop.
Diagnosis
Answer one question before reading a single stack trace: did the machine run out of something, or did the code and configuration do the wrong thing? Those two answers lead to different fixes, and the job log alone often cannot separate them. A step killed by the kernel out-of-memory killer prints no Python traceback and no Go panic. It prints nothing, and the job log shows a step that ended with exit code 137.
Five failure classes cover the large majority of red GitHub Actions runs. Sort the failure into one of them first.
| Failure class | What the job log shows | What confirms it |
|---|---|---|
| Environment drift | The job passes locally and fails on the runner, often on a version-sensitive step | Tool versions, locale, timezone, and paths printed on the runner differ from the local machine |
| Missing tooling in the image | command not found, No such file or directory on a binary, a package manager reaching the network | The binary is absent from the runner image and no setup step installs it |
| Resource exhaustion | Exit code 137, Killed, no space left on device, a step that stops mid-output | Memory or filesystem utilization at the ceiling in the Observability Usage view at the failing timestamp |
| Flaky ordering | The same commit passes on re-run, and failures move between tests | Two runs of the same SHA disagree, or the failure follows test shard order |
| Secrets and permissions | Resource not accessible by integration, HTTP 401 or 403, an empty variable | The token scope, the permissions block, or the fork status of the pull request explains the denial |
Environment drift between the local machine and the runner
Drift is the class that produces the most wasted re-runs, because the failing command works on the laptop of the person debugging it. The usual sources are compiler and runtime versions, a lockfile that resolves differently under a different package manager version, a case-sensitive filesystem on the runner against a case-insensitive one locally, and a different default working directory.
Working directory is worth checking explicitly. WarpBuild Ubuntu 24.04 ARM64 runners set the work directory to /runner/_work, while GitHub-hosted runners use /home/runner/work/. Any script with a hardcoded path breaks on that difference alone, and the error it produces looks like a missing file rather than a path assumption.
Missing tooling in the image
WarpBuild runner images carry the same tooling as GitHub-hosted runner images, so a binary that is missing on a WarpBuild runner is usually missing on GitHub-hosted runners too. That narrows the fix: the workflow needs a setup step or an install step, and the difference between the two matters for cache behavior on later runs.
Two platform limits produce failures that read as missing tooling. macOS runners do not support nested virtualization and cannot run Docker, so a Docker step in a macOS job fails at the daemon connection rather than at the image pull. Nested virtualization on Linux x64 runners is opt-in through the nested-virtualization.enabled=true label, so an Android emulator job that needs /dev/kvm fails until the label is added.
Resource exhaustion
Resource exhaustion is the class most often misdiagnosed as flakiness, because it depends on machine size rather than on code. A test suite that runs four Jest workers on a 2 vCPU, 8 GB runner and eight on a 16 vCPU, 64 GB runner fails on the small machine only. The signal is exit code 137, an ENOSPC write error, a Docker build that stops during a layer export, or a step that ends with truncated output.
Flaky ordering
Order-dependent tests fail when a shared fixture, a database row, a temp file, or a global mock survives one test and changes another. Confirm the class by running the same commit twice. If one run passes and the other fails, stop looking at the runner and start looking at test isolation and at any randomized seed the framework prints at the top of the run.
Secrets and permissions
Workflows triggered by pull_request from a fork receive no repository secrets, which makes every secret-consuming step fail with an empty value rather than an error. The GITHUB_TOKEN carries the permissions declared in the workflow, so a step that writes a check, a comment, or a package fails with Resource not accessible by integration until the permissions block grants the scope. Both failures look like broken code and are configuration.
Read the runner metrics before guessing
The WarpBuild observability documentation describes two views. Recommendations aggregates metrics by repository, workflow, job, and instance type, and flags instances that sit outside resource thresholds. Usage shows metrics and logs for one runner instance, which is the view to open when a specific job failed.
The Usage view carries three things for the failing instance: system logs from the runner, the GitHub Actions logs for the job, and the utilization charts. The logs sit together on purpose, so the timestamp of the failing step lines up against the metric charts without exporting anything.
Five metrics are collected per instance:
- CPU utilization, as the maximum rolling average over the last 30 seconds
- Memory utilization, as the maximum
- Filesystem utilization, as the maximum
- Disk I/O, as the maximum rolling average of read plus write throughput over the last 30 seconds
- Network utilization, as the maximum rolling average of read plus write throughput over the last 30 seconds
WarpBuild labels an instance under-provisioned at these thresholds:
| Metric | Threshold | Alert label |
|---|---|---|
| Max sustained CPU | 80 percent or higher | High CPU Usage |
| Max memory utilization | 80 percent or higher | High Memory Usage |
| Max filesystem utilization | 80 percent or higher | High Filesystem Usage |
| Max disk I/O | 80 percent or higher of supported throughput | High Disk IO |
The reading rule is short. If memory or filesystem sits at the ceiling at the timestamp of the failing step, the job died of resource exhaustion and a bigger label fixes it. If all five metrics stay low and flat through the failure, the machine was fine and the failure is a logic, configuration, or permissions problem, which is the point where an interactive session pays for itself.
Two collection details change what you see. Observability collects metrics and logs only for jobs longer than about 1 minute, so a job that fails in 20 seconds has no charts to read. Collection can also be paused for an organization, in which case the Usage view shows the agent initializing and no data. Telemetry is gathered with OpenTelemetry over port 33931.
Fix
Once the class is known, the fix for a logic, drift, or permissions failure is the same move: stop the job at the moment of failure and get a shell on the machine that produced it.
Add one line to open an SSH session
The Action Debugger is a free, open-source GitHub Action that lets you SSH into a running GitHub Action. The workflow addition is a single line:
- uses: Warpbuilds/[email protected]In a real job, pin it behind a failure condition so normal green runs never pause:
name: ci
on: [push]
jobs:
build:
runs-on: warp-ubuntu-latest-x64-4x
permissions:
contents: read
checks: write
steps:
- uses: actions/checkout@v4
- run: make build
- run: make test
- name: Setup interactive ssh session
if: ${{ failure() }}
uses: Warpbuilds/[email protected]
with:
limit-access-to-actor: true
timeout-minutes: 15Workflow execution pauses as soon as the Action Debugger step is invoked. An SSH session starts on the runner machine, and the SSH URL is written to the action logs and posted as a check on the corresponding GitHub run. The action holds the workflow on that step until a user connects and exits the session.
The GitHub setting to enable first
Posting the SSH URL as a check needs write permission on the checks scope. In the repository settings, under Actions and then General, set Workflow permissions to read and write permissions. On many organizations this has to be set at the organization level rather than the repository level, because the organization setting overrides the repository one. When the URL appears in the job logs but no check shows up on the run, this setting is the cause.
Access control matters on the same step. By default, if the GitHub user who triggered the run has SSH keys on their account, only that user can connect. If they have no keys on the account, anyone who has or guesses the generated SSH URL can connect to the session. Setting limit-access-to-actor: true forces the restriction, and it is required when you use named sessions.
What to run once you are on the runner
The session drops you into tmux. A blank screen on connect is usually tmux waiting, and ctrl+c drops you into a shell, at the cost of the multiplexing.
Work through the classes in order:
printenv | sortand compareGITHUB_WORKSPACE,RUNNER_TEMP,PATH, andLANGagainst the values your local shell reportswhich node python3 go dockerfollowed by each--versionto catch drift and missing tooling in one passdf -handfree -mfor filesystem and memory headroom at the exact moment of failure- re-run the failing command by hand, then bisect it by commenting out flags rather than by pushing commits
ls -la "$GITHUB_WORKSPACE"to confirm the checkout produced what the next step expects
The value of the session is that every one of those checks costs seconds instead of a full queue-and-run cycle.
Fix by class
Resource exhaustion is fixed by changing the runs-on label to a larger size, or by lowering worker counts to match the machine. Drift is fixed by pinning the tool version in the workflow with a setup action so the runner and the laptop agree. Missing tooling is fixed with an explicit install step. Flaky ordering is fixed in the test suite, and the interactive session mainly helps you reproduce it with the same seed. Permissions are fixed in the permissions block or in the organization Actions settings.
Configuration
Sizing is the most common fix after a resource failure, so keep the catalog in front of you. Linux x64 sizes, rates, and shapes:
| Runner label | vCPU | RAM | Storage | USD per minute |
|---|---|---|---|---|
warp-ubuntu-latest-x64-2x | 2 | 8 GB | 150GB SSD | $0.004 |
warp-ubuntu-latest-x64-4x | 4 | 16 GB | 150GB SSD | $0.008 |
warp-ubuntu-latest-x64-8x | 8 | 32 GB | 150GB SSD | $0.016 |
warp-ubuntu-latest-x64-16x | 16 | 64 GB | 150GB SSD | $0.032 |
warp-ubuntu-latest-x64-32x | 32 | 128 GB | 150GB SSD | $0.064 |
Rates come from the WarpBuild pricing page and are billed per minute. Runner storage is ephemeral and is deleted when the runner terminates, so anything you want to keep from a debug session has to be uploaded as an artifact before you exit.
Reproduce on the platform that failed
A platform-specific failure can be reproduced on the same platform instead of being guessed at from a Linux shell. A throwaway matrix job narrows a "works on my machine" report to one platform in a single run:
name: reproduce
on:
workflow_dispatch:
jobs:
reproduce:
strategy:
fail-fast: false
matrix:
runner:
- warp-ubuntu-latest-x64-4x
- warp-ubuntu-latest-arm64-4x
- warp-macos-15-arm64-6x
- warp-windows-latest-x64-4x
runs-on: ${{ matrix.runner }}
permissions:
contents: read
checks: write
steps:
- uses: actions/checkout@v4
- run: node --version
- run: make test
- name: Setup interactive ssh session
if: ${{ failure() }}
uses: Warpbuilds/[email protected]
with:
limit-access-to-actor: true
timeout-minutes: 20fail-fast: false matters here. The default cancels sibling jobs on the first failure, which is exactly the evidence you are trying to collect.
Debug the tail of a job without blocking it
Detached mode runs every step normally and pauses at the end of the job instead of at the debugger step. Use it to inspect the final state of the workspace, the caches, and the build output after a job that failed late:
- name: Setup interactive ssh session
uses: Warpbuilds/[email protected]
with:
detached: true
limit-access-to-actor: true
timeout-minutes: 30Always set timeout-minutes. Runner minutes are billed while the session is open, and GitHub kills workflows after 6 hours by default, so an abandoned session is a 6 hour bill.
Deterministic SSH URLs are available through named sessions, which produce a URL in the form <username>/<named-session-name>@gha.warp.build instead of a fresh random string per run. Named sessions require an API key from WarpBuild support and require limit-access-to-actor: true.
Automation and access
Teams that prefer to ask for it in natural language can point an MCP host at the WarpBuild MCP server.
If your runners live in your own cloud account, BYOC runs on AWS, GCP, and Azure, and Terraform support exists for BYOC on AWS. That matters during a drift investigation, because the instance shape that ran the failing job is described in code you can diff against the shape you expected.
Access to the Observability views follows your WarpBuild organization membership, and SSO is available for a flat $250 per month, whatever the user count. See the pricing page for the current rates.
Cost or Time Model
The re-run-and-guess loop is expensive in wall clock time and cheap in dollars. Here is the arithmetic on a single realistic failure.
Assumptions, stated so you can substitute your own:
- The job runs on
warp-ubuntu-latest-x64-4xat $0.008 per minute. - The failing step is reached 9 minutes into the job, so every re-run pays the full 9 minutes.
- The loop takes 6 iterations before the cause is found.
- Each iteration adds 3 minutes of human time to edit, commit, push, and wait for the job to be picked up.
- The interactive path pays one 9 minute run to reach the pause, then 20 minutes of connected session, all billed as runner minutes.
- Billing is per minute on both platforms in the table.
| Path | Billed runner minutes | Cost on warp-ubuntu-latest-x64-4x at $0.008 per minute | Cost on the GitHub-hosted 4-core Linux larger runner at $0.012 per minute |
|---|---|---|---|
| Re-run and guess, 6 iterations | 54 | $0.432 | $0.648 |
| One interactive debug session | 29 | $0.232 | $0.348 |
| Difference | 25 | $0.200 | $0.300 |
The GitHub-hosted figure is the published list price for the 4-core Linux larger runner, SKU linux_4_core, at $0.012 per minute, taken from the GitHub Actions minute multipliers billing reference and the GitHub pricing page, checked on 2026-08-13. Stated as a rate comparison: warp-ubuntu-latest-x64-4x (4 vCPU, 16 GB) costs $0.008 per minute against $0.012 per minute for the 4-core Linux larger runner (4 vCPU, 16 GB): 33 percent lower list price. GitHub list price checked on 2026-08-13.
Now the number that actually matters, engineer wall clock time on the same failure:
| Path | Engineer minutes | How it adds up |
|---|---|---|
| Re-run and guess, 6 iterations | 72 | 6 x (9 minutes of waiting + 3 minutes of edit, commit, and push) |
| One interactive debug session | 29 | 9 minutes to reach the pause + 20 minutes connected |
| Difference | 43 | Time returned to the engineer per failure |
Scale that with your own numbers. A team of 20 engineers hitting two such failures each per week recovers 43 x 2 x 20 = 1,720 engineer minutes per week, or about 28 hours, while the runner bill moves by 2 x 20 x $0.20 = $8.00 per week in the other direction. The compute is the small term in this model.
Two caveats keep the model honest. Jobs that fail in under a minute produce no observability charts, so the metrics half of this workflow does not apply to them. And a debug session that stays open is billed the whole time, which is why timeout-minutes belongs on every debugger step.
Full rates are on the pricing page.
For the fleet-wide view of where jobs fail and how instances are utilized across every repository, see GitHub Actions observability on WarpBuild. For the per-metric reference behind the charts described above, see how to read GitHub Actions runner metrics. If the job succeeds and the complaint is duration rather than failure, start at why GitHub Actions workflows are slow.
FAQ
How do I SSH into a running GitHub Actions job?
Add the Action Debugger step to the job. The workflow pauses at that step, prints an SSH URL in the job logs and as a check on the run, and stays paused until someone connects and exits the session. The check requires read and write workflow permissions on the repository or organization.
How do I tell a resource failure from a logic failure?
Open the Usage view on the WarpBuild Observability page for the instance that ran the job and line up the timestamp of the failing step against CPU, memory, filesystem, disk I/O, and network. WarpBuild flags an instance as under-provisioned when max sustained CPU, memory, or filesystem utilization reaches 80 percent. Flat, low metrics point at logic instead.
Why does my job fail with exit code 137 and no error message?
Exit code 137 means the process was killed with SIGKILL, which on a runner almost always means the kernel out-of-memory killer took it. Check memory utilization in the Usage view at the timestamp of the failing step, then move the job to a label with more RAM, such as warp-ubuntu-latest-x64-8x with 32 GB.
Does an interactive debug session cost runner minutes?
Yes. Runner minutes are billed for as long as the session stays open, and GitHub kills workflows after 6 hours by default, so set timeout-minutes on the step. At the warp-ubuntu-latest-x64-4x rate of $0.008 per minute, a 20 minute session costs $0.16.
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.