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 }}
# .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' }}"
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" }
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
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
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" }
}
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
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-interactiveon 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"withautoIncrementon 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 withpnpm install --frozen-lockfile, because the app's workspace dependencies have to resolve, and only the EAS command runs inside the app. -
cache: pnpmonactions/setup-node. It needspnpm/action-setupto have run first, which is the kind of ordering you only get wrong once. -
environment: previewper profile, alongside the plainenvblock. One is EAS's own environment variable store, the other is a literal baked into the build.EXPO_PUBLIC_ENVis 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)