Reproducing CI-Only Test Failures Locally With act
Tests pass locally but fail only in GitHub Actions. Use nektos/act to run the exact workflow on your machine, matching runner image, env, and secrets.
A test passes on your machine but fails every time in GitHub Actions, and the only feedback loop is pushing commits and waiting. This guide is part of CI/CD pipeline parity checks within the environment sync, secrets and CI parity baseline.
Diagnostic
Run the workflow locally with nektos/act so the failure reproduces without a push. First confirm the workflow even parses, then run the failing job.
#!/usr/bin/env bash
set -euo pipefail
# Dry run: list the jobs/steps act would execute
act -n -W .github/workflows/ci.yml
# Run the specific job that fails in CI
act push -j test -W .github/workflows/ci.yml
Expected BAD output (the failure now reproduces locally):
[CI/test] 🐳 docker run image=node:20-bullseye-slim ...
[CI/test] ❌ Failure - Main Run tests
[CI/test] exitcode '1': test failed: cannot find module 'sharp'
Error: Job 'test' failed
The module resolves on your host but not inside the runner image — proof the failure is environmental, not in your code.
Root cause
The GitHub-hosted runner is a specific Ubuntu image with a particular set of preinstalled tools, a clean environment, and only the secrets and env vars the workflow declares. Your shell carries extra PATH entries, globally installed binaries, cached native modules, and exported variables that the runner never sees. By default act uses a slim image that diverges even further, so matching the runner image, the environment, and the secrets file is what makes the reproduction faithful.
Resolution
- Map each GitHub label to the catthehacker image that mirrors the real runner. Commit this so the whole team reproduces identically.
# .actrc
-P ubuntu-latest=ghcr.io/catthehacker/ubuntu:act-latest
-P ubuntu-22.04=ghcr.io/catthehacker/ubuntu:act-22.04
- Provide secrets and env from files instead of your shell, so only declared values are present.
#!/usr/bin/env bash
set -euo pipefail
# secrets.env and vars.env hold only what the workflow references
act push -j test \
--secret-file secrets.env \
--var-file vars.env \
-W .github/workflows/ci.yml
- If the failure is a missing native dependency or system package, fix it in the workflow (or Dockerfile) — not on your host — then re-run
actto confirm before pushing.
# .github/workflows/ci.yml (excerpt)
- name: Install system deps
run: sudo apt-get update && sudo apt-get install -y libvips-dev
- name: Install and test
run: |
npm ci
npm test
Expected output
[CI/test] 🐳 docker run image=ghcr.io/catthehacker/ubuntu:act-22.04 ...
[CI/test] ✅ Success - Install system deps
[CI/test] ✅ Success - Install and test
[CI/test] 🏁 Job succeeded
The job now passes locally with the runner-matched image, so the next push will pass too.
Prevention
- Add a
make ci-localtarget wrapping theactinvocation so reproducing CI is one command for everyone. - Run
act -nin a pre-push hook to catch workflow syntax breaks before they reach the remote. - Pin the runner label to a fixed version (
ubuntu-22.04, notubuntu-latest) so the local and remote images stay aligned over time.
Apple Silicon (ARM64): add
--container-architecture linux/amd64soactpulls the amd64 runner image that GitHub actually uses; the arm64 variant masks architecture-specific failures. WSL2: pointactat the Linux Docker socket and keep the repo on the ext4 filesystem; running it against/mnt/ccauses spurious file-permission failures inside the runner container. macOS (Docker Desktop): large runner images need ample disk in the VM — prune withdocker system pruneifactfails pullingact-latest.
Rollback
act runs in throwaway containers and never mutates your repo state, so there is nothing to undo. To reclaim space from pulled runner images: docker image rm ghcr.io/catthehacker/ubuntu:act-22.04.