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
# 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 --debugStep 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.
- 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
# 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.
- name: Debug session
if: failure()
uses: mxschmitt/action-tmate@v3
with:
limit-access-to-actor: true
timeout-minutes: 15Common 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=truechanging 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.
# 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-failedWhat usually goes wrong first
- The workflow file must exist on the default branch before scheduled or dispatch triggers appear at all.
GITHUB_TOKENpermissions default to read-only in many organisations. Declare apermissions: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/checkoutgives you depth 1 on a detached HEAD, so anything needing history or a branch name needsfetch-depth: 0.
Frequently asked questions
How do I enable debug logging in GitHub Actions?
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?
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?
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?
toJSON to confirm.