Debugging Failed GitHub Actions Workflows

A failed workflow run is not a dead end — it is a diagnostic trail. GitHub Actions records the exact command, exit code, and log output for every step, and debugging is mostly the discipline of reading that trail in the right order: find the first real error, understand why the runner produced it, and confirm the fix with a targeted re-run instead of guessing and pushing again. This lesson assumes you already know how a workflow is triggered and structured, and focuses specifically on diagnosing failures in build, test, and pre-deploy jobs.

Overview / How it works

Every workflow run is made of jobs, and every job is made of steps. Each step is a separate process; GitHub Actions checks its exit code, and a nonzero exit fails the step, which by default fails the job, which fails the run. The Checks tab groups this into a tree: run → job → step, and any step that emits an ::error or ::warning workflow command is promoted to an annotation shown above the log, so you rarely need to read every line — the annotations point at the lines that matter.

Logs are streamed live and stored for the run’s retention period (90 days by default on GitHub-hosted plans, configurable). You can re-run a failed workflow in three ways from the run page or the CLI: re-run all jobs, re-run only the failed jobs, or re-run with debug logging enabled. Debug logging is controlled by two repository or organization secrets, ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG, both set to the string true. Step debug logging prints every input, environment variable name, and internal action call for each step; runner debug logging adds diagnostics about the runner process itself (job setup, network calls to GitHub, self-hosted runner health). Turn these on only while investigating, then remove them — verbose logs are harder to scan and slightly increase the chance that a poorly written action logs something it shouldn’t.

The gh CLI is often faster than the web UI for repeated debugging: gh run list, gh run view, and gh run rerun let you inspect and reproduce a failure without leaving the terminal, and gh run view --log-failed prints only the logs from failed steps.

Syntax or workflow structure

A handful of workflow keys exist specifically to make failures easier to diagnose. if: failure() runs a step only when a previous step in the same job has failed, which is how you attach diagnostics — such as uploading a log file — without running them on a normal green build. if: always() runs regardless of outcome, useful for cleanup or for uploading logs on both success and failure so you can compare them. continue-on-error: true lets a step fail without failing the job, but the job’s overall conclusion still reports success, so use it deliberately, not as a way to silence something you have not actually fixed. timeout-minutes bounds how long a step or job can run, turning a silent hang into a clear timeout failure instead of a run that never finishes.

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4

      - name: Run test suite
        id: tests
        run: npm test

      - name: Upload diagnostics on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: diagnostics
          path: |
            npm-debug.log
            coverage/

      - name: Report step outcome
        if: always()
        run: echo "tests step outcome was ${{ steps.tests.outcome }}"

Note the step id: tests and the reference ${{ steps.tests.outcome }} in the last step — giving steps IDs lets later steps, and you while reading the log, see exactly what each earlier step concluded (success, failure, cancelled, or skipped), which is invaluable once a job has more than three or four steps.

Examples

The following three examples build on each other, moving from a single failing step to a multi-platform failure to a permissions error — the three failure categories you will meet most often.

Example 1: A straightforward test failure

name: CI

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test

Expected behavior: if a test assertion fails, the Run tests step exits nonzero, the step and job turn red, and the run summary shows an ::error annotation with the failing test name and file. Nothing after that step runs. Opening the step’s log shows the test runner’s own failure output — the actual assertion mismatch — a few lines above the final Process completed with exit code 1. line that GitHub Actions itself appends.

Example 2: A failure that only reproduces on one platform

name: Cross-platform tests

on: [push]

permissions:
  contents: read

jobs:
  test:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: "20"
      - name: Install dependencies
        run: npm ci
      - name: Run tests
        run: npm test
      - name: Upload failure logs
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: test-logs-${{ matrix.os }}
          path: |
            npm-debug.log
            test-results/
          retention-days: 5

Expected behavior: fail-fast: false means all three matrix jobs run to completion even if one fails, instead of GitHub Actions cancelling the others the moment the first job goes red. Suppose windows-latest fails while the other two pass — that is a strong hint the code is joining file paths with a forward slash instead of a cross-platform path helper. The if: failure() step attaches the log and test-results artifact only for the failing matrix leg, so you can download exactly the evidence you need from the run page instead of re-running the whole matrix again to catch it.

Example 3: A failure caused by insufficient permissions

name: Label PR

on:
  pull_request:
    types: [opened]

permissions:
  pull-requests: write

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Add triage label
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.addLabels({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
              labels: ['triage']
            })

Expected behavior: before the permissions: block was added, this job’s default token had read-only access, and the step failed with an API error such as HttpError: Resource not accessible by integration — a message that has nothing to do with the script logic and everything to do with the token’s scope. Adding permissions: pull-requests: write grants exactly the scope the API call needs, and no more. For pull requests opened from a fork, note that the default GITHUB_TOKEN is still read-only regardless of this block, by design — writing labels or comments on fork PRs safely requires a separate pull_request_target-triggered workflow that never checks out or executes the fork’s code with that elevated token.

