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

  1. 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
  1. 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
  1. 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 act to 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

  1. Add a make ci-local target wrapping the act invocation so reproducing CI is one command for everyone.
  2. Run act -n in a pre-push hook to catch workflow syntax breaks before they reach the remote.
  3. Pin the runner label to a fixed version (ubuntu-22.04, not ubuntu-latest) so the local and remote images stay aligned over time.

Apple Silicon (ARM64): add --container-architecture linux/amd64 so act pulls the amd64 runner image that GitHub actually uses; the arm64 variant masks architecture-specific failures. WSL2: point act at the Linux Docker socket and keep the repo on the ext4 filesystem; running it against /mnt/c causes spurious file-permission failures inside the runner container. macOS (Docker Desktop): large runner images need ample disk in the VM — prune with docker system prune if act fails pulling act-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.