DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Both of our mobile release workflows are manual, and the runtimeVersion fingerprint is the reason one of them exists

Munchable's phone app ships through two GitHub Actions workflows that look almost identical. Same checkout, same pnpm setup, same Node 22, same expo/expo-github-action@v8 with an EXPO_TOKEN. The last step is the whole difference:

# .github/workflows/eas-build.yml
- name: Build
  working-directory: apps/mobile
  run: eas build --non-interactive --no-wait --platform ${{ inputs.platform }} --profile ${{ inputs.profile }}
Enter fullscreen mode Exit fullscreen mode
# .github/workflows/eas-update.yml
- name: Publish update
  working-directory: apps/mobile
  run: eas update --non-interactive --branch ${{ inputs.branch }} --environment ${{ inputs.branch }} --message "${{ inputs.message || 'manual update' }}"
Enter fullscreen mode Exit fullscreen mode

One compiles a new binary in the cloud. The other publishes a JavaScript bundle to binaries that already exist on people's phones. Choosing between them is not a preference, and that is the part worth writing down.

The line an update cannot cross

An over-the-air update replaces the JS bundle and the assets inside an already-installed app. It cannot replace native code, because native code is compiled into the binary that is already on the phone.

Expo's protection against you forgetting that is the runtime version. Ours is one line:

"runtimeVersion": { "policy": "fingerprint" }
Enter fullscreen mode Exit fullscreen mode

With the fingerprint policy, the runtime version is a hash derived from the native side of the project. An update is only ever delivered to installed builds whose runtime version matches the one the update was published from. Change something native, and the next build gets a different fingerprint, and the update you published yesterday simply never reaches it. The mismatch is the feature: a phone would rather keep working on an old bundle than run a bundle that expects a native module it does not have.

We have a concrete example sitting in the repository. Our label camera needs tap to focus, which expo-camera does not expose, so there is a pnpm patch:

# pnpm-workspace.yaml
patchedDependencies:
  expo-camera@57.0.5: patches/expo-camera@57.0.5.patch
Enter fullscreen mode Exit fullscreen mode

That patch spans android/src/main/java/expo/modules/camera/CameraViewModule.kt, ios/CameraViewModule.swift, and the module's compiled JS. Part of it is JavaScript and could in principle travel in an update. The Kotlin and the Swift cannot, by construction: no OTA payload has anywhere to put a .kt file. So the whole change is a build, and the fingerprint is what guarantees nobody can accidentally ship half of it.

The rule I now apply without thinking about it: if a change touches anything the native project is compiled from, it is eas build. Everything else is eas update. In a monorepo like ours the second category is wide. The app consumes shared workspace packages (@munchable/content, @munchable/pricing, @munchable/rules-engine, @munchable/design-tokens) and those are TypeScript. A change to the recipe library the app filters on device, which is the same library the website publishes at munchable.app/recipes, is a bundle change. So is copy, so is layout, so is anything in the screens. None of that needs a store review.

Why neither workflow has a push trigger

Both are workflow_dispatch only, with a comment saying so:

on:
  workflow_dispatch:
    inputs:
      platform:
        description: Platform to build
        type: choice
        options: [android, all, ios]
        default: android
      profile:
        description: EAS build profile (see eas.json)
        type: choice
        options: [development, development-simulator, preview, production]
        default: preview
Enter fullscreen mode Exit fullscreen mode

The reasons are different for each one, and both are about blast radius.

A build consumes EAS build minutes. Auto-building on every merge to a branch is a way to spend a monthly allowance on commits that changed a README. The default here is android and preview, because that is the combination I actually want nine times out of ten, and all plus production has to be chosen deliberately from a dropdown.

An update is worse, because it is instant and it is not reviewed by anybody. eas update --branch production reaches installed phones in seconds. There is no store queue to catch a bad bundle on the way out. For a one-person project that is exactly as much power as it sounds like, so the trigger is a button and the branch is a choice with preview preselected.

The workflow file says the same thing in two lines, which is deliberate, because the comment is the only place the reasoning survives:

Manual trigger to avoid accidental production pushes; add a push trigger later if you want auto-OTA on merge.

The two names that have to match, once

The setup detail that will cost you an afternoon if nobody tells you: EAS Update branches and EAS build channels are different things with the same names.

eas.json gives each build profile a channel:

"preview": {
  "distribution": "internal",
  "channel": "preview",
  "environment": "preview",
  "env": { "EXPO_PUBLIC_ENV": "preview" }
}
Enter fullscreen mode Exit fullscreen mode

A binary built on that profile listens to the preview channel. eas update --branch preview publishes to the preview branch. Nothing links them automatically. You do it once per channel:

eas channel:edit preview --branch preview
Enter fullscreen mode Exit fullscreen mode

Give them the same name and the whole thing reads as if it were one concept, which is fine right up until you want two branches pointing at one channel for a rollout. The workflow file carries that command in a comment, because it is a one-time action that nothing in CI will ever remind you about, and the failure mode is silent: the update publishes successfully and no phone ever asks for it.

Small things in the build file that earn their place

  • --no-wait. EAS compiles in its own cloud. Without this, the GitHub runner sits there billing you minutes to watch a progress bar for a build happening somewhere else. The job's success means "the build was queued", which is what you actually want from CI here.
  • --non-interactive on both. Any prompt in CI is a hang, not an error, and a hung job on a manual trigger is a job you find out about tomorrow.
  • appVersionSource: "remote" with autoIncrement on the production profile only. The build number lives on EAS rather than in a file that two branches can both edit. Nobody has ever won a merge conflict in a version field.
  • working-directory: apps/mobile. The install happens at the repository root with pnpm install --frozen-lockfile, because the app's workspace dependencies have to resolve, and only the EAS command runs inside the app.
  • cache: pnpm on actions/setup-node. It needs pnpm/action-setup to have run first, which is the kind of ordering you only get wrong once.
  • environment: preview per profile, alongside the plain env block. One is EAS's own environment variable store, the other is a literal baked into the build. EXPO_PUBLIC_ENV is the literal, and exactly one place in the app reads it: the constant that decides whether an account is required. In a production build it always is, so an app built without its backend keys fails closed to the sign-in screen instead of silently opening.

What this buys, in product terms

The app's job is to answer a question in a supermarket aisle, with whatever signal a concrete building allows. The conditions it covers are listed at munchable.app/conditions, and the per-ingredient question pages at munchable.app/answers are the website's version of the same thing. The answer itself is computed on the device, which is a product decision, and it is also the reason this release setup is pleasant: most of what we iterate on is JavaScript and data, so most of what we iterate on can go out in a minute, and the slow path exists only for the things that genuinely need a compiler.

Two workflows, one dropdown each, and a hash that stops the fast path from being used for the slow kind of change.

Top comments (0)