1.1.3 · Dev Platform

Running EAS Update Safely — stg / prd Channels Against Accidental Releases

2026.07.11~6 min

00Overview

Tools

  • EAS UpdateOTA deliveryFree
  • GitHub ActionsAuto-delivery to stgFree
  • Expo GoOn-device checks for stgFree

01Story

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?

02Solution

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:

channelPurposeDelivery
stgDevelopment / verificationAuto, via GitHub Actions
prdReleased usersManual

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.

03Result

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.