Build/Submit details page URL
No response
Summary
With runtimeVersion set to { "policy": "fingerprint" }, every eas build --local fails at the CONFIGURE_EXPO_UPDATES phase because the fingerprint eas-cli computes on the runner (pre-build) does not match the one @expo/build-tools recomputes inside the build — two non-null but different hashes. Reverting runtimeVersion to a static string builds fine, so the trigger is exactly the switch to the fingerprint policy.
I isolated the root cause by replaying both computations on the runner. The pre-build hash and the in-build hash diverge entirely because of expo prebuild, via two — and only two — fingerprint sources:
packageJson:scripts — expo prebuild rewrites the app's package.json scripts from "android": "expo start --android" / "ios": "expo start --ios" to "expo run:android" / "expo run:ios". The default skip PackageJsonAndroidAndIosScriptsIfNotContainRun excludes start --* (no "run") but includes run:* (contains "run"), so these scripts flip from unhashed pre-prebuild to hashed post-prebuild.
bareNativeDir — prebuild generates ios/ (which is gitignored under CNG, so its content hash is null), and the mere presence of that bareNativeDir source shifts the aggregate hash.
This is distinct from #3528 (there the build side computes null; here both sides compute a real hash). It is not the monorepo-hoisting / post-npm install divergence I first suspected (and that #3602 describes): I proved the dependency graph is deterministic (below). The trigger is purely the managed-vs-bare lifecycle gap — eas-cli fingerprints the managed project (no ios/, start --* scripts) while the worker recomputes at CONFIGURE_EXPO_UPDATES after expo prebuild (bare ios/, run:* scripts).
Managed or bare?
Managed - Continuous Native Generation, no committed ios/ or android/. Native customization is via config plugins (expo-secure-store, expo-camera, expo-location, expo-notifications, expo-sqlite with useSQLCipher, expo-background-task, and two local plugins under ./plugins/). Built with expo-dev-client (not Expo Go). npx expo-doctor → 20/20.
Environment
- Expo SDK 56 (React Native 0.85),
expo-updates ~56.0.21, @expo/fingerprint bundled with those
- CNG / managed (config plugins; no committed native dirs), New Architecture
- npm workspaces monorepo: Expo app at
apps/mobile; node_modules hoisted to the repo root
eas build --local on a self-hosted runner (macOS for iOS, Linux for Android)
eas-cli 20.5.1 / eas-cli-local-build-plugin 20.5.1
- No
.easignore present
eas.json → cli.appVersionSource: "remote"; preview profile is distribution: "internal", and has an eas-build-post-install script (npm --prefix ../.. run build:shared)
- A dynamic
app.config.js injects extra.gitCommit (per-commit short SHA)
apps/mobile/fingerprint.config.js: { sourceSkips: ['ExpoConfigExtraSection'] } (see "ruled out" below)
Error output
Runtime version mismatch:
- Runtime version calculated on local machine: 2356dcd61c5c096b04d62c1e8538c6230fc250c5
- Runtime version calculated on EAS: 4303a345fd2cecfadf341544c3658dfc16040ef3
This may be due to one or more factors:
- Differing result of conditional app config (app.config.js) evaluation for runtime version resolution.
- Differing fingerprint when using fingerprint runtime version policy. If applicable, see fingerprint diff below.
[CONFIGURE_EXPO_UPDATES] Skipping fingerprint diff because EAS_BUILD_ID is not set
Error: Runtime version calculated on local machine not equal to runtime version calculated during build.
at configureExpoUpdatesIfInstalledAsync (@expo/build-tools/dist/utils/expoUpdates.js:87:15)
Reproducible demo or steps to reproduce from a blank project
Rather than guess, I replayed both fingerprint computations on the runner using the project's own bundled @expo/fingerprint (v0.19.6, via the Node API createFingerprintAsync(dir, { platforms: ['ios'] })). All hashes below are real output; each step is one createFingerprintAsync call.
Step 1 — the dependency graph is deterministic (rules out hoisting / post-install / autolinking).
I fingerprinted (a) the runner's working checkout and (b) a git archive HEAD extract into a clean dir followed by a fresh npm ci (mirrors what the build does before prebuild):
(a) working tree : b762f42bcbd93bc35b02417fcd634ee4bda08241
(b) git archive + npm ci : b762f42bcbd93bc35b02417fcd634ee4bda08241 ← identical, 0 diff
So autolinking, monorepo hoisting, eas.json, .gitignore, and packageJson:scripts are all stable across a clean reinstall. The extra.gitCommit churn is separately handled by sourceSkips: ['ExpoConfigExtraSection']. None of these is the divergence.
Step 2 — expo prebuild is the entire divergence.
Running expo prebuild --platform ios --no-install on the extract and re-fingerprinting:
before prebuild : b762f42…
after prebuild : e47368fb… (sequential re-runs stable: e47368fb == e47368fb)
diffFingerprints() reports exactly 2 changed sources:
added | dir | ["bareNativeDir"] | ios
changed | contents | ["packageJson:scripts"] | packageJson:scripts
The packageJson:scripts change is prebuild rewriting the scripts:
- "android": "expo start --android",
- "ios": "expo start --ios",
+ "android": "expo run:android",
+ "ios": "expo run:ios",
Step 3 — isolating each contributor.
- Adding
sourceSkips: ['PackageJsonScriptsAll'] neutralizes the scripts contributor. The only residual diff is then added | dir | ["bareNativeDir"] | ios, and the hashes still differ (246a85af… vs 527e2245…).
- Removing the generated
ios/ (with PackageJsonScriptsAll in effect) makes the two computations identical — 246a85af… == 246a85af…, 0 diff.
So the two sources are provably exhaustive: packageJson:scripts (config-fixable) + bareNativeDir (not fixable via any sourceSkip). Notably bareNativeDir has hash: null (its contents are gitignored under CNG: apps/mobile/.gitignore contains /ios and /android), yet its presence in the source list still changes the aggregate hash.
⚠️ Tooling caveat I hit: running two createFingerprintAsync calls concurrently via Promise.all returns corrupted hashes (they appear to race on a shared temp dir). All results above are from sequential computation.
Build/Submit details page URL
No response
Summary
With
runtimeVersionset to{ "policy": "fingerprint" }, everyeas build --localfails at theCONFIGURE_EXPO_UPDATESphase because the fingerprinteas-clicomputes on the runner (pre-build) does not match the one@expo/build-toolsrecomputes inside the build — two non-null but different hashes. RevertingruntimeVersionto a static string builds fine, so the trigger is exactly the switch to thefingerprintpolicy.I isolated the root cause by replaying both computations on the runner. The pre-build hash and the in-build hash diverge entirely because of
expo prebuild, via two — and only two — fingerprint sources:packageJson:scripts—expo prebuildrewrites the app'spackage.jsonscripts from"android": "expo start --android"/"ios": "expo start --ios"to"expo run:android"/"expo run:ios". The default skipPackageJsonAndroidAndIosScriptsIfNotContainRunexcludesstart --*(no"run") but includesrun:*(contains"run"), so these scripts flip from unhashed pre-prebuild to hashed post-prebuild.bareNativeDir— prebuild generatesios/(which is gitignored under CNG, so its content hash isnull), and the mere presence of thatbareNativeDirsource shifts the aggregate hash.This is distinct from #3528 (there the build side computes
null; here both sides compute a real hash). It is not the monorepo-hoisting / post-npm installdivergence I first suspected (and that #3602 describes): I proved the dependency graph is deterministic (below). The trigger is purely the managed-vs-bare lifecycle gap —eas-clifingerprints the managed project (noios/,start --*scripts) while the worker recomputes atCONFIGURE_EXPO_UPDATESafterexpo prebuild(bareios/,run:*scripts).Managed or bare?
Managed - Continuous Native Generation, no committed
ios/orandroid/. Native customization is via config plugins (expo-secure-store,expo-camera,expo-location,expo-notifications,expo-sqlitewithuseSQLCipher,expo-background-task, and two local plugins under./plugins/). Built withexpo-dev-client(not Expo Go).npx expo-doctor→ 20/20.Environment
expo-updates~56.0.21,@expo/fingerprintbundled with thoseapps/mobile;node_moduleshoisted to the repo rooteas build --localon a self-hosted runner (macOS for iOS, Linux for Android)eas-cli20.5.1 /eas-cli-local-build-plugin20.5.1.easignorepresenteas.json→cli.appVersionSource: "remote";previewprofile isdistribution: "internal", and has aneas-build-post-installscript (npm --prefix ../.. run build:shared)app.config.jsinjectsextra.gitCommit(per-commit short SHA)apps/mobile/fingerprint.config.js:{ sourceSkips: ['ExpoConfigExtraSection'] }(see "ruled out" below)Error output
Reproducible demo or steps to reproduce from a blank project
Rather than guess, I replayed both fingerprint computations on the runner using the project's own bundled
@expo/fingerprint(v0.19.6, via the Node APIcreateFingerprintAsync(dir, { platforms: ['ios'] })). All hashes below are real output; each step is onecreateFingerprintAsynccall.Step 1 — the dependency graph is deterministic (rules out hoisting / post-install / autolinking).
I fingerprinted (a) the runner's working checkout and (b) a
git archive HEADextract into a clean dir followed by a freshnpm ci(mirrors what the build does before prebuild):So autolinking, monorepo hoisting,
eas.json,.gitignore, andpackageJson:scriptsare all stable across a clean reinstall. Theextra.gitCommitchurn is separately handled bysourceSkips: ['ExpoConfigExtraSection']. None of these is the divergence.Step 2 —
expo prebuildis the entire divergence.Running
expo prebuild --platform ios --no-installon the extract and re-fingerprinting:The
packageJson:scriptschange is prebuild rewriting the scripts:Step 3 — isolating each contributor.
sourceSkips: ['PackageJsonScriptsAll']neutralizes the scripts contributor. The only residual diff is thenadded | dir | ["bareNativeDir"] | ios, and the hashes still differ (246a85af…vs527e2245…).ios/(withPackageJsonScriptsAllin effect) makes the two computations identical —246a85af… == 246a85af…, 0 diff.So the two sources are provably exhaustive:
packageJson:scripts(config-fixable) +bareNativeDir(not fixable via anysourceSkip). NotablybareNativeDirhashash: null(its contents are gitignored under CNG:apps/mobile/.gitignorecontains/iosand/android), yet its presence in the source list still changes the aggregate hash.