Reproducing CI-Only Test Failures Locally With act
A test passes on your machine but fails every time in GitHub Actions, and the only feedback loop is pushing commits and waiting for the runner to report back. This guide is part of CI/CD pipeline parity checks within the environment sync, secrets and CI parity baseline. The fix is to stop guessing across a push cycle and instead execute the same workflow on your own machine with nektos/act, matching the runner image, the environment, and the secrets file until the failure reproduces under your fingers.
Diagnostic
Run the workflow locally with act so the failure reproduces without a push. The tool reads your .github/workflows/*.yml files, resolves the event that triggers each job, and runs every step inside a Docker container that stands in for the GitHub-hosted runner. First confirm the workflow even parses and that act maps the jobs the way you expect, then run the single job that fails in CI.
#!/usr/bin/env bash
set -euo pipefail
# Dry run: list the jobs/steps act would execute, no containers started
act -n -W .github/workflows/ci.yml
# Run the specific job that fails in CI
act push -j test -W .github/workflows/ci.yml
The -n flag is a plan-only pass: it prints the job graph and the resolved step list without pulling an image or running a container, which catches a mistyped needs: edge or an invalid if: expression in under a second. Once the plan looks right, the -j test invocation runs only the test job for a push event, which is the exact combination the remote runner failed on. If your workflow uses a build matrix, act expands it the same way GitHub does and runs every combination unless you narrow it with --matrix key:value; when only one matrix leg fails in CI, pin that leg so you are not waiting on the others while you iterate.
Match the trigger to the one that failed. A job gated behind on: pull_request will not run under the default push event, so act would silently skip it and you would wrongly conclude the failure had vanished. Name the event explicitly as the first positional argument (act pull_request, act workflow_dispatch) so the same jobs that ran remotely also run locally.
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. That single reproduction converts an intermittent, remote-only symptom into a deterministic local one you can iterate against in seconds instead of minutes.
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 interactive shell carries extra PATH entries, globally installed binaries, cached native modules under ~/.npm or ~/.cache, and exported variables inherited from your .bashrc or direnv that the runner never sees. A test that quietly depends on any of that passes on the host and fails on the runner. The sharp example is typical: the native module was compiled once against your host libvips and cached, so the host resolves it, while the clean runner has neither the cache nor the system library.
There is a second, quieter class of divergence: ordering and case. Your host may resolve a file as Config.json on a case-insensitive filesystem while the Linux runner treats config.json as a different file entirely, and your shell may expose tools in a PATH order that shadows the version CI resolves. Both produce failures that look like flaky tests but are deterministic once the environment matches.
By default act uses a slim node image that diverges even further from the real runner than your laptop does — it omits git, build toolchains, and dozens of preinstalled utilities that GitHub bakes into ubuntu-latest. So a faithful reproduction depends on three alignments: the container image must match the runner, the environment must contain only the variables the workflow declares, and the secrets must come from a file rather than leaking in from your shell. Get any one of those wrong and you either fail to reproduce the bug or invent a new one that does not exist in CI. The rest of this guide fixes each alignment in turn.
Resolution
- Map each GitHub label to the catthehacker image that mirrors the real runner. These images track the software GitHub preinstalls far more closely than the default slim image. Commit
.actrcso the whole team reproduces identically instead of each engineer running against a different base.
# .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 the declared values are present. This is the same discipline you apply through dotenv configuration management: the workflow should see exactly the keys it references and nothing else. Keep both files out of version control and generate them from your secret store.
#!/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 the Dockerfile the workflow builds) — not on your host — then re-run
actto confirm before pushing. The whole point is to change the declared environment, because that declaration is the only thing the remote runner will honour.
# .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
- Re-run the exact same
actcommand from step 2. Becauseactstarts from a fresh container each time, you are testing the workflow change in isolation, not against a container that already happens to havelibvips-devfrom a previous attempt. A green run here is a reliable predictor that the push will be green too.
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. Note the image line: it reports catthehacker/ubuntu:act-22.04 rather than the slim node default, confirming that .actrc took effect. If you still see the slim image name, act did not read your .actrc — check that you ran the command from the repository root, since act looks for .actrc in the working directory and $HOME.
Read the per-step markers rather than only the final line. Each ✅ Success line names the step, so if one step you expected to run is missing from the output, the job took a different conditional branch than CI did — usually because an if: expression evaluated against a different github context locally. Passing an event payload file with -e aligns that context. When every declared step reports success and the exit code is zero, the reproduction is faithful and safe to push.
Prevention
- Add a
make ci-localtarget wrapping theactinvocation so reproducing CI is one command for everyone, with the image mapping and secret files already wired in. New engineers should not have to remember six flags.
ci-local:
act push -j test \
--secret-file secrets.env \
--var-file vars.env \
-W .github/workflows/ci.yml
Run
act -nin a pre-push hook to catch workflow syntax breaks before they reach the remote. A malformedif:expression or a danglingneeds:reference is cheaper to catch in a local plan than after a failed push. Pair this with the checks in catching missing env vars before container startup so both the workflow shape and the required variables are validated together.Pin the runner label to a fixed version (
ubuntu-22.04, notubuntu-latest) so the local and remote images stay aligned over time. When GitHub rollsubuntu-latestforward to a new base, a workflow that worked yesterday can fail today; pinning removes that moving target and keeps your.actrcmapping honest.
Apple Silicon (ARM64): add
--container-architecture linux/amd64soactpulls the amd64 runner image that GitHub actually uses; the arm64 variant masks architecture-specific failures such as a native module that only fails to build on x86. 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 that never occur on the real runner. macOS (Docker Desktop): large runner images need ample disk in the VM — prune withdocker system pruneifactfails while pullingact-latest, and raise the VM disk allocation if pulls repeatedly abort.
Rollback
act runs in throwaway containers and never mutates your repo state, so there is nothing to undo after a run. The one exception is a bind-mounted working tree: steps that write into the checkout (a code generator, a formatter) persist those writes to your host, so run git status after an iteration and discard stray artifacts. If a workflow change you made while iterating turns out to be wrong, revert it with git restore .github/workflows/ci.yml before committing. To reclaim disk from pulled runner images:
docker image rm ghcr.io/catthehacker/ubuntu:act-22.04
Frequently Asked Questions
Why does act use a different image than GitHub by default?
By default act maps ubuntu-latest to a slim node image to keep pulls small and fast. That image is missing much of the software GitHub preinstalls, so it is fine for quick smoke tests but diverges from the real runner. Map the labels to catthehacker images in .actrc when you need a faithful reproduction of a runner-only failure.
How do I stop my shell variables from leaking into the run?
Do not pass secrets or vars with bare --secret or --env flags that read from your environment. Use --secret-file secrets.env and --var-file vars.env so only the keys the workflow declares are present. This mirrors the clean environment of the GitHub runner and prevents a value that only exists on your host from hiding the bug.
Does act support all GitHub Actions features?
Most, but not all. Matrix builds, reusable workflows, services, and most actions/* steps work. Features that depend on GitHub's backend — OIDC token minting, the artifacts API, and some deployment environments — are partially emulated or unsupported. For a runner-only test failure, which is nearly always about the environment rather than a GitHub API, the supported surface is more than enough.
Can I reproduce a failure that only happens on pull_request?
Yes. Pass the event name and, if the job reads event payload fields, a JSON payload file: act pull_request -j test -e event.json -W .github/workflows/ci.yml. The -e file supplies the github.event context the job would otherwise read from the real pull request, so conditionals keyed on the base branch or labels evaluate the same way locally.