自動化された OTA 更新:Onespot はいかにしてノート PC に触れずに 200 以上のアプリへデプロイしたか
1つの操作で200以上のReact NativeアプリにOTAアップデートを配信。OnespotがExpoのOTA Updates、GitHub Actions、そして1台のスマートフォンを駆使して、複数アプリの自動リリースをどう実現しているかを紹介します。
日本語
コピー

この記事は Sean Cann によるゲスト投稿です。彼は Onespot の共同創業者兼 CEO で、学校向けにカスタムブランドのモバイルアプリを構築するプラットフォームを手がけています。
…
昨日、私はスマホのボタンをひとつ押して、200 以上の Web アプリとモバイルアプリにアップデートをデプロイした。
3 年前の自分なら、この一文を読んで笑い転げただろう。当時、私はほとんどの Expo 開発者が今もなおやっていることをやっていた。OTA(over-the-air)アップデートを配信するには、ノート PC でターミナルを開き、ローカルで正しいリポジトリに切り替え、expo publish(今は eas update)を実行し、数分間プログレスバーを眺めながら、ローカルリポジトリに設定ミスがないことを祈る。それをアプリごとに繰り返す。
8 年前、純粋な React Native から Expo に移行したとき、OTA リリースは決断を後押しした大きな要素だった。当時の Expo はまだ SDK 20 で、いわゆる「Expo スキル」とはStack Overflow のタブを大量に開くことだった。それでも、こうしたアップデートの自動化が本当に力を発揮するようになったのは去年のことだ。
今では、うちのモバイルアプリを iPhone で開き、隠し管理画面に入り、アプリの中から直接 OTA アップデートを配信できる(ビルドやストアへの提出も同じようにできる)。
この記事では、その仕組み、具体的にどのファイルをつないでいるのか、そして大規模にアップデートを配信するときに本当に効いてくるガードレールについて書く(あとダッシュを何度か使う——LLM にダッシュを取り上げられるつもりはない!)。

課題:「ホワイトラベル」規模でのデプロイ
Onespot は学校向けにカスタムブランドのモバイルアプリを構築している。各アプリはノーコードのオールインワンプラットフォームで、コミュニケーション、請求、フォーム、グループチャットなどの機能を備えている。つまり顧客ごとに、iOS と Android のストアに並ぶ独立したアプリが 1 本ずつできる——アプリアイコン、アプリ名、スプラッシュスクリーン、bundle identifier、ストアの説明文、すべてその顧客専用だ。

内部的には、すべてのアプリが同じ React Native + Expo のコードベースと、同じ Firebase バックエンドを共有している。このアーキテクチャはイテレーション速度の面で極めて強力で、バグを 1 回直せばすべてのアプリで直る。だが新たなデプロイ問題も生む。数百本のアプリに効率よくアップデートを配信するにはどうすればいいのか?
自動化を組む前、アプリ 1 本分の変更をデプロイする手順はおおよそこうだった。
-
そのアプリの config files(bundle ID、slug、認証情報など)を対象アプリ向けに更新する
-
ローカルでアプリを起動し、設定が正しいか確認する
-
ターミナルを開き(自分のノート PC で)、Expo の publish/build/submit コマンドを実行する
-
完了を待つ
-
ストアビルドなら、該当ストアにアップロードして審査に提出する
-
次のアプリで同じことを繰り返す……
1 本あたり設定・ビルド起動・配信に 2〜3 分しかかからないとしても、200 本にちょっとしたアップデートを 1 回配るだけで 7〜10 時間かかる。小さいチーム(あるいは一人で開発している人)には明らかに持続不可能だ。すべてのアプリに修正を配るのを、1 本に配るのと同じ手軽さにする必要があった。