Step by step

  1. Open the failed run and read the annotations at the top before scrolling through logs — they usually point straight at the first real error.
  2. Expand the failed step and look for the first error line, not the last one; later lines are often the process unwinding after the real cause.
  3. Check ${{ steps.<id>.outcome }} and conclusion for earlier steps if the failure seems to come from missing state rather than a command itself.
  4. Re-run just the failed jobs first to rule out a flaky, non-deterministic failure before changing anything.
  5. If the cause is not obvious, re-run with debug logging: set the ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG secrets to true, then re-run.
  6. Pull logs locally with the CLI for faster searching: gh run view <run-id> --log-failed.
  7. Reproduce the failing command in an equivalent local environment when possible — same runtime version, same OS family — rather than iterating by pushing commits.
  8. Add a temporary diagnostics step, gated with if: failure(), to upload logs, dependency lock files, or environment output as artifacts instead of guessing blind.
  9. Fix the root cause, remove any temporary debug secrets or steps, and confirm with a clean re-run rather than trusting the fix on faith.
gh run list --workflow=ci.yml --limit 5
gh run view 123456789 --log-failed
gh run rerun 123456789 --debug

Common Mistakes

Mistake 1: Debugging the last error instead of the first one. A single root cause often produces several downstream failures — a missing dependency install can make five later steps fail with unrelated-looking errors. Scrolling straight to the bottom of the log and fixing whatever is there wastes time on symptoms.

Run npm run build
sh: vite: command not found
Error: Process completed with exit code 127.

Run npm test
Error: Cannot find module 'vitest'
Error: Process completed with exit code 1.

Correction: both failures trace back to the same cause — npm ci either was skipped or failed silently earlier in the job, so no dependencies were installed. Fixing the install step resolves both symptoms; chasing the missing vitest module alone would not.

Mistake 2: Using continue-on-error to make a red job go green. This is the most common way teams accidentally ship broken code: a flaky or failing step gets marked as non-blocking under deadline pressure, the job reports success, and a later deploy job runs on top of it.

# Before: silently masks a real failure
- name: Run tests
  continue-on-error: true
  run: npm test

# After: the step fails loudly, and only a step that is
# genuinely optional (like a non-blocking linter) uses continue-on-error
- name: Run tests
  run: npm test

- name: Run optional style check
  id: style
  continue-on-error: true
  run: npm run lint:style

- name: Report optional check result
  if: steps.style.outcome == 'failure'
  run: echo "style check failed but did not block the build"

Correction: reserve continue-on-error for steps whose failure genuinely should not block the pipeline, check their outcome explicitly if you need to react to it, and never apply it to the tests or checks that decide whether code is safe to deploy.

Best Practices

  • Read annotations first, logs second — annotations are generated specifically to save you from scanning full output.
  • Give meaningful steps an id so you can reference outcome and outputs when a failure depends on earlier state.
  • Upload logs and test reports as artifacts on failure with if: failure(), and set a short retention-days so debugging evidence does not accumulate indefinitely.
  • Pin actions to a specific version, or to a full commit SHA for anything security-sensitive; an upstream action changing behavior under an unpinned tag looks exactly like a mysterious new bug and wastes debugging time on the wrong target.
  • Use fail-fast: false on matrix builds while you are investigating a platform-specific failure, so the other legs finish and give you comparison data.
  • Turn on ACTIONS_STEP_DEBUG and ACTIONS_RUNNER_DEBUG only for the run you are investigating, then remove them — leaving them on permanently makes every future log noisier.
  • Never print a secret to prove it is set. Check for its presence with a boolean, for example if: ${{ secrets.DEPLOY_HOST != '' }}, rather than echoing the value.
  • Grant only the permissions: a job actually needs; a job that only reads code should keep contents: read, and a job that must comment or label should add just that one scope rather than defaulting to broad write access.

Practice Exercises

  1. Take a workflow with a single test job and intentionally break a test. Open the failed run, identify the annotation, then re-run with ACTIONS_STEP_DEBUG enabled and compare how much extra detail appears in the log.
  2. Add a matrix build across two operating systems to an existing test job, set fail-fast: false, and add an if: failure() step that uploads logs as an artifact named after ${{ matrix.os }}. Confirm the artifact only appears for the platform that actually fails.
  3. Write a workflow step using actions/github-script that attempts to add a label to a pull request with only contents: read permission granted. Observe the permission error, then correct the permissions: block and confirm the step succeeds.

Summary

Debugging a failed GitHub Actions run is a search problem, not a guessing game: annotations and step outcomes point at the first real error, debug logging and the gh CLI give you more detail on demand, and diagnostic steps gated on if: failure() or if: always() let you capture evidence without slowing down a healthy build. The two failure modes worth watching for specifically are chasing symptoms instead of the root cause, and using continue-on-error to hide a failure rather than fix it — both save time in the moment and cost far more later. Combined with minimal permissions: scopes and pinned action versions, a disciplined debugging habit turns a red run from a blocker into a fast, routine fix.