How Do I Reproduce a CI Failure Locally?

Start on the runner: open a debug session on the machine that failed, then move locally once you know whether toolchain, work directory, or memory differs.

Answer

Start on the runner instead: open a debug session on the machine where the job failed, read the environment there, and move to a local reproduction only once you know which piece of the environment differs. A local run that passes tells you your laptop and the runner disagree somewhere, and it leaves the location of the disagreement open, which is the part that costs the afternoon.

Three differences cover most failures that survive a local run, and each one is a single command inside a session on the failing machine: the toolchain version the runner image resolves, the work directory the job checks out into, and the memory available to the process. Once you have the differing value, the local reproduction becomes a specific experiment rather than a search.

Question in front of youWhere it gets answeredWhat it costs
What does the failing machine look like at the failing stepA debug session opened by the Action Debugger stepRunner minutes for the length of the session
Did the machine exhaust CPU, memory, or disk while that step ranThe runner telemetry for that job, covered in GitHub Actions observabilityNothing extra; charts are already recorded
Does the same command fail under my local toolchainA local shell, after the checklist belowYour own machine time

The Action Debugger is part of the WarpBuild product surface alongside snapshot runners, remote Docker builders, CI observability, and an MCP server. It is an ordinary open-source GitHub Action, so the step behaves the same on a GitHub-hosted label and on a warp- label. A Windows or macOS failure gets reproduced on the platform that produced it.

Detail

Pause the job at the step that failed

Three inputs turn the debugger 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: true