中心となる考え方:「どのアプリをデプロイ中か?」はデータの問題
この仕組みの土台は、「どのアプリをデプロイ中か?」を手作業のステップとして扱うのをやめ、データに変えたことにある。私たちは JSON のアプリレジストリを作った——ひどく安直な名前にして、apps.jsonと呼んでいる——これがシステム上のすべてのアプリを定義する。アプリのビルドごとに変わりうるものは、すべてこのレジストリを正とする。名前、slug、bundle identifier、EAS project ID、ストア ID、バックエンド識別子、バージョン番号などだ。
最終的な apps.json はだいたいこんな感じになる。
{
"montessori_apps": {
"amare": {
"name": "Amare",
"slug": "amaremontessori",
"bundlePackageID": "com.seabirdapps.amaremontessori",
"easProjectID": "9c3a7f8e-2b41-4d9e-a6c5-1234abcd9876",
"databaseAppID": "MLvbKPmILkLvp8Cq1234",
"appleAppID": "1234567890",
"version": "20.0.0",
"androidBuild": 2,
"iosBuild": 3
},
"appleseed": {
"name": "Appleseed",
"slug": "appleseedmontessori",
...
},
...
}
}
設定の自動生成
レジストリができたので、Onespot 用の Python スクリプトを書いた(もちろん名前は onescript.py にした)。アプリ ID(または ID のリスト)を受け取り、そのアプリに必要な設定ファイルをすべて生成する。
私たちの構成では、次のものを生成する。
-
standalone/config.js:app.config.jsが使う設定モジュール -
eas.json:生成後、CI が正しいメタデータでビルド・提出できる -
google-services.json:正しい Android 設定に更新 -
standalone/appImages.js:正しいアイコン・スプラッシュ画像を指す -
.easignore:他のすべてのアプリのアセットディレクトリを除外し、EAS のアップロードを高速に保つ
最後の項目は重要だ。リポジトリに数百本分のアプリのアセットが積み上がっていると、除外設定がなければビルドやアップデートが耐えがたいほど遅くなる。.easignore の使い方をもっと知りたいならこちらを参照。単一アプリのデプロイにも役立つ。
アプリ設定の整理
この仕組み全体が動くのは、Expo の設定がコードそのものだからだ。
私たちの app.config.js は、選択したアプリの生成済み設定を読み込み、標準の Expo 設定フィールドにマッピングする。アプリ設定の使い方についてはこちらを参照。
私たちの app.config.js はだいたいこんな感じだ。
import { standaloneConfig } from "./standalone/config";
// Incremented each time we publish an OTA update
const PUBLISHED_VERSION = 692;
export default ({ config }) => ({
...config,
name: standaloneConfig.name,
version: standaloneConfig.version,
slug: standaloneConfig.slug,
scheme: standaloneConfig.scheme,
ios: {
...config.ios,
bundleIdentifier: standaloneConfig.bundlePackageID,
buildNumber: `${standaloneConfig.iosBuild}`
},
android: {
...config.android,
package: standaloneConfig.bundlePackageID
},
updates: { url: `https://u.expo.dev/${standaloneConfig.easProjectID}` },
runtimeVersion: { policy: "sdkVersion" },
extra: {
publishedVersion: `${PUBLISHED_VERSION}`,
databaseAppID: standaloneConfig.databaseAppID,
eas: { projectId: standaloneConfig.easProjectID },
},
});
ここまで来れば、「アプリの切り替え」は「standalone/config.js を変える」だけになる。
Python でデプロイする
最後のステップは、設定済みのアプリを実際にデプロイ、ビルド、提出することです。ありがたいことに EAS のおかげでこれは簡単で、ターミナルでコマンドを1行打つだけです。
例の頼りになる onescript.py ファイルでは、デプロイはだいたいこんな感じになります。
def publish_app(app_id):
app = all_apps[app_id] # from apps.json
write_all_files(app) # generates all the config files
os.system("npx eas-cli update --branch=main --auto")
ビルドや提出もほぼ同じで、違うのは最後の行だけです。
-
ビルド:
os.system(f"npx eas-cli build --platform {platform} --no-wait{non_interactivity_flag_if_ci}") -
ビルド+提出:
os.system(f"npx eas-cli build --platform {platform} --auto-submit --no-wait{non_interactivity_flag_if_ci}")

