Skip to content
Latchkey

How to Debug a Failing GitHub Actions Workflow

Work in order: make the logs verbose, dump the context, reproduce the environment, and only then get an interactive shell. Most failures are solved before the last step.

A workflow that fails only in CI is a workflow whose environment differs from yours in a way you have not identified yet. The productive approach is to narrow that difference systematically rather than pushing commits to see what happens.

These four steps escalate in cost. Do them in order; the first two resolve most failures in a single run.

Step 1: turn on debug logging

Terminal
# Repository -> Settings -> Secrets and variables -> Actions -> Variables
#   ACTIONS_STEP_DEBUG   = true    (step-level detail)
#   ACTIONS_RUNNER_DEBUG = true    (runner and job setup detail)

# then re-run the failed job
gh run rerun <run-id> --failed --debug

Step 2: dump the context

Expressions resolve a missing property to an empty string rather than failing, so a wrong reference looks like a logic bug. Print the contexts and the answer is usually immediate.

.github/workflows/ci.yml
- name: Dump contexts
  run: |
    echo '${{ toJSON(github) }}'
    echo '${{ toJSON(needs) }}'
    echo '${{ toJSON(steps) }}'
    echo '${{ toJSON(matrix) }}'

- name: Dump environment
  run: |
    env | sort
    nproc && free -h && df -h /
    echo "shell: $SHELL"; echo "cwd: $(pwd)"

Step 3: reproduce the runner locally

Terminal
# same base image the runner uses, same CI env var
docker run --rm -it -v "$(pwd):/w" -w /w \
  -e CI=true ubuntu:24.04 bash

# or run the workflow locally
act -j <job-id>

Step 4: get a shell on the runner

When the environment difference still is not visible, open an interactive session on the runner itself. This is last because it holds a runner for the duration and costs minutes.

.github/workflows/ci.yml
- name: Debug session
  if: failure()
  uses: mxschmitt/action-tmate@v3
  with:
    limit-access-to-actor: true
    timeout-minutes: 15

Common causes ranked by frequency

  • Case sensitivity: Linux runners are case sensitive, macOS is not. An import with the wrong case works locally and fails in CI.
  • A tool installed globally on your machine but not declared in the repository.
  • CI=true changing tool behaviour, most often promoting warnings to errors.
  • Exit code 137: the out-of-memory killer, not an application error.
  • A shallow, detached checkout breaking anything that needs history or a branch name.
  • A cache restored from a run with different inputs, producing a subtly wrong tree.

Verify it actually works

A workflow that runs is not a workflow that works. Confirm the behaviour on a real event rather than on a manual dispatch, because trigger conditions, permissions, and context values all differ between the two.

Terminal
# 1. validate the file before pushing
docker run --rm -v "$(pwd):/repo" --workdir /repo rhysd/actionlint:latest -color

# 2. trigger the real event, not workflow_dispatch
git commit --allow-empty -m "ci: verify trigger" && git push

# 3. watch it and read the conclusion, not just the colour
gh run watch
gh run view --log-failed

What usually goes wrong first

  • The workflow file must exist on the default branch before scheduled or dispatch triggers appear at all.
  • GITHUB_TOKEN permissions default to read-only in many organisations. Declare a permissions: block listing every scope the job needs.
  • Fork pull requests get a read-only token and no access to secrets, regardless of workflow configuration.
  • actions/checkout gives you depth 1 on a detached HEAD, so anything needing history or a branch name needs fetch-depth: 0.

Frequently asked questions

How do I enable debug logging in GitHub Actions?
Set the repository variables ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG to true, then re-run the job. The runner one is frequently forgotten and is what reveals failures during job setup, before your steps execute.
Can I SSH into a GitHub Actions runner?
Not natively, but an action such as action-tmate opens an interactive session. Always set limit-access-to-actor: true and a timeout, because otherwise the session is reachable by anyone who can read the log and will bill until the job times out.
Why does my workflow work locally but fail in CI?
The usual causes are case sensitivity on Linux, a globally installed tool that is not declared in the repository, CI=true changing tool behaviour, memory limits, and the shallow detached checkout that actions/checkout produces by default.
What does an empty value in a GitHub Actions expression mean?
That the context is not available at that key. GitHub resolves a missing property to an empty string rather than failing, so the workflow runs with a silently wrong value. Dump the context with toJSON to confirm.

Related guides

References

Run this faster and cheaper on Latchkey managed runners - self-healing included. Start free → 30-day trial · No credit card