自动化 OTA 更新:Onespot 如何在不碰笔记本电脑的情况下部署到 200 多个应用

一键向 200 多个 React Native 应用部署 OTA 更新。看看 Onespot 如何借助 Expo 的 OTA Updates、GitHub Actions 和一部手机,实现多应用自动发布。

中文
复制
Automating OTA Updates: How Onespot deploys to 200+ apps without touching a laptop

本文是 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 把它们从我手里夺走!)

OTA 更新

挑战:以“白标”规模部署

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}")

用 Python 发布

我们也在同一个脚本里发布 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 amaresubmit amarepublish 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 触发

下一步往哪走?

一旦部署变成又一个由 API 触发的动作,可改进的空间就会迅速变得非常大。

几个可以考虑的方向:

把 apps.json 从版本控制里挪进数据库。 这看起来是顺理成章的下一步。现在新增或更新一个应用仍然要改代码、提交 commit,可这些数据本质上是运营数据,不是源代码。把应用注册表搬进数据库之后,我们就能动态地创建、更新或停用独立应用的数据,还能配上正经的校验、审计日志和权限。CI 可以在运行时拉取注册表,这样上线一个全新应用就变成了一次数据录入,而不是一套 git 流程。

把 AI agent(比如 Cursor 的 API)直接接到我们的应用和部署流程上。 我们已经在通过 LinearSlack 使用 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 预览

来源: Expo Blog← 返回首页