同じスクリプトで web ビルドもデプロイしています(うちの場合は expo export --platform web とホスティングへのデプロイです)。Expo アプリをウェブサイトとして公開する方法はこちらに書いてあります。
デプロイをノート PC から追い出す
設定の生成と一括更新を自動化したあと、また別のボトルネックにぶつかりました。デプロイが全部開発者のマシン上で動いていたのです。最初のうち、私は自分の MacBook で onescript.py を走らせていました。問題は明らかで、ファイルを書き換えている間は作業ディレクトリがロックされ、途中で失敗したりローカル環境の妙な不具合が出たりすればすぐ壊れ、しかも機密の認証情報を自分のマシンに置かねばなりませんでした。
解決策は——ここまで読んでいる人ならおそらく一番気になる部分でしょうが——すべてのデプロイを CI の中で走らせることでした。うちは GitHub Actions を使っていますが、EAS Workflows でも構いません。Expo と React Native のために設計された、Expo の新しい CI/CD です。
やり方はこうです。GitHub Actions の workflow(名前のとおりの onescript.yml ファイルに置いています)を repository dispatch で起動し、onescript.py の中の任意の関数をリモートから実行できるようにしました。この構造のおかげで自由度は最大になります。簡単な Python 関数を書いてスクリプトに足すだけで、やりたいことを何でも起動できるようになったのです。
.github/workflows/onescript.yml の簡略版はこんな感じです。
name: API-Triggered Onescript Command
on:
repository_dispatch:
types: [onescript-command]
jobs:
onescript:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Parse command
run: |
COMMAND="${{ github.event.client_payload.command || '' }}"
echo "PARSED_COMMAND=$COMMAND" >> $GITHUB_ENV
(...set up Node, Python, EAS, App Store & Google Play credentials for submissions, and other dependencies...)
- name: Clear build cache
run: |
rm -rf web-build/
rm -rf .expo/
- name: Execute onescript command
run: |
echo "Executing: python3 onescript.py ${{ env.PARSED_COMMAND }}"
python3 onescript.py ${{ env.PARSED_COMMAND }}
env:
CI: true
(...credentials)
- name: Cleanup
if: always()
run: rm -f (...credentials files)
スマホからデプロイを起動する
これで、GitHub でリポジトリを開いて数回クリックすればデプロイを起動できるようになりました。でも、そこで止まる必要があるでしょうか。
Onespot の API に認証付きの /trigger-onescript エンドポイントを足せば(GitHub の REST API を使います)、どこからでもこの workflow を起動できます。
うちの fetch コマンドはだいたいこんな形で、command には publish amare、submit amare、publish all_apps、あるいはスクリプトに処理させたいものなら何でも入ります(安全性のチェックはサーバー側で行います)。
fetch(
"https://api.github.com/repos/<org>/<repo>/dispatches",
{
method: "POST",
headers: {
Authorization: `token ${GITHUB_ACCESS_TOKEN}`,
Accept: "application/vnd.github.v3+json",
"Content-Type": "application/json"
},
body: JSON.stringify({
event_type: "onescript-command",
client_payload: { command }
})
}
);
API から起動できるようになると、もっと面白い可能性が見えてきます。チームの誰でも——開発者でなくても——アプリの更新デプロイ、ビルド、App Store への提出ができるようになり、私の手を借りる必要がなくなるのです。やることは、自分たちのアプリからこの新しいエンドポイントを呼ぶだけ。他のエンドポイントを呼ぶのと何も変わりません。
あとは一番大事なステップに進むだけです。アプリの中に超クールな極秘ダッシュボードを設計するのにたっぷり時間を使って、チームの全員に一流ハッカーの気分を味わってもらうのです……

