Debugging Works-on-My-Machine Runtime Drift
A test passes locally but fails in CI. Systematically diff runtime versions, env vars, locale, timezone, and CPU architecture to find and close works-on-my-machine drift.
A test or build passes on your laptop and fails in CI with no code difference — the classic "works on my machine" symptom that runtime parity frameworks exist to eliminate.
Diagnostic
Capture a normalized runtime fingerprint in both environments and diff them. Run this script locally and as a CI step, then compare the two outputs.
#!/usr/bin/env bash
set -euo pipefail
# runtime-fingerprint.sh — emit a deterministic snapshot of the runtime.
{
echo "arch=$(uname -m)"
echo "kernel=$(uname -s)"
echo "node=$(node -v 2>/dev/null || echo none)"
echo "python=$(python3 -V 2>&1 || echo none)"
echo "tz=$(date +%Z)"
echo "lc_all=${LC_ALL:-unset}"
echo "lang=${LANG:-unset}"
echo "node_env=${NODE_ENV:-unset}"
} | sort
Expected BAD output — diffing local (left) against CI (right) reveals the divergent rows:
$ diff <(./runtime-fingerprint.sh) ci-fingerprint.txt
< arch=arm64
> arch=x86_64
< node=v20.18.0
> node=v18.20.4
< tz=CEST
> tz=UTC
< lang=en_US.UTF-8
> lang=C
Root cause
"Works on my machine" is almost never the code; it is an undeclared dependency on the runtime. The four usual culprits are a different language/runtime version (a Array.prototype method or f-string feature present locally but not in CI), divergent environment variables (a NODE_ENV or feature flag set in your shell but absent on the runner), and locale/timezone drift — string sorting, number parsing, and date formatting all change with LANG/LC_ALL/TZ, so a test that sorts names or formats a timestamp passes under en_US.UTF-8/CEST and fails under C/UTC. CPU architecture adds a fifth: arm64 locally versus x86_64 in CI exposes native-module and floating-point differences. None of these live in the repo, so the diff is invisible until you fingerprint both sides. Pinning each axis turns the runtime into a declared, version-controlled dependency.
Resolution
- Pin the language/runtime version so both sides resolve identically.
- Declare every required env var in
.env.exampleand inject the same set into CI. - Force locale and timezone explicitly in both environments.
- Pin the container platform so architecture matches the CI runner.
Pin runtime versions in a manifest both the workstation and CI consume:
# .tool-versions (asdf/mise) — single source for local and CI
nodejs 20.18.0
python 3.12.4
Normalize locale, timezone, and architecture in Compose so local execution mirrors the runner:
# docker-compose.yml
services:
app:
build: .
platform: linux/amd64 # match CI's x86_64 runners
environment:
- LANG=C.UTF-8
- LC_ALL=C.UTF-8
- TZ=UTC
- NODE_ENV=test
Validate the env contract so a missing variable fails loudly rather than drifting silently — see catching missing env vars before container startup:
#!/usr/bin/env bash
set -euo pipefail
# Assert every key declared in .env.example is exported before running tests.
while IFS= read -r key; do
[ -z "$key" ] && continue
[ -n "${!key:-}" ] || { echo "missing env var: $key"; exit 1; }
done < <(grep -vE '^\s*#|^\s*$' .env.example | cut -d= -f1)
echo "env contract satisfied"
Expected output
Once each axis is pinned, the fingerprints match and the diff is empty:
$ diff <(./runtime-fingerprint.sh) ci-fingerprint.txt
$ echo $?
0
The previously CI-only failure now reproduces (and passes) identically in both places, confirming parity rather than coincidence.
Prevention
- Run
runtime-fingerprint.shin CI and fail the job if it diverges from a committed baseline — this is the lightweight companion to automating runtime parity checks between local and staging. - Keep the
.tool-versionspin and the Composeplatform/locale block under review in every PR. - Add the env-contract assertion to your onboarding health-check script so drift surfaces before tests run.
macOS (Docker Desktop): the host
TZandLANGcome from system settings, not the container; never assume the host locale — always setTZ/LC_ALLinside Compose. WSL2: the WSL distro often defaultsLANGtoC.UTF-8while Windows isen_US; pin locale explicitly so WSL and CI agree. Apple Silicon (ARM64): withoutplatform: linux/amd64, native modules compile for arm64 and behave differently than CI's x86_64 — pin the platform to reproduce CI faithfully.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- .tool-versions docker-compose.yml # revert pins if they break local-only workflows