expo / expo/eas-cli

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

Open
#3,954 0 comments 0 reactions 0 assignees View on GitHub
needs review
Dominant language
TypeScript
Stars
1.4k
Forks
236
Avg merge
3d 1h
Merged PRs (30d)
91

Description

### 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: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.
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 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:

```diff
- "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.

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.