次はどこへ向かうのか
デプロイが API で起動できる単なるアクションの一つになれば、改善の余地は一気に広がります。
検討できそうな方向をいくつか挙げます。
apps.json をバージョン管理からデータベースへ移す。 これは自然な次の一手に見えます。今はアプリを追加・更新するたびにコードを直して commit する必要がありますが、このデータは本質的に運用データであってソースコードではありません。アプリのレジストリをデータベースに移せば、個々のアプリのデータを動的に作成・更新・無効化でき、ちゃんとしたバリデーション、監査ログ、権限も付けられます。CI が実行時にレジストリを取得するようにすれば、まったく新しいアプリの立ち上げは git の手順ではなくデータ入力になります。
AI agent(たとえば Cursor の API)をアプリとデプロイの流れに直接つなぐ。 私たちはすでに Linear と Slack 経由で Cursor agent を使っていて、チームの誰でも変更内容を説明すればコードを生成できます。ただ、この考え方はもっと先まで行けます。少し手を加えれば、同僚が変更を普通の言葉で説明し(たとえば「ユーザーがプッシュ通知を送る前に確認ダイアログを出して」)、Cursor(あるいは他の AI agent)がコードを書き、レビューし、Expo のプレビューデプロイを起動し、そのデプロイをテストしてレビューし、あとは人に引き継ぐか、十分に動くと確信できれば更新をそのまま自動デプロイする、という流れになります。さらに進めば、AI agent が顧客のフィードバックを直接受け取り(たとえば「え、あの通知を送るつもりじゃなかったんだけど!」)、必要な変更を判断し(たとえば「送信前に確認ダイアログを出す」)、コードを書いてテストし、デプロイし、顧客が要望を出してから数分後には新機能を伝えることだってできます。明らかにリスクは大きいですが、思考より速くイテレーションしたい会社にとって、これが現実的な選択肢になるのは時間の問題だと思います。
最終的には、私たちはみんな OpenClaw agent に鍵を渡して、ソフトウェア開発をきっぱりやめて、人間がコーディングの過程で役に立っていた時代を懐かしむのでしょうか。冗談です、絶対にやめてください……AI が Moltbook でそのことを嘲笑してきますよ。
考えておくべきセキュリティと対策
何かを簡単にできるようにするなら、同じくらい大事なのは、それを簡単に間違えられないようにすることです。こうした自動化を組むときに私たちが考えたポイントをいくつか挙げます。
「CI の起動」は特権操作として扱う。 デプロイワークフローを起動できるのは、認証済みの信頼できる主体ごく一部だけだ。私たちの構成では、CI を起動する API エンドポイントはサーバー側の認証で保護されており、クライアントやエンドユーザーに直接公開されることはない。
コマンドはサーバー側で検証し、生の入力を信用しない。 CI に送られたコマンドはすべてパースされ、許可リスト(publish <app>、build <app>、submit <app> など)と照合される。任意のシェル実行は不可能で、API は既知のコマンドを既知の Python 関数にマッピングするだけだ。
環境ごとに分離した最小権限の認証情報を使う。 CI の認証情報(EAS token、App Store Connect のキー、Play Store のサービスアカウント)は CI の中だけに置き、開発者のマシンには置かない。可能な限り、token のスコープはワークフローに必要な範囲に厳密に限定する。それ以上は与えない。
すべてを記録し、監査できるようにする。 デプロイのたびに、誰が起動したか、どのコマンドが実行されたか、どのアプリに影響したかが残る。問題が起きたときには明確な記録がある。推測する必要も、Slack を掘り返す必要もない。
要所に人間のチェックポイントを置く。 自動化は無監督を意味しない。ストアへの提出や大量更新では、ワークフローを先に進める前に人間のレビューと承認を今も求めている。どんな機能でもそうするように、小さく始めて段階的に全自動へ移行するのがいい。
自動化は必ず失敗すると想定し、後始末を用意する。 CI ジョブは中断されたり、部分的に失敗したり、外部の制限にぶつかったりする。スクリプトは冪等で、安全に再実行でき、リポジトリやビルドの状態を妙な形で残さずきれいに復帰できるべきだ。
最後にいくつか
そもそもこれは「デプロイ基盤」を作るという大それた計画から始まったわけではない。もっと現実的な困りごとが出発点だった。サポートするアプリが増えるにつれて、小さな修正をリリースする手間が線形に増えていったのだ。Expo の OTA 更新のおかげで素早くイテレーションできるようになったが、その恩恵を本当に受けられるようになったのは、デプロイを一級のシステムとして——アプリのアーキテクチャそのものと同じくらい真剣に設計する価値のあるものとして——扱い始めてからだ。
最大の意識の転換は、そしてここまで読んだ人に持ち帰ってほしいのは、スケールとは性能やコードの再利用だけの話ではなく、人を反復的で間違えやすいループから解放することだという気づきだ。
「どのアプリをデプロイ中か」がデータになり、「どうデプロイするか」が API 呼び出しになれば、あとは自然に流れる。CI は見張る道具ではなく、信頼できるエンジンになる。やがてデプロイは危険なものだとは感じられなくなる。
デプロイフローの一部を自動化し始めたいなら、EAS Workflows でプレビュー更新を公開する や GitHub Actions で PR プレビューを作成する を見てみるといい。