自动化 OTA 更新:Onespot 如何在不碰笔记本电脑的情况下部署到 200 多个应用
一键向 200 多个 React Native 应用部署 OTA 更新。看看 Onespot 如何借助 Expo 的 OTA Updates、GitHub Actions 和一部手机,实现多应用自动发布。
中文
复制

本文是 Sean Cann 的客座文章——他是 Onespot 的联合创始人兼 CEO,这个平台为学校构建自定义品牌的移动应用。
…
昨天,我在手机上点了一下按钮,就把一次更新部署到了 200 多个 Web 和移动应用上。
三年前,这句话会让我笑出声。那时候,我会做大多数 Expo 开发者至今仍在做的事。要发布一次 OTA(over-the-air)更新,我得在笔记本电脑上打开终端,在本地切换到正确的仓库,运行 expo publish(现在是 eas update),然后盯着进度条看几分钟,同时祈祷本地仓库里没有什么配置错误。接着,每更新一个应用,就把这套流程重来一遍。
八年前,从纯 React Native 转向 Expo 时,OTA 发布是我们做这个决定的重要因素——那时候 Expo 还停留在 SDK 20,所谓“Expo 技能”就是打开一堆 Stack Overflow 标签页。但直到去年,我们才真正把自动化这些更新的全部威力释放出来。
现在,我在 iPhone 上打开我们任意一个移动应用,进入它隐藏的管理后台,然后就能直接在应用里发布 OTA 更新(甚至构建和提交应用)。
这篇文章会讲清楚这套做法、它具体串起了哪些文件,以及在大规模推送更新时真正重要的那些护栏(另外我要用几个破折号——我不会让 LLM 把它们从我手里夺走!)

挑战:以“白标”规模部署
Onespot 为学校构建自定义品牌的移动应用。每个应用都是一个无代码的一体化平台,包含沟通、账单、表单、群聊等功能。因此每个客户都会得到一个独立的、上架 iOS 和 Android 应用商店的应用——有自己的应用图标、应用名称、启动屏、bundle identifier、应用商店描述等等。

在底层,所有应用共享同一套 React Native + Expo 代码库和同一个 Firebase 后端。这种架构对迭代速度来说极其强大——修一次 bug,所有地方都修好了。但它也带来了一个新的部署问题:我们怎样才能高效地把更新发布到几百个应用上?
在搭建自动化流程之前,每部署一个应用的变更大致是这样的:
-
更新该应用的 config files(bundle ID、slug、凭证等),指向目标应用
-
在本地运行应用,确认配置无误
-
打开终端(在我自己的笔记本上),执行 Expo 的 publish/build/submit 命令
-
等待更新完成
-
如果是商店构建,上传到对应的应用商店并提交审核
-
对下一个应用重复以上步骤……
即便每个应用只花 2–3 分钟处理配置、启动构建或发布,给 200 个应用推一次简单更新也要 7–10 小时。对小团队(或者单打独斗的开发者)来说,这显然不可持续。我们需要一种方式,让给所有应用推修复和给一个应用推修复一样轻松。

核心思路:“我在部署哪个应用?”是个数据问题
我们方案的基础,是不再把“我在部署哪个应用?”当成一个手动步骤,而是把它变成数据。我们建了一个 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 让这件事变得很简单,一行终端命令就够。
在我们那个可靠的 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 应用作为网站发布,可以看这里。
把部署从笔记本上搬走
在把配置生成和批量更新自动化之后,我们又撞上了另一个瓶颈:这些部署都跑在开发者本机上。一开始,我在自己的 MacBook 上跑 onescript.py。问题很明显:重写文件时工作目录被锁住,跑到一半失败或本地环境的怪毛病都会让它崩掉,而且敏感凭据还得放在我自己的机器上。
解决办法——也就是还读到这里的人大概最关心的部分——是把所有部署都放进 CI 里跑。我们用的是 GitHub Actions,你也可以用 EAS Workflows——Expo 新推出的 CI/CD,专为 Expo 和 React Native 设计。
我们的做法是一个 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 提交,不用再找我帮忙。我们要做的,就是在自己的应用里调用这个新端点,跟调用其他任何端点没有区别。
接下来我就能去做最重要的一步了:花大把时间在我们的应用里设计一个超酷的绝密 dashboard,让所有团队成员都觉得自己像职业黑客……

下一步往哪走?
一旦部署变成又一个由 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>)进行校验。任意 shell 执行绝无可能;API 只把已知命令映射到已知的 Python 函数。
使用按环境隔离、权限最小的凭据。 CI 凭据(EAS token、App Store Connect 密钥、Play Store 服务账号)只存在于 CI 中,不放在开发者机器上。只要可能,token 的作用范围就严格限定为工作流所需的内容——不多给一分。
记录一切,并让它可审计。 每次部署都会记录是谁触发的、运行了什么命令、影响了哪些应用等。出问题时,有清晰的书面记录——不用猜,也不用去 Slack 里考古。
在关键环节设置人工检查点。 自动化不等于零监督。对于商店提交或大批量更新,我们仍要求在工作流继续之前经过人工审核/批准。就像做任何功能一样,最好从小处着手,一步一步过渡到全自动系统。
假设自动化一定会失败,并为清理做好准备。 CI 任务可能被中断、部分失败,或撞上外部限制。脚本应当幂等、可安全重跑,并能干净地恢复,不把仓库或构建状态留在奇怪的状态里。
最后几点想法
这一切最初并不是什么构建“部署基础设施”的宏大计划。它源于一个非常实际的困扰:随着我们支持的应用越来越多,发布一个小修复的难度也在线性增长。Expo 的 OTA 更新让我们有能力快速迭代,但直到我们把部署当作一等系统——一个值得像应用架构本身那样认真设计的东西——才真正享受到这种能力带来的好处。
最大的思维转变——也是我希望读到这里的人能带走的一点——是意识到规模不只是性能和代码复用的问题,而是把人从重复、易错的循环中解放出来。
当“我在部署哪个应用?”变成数据,“怎么部署?”变成一次 API 调用,剩下的就顺理成章了。CI 不再是你需要盯着的工具,而成了你可以信任的引擎。最终,部署不再让人觉得有风险。
如果你想开始自动化部署流程中的某些环节,我建议看看 用 EAS Workflows 发布预览更新 或 用 GitHub Actions 创建 PR 预览。