概要
Deploy OTA updates to 200+ React Native apps with one tap. という体験を、Onespot がどのように実現したかを解説します。この記事は Onespot の共同創業者兼CEO、Sean Cann によるゲスト投稿で、Expo の OTA Updates、GitHub Actions、そしてスマートフォンからのトリガーでマルチアプリ公開を自動化した具体的なアプローチを紹介します。
昨日、私は iPhone でボタンを1回タップして200以上のウェブとモバイルアプリにアップデートをデプロイしました。3年前なら笑っていたでしょう。以前はほとんどの Expo 開発者と同じように、ローカルでリポジトリに移動して、expo publish(現在は eas update)を実行し、数分間進行を監視していました。これをアプリごとに繰り返すのは非現実的です。
オンスポット(Onespot)は学校向けのカスタムブランドモバイルアプリを構築するプラットフォームで、各顧客に対してストアに載る独立したアプリ(アイコン、名前、スプラッシュ、bundle identifier、ストア説明など)を提供します。内部的には単一の React Native + Expo コードベースと単一の Firebase バックエンドを共有します。この設計はイテレーション速度に強力ですが、何百ものアプリへ効率よく配信するという新たなデプロイ課題を生みます。
課題:ホワイトラベル規模でのデプロイ
以前のワークフローは以下の通りでした:
- アプリ固有の設定(bundle IDs, slug, credentials など)を更新
- ローカルで動作確認
- ターミナル(個人のラップトップ)で Expo の publish/build/submit を実行
- アップデートの完了を待機
- ストアビルドなら適切なストアにアップロードして審査申請
- 次のアプリへ繰り返す
1アプリあたり2〜3分かかると、200アプリの更新に7〜10時間かかる計算です。小規模チームや個人開発者にはスケールしません。そこで、全アプリに対して1つのアプリにデプロイするのと同じ簡単さで修正を届けられる仕組みが必要になりました。
キーアイデア:「どのアプリをデプロイするか」はデータの問題にする
私たちの解決の基盤は、"どのアプリをデプロイするか" を手作業として扱うのをやめ、それをデータに変えることでした。apps.json という単一の JSON レジストリを作り、システム内のすべてのアプリを定義しました。これがビルドごとに変わる情報(名前、slug、bundle identifiers、EAS project IDs、ストア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",
...
},
...
}
}
設定ファイルの自動生成
このレジストリを元に、onescript.py という Python スクリプトを作成しました。apps.json の app ID(またはバッチ)を受け取り、そのアプリに必要なすべての設定ファイルを生成します。生成されるファイル例:
- standalone/config.js:app.config.js が読み込む設定モジュール
- eas.json:CI が適切なメタデータで build/submit できるように生成
- google-services.json:Android 用に更新
- standalone/appImages.js:正しいアイコン/スプラッシュ資産を指す
- .easignore:他アプリの資産フォルダを除外して EAS のアップロードを高速化
特に .easignore は重要です。多数のアプリ資産が単一リポジトリにある場合、除外がないとビルド/アップデートが極端に遅くなります。.easignore の使い方については公式ドキュメントを参照してください。
アプリ設定の構造
Expo の設定がコードであることが、この仕組みを可能にしています。私たちの app.config.js は生成された standalone の設定を読み込み、標準の 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 での公開(publish)
最終ステップは、そのアプリを実際に publish、build、submit することです。EAS はワンライナーで簡単にしてくれます。onescript.py 内での公開は次のようになります:
def publish_app(app_id):
app = all_apps[app_id]
write_all_files(app)
os.system("npx eas-cli update --branch=main --auto")
ビルドやサブミットはほぼ同様ですが、最後のコマンドが異なります:
-
Build:
os.system(f"npx eas-cli build --platform {platform} --no-wait{non_interactivity_flag_if_ci}")
-
Build + submit:
os.system(f"npx eas-cli build --platform {platform} --auto-submit --no-wait{non_interactivity_flag_if_ci}")
また同じスクリプトで web ビルド(expo export --platform web とホスティングへのデプロイ)も行っています。Expo をウェブとして公開する方法の詳細は公式ドキュメントを参照してください。
デプロイをローカルからCIへ移動
設定生成とバッチ更新を自動化した後、次のボトルネックは「デプロイを開発者のローカル環境で実行すること」でした。ローカルでスクリプトを実行すると、作業ディレクトリが書き換えられてロックされたり、中断時に脆弱になったり、機密資格情報がローカルに残るリスクがあります。
解決策は CI 上で実行することです。我々は GitHub Actions を使っています(EAS Workflows という Expo 向けの CI/CD を使う選択肢もあります)。構成は repository_dispatch トリガーで起動される .github/workflows/onescript.yml です。これにより onescript.py の任意の関数をリモートで実行できます。簡略化した workflow の例:
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 )
この構造により、onescript.py に関数を追加するだけで CI から任意の操作を呼び出せるようになりました。
スマホからのデプロイトリガー
GitHub リポジトリ内でクリックしてトリガーすることもできますが、さらに進めて社内 API に /trigger-onescript という認証付きエンドポイントを追加し、GitHub の REST API を呼ぶことでどこからでもワークフローを起動できるようにしました。サーバー側で安全に検証したうえで、command(例: "publish amare", "submit amare", "publish all_apps")を送ります。fetch の例:
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 トリガーで実行できるようになると、改善の余地は一気に広がります。いくつか考えているアイデア:
- apps.json をソース管理から外してデータベースに移す
- これによりアプリの追加や更新がコードコミットではなく、動的なデータ操作で可能になります。バリデーション、監査ログ、権限管理も整えられます。
- AI エージェント(例: Cursor の API)をデプロイプロセスやアプリに直接連携
- チームが自然言語で変更を記述すると、コード生成や設定更新、デプロイ操作までつなげられる可能性があります。
この投稿では、Onespot がどのようにして複数アプリの OTA 配信を自動化し、最終的にスマートフォンのワンクリックで200以上のアプリにデプロイできるようにしたかを説明しました。興味があれば、apps.json の管理方法や CI セットアップの詳細、onescript.py の実装のベストプラクティスについてさらに深掘りします。