廣川政樹です。Dolice Labs として個人で 6 本のアプリを並行運用していますが、いちばん理解されにくく、いちばん事故に直結しているのが Expo の runtimeVersion 設定だと感じています。
きっかけは 2026 年 4 月、Rork で作った壁紙アプリのひとつを Expo SDK 50 から 51 へ上げた翌日でした。手元のシミュレータでも、本番ビルドの TestFlight でも問題なく動いていたのに、App Store で配信中の旧バージョン(SDK 50)を入れているユーザーから「アイコンの並びが昨日と変わって、お気に入りが消えた」という問い合わせが連続で 3 件届きました。原因は単純です。SDK 51 用に最適化した JS バンドルを、eas update --branch production で旧バージョンの runtimeVersion にも配信してしまっていたのです。
公式ドキュメントは「runtimeVersion を合わせる必要がある」と書いてはいますが、その「合っていない」状態がどう壊れるか、どう自動で防ぐかまでは踏み込んで書かれていません。累計 5,000 万ダウンロードを支えてきた 1 個人開発者として、6 アプリを抱えたまま SDK を上げ続けるための実装一式を、ここに置いておきます。
なぜ Runtime Version が「沈黙の地雷」になるのか
EAS Update は、JS バンドルとアセットを App Store / Google Play の審査を経ずに端末へ届けてくれます。ただし配信先は「ブランチ」ではなく「同じ runtimeVersion を持つビルド済みバイナリ」です。ここに 3 つの事実が重なります。
runtimeVersion: "appVersion" を採用すると、app.json の version を上げるたびに runtimeVersion も自動で上がります。旧バージョンを保有しているユーザーは新しい OTA を受け取れません。逆もまた然りで、SDK だけ上げて version を据え置くと、Native と JS のバージョンがずれた OTA が古いネイティブに配信されます。
SDK のアップグレードは React Native の Hermes バイトコードや内部ブリッジを差し替えます。SDK 51 でビルドした JS を SDK 50 のネイティブが読むと、Hermes のバージョンミスマッチで JSI 呼び出しが落ちます。シミュレータと TestFlight では再現しません。
EAS Update は配信に失敗してもユーザーに通知しません。eas update 側は緑色で Published と出るのに、端末側は静かに古い JS を使い続けます。Sentry にも何も来ません。
この 3 つを抑えないまま SDK を上げると、「自分の端末では直っているのに、ユーザーからの『壊れた』報告が止まらない」という地獄が起きます。
3 つのポリシーをどう使い分けるか
runtimeVersion には大きく 3 つの設定値があります。Dolice Labs では、運用フェーズと SDK 移行の頻度に応じてアプリごとに使い分けています。
"appVersion" — リリース頻度が低いアプリ向け
app.json の version と一体運用されるため、設定ミスは起きません。ただし version を上げない限り OTA も別ランタイムにならないので、メジャーリリースの間隔が短いと「同じ version の中に複数の SDK を混ぜる」ことができず柔軟性は落ちます。私の 6 アプリのうち、月 1 本ペースでストア更新する 2 本はこれを採用しています。
sdkVersion 直接指定 — SDK 1 つを長く使い倒すアプリ向け
"runtimeVersion": "50.0.0" のように Expo SDK バージョンを書きます。SDK を上げない限り runtimeVersion はそのままなので、OTA を頻繁に出してもネイティブとずれることはありません。AdMob の収益化チューニングを月 30 回 OTA で配信している 2 本はこれを採用しています。
明示的セマンティックバージョン — 共通コードベースを 2 系統に分けるとき向け
"runtimeVersion": "2026.5.1" のような独自の番号体系で、ストアバージョンとは独立に管理します。私が iPad 専用 UI を別ランタイムで配信した時に使ったやり方です。新旧 UI を併存させ、Native 変更を伴う差分だけ別系統で動かせます。
公式は新規アプリで "appVersion" を推奨していますが、Rork で生成したコードのように JS 側の差分が頻繁に出る案件では sdkVersion 固定の方が事故率が下がる、というのが私の判断です。
6 アプリを抱えながら SDK 50→51 を上げる移行手順
実際に 2026 年 4 月から 5 月にかけて行った作業の流れを、Rork 製アプリ前提で書き残します。完了まで 3 週間で、その間 1 件もユーザー影響を出していません。
Step 1. 全アプリの runtimeVersion を整える
まず作業前に、6 アプリの app.json を確認して、runtimeVersion が "appVersion" のものを "50.0.0" のように直接値に置き換えます。SDK アップグレード中だけは「Native と JS が同じランタイム上にいる」状態を強制したいからです。
// app.json
{
"expo" : {
"name" : "Wallpaper Pro" ,
"version" : "3.4.0" ,
"runtimeVersion" : "50.0.0" ,
"updates" : {
"url" : "https://u.expo.dev/PROJECT_ID" ,
"fallbackToCacheTimeout" : 0
}
}
}
Step 2. eas.json でビルドプロファイルとチャンネルを分離する
ここが事故防止の中核です。SDK 50 と 51 で別チャンネルを用意し、間違って混ぜられない構造にします。
// eas.json
{
"cli" : { "version" : ">= 12.0.0" , "appVersionSource" : "remote" },
"build" : {
"production-sdk50" : {
"channel" : "prod-sdk50" ,
"env" : { "EXPO_PUBLIC_SDK_LINE" : "50" },
"ios" : { "simulator" : false },
"android" : { "buildType" : "app-bundle" }
},
"production-sdk51" : {
"channel" : "prod-sdk51" ,
"env" : { "EXPO_PUBLIC_SDK_LINE" : "51" },
"ios" : { "simulator" : false },
"android" : { "buildType" : "app-bundle" }
}
},
"submit" : {
"production-sdk50" : { "ios" : { "appleId" : "YOUR_APPLE_ID" } },
"production-sdk51" : { "ios" : { "appleId" : "YOUR_APPLE_ID" } }
}
}
channel を prod-sdk50 と prod-sdk51 に分け、間違って eas update --channel prod-sdk51 を SDK 50 のビルドに当てても、端末側の channel と一致しないので受け取らない設計です。
Step 3. ブランチを SDK 別に切り、デプロイスクリプトに安全弁を入れる
リポジトリ側のブランチも main-sdk50 と main-sdk51 に分け、配信スクリプトで「現在チェックアウトしているブランチ」と「対象 channel」が一致しているかを最初に確認します。
#!/usr/bin/env bash
# scripts/publish-update.sh
set -euo pipefail
CHANNEL = " ${1 :? usage : publish-update . sh < channel > } "
BRANCH = "$( git rev-parse --abbrev-ref HEAD)"
# 1. ブランチと channel の対応を強制
case " $CHANNEL " in
prod-sdk50 ) [[ " $BRANCH " == "main-sdk50" ]] || { echo "❌ branch mismatch: $BRANCH on $CHANNEL " ; exit 1 ; } ;;
prod-sdk51 ) [[ " $BRANCH " == "main-sdk51" ]] || { echo "❌ branch mismatch: $BRANCH on $CHANNEL " ; exit 1 ; } ;;
*) echo "❌ unknown channel: $CHANNEL " ; exit 1 ;;
esac
# 2. Native 差分が混ざっていないか確認(ios/ android/ に差分があれば停止)
if ! git diff --quiet HEAD~10..HEAD -- ios android Podfile package.json ; then
echo "⚠️ Native or dependency changes detected. OTA will not work safely."
echo " Promote via a store release instead."
exit 2
fi
# 3. 直近 24h の crash-free 率を確認(後述)
node scripts/check-crash-free.mjs " $CHANNEL " || { echo "❌ crash-free below threshold" ; exit 3 ; }
# 4. 配信
npx eas update --branch " $CHANNEL " --message "release $( date -u +%FT%TZ)"
Native 差分が混ざっていないか確認 のステップは何度も自分を救ってくれました。Rork のコード生成プロンプトに頼んだ修正が、たまに ios/Podfile や package.json の依存を触ってくることがあり、これを OTA に乗せると現場が壊れます。
Step 4. crash-free 率と連動した自動停止
Sentry の Releases API で直近 24 時間の crash-free 率を取得し、99.5% を下回っていたら OTA を出さない、という安全弁を入れます。Dolice Labs では 99.5% を「個人で運用できる体力の上限」として運用しています。
// scripts/check-crash-free.mjs
import https from "node:https" ;
const SENTRY_TOKEN = process.env. SENTRY_AUTH_TOKEN ;
const ORG = process.env. SENTRY_ORG ;
const PROJECT = process.env. SENTRY_PROJECT ;
const channel = process.argv[ 2 ];
const url = `https://sentry.io/api/0/organizations/${ ORG }/sessions/?project=${ PROJECT }&statsPeriod=24h&field=sum(session)&field=count_unique(user)&field=crash_free_rate(session)` ;
const res = await fetch (url, { headers: { Authorization: `Bearer ${ SENTRY_TOKEN }` } });
const data = await res. json ();
const rate = data.groups?.[ 0 ]?.totals?.[ "crash_free_rate(session)" ] ?? 1 ;
console. log ( `channel=${ channel } crash_free_rate=${ ( rate * 100 ). toFixed ( 2 ) }%` );
if (rate < 0.995 ) process. exit ( 1 );
これを Step 3 のスクリプトから呼ぶことで、Sentry のダッシュボードを毎回見に行かなくても、危険な状態では自動で配信が止まります。Dolice Labs の 6 アプリ全てに同じスクリプトを共有しており、4 月の SDK 51 移行中に 1 アプリで一時的に crash-free が 99.2% まで落ちたときも、配信が自動停止して傷口の拡大を防げました。
Step 5. 旧 SDK のサポート期間を「3 ヶ月」と決め、ストア提出をずらす
6 アプリ同時に SDK 51 へジャンプしないのが個人開発の鉄則です。私は 1 週間に 1 本ずつ、ストア提出と SDK 51 への移行を進めました。SDK 50 の OTA 配信は、最後の 1 本がストアで承認されるまで「3 ヶ月は止めない」と決めています。古いネイティブを持ったままアプリを開いてくれるユーザーが、AdMob の収益の 8% 強を占めていたためで、ここを意図せず切るのは事業判断としても下手です。
「Native 変更を OTA に乗せてしまった」事故からの 30 分復旧手順
それでも事故は起きます。2026 年 5 月、別の SwiftUI ブリッジを伴う変更を、私のミスで OTA チャンネルに混ぜたことがありました。気づいたのは Sentry の JSI: method does not exist が 1 分間に 40 件出た瞬間です。そこから次の手順で約 30 分でユーザー影響を止めました。
eas update --branch prod-sdk51 --republish --group <PREVIOUS_GROUP_ID> で 1 つ前の正常な OTA に切り戻す。--group には EAS dashboard で直前の Update Group ID を貼ります。
切り戻し後、Sentry のリリースを 5.4.0+ota-rollback のように分けて記録し、影響範囲(アクティブセッション数 / ユニークユーザー数)を 5 分後・15 分後・60 分後で計測する。
App Store Connect のレビューチームに Expedited Review を申請する。理由欄には「JSI bridge incompatibility caused by OTA update; reverted via EAS Update; submitting native fix for parity」と書きます。私の経験では、こう書けば 4 〜 8 時間で通ります。
Native fix を SDK 51 ブランチに当てて新規ビルドを Submit。Phased Release は 1% 起点で開始し、24h ごとに 5% → 20% → 50% → 100% と上げる。
切り戻しから次の Native 修正配布までの間、Remote Config で問題機能をオフにしておくと、復旧後にもう一度オンに切り替えやすくなります。SwiftUI と React Native のブリッジが絡むコードでは「OTA で戻したあと、Remote Config で機能をオフ → 落ち着いたらオン」の 2 段構えにしておくと、ユーザーへの揺れが最小化されます。
公式ドキュメントには書かれていない実用上の落とし穴
app.json の runtimeVersion を変更するだけでは、すでにビルド済みのバイナリは古い runtimeVersion のままです。チャンネルを分けずに runtimeVersion を上げると、配信できる対象がいきなりゼロになります(公式 docs の Static / Native まわりは正しいのですが、誤解しやすい)。
Hermes バイトコードのバージョンは Expo SDK に固定されています。SDK 51 でビルドした JS は SDK 50 のネイティブからは「読めない JS」になります。Hermes のバージョンミスマッチエラーは Sentry に出ないことが多く、JSI レベルで落ちます。
expo-updates を enabled: false のままビルドすると、OTA は永遠に届きません。リリースビルドだけ enabled: true にする運用は、eas.json の env で EXPO_UPDATES_ENABLED を分けるのが安全です。
iOS の場合、Background Fetch で OTA を取りに行く挙動は端末状態に強く依存します。AppDelegate 起動時の同期取得 + fallbackToCacheTimeout: 0 を組み合わせて、最初の 1 秒だけ取得を試して諦める運用が、私の 6 アプリでは事故率がいちばん低かったです。
次の一手
ここまでの実装は、Dolice Labs の壁紙アプリ・癒し系アプリ・引き寄せ系アプリの 6 本で 2 年間に渡って磨いてきたものです。明日の自分のために残すなら、以下の順で着手するのが効率的だと感じています。
全アプリの app.json を runtimeVersion: "<SDK 番号>" 固定に変えて、まずは「ずれない状態」に整える。所要時間は 1 アプリあたり 5 分。
eas.json を SDK 別チャンネルに分割し、scripts/publish-update.sh の安全弁を入れる。所要時間 1 時間。
Sentry の crash-free 連動と Update Group の切り戻し手順を README に書く。所要時間 1 時間。
このうち 1 と 2 だけでも先にやっておくと、次に SDK を上げるときの精神的負担が一気に下がります。私自身、5,000 万ダウンロードを支えてきた中で、事故を「起こさない」ではなく「起きても 30 分以内に止める」設計に振った瞬間から、個人開発の体力が長く続くようになりました。同じ轍を踏まずに済めば、それが何よりです。
お読みいただきありがとうございました。