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 classWhat the job log showsWhat confirms it
Environment driftThe job passes locally and fails on the runner, often on a version-sensitive stepTool versions, locale, timezone, and paths printed on the runner differ from the local machine
Missing tooling in the imagecommand not found, No such file or directory on a binary, a package manager reaching the networkThe binary is absent from the runner image and no setup step installs it
Resource exhaustionExit code 137, Killed, no space left on device, a step that stops mid-outputMemory or filesystem utilization at the ceiling in the Observability Usage view at the failing timestamp
Flaky orderingThe same commit passes on re-run, and failures move between testsTwo runs of the same SHA disagree, or the failure follows test shard order
Secrets and permissionsResource not accessible by integration, HTTP 401 or 403, an empty variableThe 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:

MetricThresholdAlert label
Max sustained CPU80 percent or higherHigh CPU Usage
Max memory utilization80 percent or higherHigh Memory Usage
Max filesystem utilization80 percent or higherHigh Filesystem Usage
Max disk I/O80 percent or higher of supported throughputHigh 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: 15

Workflow 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 | sort and compare GITHUB_WORKSPACE, RUNNER_TEMP, PATH, and LANG against the values your local shell reports
  • which node python3 go docker followed by each --version to catch drift and missing tooling in one pass
  • df -h and free -m for 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 labelvCPURAMStorageUSD per minute
warp-ubuntu-latest-x64-2x28 GB150GB SSD$0.004
warp-ubuntu-latest-x64-4x416 GB150GB SSD$0.008
warp-ubuntu-latest-x64-8x832 GB150GB SSD$0.016
warp-ubuntu-latest-x64-16x1664 GB150GB SSD$0.032
warp-ubuntu-latest-x64-32x32128 GB150GB 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: 20

fail-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: 30

Always 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-4x at $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.
PathBilled runner minutesCost on warp-ubuntu-latest-x64-4x at $0.008 per minuteCost on the GitHub-hosted 4-core Linux larger runner at $0.012 per minute
Re-run and guess, 6 iterations54$0.432$0.648
One interactive debug session29$0.232$0.348
Difference25$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:

PathEngineer minutesHow it adds up
Re-run and guess, 6 iterations726 x (9 minutes of waiting + 3 minutes of edit, commit, and push)
One interactive debug session299 minutes to reach the pause + 20 minutes connected
Difference43Time 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.