if: ${{ 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 there, an SSH session opens on the runner machine, and the URL is printed in the action logs and posted as a check on the GitHub run, per the Action Debugger documentation. The workspace, the environment variables, and the half-written build outputs are still on disk at that point, which is the state a local reproduction is trying to recreate from scratch.

limit-access-to-actor: true restricts the session to the GitHub user who triggered the run. Left off, the default depends on that account: a triggering user with SSH keys on their GitHub account is the only one who can connect, and an account with no keys leaves the session reachable by anyone who has or guesses the URL. Run logs on a public repository are public, and the URL is in them.

The checks: write permission is what lets the URL appear as a check on the run. When the URL shows in the logs and no check appears, workflow permissions are the cause, and many organizations set that at the organization level.

Reconnect to the same address with a named session

Session URLs are long random strings, generated fresh on every run so nobody can guess one. When several people join the same reproduction, or when you script the connection, a deterministic address is easier to work with. Named sessions produce the form <username>/<named-session-name>@gha.warp.build on every run.

      - name: Setup interactive ssh session
        if: ${{ failure() }}
        uses: Warpbuilds/[email protected]
        timeout-minutes: 15
        with:
          limit-access-to-actor: true
          named-session-name: payments-api-repro
          named-session-api-key: ${{ secrets.WARPBUILD_API_KEY }}

Named sessions need an API key issued by WarpBuild support, and limit-access-to-actor has to be true when they are in use, since a predictable address removes the guessing barrier. Both inputs are documented in the Action Debugger reference.

The three checks that explain the difference

Run these in the session before you open a local shell. Each row is a value you can then pin locally.

CheckCommand in the sessionWhat a mismatch means
Toolchain versionnode --version, python3 --version, go version, xcodebuild -versionThe image resolved a different version than your machine did. WarpBuild runner images carry the same tooling as GitHub-hosted runners (cloud runners documentation), and per-image contents are published in actions/runner-images and in the GitHub-hosted runner reference, checked on 2026-08-13
Work directorypwd, echo "$GITHUB_WORKSPACE", ls -aAn absolute path compiled into a build, a cache key, or a coverage report breaks when the root moves. Ubuntu 24.04 ARM64 runners set the work dir to /runner/_work, against /home/runner/work/ on GitHub-hosted runners
Available memoryfree -m on Linux, vm_stat on macOS, plus nprocA laptop with 64 GB hides a leak that a 16 GB runner surfaces. Exit code 137 with no traceback is the signature

Two more commands are worth the seconds they take: df -h for the filesystem behind no space left on device, and printenv | sort to diff the environment against your shell, since a secret or a PATH entry that exists in one place and not the other produces failures that read as logic bugs.

Memory is the check that a local run is worst at, because the shapes differ. Rates and shapes below come from the pricing page, checked on 2026-08-13.

LabelvCPURAMPer minute15 minute session
warp-ubuntu-latest-x64-2x28 GB$0.004$0.06
warp-ubuntu-latest-x64-4x416 GB$0.008$0.12
warp-ubuntu-latest-x64-8x832 GB$0.016$0.24
warp-windows-latest-x64-4x416 GB$0.016$0.24
warp-macos-latest-arm64-6x622 GB$0.08$1.20

A test suite that peaks at 20 GB passes on a 32 GB laptop, passes on warp-ubuntu-latest-x64-8x, and dies on warp-ubuntu-latest-x64-4x. Reading free -m in the session settles that in one command, and the fix is a label change rather than a code change.

Bound the session so it releases the machine

Runner minutes bill for the whole time a session sits open, and GitHub kills workflows after 6 hours by default, so an abandoned session bills 360 minutes: $2.88 on warp-ubuntu-latest-x64-4x at $0.008 per minute and $28.80 on warp-macos-latest-arm64-6x at $0.08 per minute. timeout-minutes: 15 on the step caps the same pause at $0.12 and $1.20. The key sits beside uses rather than under with.

Detached mode changes when the pause happens. With detached: true, every step of the job runs as normal and the action pauses at the end of the job, which suits a workflow whose later steps upload artifacts you also want to inspect. Pair it with timeout-minutes as well, since a detached session holds the machine the same way an inline one does.

Move to the local reproduction with a specific target

With the differing value in hand, the local run has a job: pin the toolchain version the image resolved, check out into a path with the same shape, or cap memory for the process to the runner's RAM. That reproduction is fast because it tests one variable.

Some failures have no local equivalent, and the session is the end of the path rather than a step toward one. Linux x64 nested virtualization is opt-in through a label, and an Android emulator job that reaches /dev/kvm needs a permissions step on any GitHub-compatible runner, which the common issues documentation covers with the exact udev rule. macOS runners cannot run Docker, so a container step there fails on the platform regardless of what your laptop does. And a job that never starts is a routing problem rather than a build failure: the same page covers bot repository access, runner group access, and the workflow path restrictions that keep a group from picking up a job.

For the ordered procedure across all five failure classes, read the guide to debugging GitHub Actions failures. For the fleet view of where jobs fail and how instances are utilized, see GitHub Actions observability.

Why does the job pass on my laptop and fail on the GitHub Actions runner?

Three differences explain most of these cases: the toolchain version resolved by the runner image, the work directory the job checks out into, and the memory the machine has. A debug session answers all three in about a minute, and the answer tells you what to pin before you run the same command locally. The step and its inputs are in the Action Debugger documentation.

How do I tell whether the runner ran out of memory rather than the code failing?

Read the runner telemetry for that job. A step killed by the kernel out-of-memory killer prints nothing and ends with exit code 137, so the log looks empty while the memory chart for the instance sits at its ceiling. Metrics and logs are collected for jobs longer than about 1 minute, so a 20 second failure has no charts. Both readings are covered in GitHub Actions observability and in the answer on debugging a failed GitHub Actions job.

How do I keep a debug session from billing for six hours?

Set timeout-minutes on the debugger step and gate the step behind if: ${{ failure() }}. GitHub kills workflows after 6 hours by default, and the runner bills for the whole time the session is open, so a forgotten session on warp-ubuntu-latest-x64-4x at $0.008 per minute bills $2.88 against $0.12 for a 15 minute cap. Rates from the pricing page, checked on 2026-08-13.

Can I open the session on a Windows or macOS runner too?

Yes. The debugger step runs wherever the job runs. A Windows path failure or an Xcode failure gets reproduced on the platform that produced it. Session mechanics, access control, and named sessions are covered in the answer on SSH access to a GitHub Actions runner.

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.