Skip to content

Title: eas build --local: fingerprint runtime version calculated on the runner ≠ recalculated in-build (npm-workspaces monorepo) #3954

Description

@davebirr

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:

  1. packageJson:scriptsexpo 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.
  2. 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 gapeas-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.jsoncli.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 identical246a85af… == 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs reviewIssue is ready to be reviewed by a maintainer

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions