Running EAS Update Safely — stg / prd Channels Against Accidental Releases
00要約Overview
Tools
- EAS UpdateOTA deliveryFree
- GitHub ActionsAuto-delivery to stgFree
- Expo GoOn-device checks for stgFree
01物語Story
Situation
1.1.1 and 1.1.2 established the dev loop and the release path. Merge to main, and an OTA goes out. Up to the first release, that setup is enough.
Complication
The problem is the maintenance phase after release. The common setup looks like this:
merge to main
↓
EAS Update
↓
released users
In this shape, if debug code or a temporary fix slips into main by mistake, the OTA carries it straight to released users. Worse, OTAs can still be delivered after native code changes — and incompatible JavaScript landing on an old binary means crashes.
Question
How do you make sure in-development code, or a native-incompatible update, never reaches released users by accident?
02解決Solution
Criteria
- In-development code never reaches released users
- Native changes never crash old binaries
- The Expo Go dev experience survives
- Release timing stays in my own hands
Answer
The starting point is knowing exactly when an OTA is delivered. Delivery requires two conditions at once:
channel matches
AND
runtimeVersion matches
- channel … the delivery destination (dev or production)
- runtimeVersion … native compatibility
Only a device matching both receives the OTA. Flip it around: shift either one, and that device receives nothing. That property is what the two delivery paths are built on.
channel separates the destination. LycoApp splits delivery into stg and prd:
| channel | Purpose | Delivery |
|---|---|---|
| stg | Development / verification | Auto, via GitHub Actions |
| prd | Released users | Manual |
A merge to main auto-delivers only to stg; nothing goes to prd unless explicitly run. The path from in-development code to released users is now structurally sealed.
runtimeVersion guards native compatibility. It uses the appVersion policy:
{
"runtimeVersion": {
"policy": "appVersion"
}
}
Now an app at version 1.0.0 has runtimeVersion 1.0.0. Change native code, bump the version to 1.0.1, and runtimeVersion follows automatically — so 1.0.0 binaries never receive the 1.0.1 OTA. Native-incompatibility crashes are prevented by the most ordinary act in development: bumping the version.
Expo Go gets one exception. Expo Go only understands runtimeVersion = exposdk:<SDK version>. So in GitHub Actions, for stg deliveries only, the policy is switched temporarily to sdkVersion:
{
"runtimeVersion": {
"policy": "sdkVersion"
}
}
This rewrite happens only inside CI and is never committed to Git. Store builds and prd OTAs keep appVersion as usual. A small mechanism to hold both the dev experience (Expo Go) and production safety (appVersion) at once.
Reason
Because I wanted the mechanism, not my attention span, to do the protecting. An operation that relies on "being careful not to deliver to production" will have its accident eventually. People make mistakes. So the delivery conditions themselves were changed: shift the channel and released users are unreachable; shift the runtimeVersion and old binaries are unreachable.
Hold the conditions of delivery,
and what must not arrive, simply never arrives.
That, I think, is the design that lets you sleep at night.
Options
- Stay on a single channel — main → EAS Update → released users, the plain setup. Simplest configuration, but it keeps carrying both the accidental-release risk and the native-crash risk. Fine up to the first release; separate before entering maintenance.
03結果Result
Good
The daily flow settled into this:
fix ↓ merge to main ↓ GitHub Actions ↓ auto OTA to stg ↓ check in Expo Go
Only when releasing do I run the prd EAS Update manually from GitHub Actions. The result: in-development code never reaches released users, native changes never crash anyone, the comfortable Expo Go loop survives, and release timing stays in my hands. All four criteria, standing.
Bad
The price is one extra step and some opacity. prd is manual, so every release needs my own hands. And the CI-only runtimeVersion rewrite confuses anyone who doesn't know the trick — "why is the policy different only here?" This document exists partly to fill that gap.
Follow-up
Once built, this stg / prd separation carries to every next app unchanged. So it became a Git template repository — new apps start as a copy. Ship the first release on the plain setup from 1.1.1 and 1.1.2, then fold in this separation and template it. Every app after that starts life on safe OTA rails.