Passes Locally, Fails in CI
Works locally but fails in CI? Check differences in order: the commit CI actually ran, the shell and commands, dependency resolution, then architecture.
Code the agent wrote passes on your machine and fails only in GitHub Actions. Shown the failure, agents usually start editing tests or app code. But what differs between local and CI is usually around the code, not the code. Here is the order we check, from cases in our test environment. For the cost and configuration of running Claude Code itself in CI, see Running Claude Code in CI: Costs and Traps.
First, see what CI ran
For pull_request events, GITHUB_REF is refs/pull/<number>/merge and GITHUB_SHA is the last merge commit of the PR merge branch; use github.event.pull_request.head.sha for the head commit (https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows, checked 2026-10-16). CI tests your branch merged into the base. Compare the checked-out commit in the log with your git rev-parse HEAD.
Then, the shell and commands
We reproduced these locally with dummy files (GNU bash 5.2.37, mawk 1.3.4, 2026-10-16).
An EXIT trap rewrites the exit code.
set -e
cleanup() { echo "cleanup"; [ -f /tmp/cired/none.tmp ] && rm /tmp/cired/none.tmp; }
trap cleanup EXIT
exit 0
cleanup
t2 exit=1
exit 0, yet exit code 1: with no file to clean, the final test is false and its status sticks. Locally a leftover temp file made it pass; a clean CI run fails. End the cleanup with true or use if.
pipefail plus an early-exiting grep. Under set -euo pipefail, piping large output into grep -q ended the script with exit=141: grep quits on the first match, the writer gets SIGPIPE, pipefail reports it. Timing-dependent, so it may pass locally.
awk implementations. IGNORECASE is a GNU awk feature. mawk silently ignored it and matched only error; tolower($0) ~ /error/ matched all three variants. The gawk manual lists IGNORECASE as a gawk built-in variable (https://www.gnu.org/software/gawk/manual/html_node/User_002dmodified.html, checked 2026-10-16); macOS awk isn’t GNU awk, so it may ignore it too. We did not reproduce this on macOS.
Old bash and empty arrays. Per the bash CHANGES file, expanding an empty array as "${a[@]}" under set -u stopped raising “unbound variable” in 4.4 (https://git.savannah.gnu.org/cgit/bash.git/plain/CHANGES, checked 2026-10-16); older versions fail. Our bash 5.2 passed. macOS is commonly said to ship bash 3.2 as /bin/bash, so a macOS runner using it may fail only in CI; we did not reproduce this on macOS. Check bash --version in CI.
Dependency resolution
In a pnpm workspace, a type package we never declared was picked up locally via hoisting from another package. CI’s clean install didn’t hoist it, and type checking failed. Declare what you use, and reinstall from the lockfile to match CI.
Build vs run architecture
We built an app with a native module on a machine of a different architecture than where it ran. The runtime binary wasn’t bundled, and every request on routes using that module errored while other pages worked.
Environment variables
For missing or ignored variables, see Claude Code Env Vars Not Working.
Asking the agent
Before fixing a CI failure, list local vs CI differences in this order:
- the commit CI checked out vs local HEAD
- shell and command implementations/versions (bash, awk, sed)
- how dependencies were installed, and whether it matches the lockfile
- OS/CPU for build and run
Summary
- Local-pass/CI-fail is usually about the surroundings, not the code.
pull_requestCI tests the merge with the base; compare commits first.- EXIT traps, pipefail with
grep -q, awk variants and old bash pass locally. - Declare dependencies; reinstall from the lockfile.
- Match build and run architectures for native modules.