Skip to content
TutorialDevOps

How to Debug GitHub Actions CI Failures Systematically

D

Dinesh Wijethunga

August 3, 2026 4 min readBeginner 31 views
ShareX / TwitterLinkedIn
How to Debug GitHub Actions CI Failures Systematically

CI failures have a reputation for being cryptic. After years of staring at GitHub Actions logs, I've found that 90% of all failures fall into one of five categories. If you know the categories, you can diagnose any failure in under 10 minutes.

Step 1: Read the Full Log

GitHub Actions collapses log sections by default. Always expand the failing step and scroll to the first error, not the last. PHP and Laravel produce cascading errors — one root cause produces 50 error lines. The one that matters is always at the top.

The Five Failure Classes

Class 1: Missing File or Directory

Symptom: No such file or directory, Please provide a valid cache path, Class not found

Cause: A file exists on your machine but was never committed to git. Common culprits: storage skeleton directories, empty test directories, generated config files.

Fix: Run git status and git ls-files --others --exclude-standard locally. Commit what's missing. For directories, add a .gitignore placeholder so git tracks the folder.

Class 2: Wrong Connection String

Symptom: SQLSTATE[HY000] [2002] Connection refused, cURL error 7: Failed to connect

Cause: CI is trying to connect to a service (database, Redis, Reverb, Pusher, Mailpit) that isn't running in CI.

Fix: Add the service to your workflow services: block, OR set the connection to a CI-appropriate driver:

echo "BROADCAST_CONNECTION=log" >> .env.testing
echo "QUEUE_CONNECTION=sync" >> .env.testing
echo "MAIL_MAILER=array" >> .env.testing
echo "SCOUT_DRIVER=null" >> .env.testing

Class 3: Missing Environment Variable

Symptom: Undefined array key, a 500 where a config value was expected, Target class [X] does not exist

Cause: Your .env.example is missing a key that the application reads. Works locally because your real .env has it; fails in CI because CI starts from .env.example.

Fix: Add the missing key to .env.example with a safe default value.

Class 4: SQL Dialect Mismatch

Symptom: Tests pass locally (SQLite), fail in CI (MySQL). FUNCTION X does not exist, Ambiguous column 'status'

Cause: Raw SQL written against SQLite syntax.

Fix: Replace with MySQL equivalents:

// Wrong (SQLite)
->selectRaw("first_name || ' ' || last_name as full_name")
->selectRaw("strftime('%Y-%m', created_at) as month")

// Correct (MySQL)
->selectRaw("CONCAT(first_name, ' ', last_name) as full_name")
->selectRaw("DATE_FORMAT(created_at, '%Y-%m') as month")

Class 5: Tooling Version Difference

Symptom: A step that worked last week suddenly fails. Different error messages than your local run.

Cause: A dependency was updated — PHPStan 1.x → 2.x dropped config options; ESLint 9 removed legacy commands; PHP 8.4 changed behaviour of some functions.

Fix: Pin major versions in composer.json/package.json for tools that release breaking changes. Check the changelog when a step breaks with a "deprecated option" or "unknown argument" error.

Reproducing CI Locally

The fastest way to debug CI is to reproduce it locally with the same env vars:

# Create a clean test database
mysql -uroot -p -e "DROP DATABASE IF EXISTS your_app_test; CREATE DATABASE your_app_test;"

# Export the same env vars CI uses
export DB_CONNECTION=mysql DB_HOST=127.0.0.1 DB_PORT=3306
export DB_DATABASE=your_app_test DB_USERNAME=root DB_PASSWORD=secret
export QUEUE_CONNECTION=sync BROADCAST_CONNECTION=log

# Run the exact commands CI runs
composer install --no-interaction --prefer-dist
php artisan migrate --env=testing --force
./vendor/bin/pest

If it fails here, iterate fast. If it passes here but fails in CI, the difference is in one of the five classes above.

Reading Exit Codes

Exit codeMeaning
0Success
1Tests ran, some failed
2Configuration error — Pest: test directory not found; PHPStan: config error
127Command not found — binary not installed or not in PATH
130Process killed — usually OOM, increase --memory-limit

Exit code 2 from Pest means it never reached your tests. Check that every directory listed in phpunit.xml exists in git and contains at least one test file.

The Diagnostic Checklist

When CI goes red:

  1. Expand the failing step log and find the first error
  2. Identify which of the five classes it belongs to
  3. Check if it's exit code 2 (config) vs 1 (test failure)
  4. Reproduce locally with the same env vars
  5. Fix, push, watch CI — don't guess and iterate blindly

With this system you go from red CI to root cause in minutes, not hours. That's the whole job — build the pipeline once, trust it forever.

This is the final post in the series. If you followed from Post 1 you now have: a GitHub Actions workflow running on every push, Pest tests against real MySQL, PHPStan and Pint quality gates, secure secrets management, zero-downtime VPS deployment, and the debugging playbook for when things go wrong. That's a production-grade CI/CD pipeline — the same foundation I put on every serious Laravel project.

Frequently Asked Questions

What does exit code 2 mean in a failing CI test step?
A configuration error, not a test failure — for Pest it usually means a configured test directory does not exist in the checkout. Exit code 1 means tests ran and some failed; 127 means a binary was not found.
How do I reproduce a GitHub Actions failure locally?
Export the same env vars CI uses (DB connection, QUEUE_CONNECTION=sync, BROADCAST_CONNECTION=log), create a clean test database, and run the exact commands from the workflow file in order.
D
Dinesh Wijethunga

Senior Full Stack Developer · Building SaaS products & teaching Laravel/React · 10+ years experience · Founder of Orion360 · Based in Dubai, UAE.

Was this post helpful?

Reviews & Ratings

Sign in to leave a review.

Comments(0)

Guest comments are held for moderation.

You might also like