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. Left as-is:

merge to main
      ↓
  EAS Update
      ↓
released users

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.
  • Manage with Fingerprint — this is the currently recommended approach, but I don't do version management that strict, so I passed. I don't push OTAs to past versions anyway (everyone, please update your app).

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.

■ Reproduce it yourself

A note for anyone who wants to reproduce this setup. Not meant to be read straight through — the prompt shows a few lines and scrolls.

Paste the following.

Split the EAS delivery into "dev" and "production" channels so that a merge
during development is never delivered to released users by mistake via OTA.

[What to do]
1. Channel design
   - stg (dev verification): auto-delivered when I merge to main. Only I (the dev)
     subscribe to it
   - prd (production): for store users. No auto-delivery; delivered only on a manual trigger
   → Make "the only thing that runs automatically is stg," so even if debug code
     mixes into main, it structurally never reaches released users

2. Change eas-update.yml to be stg-only
   - main push → eas update to channel stg only
   - Since Expo Go only reads exposdk-style runtimes, only on stg publish,
     rewrite runtimeVersion to the sdkVersion policy inside CI temporarily
     (do NOT commit it to the repo)

3. Add a new eas-update-prd.yml (manual trigger / workflow_dispatch)
   - eas update to channel prd
   - keep runtimeVersion on app.json's appVersion policy
     (= bumping version means old binaries don't get the OTA — a "native-compat fence")

4. Misdelivery guard
   - If you use a debug flag for ads etc. (e.g. FORCE_TEST_ADS = true), add a guard
     step at the top of the prd-delivery and iOS-build workflows that fails CI if
     that flag is still left true

5. Documentation
   - Write, as operating rules in OPERATIONS.md, which channel configuration exists
     and which action delivers to which channel, and put scenario-based procedures
     ("I want to deliver to released users," "I want to roll back," etc.) in RUNBOOK.md

When you're done, summarize the daily dev flow for me (during dev I watch the stg
branch in Expo Go; only when I want to deliver to released users do I run the prd
workflow manually).

Honestly, when you're building out the dev environment, use Claude's smarter model — skimping on credits there got me stuck over and over and burned far more credits in the end. When something's unclear, send the screenshot or error message straight to Claude and it explains.