<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>OTA Updates · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/ota-updates/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/ota-updates/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>笔记本就是服务器：用 Cloudflare Tunnel 把副业项目放上公网</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/self-host-behind-cloudflare-tunnel/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Cloudflare Tunnel</category>
      <category>cloudflared</category>
      <category>Self-Hosting</category>
      <category>Expose Localhost</category>
      <category>Docker Compose</category>
      <category>Django</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Security</category>
      <category>Dev Costs</category>
      <description>Cloudflare Tunnel 是什么、安全性如何、要花多少钱？不开任何端口，让自己机器上的服务拿到真正的 HTTPS 主机名，再用 ingress 白名单决定公网能访问哪些路径，附 Docker Compose 配置。</description>
      <content:encoded><![CDATA[<p>我的每个想法都是这样开始的：在笔记本上跑起来了，能用，然后想让别人也看看。有很长一段时间，下一步总是一张云账单：一台机器、一个数据库、一个 bucket、一个指过去的域名，再为一个只有三个人会看的东西按月付费。对副业项目，或者一个正经项目的第一个月来说，这个顺序是反的：还不知道会不会有生产环境，就先为生产环境掏钱了。</p>
<p>这篇文章讲的是另一种顺序。服务继续跑在开发它的那台机器上，由 Cloudflare Tunnel 给它一个公开的 HTTPS 主机名。笔记本上没有任何东西对互联网监听：不做端口转发，不碰路由器设置，也不暴露 IP 地址。是机器主动连出去到 Cloudflare，Cloudflare 再把请求顺着这条连接送回来。Tunnel 本身免费，TLS 证书、DDoS 防护和缓存则随你多半已经有的 Cloudflare zone 一起附送。</p>
<p>例子是真实的。我的<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/" >一个产品</a> 跑在 Fly.io 上，它的移动应用从后端获取 JavaScript 的 OTA 更新（自托管的 Expo Updates）。我想在自己的 Mac 上再跑一份更新服务器，给开发构建用，生产环境原封不动。下面的输出都来自那个下午，包括没跑通的那部分。</p>

<h2 class="relative group">Cloudflare Tunnel 到底是什么
    <div id="cloudflare-tunnel-到底是什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cloudflare-tunnel-%e5%88%b0%e5%ba%95%e6%98%af%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>普通的服务器是等别人连进来的。要从互联网访问一台笔记本，你得在路由器上开端口、转发到笔记本，还得指望运营商给你一个公网 IPv4 地址（很多地方都不给，我这里也是）。然后，那个端口上监听的所有东西，整天都暴露在互联网上每一个扫描器面前。</p>
<p>Tunnel 把方向反过来。<code>cloudflared</code> 跑在你的应用旁边，主动向 Cloudflare 的边缘建立出站连接，一共四条，连到附近的两个数据中心。你 zone 里的一条 DNS 记录把主机名指向这个 tunnel。当请求打到这个主机名时，Cloudflare 终止 TLS，把请求顺着其中一条连接转发下来，<code>cloudflared</code> 再按你配置里写的交给对应的本地服务。</p>
<figure class="diagram">
  <div role="img" aria-label="请求经 HTTPS 到达 Cloudflare，经过它的规则，到达 tunnel 端点，再顺着 cloudflared 从你机器上建立的出站连接下来；cloudflared 按主机名和路径把它交给 API、edge，或者返回 404"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 520" aria-hidden="true" focusable="false">
<defs><marker id="tu-ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="d-head" d="M0,0 L10,5 L0,10 z"/></marker></defs>
<text class="d-title" x="160" y="60" text-anchor="middle" font-size="30">互联网</text>
<text class="d-title" x="560" y="60" text-anchor="middle" font-size="30">Cloudflare</text>
<text class="d-title" x="1020" y="60" text-anchor="middle" font-size="30">你的机器</text>
<rect class="d-box" x="40" y="200" width="240" height="100" rx="14"/>
<text class="d-label" x="160" y="245" text-anchor="middle" font-size="24">手机 / 浏览器</text>
<text class="d-sub" x="160" y="278" text-anchor="middle" font-size="18">ota.example.com</text>
<rect class="d-panel" x="400" y="110" width="320" height="300" rx="30"/>
<rect class="d-box" x="430" y="140" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="188" text-anchor="middle" font-size="22">TLS、重定向规则、WAF</text>
<rect class="d-box" x="430" y="290" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="338" text-anchor="middle" font-size="22">Tunnel 端点</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">出站 QUIC</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">（不开端口）</text>
<path class="d-line" d="M1010,315 L1048,200" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,330 L1048,325" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,345 L1048,410" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="1020" y="500" text-anchor="middle" font-size="18">ingress 规则：主机名 + 路径白名单</text>
</svg>
</div><figcaption>机器只会往外连。Cloudflare 的 zone 规则在 tunnel 之前执行；ingress 规则决定什么能被访问到。</figcaption>
</figure>

<p>从安全角度看，关键在于路由器上没有任何入站规则，你的 IP 地址也从不出现在 DNS 里。有人扫描你家的网络连接，什么也找不到，因为上面根本没有东西在监听。</p>

<h2 class="relative group">从头到尾的搭建
    <div id="从头到尾的搭建" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%bb%8e%e5%a4%b4%e5%88%b0%e5%b0%be%e7%9a%84%e6%90%ad%e5%bb%ba" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>你需要一个托管在 Cloudflare 上的域名（免费套餐就够），以及装好 <code>cloudflared</code>（Mac 上 <code>brew install cloudflared</code>）。</p>
<p><strong>1. 登录一次。</strong> 这会打开浏览器，你选好 zone，它会保存一张证书，让这台机器可以在那个 zone 里创建 tunnel 和 DNS 记录：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel login</span></span></code></pre></div></div>
<p><strong>2. 创建 tunnel。</strong> 这个项目的所有东西我都放在单独的文件夹里，因为凭据文件就是 tunnel 的身份：谁拿到它，谁就能替你的主机名提供流量。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">mkdir -p ~/.cloudflared/myapp <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/.cloudflared/myapp
</span></span><span class="line"><span class="cl">cloudflared tunnel create --credentials-file ./creds.json myapp-local
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> creds.json</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
</span></span><span class="line"><span class="cl">Created tunnel myapp-local with id 1ade030c-…</span></span></code></pre></div></div>
<p><strong>3. 把主机名指向它。</strong> 每个主机名一条命令，创建一条指向 <code>&lt;tunnel-id&gt;.cfargotunnel.com</code> 的代理 CNAME：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local ota.example.com
</span></span><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local checkin-local.example.com</span></span></code></pre></div></div>
<p><strong>4. 写 ingress 规则。</strong> 这是对安全最要紧的部分，所以下面单独一节来讲。</p>
<p><strong>5. 跑起来。</strong> 我的应用本来就跑在 Docker Compose 下，所以 <code>cloudflared</code> 只是同一个网络里多出来的一个容器。它按服务名访问应用，应用根本不需要在宿主机上发布端口：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># compose.tunnel.yml, used alongside the project&#39;s docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:2026.10.7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel --config /etc/cloudflared/config.yml run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${HOME}/.cloudflared/myapp:/etc/cloudflared:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">api, edge]</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic</span></span></code></pre></div></div>
<p>四条连接连到首尔，笔记本就从外向内地上了互联网。<code>restart: unless-stopped</code> 让它在重启后、Docker 一启动就自动恢复。</p>

<h2 class="relative group">暴露路径，而不是服务器
    <div id="暴露路径而不是服务器" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9a%b4%e9%9c%b2%e8%b7%af%e5%be%84%e8%80%8c%e4%b8%8d%e6%98%af%e6%9c%8d%e5%8a%a1%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这是我差点犯的错，我猜也是最常见的一个。最省事的配置只有一行：<code>hostname → http://localhost:8000</code>。这会把<strong>整个开发服务器</strong>都发布出去。</p>
<p>开发服务器不是为这个设计的。我的开着 <code>DEBUG=True</code>，任何错误都会显示完整的 traceback 和配置。它还关掉了认证（<code>AUTH_MODE=disabled</code>），任何 bearer token 都会让你以测试用户身份登录。这在 <code>localhost</code> 上完全合理，放到公网主机名上就是灾难。你的多半也有类似的东西：用默认密码的管理后台、灌测试数据的端点、debug toolbar，或者因为只有你自己在调用、所以信任调用方的 API。</p>
<p>所以 tunnel 配置是一份白名单。<code>cloudflared</code> 按顺序拿每个请求去匹配 ingress 规则，按主机名，也按路径上的正则表达式。第一条匹配的生效，最后一条兜住其余所有请求：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-local</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/creds.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># OTA: the two read-only endpoints the phone calls, and the files they link.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/api/expo-updates/(manifest|status)/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/media/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Device check-ins, authenticated by each device&#39;s own key.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">checkin-local.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/c/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://edge:8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Everything else, including /admin/ and the publish endpoint: 404 at the edge.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>发布端点就在同一台服务器上、同一个前缀下（<code>/api/expo-updates/import-update/</code>），但它不在列表里。管理后台和 API 的其余部分也不在。这些请求根本出不了 Cloudflare。</p>
<p>你不必盲信自己的正则；<code>cloudflared</code> 会告诉你一个 URL 匹配的是哪条规则：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                            service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                      service: http://edge:8081
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/health                  service: http_status:404</span></span></code></pre></div></div>
<p>我会把这个检查写进脚本，每次改配置都跑一遍。只要几秒钟，而一条比你本意匹配得更多的正则，正是那种只会从别人那里得知的错误。</p>
<p>还有两条经验法则：</p>
<ul>
<li><strong>暴露出去的东西应该自己负责认证。</strong> OTA 端点提供的是公开的只读数据。设备 check-in 用每台设备自己的密钥签名。两者的安全都不依赖网络。如果你想给别人看一个没有登录的页面，就在那个主机名前面放 <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a>（50 个用户以内免费），而不是自己去做登录。</li>
<li><strong>别把 tunnel 当成开发流程里常驻后台的一部分。</strong> 有意识地启动它，只为你想要的那个主机名，用完就停。<code>docker compose stop cloudflared</code> 一条命令就把一切从互联网上撤下来。</li>
</ul>

<h2 class="relative group">吃掉我主机名的那个重定向
    <div id="吃掉我主机名的那个重定向" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%90%83%e6%8e%89%e6%88%91%e4%b8%bb%e6%9c%ba%e5%90%8d%e7%9a%84%e9%82%a3%e4%b8%aa%e9%87%8d%e5%ae%9a%e5%90%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Tunnel 连上了，规则也验证过了，我从外部试了一下：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/status/?…   301
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/   301
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                       301
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                 301</span></span></code></pre></div></div>
<p>每条路径都返回同一个 301，包括那些 tunnel 本该回 404 的。这说明请求根本没到 <code>cloudflared</code>。<code>Location</code> 头写的是 <code>https://www.example.com/</code>，再加上 <code>server: cloudflare</code> 头，事情就清楚了：是 Cloudflare 自己在应答。</p>
<p>这是我自己挖的坑。几个月前我加了一条 zone 级的重定向规则，“其他任何子域名 → www”，用来当<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >那些遍历编造出来的子域名的扫描器的黑洞</a>。重定向规则在 Cloudflare 的请求流水线里执行，早于请求被发往源站，而 tunnel 不过是一个源站。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >同一条规则也拦住过我的预览环境</a>，让 Workers 根本见不到请求。修法和上次一样：在规则表达式里再加一个例外。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">… and not http.host in {&#34;ota.example.com&#34; &#34;checkin-local.example.com&#34;}</span></span></code></pre></div></div>
<p>如果你的 tunnel 主机名返回的是 Cloudflare 的重定向或错误页面，而不是你的应用，那在调试 tunnel 之前，先检查这个 zone 的 <strong>Rules → Redirect Rules</strong>、Page Rules 和所有 WAF 自定义规则。<code>cloudflared</code> 会记录它转发的每个请求，所以日志为空就说明请求根本没走到那一步。</p>

<h2 class="relative group">它做不到的事
    <div id="它做不到的事" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%83%e5%81%9a%e4%b8%8d%e5%88%b0%e7%9a%84%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<ul>
<li><strong>来自公网的 UDP。</strong> 公开的 tunnel 主机名只承载 HTTP(S)（外加 WebSocket 和 gRPC）。我产品的设备平时通过 UDP 上的 CoAP 做 check-in，这走不了 tunnel；连本地副本的设备会退回到 HTTPS check-in，每次要多花几秒钟。如果你的想法是游戏服务器或 VoIP，tunnel 就不是合适的工具。</li>
<li><strong>在线率。</strong> 这是你的笔记本。合上盖子，站点就挂了；Cloudflare 会显示一个 530 错误页。拿来做演示、十个人的内测或者一份 staging 副本都没问题，但不适合任何有人依赖的东西。</li>
<li><strong>编译进构建里的东西。</strong> 移动应用的更新 URL 是原生配置，所以只有用 tunnel URL 构建出来的包才会用它。我的商店构建依然连着生产环境，这也正是这套方案敢拿来试的原因。</li>
</ul>

<h2 class="relative group">成本
    <div id="成本" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%90%e6%9c%ac" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>免费套餐的 Cloudflare zone、tunnel、它的证书和 DNS 记录：0 美元。机器本来就开着。唯一真正的成本是，现在你自己就是运维值班团队。</p>
<p>它帮你省下的，是从“在我机器上能跑”到“值得花钱托管”之间的那个阶段。当天下午你就能把链接发给测试、设计师或者第一个客户。如果没人在意，你什么也没损失。如果有人在意，迁到真正的主机上也只是一处改动：把 CNAME 指到别处。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>用 Codemagic Patch 替换我自建的 Expo 更新服务器</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/</guid>
      <pubDate>Sun, 13 Sep 2026 00:00:00 +0900</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Codemagic Patch</category>
      <category>CodePush</category>
      <category>自托管</category>
      <category>移动开发</category>
      <category>CI/CD</category>
      <description>我把一个真实的 Expo 应用，连原生工程一起，从 expo-updates 迁到了 Codemagic Patch：本地栈、迁移过程、一个我自己造成又通过 OTA 修掉的 bug，以及一次发布真的崩了之后回滚是什么样子。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>声明。</strong> 这次迁移和这篇文章是 Codemagic 付费委托的。他们只对事实准确性做了一轮审阅，不涉及语气，文中没有任何内容是应他们要求修改的。凡是我遇到问题并反馈了的地方，我都会说明；如果你读到这里时问题已经修复，文中会写成已修复。</p>
</blockquote><blockquote><p><strong>2026 年 9 月 23 日更新。</strong> 我反馈的问题，Codemagic 的开发者都逐一处理了。大部分已经修复，包括 iOS 版本检测的 bug、重复计数的安装、首次运行时的静默，以及那条吵闹的调试命令；文档现在也写到了已提交的原生工程和崩溃回滚的额度。下文中每个问题出现的地方我都标注了，仍未解决的那几个保持原样。</p>
</blockquote><blockquote><p><strong>2026 年 10 月 16 日更新。</strong> 迁到 Patch 之后的第一次原生升级（Expo SDK 58）构建出了一个完全不含 Patch 配置的 Android release，而且没有任何地方报错。来龙去脉和只改一个文件的修复写在 <a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/#%e7%ac%ac%e4%b8%80%e6%ac%a1%e5%8e%9f%e7%94%9f%e5%8d%87%e7%ba%a7" >第一次原生升级</a>。</p>
</blockquote><p>二月我写过一篇<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/" >自己搭 Expo 更新服务器</a>的文章。它现在还在跑。我运营的社区应用 maru 从那以后每一次 OTA 修复都走它，两个平台都是，每月大约一美元。</p>
<p>这也意味着每一块都归我管：几张数据库表、一个 API 接口、一条管理命令、一个发布脚本、一个共享密钥，还有一堆坑，每个坑都是靠一次坏掉的更新学来的。坐下来做规划时我才发现，那个脚本从我写它的第一天起，每次生产发布之后都会打印 &ldquo;Sent 0 push notifications&rdquo;，读的是一个这台服务器从未返回过的字段。没人注意到，最没注意到的就是我。自己维护一套东西，大概就会变成这样。</p>
<p>所以当 Codemagic 问我要不要试试 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>，他们那套<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">开源</a>、可自托管的 CodePush 替代品，我答应了，只有一个条件：要用真实的应用，而不是 demo。真实的签名、真实的发布流程、已经提交进仓库并且手改过的原生工程，全套。大多数打算离开 EAS Update 的人，要迁的都是这样的应用，不是刚生成的模板。</p>
<p>下面就是整个过程，毛边都留着。</p>

<h2 class="relative group">Patch 是什么
    <div id="patch-是什么" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#patch-%e6%98%af%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Patch 是一台你自己跑的服务器，加一个放进应用里的 SDK。服务端包括一个 API、一个后台 worker、Postgres、S3 兼容的对象存储和一个 Web 面板，打包成一套 Docker Compose 栈。客户端是 <code>@codemagic/react-native-patch</code>，一个支持 iOS 和 Android 的 TurboModule，附带 Expo 配置插件，再加一个 CLI，<code>cmpatch</code>，负责打包 JavaScript 并发布。</p>
<p>读完源码之后，有两个设计决定让我印象深刻。</p>
<p>第一，应用从不向 API 询问有没有更新。发布一个 release 时，worker 会把纯 JSON 的清单写进对象存储，应用只是去取那些文件。一次更新检查就是对存储或 CDN 的两个静态 GET，API 只接收指标。我原来的接口站在每一次应用启动的请求路径上；这一套想站也站不上去。</p>
<p>第二，每个 release 都精确地写明它面向哪个二进制版本，CLI 会对你的原生工程算一个指纹，拒绝把 JavaScript 发到原生代码对不上的二进制上。我的服务器只有一个版本字符串，剩下的全靠祈祷。</p>

<h2 class="relative group">先在自己笔记本上试
    <div id="先在自己笔记本上试" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%88%e5%9c%a8%e8%87%aa%e5%b7%b1%e7%ac%94%e8%ae%b0%e6%9c%ac%e4%b8%8a%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>文档一上来让你搭的是本地评估栈，从这里开始是对的。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">npm install -g @codemagic/patch-cli
</span></span><span class="line"><span class="cl">cmpatch selfhost local-eval up</span></span></code></pre></div></div>
<p>CLI 两秒就装好了。整套栈第一次起来花了 3 分 14 秒，大部分时间在本地构建服务器和面板的镜像，而且在非交互模式下，这期间它什么都不打印，看起来和挂死一模一样。（现已修复：它现在每一步会打印一行进度。）然后四个健康的容器就出现了，全部绑定在 localhost：面板在 8080，API 在 3000，Postgres，还有 MinIO 在 9100。它很懂事地避开了我自己那套栈已经占用的端口。</p>
<p>这个模式下登录是关掉的，每个页面上都有一条警告，叫你别把它暴露出去。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAACQBACdASogABQAPv1ko0eloqGYBgBbD8TgCzgCWNWXs6T53beCcoRI0PP/N6AA/K7n/8I/+iwVHjl71MVWujkm+9TgA0wAr+PZ+x6+cQcgCHdovZ75vexrOBrFPrvj8AA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_e5915d9b78052f15.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="本地评估模式的登录页，邮箱已预填，并提示认证已禁用"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_8ae8b3cbcb1d7ae7.webp"></span></div>
  <figcaption>本地评估模式用一个预填好的邮箱代替了 GitHub 登录。</figcaption>
</figure>

<p>栈自带一个叫 Example Data 的种子应用，在你发布任何东西之前值得先看一眼，因为它展示了面板是用来干什么的。接下来两张截图里的数字都是种子数据，不是真实用户。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQBACdASogABUAPw1uqkqmIyGYDAQAYxDE9IABUVMfRogwtMiXImNo4AD+1hmAfbeuR0QbIc0lZuw4PBjyhGJq5t/3NdR81x+BG5H496NeiObQODoB8gkWF2AAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_453fd7641ffbc69b.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1037"
      alt="种子应用 Example Data 的发布历史，有一个 10% 的金丝雀发布，以及每个 release 的成功和失败次数"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_30abe30dd08f2234.webp"></span></div>
  <figcaption>种子演示数据：发布历史，含一个 10% 的金丝雀发布、目标版本，以及每个 release 的成功和失败次数。</figcaption>
</figure>

<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoQAAABXRUJQVlA4IHgAAABwBACdASogABcAPw1qrEsmIqIYDAQAYxDE9IBnsglwh49zWML+lyXzzjPEgAD+4AZDuD4GlZfO+rTnJJ+yBUjSHGV1a2gPc+tdTO7IIMf86boVjtRW8VJ7KXLfxZsJzumvn68JtLzOqetoXfqt/I/Omlo4DyEAAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_91146bb0b6c75d52.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1155"
      alt="种子应用的部署指标：版本分布、随时间的采用率、更新结果"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_1f6ae6ce53428957.webp"></span></div>
  <figcaption>种子演示数据：设备实际在跑哪些 release，以及安装进行得如何。</figcaption>
</figure>

<p>作为对比，我的面板就是后台里的一张更新列表，带一个“启用”复选框。到底有多少台手机真的装了更新，我完全不知道。</p>
<p>CLI 登录是一套正经的浏览器流程，带 PKCE 和 localhost 回调，批准页面会告诉你是哪个账号在请求，以及授权码大约一分钟后过期。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRn4AAABXRUJQVlA4IHIAAADwBACdASogABQAPv1ioUeloqGYBgBbD8TxAFnAEsa4FDLDrochGk8HT/WQ+e1SUMAA/K7n5mOlQFSkxh0DtgfJ9tHRhVgvwu2jEApXgWL2ekdKJHkUb1NzXgNcjv6IA1JC+dF2JqE73QqxENNNeYvZMAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c7af1bedbd1fa8b3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="面板询问是否批准一次 CLI 登录"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c267548bca14bf21.webp"></span></div>
  <figcaption>cmpatch login 会请面板来批准这个终端。</figcaption>
</figure>


<h2 class="relative group">对准一个真实应用
    <div id="对准一个真实应用" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%af%b9%e5%87%86%e4%b8%80%e4%b8%aa%e7%9c%9f%e5%ae%9e%e5%ba%94%e7%94%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Patch 要求每个平台一个应用，每个应用会自动建好 Staging 和 Production 两个 deployment。我在面板里建了 <code>maru-ios</code>，又用 <code>cmpatch app create</code> 建了 <code>maru-android</code>，主要是两种方式都看看。建完应用后弹出的对话框是整个产品里对迁移最有用的一屏：两个 deployment key、SDK 需要的两个 URL，还有一句说明：deployment key 不是机密。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoIAAABXRUJQVlA4IHYAAAAQBQCdASogABQAPuFWmkekIqEb9ABDDgT0gF8kHTL70/EPugF6lMUZ0A+0pWU0WRlAAPpGgpz7VeOvAMdDs/q/I00JZTDCozGF/p+yUbXDUjfdJacA2ZbVan+1M3EfSih3MXlM1+RWV02wNY/nmH4Pte3keQAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_ec9ad24dac45b7f9.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="应用创建完成的对话框，显示 Staging 和 Production 的 deployment key 以及 SDK 的 URL"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_bd9fb369cb1053b8.webp"></span></div>
  <figcaption>SDK 需要的一切，一屏看全。</figcaption>
</figure>

<p>两件小事。<code>cmpatch init</code> 当时是把项目关联到已经存在的应用上，所以不管迁移指南怎么暗示，它都不可能是第一条命令。这一点后来修好了：在交互模式下，它现在会自己给每个平台建一个应用。另外，对话框给的 API URL 是 <code>http://localhost:8080</code>，而文档和 CLI 说的是 3000。两个都能用，因为两个端口都同时提供这两种服务，但你会犯嘀咕。</p>
<p>接着我对着还没动过的应用跑了 <code>cmpatch doctor</code>，三秒钟它就找出了一个真问题。它报告 iOS 二进制版本是 2.0.0。应用其实是 2.5.1。</p>
<p>CLI 会找出 <code>ios/</code> 下所有的 <code>Info.plist</code>，如果没有一个路径里带 &ldquo;test&rdquo;，就按字母顺序取第一个。maru 有一个 WidgetKit 扩展，它的目录按字母排在应用本身的目录前面，于是 CLI 拿到的是扩展的 <code>Info.plist</code>。这个 plist 还停在 2.0.0，这是我的 bug，但后果很难看：自动检测会把每一个 iOS release 都对准一个没有任何手机在跑的版本，而且不会报任何错。更新只是永远不会到，正是我在自建服务器那篇里警告过的那种故障。Android 读 <code>versionName</code> 是对的。绕过的办法是传 <code>--target-binary-version</code> 或 <code>--plist-file</code>，我的发布脚本现在会先检查 <code>app.config.js</code> 和两个原生工程的版本一致，然后显式传入版本。后来同一个 bug 又让 <code>doctor</code> 自己的清单检查在一个运行正常的 deployment 上失败，因为它跑去找 2.0.0 版本的清单了。修好过期的 plist 之后，<code>doctor</code> 的 24 项检查全部通过。</p>
<p>Codemagic 后来修复了这个问题。CLI 现在读取的是应用 target 的 <code>Info.plist</code>，而不是扩展的；如果有不止一个 target 可能是应用，<code>cmpatch</code> 会问你是哪一个。</p>

<h2 class="relative group">JavaScript 这一侧
    <div id="javascript-这一侧" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#javascript-%e8%bf%99%e4%b8%80%e4%be%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>去掉 <code>expo-updates</code>、加上 SDK，是两行的改动。把应用原来围绕更新做的那些事换过来，要多想一想。</p>
<p>maru 在启动时和每次回到前台时检查更新，最多每十五分钟一次。发现更新就下载，然后提议重启；你说不，它就在下一次冷启动时应用。设置页里还有一个手动检查，显示当前跑的是哪个 bundle，错误上报器也会给每份报告打上它来自哪个更新的标签。</p>
<p>从 SDK 源码里学到的第一件事是，它在 import 时就用 <code>TurboModuleRegistry.getEnforcing</code> 查找原生模块，模块不存在就直接抛错。maru 还有一个 Web 构建，顶层 import 会把 Web 应用整个搞挂。所以一切都走一个懒加载 SDK 的小模块，配一个什么都不返回的 <code>.web.ts</code> 孪生文件，这样还能把 SDK 彻底挡在 Web bundle 之外。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="c1">// ota.ts (ota.web.ts exports a patchSdk() that always returns null)
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">Platform</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;react-native&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">type</span> <span class="nx">PatchSdk</span> <span class="o">=</span> <span class="k">typeof</span> <span class="kr">import</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">sdk</span>: <span class="kt">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kd">function</span> <span class="nx">patchSdk</span><span class="p">()</span><span class="o">:</span> <span class="nx">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">!==</span> <span class="kc">undefined</span><span class="p">)</span> <span class="k">return</span> <span class="nx">sdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">Platform</span><span class="p">.</span><span class="nx">OS</span> <span class="o">===</span> <span class="s2">&#34;web&#34;</span><span class="p">)</span> <span class="k">return</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">=</span> <span class="kc">null</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sdk</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">)</span> <span class="kr">as</span> <span class="nx">PatchSdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sdk</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">sdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>自动检查本身主要就是 <code>sync()</code>，它从不抛错，而是 resolve 成一个状态：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">status</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">patch</span><span class="p">.</span><span class="nx">sync</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">installMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESTART&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">mandatoryInstallMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESUME&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">status</span> <span class="o">===</span> <span class="s2">&#34;update-installed&#34;</span><span class="p">)</span> <span class="nx">offerRestart</span><span class="p">();</span></span></span></code></pre></div></div>
<p>SDK 对强制 release 的默认值是 <code>IMMEDIATE</code>，不管用户正在干什么都会直接重载 JavaScript。我改成了下次从后台回来时再应用，因为弄丢一篇写了一半的帖子，这个 bug 比大多数修复带来的好处都要糟。当时 <code>InstallMode</code> 是字符串联合类型，不是文档示例里暗示的那种枚举对象；现在它导出为一个常量对象，所以 <code>InstallMode.ON_NEXT_RESTART</code> 可以照文档那样用了。</p>

<h2 class="relative group">原生这一侧，当 ios/ 和 android/ 已经提交进仓库
    <div id="原生这一侧当-ios-和-android-已经提交进仓库" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8e%9f%e7%94%9f%e8%bf%99%e4%b8%80%e4%be%a7%e5%bd%93-ios-%e5%92%8c-android-%e5%b7%b2%e7%bb%8f%e6%8f%90%e4%ba%a4%e8%bf%9b%e4%bb%93%e5%ba%93" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>迁移指南说，加完配置插件后跑 <code>npx expo prebuild --clean</code>。我在另一个 checkout 里跑了，它删掉了随包的图片、我的 Android 网络安全配置、iOS 隐私清单和 <code>Podfile.lock</code>，还把 <code>build.gradle</code> 里一个给测试工具做的修复连同 <code>Info.plist</code> 里的几百行一起回退了。不加 <code>--clean</code>，Expo 照样把两个目录清空。</p>
<p>这不怪 Patch。maru 一开始是托管的 Expo 应用，后来长出了原生定制，所以 <code>ios/</code> 和 <code>android/</code> 是提交进仓库的，EAS 拿它们原样构建。但长这样的应用多得很，指南当时没有提醒。现在有了：Expo 迁移指南分成两条路径，生成原生工程的走 prebuild，已提交原生工程的则列出需要手动应用的具体改动。</p>
<p>插件实际改的东西，其实少到可以手动完成。iOS 上，<code>AppDelegate.swift</code> 里一个 import 加一行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">return</span> <span class="n">CodemagicPatch</span><span class="p">.</span><span class="n">bundleURL</span><span class="p">()</span> <span class="p">??</span> <span class="n">Bundle</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">url</span><span class="p">(</span><span class="n">forResource</span><span class="p">:</span> <span class="s">&#34;main&#34;</span><span class="p">,</span> <span class="n">withExtension</span><span class="p">:</span> <span class="s">&#34;jsbundle&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Android 上，<code>MainApplication.kt</code> 里一个 import 加一个参数（这是 React Native 0.82+ 的写法；更早的版本是改写 <code>getJSBundleFile()</code>）：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nc">ExpoReactHostFactory</span><span class="p">.</span><span class="n">getDefaultReactHost</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="n">jsBundleFilePath</span> <span class="p">=</span> <span class="nc">CodemagicPatch</span><span class="p">.</span><span class="n">getJSBundleFile</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...</span></span></span></code></pre></div></div>
<p>再加上每个平台三个配置值，以及删掉旧的 <code>expo-updates</code> 设置：两个工程加起来新增 13 行、删除 20 行。</p>
<p>插件会在 prebuild 时把那些配置值写成字面量，对于提交了原生工程的项目，这意味着每次构建都只能有一个写死的 deployment。我想让 preview 构建走 Staging、商店构建走 Production，用的还是同一个工程，所以这些值改成从构建环境来。<code>Info.plist</code> 里，Xcode 会展开构建设置：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;key&gt;</span>CodemagicPatchDeploymentKey<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;string&gt;</span>$(PATCH_IOS_DEPLOYMENT_KEY)<span class="nt">&lt;/string&gt;</span></span></span></code></pre></div></div>
<p><code>android/app/build.gradle</code> 里：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="cl"><span class="kt">def</span> <span class="n">patchEnv</span> <span class="o">=</span> <span class="o">{</span> <span class="n">name</span> <span class="o">-&gt;</span> <span class="n">System</span><span class="o">.</span><span class="na">getenv</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="o">(</span><span class="n">findProperty</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="s2">&#34;&#34;</span><span class="o">)</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="n">resValue</span> <span class="s2">&#34;string&#34;</span><span class="o">,</span> <span class="s2">&#34;CodemagicPatchDeploymentKey&#34;</span><span class="o">,</span> <span class="n">patchEnv</span><span class="o">(</span><span class="s2">&#34;PATCH_ANDROID_DEPLOYMENT_KEY&#34;</span><span class="o">)</span></span></span></code></pre></div></div>
<p>这些 key 于是和 <code>EXPO_PUBLIC_</code> 那些值一起放在 <code>eas.json</code> 的各个构建 profile 里。值为空时 SDK 会安静地什么都不做，本地构建没关系，商店构建就是灾难，所以 <code>app.config.js</code> 现在会在 preview 或 production 的 EAS 构建缺了这些值、或者还指向 localhost 时直接抛错。</p>

<h2 class="relative group">第一次更新
    <div id="第一次更新" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac%e4%b8%80%e6%ac%a1%e6%9b%b4%e6%96%b0" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>第一个 release 我改的是引导页的大标题，不用登录就能看到，然后发布：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./scripts/publish-update.sh ios --notes <span class="s2">&#34;Onboarding headline over the air&#34;</span></span></span></code></pre></div></div>
<p>耗时 30 秒，大部分是 Metro 和 Hermes。上传完成 0.6 秒后，服务器的 worker 就发布了清单。这个更新是一个 7.3 MB 的压缩 tarball，而我旧的导出流程产出的是 11 MB 的 Hermes bundle 加资源。</p>
<p>这里有一件事要做对：JavaScript bundle 在构建时会把 <code>EXPO_PUBLIC_</code> 那些值内联进去，所以一次更新必须用和它要落上去的那个二进制相同的值来构建，否则你发出去的 JavaScript 会连到错误的后端。我的脚本从构建那个二进制的同一个 <code>eas.json</code> profile 里加载它们。Patch 的 CI 文档现在也把这一点写清楚了，还说明了 <code>release-react</code> 本身不会读取 EAS profile。</p>
<p>然后我重新启动应用。设备日志显示整个更新检查就是对对象存储的两次请求，<code>meta.json</code> 和 <code>2.5.1/manifest.json</code>。启动三秒后 SDK 写下了一条 <code>Downloaded</code> 事件，把更新暂存好，应用弹出了重启提示。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update-poster.jpg" style="background:url('data:image/webp;base64,UklGRoYBAABXRUJQVlA4IHoBAAAwCgCdASogAEYAPw1oq0omIqIWagaoYxDE9IBo3u6bOACEWXWx1HDG/AwrSOYwsJmgoDuewmmxiJfqdlf28aG4OgV3Vy0nk3lgu8Wi25BY3FxW+lY1wMNQ5gAA/uqWQwe/E/6WeZpIf2UpLel/fKpwqlcmjLMeA7F4ReOblq5eH4qIOx1w7ZF3v0mxWMH8e4hZoalFig747vzom14jgdSClOC5vthxNjGbdjdmv0JuF4Bd3uw2FZbc3Dlojmgl1fUvZqyymqEqzOWLtImgLAZjJhWZgigFmKgGd+GQ0SIKjnJHHdXOZrb/cZMTM1wrOB56DkWBsUxEsixjmVu+Vb3yqJNYglAA21u7aH9XnZ18oNyTlPWugRrsPfZ+/F1gGsmkViDzBGO0f1wXrNBJimlGcHkGkCt7LH9QNjPcy1A9c2dBEltQFPx6XWi1nm9wND9YTEPiTwnjvIduwaNn9YfPJS9b+I1LMURaw77It/AhHjT2gmi4ar24AAA=') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="应用以随包 bundle 重新启动，提示有更新，然后重启进入 release v1">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>重新启动、sync() 返回 update-installed 时应用自己的提示，然后是通过 OTA 送达的 release v1。</figcaption>
</figure>


<h2 class="relative group">bug 是我的
    <div id="bug-是我的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#bug-%e6%98%af%e6%88%91%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>然后我看了看面板。一台模拟器装了 release v1，面板却显示两次成功安装、两个活跃用户。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAADwAwCdASogABQAPw1wqUqmIyGYDAQAYxDE9IABUVMd2rf8uKbf68owAP7WGYB/h1ia3xpTYkbuTZlMczaiJLdSTP+17jDH1qfCOJ+8A+IA+QScXzRlnhc+zceAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_13e5d2d984250bf3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="Staging deployment 显示一台设备对应两个活跃用户和两次成功"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_b455b1f0b69680b5.webp"></span></div>
  <figcaption>一台模拟器，被数了两次。</figcaption>
</figure>

<p>SDK 把自己的状态和待发送的指标事件以文件形式存在应用容器里，追起来很方便。里面有两条 <code>Success</code> 事件，时间戳完全相同，id 不同。我的 hook 在挂载时用 <code>notifyAppReady()</code> 确认当前 bundle，紧接着就调用 <code>sync()</code>，而 <code>sync()</code> 自己也会调用 <code>notifyAppReady()</code>。两次调用重叠了，各自都看到这个 release 处于待确认状态，各自都记了事件。服务器按事件 id 去重，但这两条 id 不同，所以都算数了，而且这些重复是永久的。</p>
<p>我这边的修复是每次启动只做一次共享的确认，其他所有东西都等它。SDK 自己也可以防一下重叠调用，这一点我提了建议。现在它确实这么做了：重叠的 <code>notifyAppReady()</code> 调用，包括 <code>sync()</code> 内部那一次，会共用同一次确认，所以每次安装只记一次。SDK 参考文档现在也写明了 <code>sync()</code> 会自己确认就绪，用它的应用不需要再单独调用 <code>notifyAppReady()</code>。</p>
<p>舒服的地方在于发修复。这是纯 JavaScript 的改动，所以作为 release v2 发了出去，而且因为可以给 v1 上的设备发二进制差分，v2 下载下来是一个 543 KB 的 patch，而不是 7.3 MB 的 bundle，大约是原来的 7%。重启之后：一次下载、一次安装、一次成功、一台活跃设备。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAADwAwCdASogABQAPw1qqksmIqGYDAQAYxDE9IABUVMd2rfRI1O5mmXAAP7WGYB/h1ia3xpTYkbuTZlMczaiJLkoVKAckjyruR4hTL7NmaqeP1qAfIJOLnmwKkXiX44AAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_d07d239ec0e34973.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="发布历史显示 v1 的数字虚高，v2 正确"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_dab633c48fa0be46.webp"></span></div>
  <figcaption>v1 的重复记录留着；v2 数得对。</figcaption>
</figure>


<h2 class="relative group">故意弄坏它
    <div id="故意弄坏它" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%95%85%e6%84%8f%e5%bc%84%e5%9d%8f%e5%ae%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>回滚是我最在意的功能，因为旧方案在设备端什么都没有。一个坏更新会让每次启动都崩溃，直到我注意到并把复选框取消勾选。</p>
<p>所以我发了一个 v3，在第一次渲染之前就抛错，也就是它永远没机会调用 <code>notifyAppReady()</code>。应用下载了它，提示重启，然后死回了主屏幕。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed-poster.jpg" style="background:url('data:image/webp;base64,UklGRjACAABXRUJQVlA4ICQCAADQDgCdASogAEYAPw1+rU4mJCIhKbqpIMYhieIAuzPq4M7sW3lP/kPxexnET5ASwFv8Htr8DNovWXwo2ZQynTi8ui+9q1qzPLfv97/wPQgMDj/+e/pvE9miOHoCzZC5Gv/U0oN8n33d1NMZxA4+qT/uNT35EBYJZa/w3Ts1AUToAP5vfw4j/pjBP6ZaA8LejegsfSPzEPXc0mvX+J1dMRFcTwhgh8BttzpcI4CRjUDkbGoa6UAnxK5kCSatezNL4gQf4nZeHytilXmRtp2c1xohb5/y8HBhWsukQWCAk8rKnNMQYUQJEVbasq2JgftASZSBFtiUkjtFF9K+EYtO0DOtXBWDPeHJf1TqqEHHwl4CQ1zsv9Cx+l8KPR2tUhRR8Ctso49YsumZzdxVCAKQdb9pNx77EfSYM6ALaYnzRt4qTUeUBf3ENt66RmZKq5nGflJ9lEW0oDnWjx6zoAqeJS44t1nUDDIZm1gayu9+nhUWZE+InoYr7YlMmmXX2kklVMVsT0s5xOQqPPlW4UoSUv0NgFVDavqEtwMzPqNuUIjjfwXToJhzDbgUidZqS0gSOrBna2Xj2XcOmf2KfPjtTFgKpl74NNsPw+SkvLP+XIfGRP7f4DZ3SF5r4kITD9QK1Mb4Z3frLXQeA432gyyslKobZUjhHzgpw6Lsv1z4B+zVfYG/nF6OiGZ+SPJQMFRe2LmOB4/oyDsE7meakIUBZvS1zSAAAA==') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="应用在坏 release 上崩溃后的模拟器主屏幕">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>v3 启动即崩。</figcaption>
</figure>

<p>下一次启动又跑了 v3，又崩了，这让我很意外，因为文档说在 <code>notifyAppReady()</code> 之前崩溃的 bundle 会被回滚。源码解释了原因：SDK 允许三次未确认的启动，之后才判定为崩溃，因为一次未确认的启动也可能只是 iOS 在任何 JavaScript 跑起来之前就杀掉了一个健康的进程。第四次启动回到了 v2。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back-poster.jpg" style="background:url('data:image/webp;base64,UklGRsABAABXRUJQVlA4ILQBAABQCgCdASogAEYAPw1mq0umIqGU2q7YYxDE9IBowO6mweBInrUPWk42PSTT65hMmOGGOXZWA/sd5q9BGdjSK2j84FuUL93nyJVQuphNZ6mwXQhv8NVWhs+Q6BIAAP74ukd3WXEH+9Z7Rrx8W8zZkkK5+g1e2Lc8Yi325om3Qy3bb7uYsUutPzhS99cwA1kptzZhyUOs2MW6lH9eP8+SPePVMJGvfSIxSO6tL43kYr+PfLzJw/oJabDsUcKslfz5t3nFCXsiVKRSWBi80g/4Y8CJZiDrEvRejhn6wF7DlE4cfLc64tC8OpWtwMjLNLzfe0pK5MjRdPbq4v5sKsY7T49CjMzJz033ddGNJ1Vmp+9decCnIDCYl2pLqRBhrM5E+D4Cw6TfkPAN/WpX0LXCs9Wr8ACcRRwajxQnbjxLU1tKX+yJyrshjPphgACyyYJKjidt2xw5zVQZcF6XykRe430a0dWMKdcBvnMqq2qBswoqRDNp4NIHmM7r87FSOG3y5ScC9wPZH2OoU+6Am86Nz+s2Tk/J+sj1gZFVQqfqXFWH4AEGF/cO+yLfluiKfZvqAOcwbgAA') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="自动回滚后，应用再次运行在 v2 上">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>第四次启动：回滚到 v2，没有任何人碰过服务器。</figcaption>
</figure>

<p>然后设备自己上报了这次失败，类型是 <code>crash_rollback</code>，面板把它记在了 v3 名下。这个设计合理，但有必要把它的含义说白：一个坏 release 会让每个用户崩三次启动才能恢复，而当时文档没有提这个数字。现在提了，而且这个额度可以配置，原生侧用 <code>CodemagicPatchMaxLaunchAttempts</code>，或者用 Expo 插件的 <code>maxLaunchAttempts</code>。这正是先发 Staging、再小比例发 Production 的理由。</p>
<p>替其他所有人叫停一个坏 release 是一条命令，不到一秒：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cmpatch release rollback --app maru-ios --deployment Staging</span></span></code></pre></div></div>
<p>它不改写历史。它把上一个 release 重新发布成一个新的（v4，标记为 v2 的回滚），所以崩溃的那个 release 连同它的失败计数一起留在列表里，这是诚实的记录。面板上通过一个对话框做同样的事。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAABwBACdASogABQAPw1qp0emIyGYBABjEMT0gGfQEs2nvxX7EOeFbRn3H5Si4AD+1iXJGzIunVk/ewlUVdIUbFiwAGX5QAkFFcZDl2at4PkETvjCXCOVoXFdscz2ut9AAAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="在面板里回滚 Staging：上一个 release 被重新发布为 v4">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback.mp4" type="video/mp4">
    </video></div>
  <figcaption>在面板里回滚：v2 以 v4 的身份回来，v3 留在列表里。</figcaption>
</figure>

<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmoAAABXRUJQVlA4IF4AAAAwBACdASogABQAPw1op0emIyGYBABjEMTygAFRZbu1b6Gdq9jYHm/TLAAA/tYZgH+HWJrfGlNiRvwNU+RVn1Uhl5QjE2FNRxQPa3YDnfLX9O0wDNn7xOtAkLPDgAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5552e433d6a65a3f.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="回滚后的发布历史，v4 标记为回滚，v3 显示一次失败"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5066c51b1ba693c2.webp"></span></div>
  <figcaption>v4 是回滚；v3 留着它的失败记录。</figcaption>
</figure>


<h2 class="relative group">Android
    <div id="android" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#android" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Android 有两个意外，都不是 Patch 的问题。我给 SDK 加的 R8 keep 规则放在 <code>app.config.js</code> 里，而它只能通过 prebuild 才能到达原生工程，所以我第一个 release 构建等于无意中测试了 SDK 在没有 keep 规则的情况下能不能扛住 R8。能：R8 把它所有的类都重命名了，更新照样下载、安装、回滚，一切正常。另外，刚冷启动的模拟器加上还占着内存的 Gradle，慢到我的第一次测试在 SDK 完成那个早已开始的下载之前 45 秒就放弃了。</p>
<p>Release v1 应用成功，一次成功、一台活跃设备，一个会崩的 v2 在同样的三次启动额度之后回滚了。</p>
<figure>
  <div class="phone-frame" style="aspect-ratio: 900 / 1892; --screen-radius: 9.85% / 4.43%"><span class="phone-frame__screen phone-frame__ph" aria-hidden="true" style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%; background:url('data:image/webp;base64,UklGRsgBAABXRUJQVlA4ILwBAABQCwCdASogAEcAPw1wr06mIqIhJngMAMYhiekAzRAKJZ22yvEtm7/GFRCOt1Z6YnyGNWf+i7i0ewagB55W+rOp9DBjbctVlnDXK6lGDu9FH315DSWJUMBYkPGJY2+xw/B5QDAA/vURG5LVrYKJoO2SXy+UxQiUlovs8pxF48eKxgmVxH4hV0qd23H4fTbOXId4reHVkO/ZtsoHNdCHkv0JWiIqSQvxGntYFegdbOEkel+at+Jvs/b4yghmeIXnBH7M31JdyN1H2WvMPx2TwLZfiUh7Vs/c0xs3ET28MPX4PV32jfA2Qo55LcWZBHZVnPmnDwZLLo2Dv++B0SqBKWqbTWPQKOgzF+6YGKB7vcORmV6iyLh5aW30GEJ6lcPq8nat/61jd9i9CZePrzdLJPqGGJyPCbsjPkfhTd6BEdIQraUcOjRfSxAroG5B9nslpJZA63tGk2R93MKzS48w8pEUJKeCNc9lq7Q91gnOgYXePvVHH49kJkJCf5DnRUSgdBqJS0Dh6nG2R7OcW67l22CzkV09rMSoVJlTLhktX5d3Ximn/zbkXSgGUos1pjcQR73ncJ+50rXoCD05SAA=') center/cover no-repeat"></span>
    <img
      class="phone-frame__screen"
      style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp"
      width="600" height="1333"
      alt="Android 应用显示通过 OTA 送达的大标题"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp">
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/pixel-8-frame_hu_52e6b4e761a590c9.webp" width="900" height="1892" alt="" aria-hidden="true" decoding="async">
  </div>
  <figcaption>Android：回滚后重新回到 v1。</figcaption>
</figure>


<h2 class="relative group">顺利路径之外
    <div id="顺利路径之外" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%a1%ba%e5%88%a9%e8%b7%af%e5%be%84%e4%b9%8b%e5%a4%96" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>更新能到、能回滚是核心，但一个团队天天用的是其他那些功能，所以我一个一个过了一遍。</p>
<p>灰度发布是确定性的，这点我喜欢。<code>md5(deviceId + &quot;-&quot; + releaseLabel)</code> 的前八位十六进制数字对 100 取模，小于灰度百分比的设备就在圈内，所以同一台手机对某个 release 要么一直在、要么一直不在。我算出模拟器在下一个 release 里落在 91 号桶，按 25% 发布，模拟器果然什么都没收到。用 <code>cmpatch release patch</code> 提到 95%，不到一秒，下次启动它就更新了。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnIAAABXRUJQVlA4IGYAAAAQBACdASogABQAPw1qqEemIyIYBABjEMT0gGH0Es2nvxX9G/4/1g7tAAD+1iXJGzIunVcHqQBmnStoD1oAHJUjpBT6bKy8GQx+Yo2Ka5cGJlghQl6jWcyOWHRQDYW9x5JJDLafQAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="在面板里把一个 release 的灰度从 25% 提到 95%">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout.mp4" type="video/mp4">
    </video></div>
  <figcaption>把部分灰度从 25% 提到 95%，没有重新发布。</figcaption>
</figure>

<p>接着我的下一个 release 失败了，返回 409：&ldquo;deployment has an active rollout below 100 percent&rdquo;。每个 deployment 同时只允许一个部分灰度，所以你得先把金丝雀发完、禁用或者回滚，才能发别的。这合理，也有文档，但一条每次合并都发布的流水线会在金丝雀进行到一半时撞上它。我反馈之后，CLI 在这里会把话说清楚了：如果是一个进行中的部分灰度挡住了你，它会告诉你等也没用，以及怎么把灰度发完或禁用；如果是另一个发布任务还在跑，它会让你用 <code>release inspect --wait</code> 等着。文档现在也有一节专门讲按顺序进行的发布变更。</p>
<p>强制 release 用的是 SDK 的 <code>mandatoryInstallMode</code>。我设的是下次回到前台时应用，应用下载了更新并弹出提示，我无视提示，把应用切到后台再切回来，新版本就直接在那里了。</p>
<p>禁用一个 release 做的不只是停止提供它。我一禁用最新的那个，清单就立刻指向了仍然启用的最新 release，禁用版本上的设备会被推一个回到它的 patch。把某个二进制版本的 release 全部禁用，清单的目标就变成 null，SDK 会把应用退回到它随二进制自带的 bundle。我的更新 hook 起初没有对这种情况弹提示，现在有了。</p>
<p>从 Staging 提升到 Production 不到一秒，而且复用的正是测试过的那个包，同样的哈希和备注，不会重新构建。它还接受一个灰度百分比，所以“提升到 Production 的 10%”就是一条命令。</p>
<p>构建和发布也可以拆成两步。<code>cmpatch bundle</code> 产出一个 <code>.cmpatch</code> 产物，面板的 &ldquo;Bundle upload&rdquo; 选项会在上传前读取它：平台、目标版本、指纹、签名状态、大小和打包器。这适合 CI 构建产物、再由别人决定什么时候发布的场景。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnwAAABXRUJQVlA4IHAAAAAwBACdASogABQAPw14sE+mI6IhKAgAxiGJ6QAAM7aVPPd3tO2d2B9IEAAA/tYlyRsyLp1nLc5Hh5nniWZ+UIxN4AAKrWhTSPPLnDPJV90MvTw1QAmDXcvY3C3ghY+Uo+Wdxkjji3HNEgzIPqpIwAAA') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="在面板里上传 .cmpatch 产物：先读出平台、版本、指纹和大小，然后警告指纹不一致">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload.mp4" type="video/mp4">
    </video></div>
  <figcaption>bundle 上传：面板先读产物，然后指纹保护介入。</figcaption>
</figure>

<p>也是在这里，我真正撞上了指纹保护。修好过期的 widget plist 之后，服务器以 409 拒绝了我的下一个 release，因为原生工程的指纹和它为 2.5.1 记录的那个对不上了。Android 上，改一下 <code>app.config.js</code> 里的注释就够触发了，因为 Expo 把应用配置也算进指纹。面板把同样的检查显示成一条警告，带两个指纹和一个 &ldquo;Upload anyway&rdquo; 按钮。这两次已安装的二进制上都没有任何原生的东西变过，所以覆盖是对的。但在真实使用里，诚实的答案几乎总是升版本号、重新做一次商店构建，我的发布脚本现在遇到这种情况就会这么说。</p>
<p>代码签名用的是 RS256：CLI 用 RSA 私钥给包的哈希签名，SDK 用编译进二进制的公钥校验。公钥以一行 base64 的形式放进去，正好契合我走构建设置的做法，两个平台我都测了。应用里带了公钥之后，未签名的 release 在下载前就被拒了。给应用打开 &ldquo;require code signing&rdquo; 后，服务器拒绝了未签名的发布，错误信息里写明了修法并链接到指南。签名的 release 正常安装，SDK 记录签名为已验证。当我禁用签名的那个 release、服务器退回到一个更早的未签名 release 时，SDK 也把它拒了。要注意的是，公钥必须在你发出的第一个二进制里就带上，因为更新没法把它加进去。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRnAAAABXRUJQVlA4IGQAAABwBACdASogAB0APw14sE+mI6IhKAgAxiGJ6QAAM+3OuAEp3oUrP1GNW2QgAAD+1hmAf4eqLtdnP/9v1g2ZTHRXrrujcZyW6apQhqigB3MdqokDYA7HESmkf+QYJtIlWU+VYAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_2173e76e5a1b2cec.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="1264"
      alt="Release 详情显示签名为 Signed, sha256"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_f18ca5bb6112ee2a.webp"></span></div>
  <figcaption>一个签过名的 release。</figcaption>
</figure>

<p>团队和 CI 也都覆盖到了。成员会拿到四种团队级角色之一（owner、admin、developer、viewer），邀请会一直等到对方第一次登录。个人访问 token 在空的 home 目录下也能用，通过 <code>--token</code> 或环境变量传入都行，token 列表会显示每一个上次使用的时间。我还写了一个手动触发的 CI workflow，在 Linux runner 上跑同一个发布脚本，签名密钥来自 secret，不过还没跑过，因为它需要托管的服务器。</p>
<p>有一个工具当时我会跳过，<code>cmpatch debug ios</code>。它按 &ldquo;OTA&rdquo; 不区分大小写过滤系统日志流，而这会匹配到 &ldquo;Rotation&rdquo;，于是一次二十秒的应用启动产出了满屏的 SpringBoard 消息，应用自己的一条都没有。这已经修好了：它现在只显示带 SDK 标记的行。SDK 本身日志不多，主要是错误和开发构建下的警告，所以输出会很安静。你可能看到的那条 <code>getpwuid_r</code> 警告来自 <code>xcrun simctl</code>，不是 Patch。</p>

<h2 class="relative group">到底有多难
    <div id="到底有多难" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%88%b0%e5%ba%95%e6%9c%89%e5%a4%9a%e9%9a%be" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Codemagic 想听我的看法，那就尽量说得直白。</p>
<p>试用很容易。两条命令、几分钟，你就有了真正的服务器、一个带演示数据的面板，和一个可以对准自己应用的 CLI。我会说这是头一个小时里最好的部分。</p>
<p>集成 SDK 是中等工作量，而且难点大多在你自己的项目里，不在 Patch。在一个原生目录是生成出来的新 Expo 应用上，确实就是插件加几行替换 <code>expo-updates</code> 调用的代码。在 maru 这样的应用上，原生工程已提交、有 widget 扩展、有 Web 构建、有 R8 和构建 profile，活儿都在边边角角。我得手动应用原生改动来避开 prebuild，把配置改成构建时注入好让 Staging 和 Production 构建能不一样，还要把 SDK 挡在 Web 之外。我还得保证更新里的 JavaScript 和二进制用同样的环境构建。搞明白之后没有一件是难的。我做的时候，这些大多不在文档里，不过已提交原生工程的路径和 bundle 构建时的环境，现在都写进去了。</p>
<p>日常使用很好。发布是一条命令，半分钟左右。灰度、提升、禁用和回滚，从 CLI 或面板操作都不到一秒，错误信息会说出了什么问题、该怎么办。服务器的那些保护（指纹检查、同时只能一个金丝雀、签名）严格得不止一次拦住我做错事。</p>
<p>在生产环境跑它是更大的承诺。它是一套带 Postgres 和对象存储的 Docker Compose 栈，你得托管、备份、升级，还需要两个域名和一个 OAuth 应用。对已经在跑服务器的团队，这是一个安静的下午。对那些正因为不想碰服务器才用 EAS 的团队，这才是切换的真实成本，比 SDK 那部分工作更大。</p>
<p>按挂钟时间算，我 12:29 装好 CLI，13:34 第一个更新在模拟器上应用成功，其中半小时是一次 Xcode 构建卡住了，原因和 Patch 无关。这篇文章里的所有东西，两个平台，都在下午过半之前做完了，托管服务器不算在内。</p>
<p>最耗我时间的几件事，按顺序：<code>expo prebuild --clean</code> 抹掉了我提交的原生工程，然后是研究怎么手动接好一切；iOS 版本检测的 bug，它会把更新发给一个没人在跑的版本；重复计数的安装，虽然是我自己造成的，但任何人都很容易重蹈，因为 <code>sync()</code> 自己会调 <code>notifyAppReady()</code>；还有没写进文档的三次启动崩溃额度，在一次测试进行到一半时把我吓了一跳。再往后是一些本身正确、但需要规划的事：无害的原生相关改动触发指纹保护，每个 deployment 同时只能一个金丝雀，以及不能重叠的生命周期任务，一旦脚本要连着做几件事，这就很要紧。剩下的都是小事：首次运行时静默的搭建过程、一条吵闹的调试命令，还有几个当时和代码对不上的文档示例。</p>
<p>这些我都连同细节一起写给了 Codemagic，到 9 月 23 日，大部分已经修复：版本检测、重叠的确认、prebuild 的提醒、崩溃额度、静默的搭建过程、调试命令、<code>InstallMode</code> 的示例，以及发布任务或金丝雀挡路时给出的报错。还没解决的都是小事：面板显示 API 在 <code>:8080</code>，而其他地方都说 <code>:3000</code>；SDK 在 Web 和 Jest 里一 import 就抛错；以及文档里的几处空白。</p>

<h2 class="relative group">上生产
    <div id="上生产" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%8a%e7%94%9f%e4%ba%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上面的一切都是在评估栈上跑的。生产就是同一个应用、同一套脚本对准一台真实的服务器，下面是 maru 的计划。</p>
<!-- 编辑备注：本节是操作步骤。真正切换之后，替换成实际发生的情况（主机、耗时、出了什么问题）。 -->
<p>官方支持的服务器部署方式是 Docker Compose 安装：一台开放 80 和 443 端口的 Linux 机器、两个域名（一个给 API 和面板，一个给下载），以及一个用于登录的 GitHub OAuth 应用，没有它服务器不会启动。安装器负责剩下的事，包括 TLS：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">scripts/selfhost/install.sh <span class="se">\
</span></span></span><span class="line"><span class="cl">  --api-domain updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --storage-domain storage.updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --email you@example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --github-oauth-client-id &lt;id&gt; --github-oauth-client-secret &lt;secret&gt;</span></span></code></pre></div></div>
<p>它会写一个 <code>.env.selfhost</code>，里面是数据库和存储的密钥，应该放进你的密码管理器。</p>
<p>不过你不一定要用自带的数据库和存储，这正是它能嵌进我这种栈的原因。maru 的 API 已经跑在 Fly.io 上，文件放在 Tigris，Patch 在两者上都能工作。Compose 文件底下，服务器就是一个以 <code>MODE=all</code>（API 和 release worker 一起）运行的容器，需要一个 Postgres 数据库、S3 兼容存储，和一个供文件访问的公开 HTTPS URL。也就是一个用仓库的 Dockerfile 构建的 Fly 应用（目前还没有发布的镜像，所以由 <code>fly deploy</code> 来构建）、一个 Fly Postgres 数据库，和一个 Tigris 桶：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">MODE</span><span class="o">=</span>all
</span></span><span class="line"><span class="cl"><span class="nv">DATABASE_URL</span><span class="o">=</span>postgresql://…            <span class="c1"># Fly Postgres</span>
</span></span><span class="line"><span class="cl"><span class="nv">STORAGE_ADAPTER</span><span class="o">=</span>s3
</span></span><span class="line"><span class="cl"><span class="nv">S3_ENDPOINT</span><span class="o">=</span>https://fly.storage.tigris.dev
</span></span><span class="line"><span class="cl"><span class="nv">S3_REGION</span><span class="o">=</span>auto
</span></span><span class="line"><span class="cl"><span class="nv">S3_BUCKET</span><span class="o">=</span>myapp-patch
</span></span><span class="line"><span class="cl"><span class="nv">S3_FORCE_PATH_STYLE</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">PUBLIC_BASE_URL</span><span class="o">=</span>https://myapp-patch.fly.storage.tigris.dev/codemagic-patch</span></span></code></pre></div></div>
<p>两点提醒。在 Compose 之外运行，仓库里的文档把它定位为参考材料，而不是这个首个开源版本里受支持的路径，所以在 Fly 上，升级和备份得靠你自己。我也还没这样部署过；这是计划，不是报告。另外，如果你读过我写 <a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >Fly 缩容到零</a>的那篇，记得至少留一台机器在跑。手机做更新检查时从不等 API，因为清单直接来自存储，但不然的话，每一次指标上传和每一个 release 任务都会从一次冷启动开始。</p>
<p>Tigris 是我最期待的部分。maru 的文件本来就在那儿，而且它在每次应用启动都要访问的那个 URL 前面放了一层 CDN。这么做的话要留意清单的缓存：用普通的 base-URL 分发适配器时，Patch 会把清单以 <code>no-cache</code> 提供，因为它没法清 CDN，而一份按自己 TTL 缓存的清单会一直提供你刚刚回滚掉的那个 release。配置参考现在在 <code>MANIFEST_CACHE_CONTROL</code> 下写明了这一点，文档站也讲到了 S3 兼容存储。</p>
<p>在托管服务器上，重新建一遍应用，把新的 key 放进 <code>eas.json</code> 的 <code>preview</code> 和 <code>production</code> profile（分别是 Staging 的 key 和 Production 的 key），再创建一个用于发布的 token：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cmpatch token create --name ci</span></span></code></pre></div></div>
<p>想要代码签名的话，现在就定：生成生产环境的密钥对，私钥作为 CI secret 保管，公钥放进构建 profile，然后给两个应用都打开 &ldquo;require code signing&rdquo;。第一个 Patch 二进制还需要一个新版本号，因为商店里已有的二进制跑的是 <code>expo-updates</code>。先用 preview profile 构建给 TestFlight 和 Play 内部测试，交给任何人之前，先确认 key 真的进了构建出来的 <code>Info.plist</code> 和 Android 资源。这是我本地测试唯一没有覆盖到的一环，因为环境变量是我手动导出的。然后发到 Staging，确认每个测试者一次下载、一次成功，之后才构建商店版本。</p>
<p>生产 release 从小开始，逐步放大：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./scripts/publish-update.sh ios --production --rollout <span class="m">10</span>
</span></span><span class="line"><span class="cl">cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage <span class="m">100</span></span></span></code></pre></div></div>
<p>旧服务器这期间继续开着。所有还没更新的 2.5.1 副本仍然在向它检查，给它们的热修复也仍然走旧脚本。等日志显示那些请求停了，旧服务器连同它的发布脚本和密钥就可以全部删掉。</p>

<h2 class="relative group">第一次原生升级
    <div id="第一次原生升级" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%ac%ac%e4%b8%80%e6%ac%a1%e5%8e%9f%e7%94%9f%e5%8d%87%e7%ba%a7" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><em>2026 年 10 月 16 日补充。</em></p>
<p>从 10 月初开始，maru 的生产更新就交给 Patch 来发了。那之后的第一次原生发布是升级到 Expo SDK 58：React Native 0.88，Android 上还有 Gradle 插件的一个新大版本。原生改动没法 OTA，所以这是一个新的二进制 2.6.0，而我的发布脚本在这个二进制存在之前会拒绝发 2.6.0 的更新。两个平台第一次就都干净地构建成功了。</p>
<p>上面的生产部分说过，交给任何人之前，先确认 key 真的进了构建出来的 <code>Info.plist</code> 和 Android 资源。这次我真的查了，而这一节正是因为这次检查才有的。iOS 构建里四个 Patch 设置都在。Android 构建里一个都没有：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">bundletool dump resources --bundle<span class="o">=</span>build.aab --values <span class="p">|</span> grep -A1 <span class="s2">&#34;string/Codemagic&#34;</span></span></span></code></pre></div></div>
<p>这条命令对 2.5.11 的构建打印出四个 key，对 2.6.0 什么都没打印。问题不在环境变量：Gradle 的 <code>generateReleaseResValues</code> 四个都写了。它们是之后被 release 构建的资源缩减器（resource shrinker）删掉的，而它在 Android Gradle Plugin 9 里激进了很多。同一个应用的资源从 9,240 个降到了 7,116 个。SDK 用 <code>getIdentifier</code> 按名字读取配置，缩减器看不到这种用法，于是把这四个字符串当成没用的删掉了。缺失的字符串读出来是空的，而空意味着&quot;没有更新&quot;。什么都不会失败。Android 2.6.0 会正常安装、正常运行，却永远收不到更新，唯一的迹象是面板上一台 Android 2.6.0 设备都没有。</p>
<p>修复是一个资源文件，告诉缩减器保留这四个字符串：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="c">&lt;!-- android/app/src/main/res/raw/codemagic_patch_keep.xml --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;resources</span> <span class="na">xmlns:tools=</span><span class="s">&#34;http://schemas.android.com/tools&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="na">tools:keep=</span><span class="s">&#34;@string/CodemagicPatch*&#34;</span> <span class="nt">/&gt;</span></span></span></code></pre></div></div>
<p>别把它叫做 <code>keep.xml</code>：React Native 的 bundle 任务已经会为你的 JavaScript 资源生成一个 <code>raw/keep.xml</code>（图片和字体能幸存就是因为它），两个文件会冲突。这条规则更合适的位置是 SDK 本身，因为库的 keep 文件会自动合并进应用；而 release 构建在没有 deployment key 的情况下启动时，只要有一条警告，就能把这种悄无声息的情况变成看得见的。这和前面 iOS 版本检测的 bug 是同一类失败：什么都不报错，更新就是永远不来。</p>
<p>同一次检查还发现了第二个问题，跟 Patch 无关，但出于另一个原因值得知道。Android 的 release 构建一启动就崩溃，而且崩了两次，原因是 Expo 自己模块里的两个不同问题。第一个是 Expo 的 widget 模块没能注册它的原生类，所以应用在任何 JavaScript 运行之前就挂了。修好之后，应用能走到启动 JavaScript 这一步，然后就停住了，因为有三个已经迁到 Expo 新模块系统的模块从来没被注册。这两种情况 Patch 的崩溃回滚都帮不上忙，因为根本没有可以回滚的更新。它保护你不受坏更新的影响，而不是坏二进制。不管用不用 Patch，原生升级在上商店之前都得在真机或模拟器上装好、打开一次。</p>
<p>同一轮测试还带出了一件小事。<code>sync()</code> 失败时会返回 <code>&quot;error&quot;</code>，除非应用跑在开发模式下，否则不会告诉你原因，所以 release 构建只能在日志里记一句出错了。现在我的 hook 在 sync 失败后会调用一次 <code>checkForUpdate()</code>，把它抛出的错误记下来，我就是这样发现问题不是服务器坏了，而是一个本地模拟器构建的 Patch URL 是空的（<code>unsupported URL</code>）。如果最后一次的错误在 release 构建里也能读到，会有帮助。</p>
<p>我的原生发布检查清单现在多了以前没有的三行：从构建出来的 <code>.ipa</code> 和 <code>.aab</code> 里导出 Patch 设置，把 deployment key 和构建 profile 对一遍，以及在提交前启动 release 构建本身，而不是 debug 构建。</p>

<h2 class="relative group">想对正在付 Expo 账单的人说的话
    <div id="想对正在付-expo-账单的人说的话" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%83%b3%e5%af%b9%e6%ad%a3%e5%9c%a8%e4%bb%98-expo-%e8%b4%a6%e5%8d%95%e7%9a%84%e4%ba%ba%e8%af%b4%e7%9a%84%e8%af%9d" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你付费用 EAS Update 是因为搭服务器看起来太费事，Patch 是一个实在的答案：你确实要跑一台服务器，但那是一套 Compose 栈和一个安装器，不是你要维护的代码。和我自己搭的那套相比，你会得到灰度发布、每个 release 的安装和失败数字、二进制 patch、指纹保护、代码签名、设备端的崩溃回滚，和一个回滚按钮。这些我一个都没做过。</p>
<p>代价是：在任何人收到 Patch 更新之前先要发一个新二进制，因为 SDK 得进到应用里；一台要持续升级和备份的服务器；release 坏得够彻底时每个用户三次崩溃启动（默认值）；以及原生工程已提交的话，一些手工活。它也还年轻。这是 0.3.0 版本，我碰到了一个版本检测 bug、一条吵闹的调试命令和文档里的几处空白，还自己造了一个 SDK 本可以防住的 bug。版本检测和调试命令后来都修好了，文档的空白大多补上了，SDK 现在也能防住我犯的那个错。这些都没有挡住迁移，而且全都看得见，在 CLI 里、在面板里、或者在设备上 SDK 自己的文件里，这一点我的旧方案可说不上。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>不用 EAS，自建 Expo OTA 热更新：用 Django 和 Tigris 搭更新服务器</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/</guid>
      <pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Django</category>
      <category>Mobile Development</category>
      <category>CI/CD</category>
      <category>Self-Hosted</category>
      <category>Tigris</category>
      <category>S3</category>
      <description>Expo 的 OTA 热更新是怎么工作的，以及我怎么不用 EAS Update 自己托管：一台实现 Expo Updates 协议的 Django REST 服务器，bundle 放在 Tigris S3 上，每月成本大约一美元。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>2026 年 9 月 13 日更新。</strong> 这篇发出之后，Codemagic 发布了 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>，一个 CodePush 路线的<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">开源</a>、可自托管 OTA 服务器。如果你是因为不想自己写服务器才找到这里的，它现在是一个实在的选项：我已经<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >把一个应用从下面这套方案迁到了 Patch</a>，并把过程写了下来，<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/#%e5%86%99%e5%9c%a8%e6%9c%80%e5%90%8e" >写在最后</a>一节里也有一段简短说明。声明：那次迁移和那篇文章是 Codemagic 付费委托的，他们也希望在这里提一句。正文里的代码后来已精简为简短的示例。</p>
</blockquote><p>我做的报价应用，bug 修复都是通过 OTA 推送的：JavaScript 改动不用等 App Store 审核，直接下发到用户手机上。Expo 官方的 EAS Update 本来就能做这件事，但我还是用 Django REST Framework 和 Tigris S3 自己搭了一套。从那以后，iOS 和 Android 的更新全都走这台服务器。</p>
<p>Expo 的 OTA 更新（也就是大家常说的热更新）是这样运作的：应用里的 <code>expo-updates</code> 库向更新服务器请求一份清单，请求头里带着自己的平台和运行时版本。服务器返回最新的兼容更新，以及这次更新的 JavaScript bundle 和资源文件的地址，应用把它们下载下来，下次启动时就跑新的 bundle。EAS Update 是 Expo 官方托管的一种实现，但任何实现了这套协议的服务器都能用，我自己搭的就是这样一台。</p>
<p>这篇文章把整个方案过一遍：数据模型、清单（manifest）接口、发布流水线，还有那些只有真正跑起来才会踩到的坑。</p>

<h2 class="relative group">为什么要自己搭
    <div id="为什么要自己搭" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a6%81%e8%87%aa%e5%b7%b1%e6%90%ad" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>对我来说主要是钱的问题。EAS Update 按用量计费，用户多了、更新频繁了，账单就跟着涨；而 Tigris 这类 S3 兼容存储几乎不要钱。当然还有别的理由：有些行业要求应用资产必须留在自己的基础设施里；服务器在自己手上，整个更新流程就都可控，想按用户群灰度、想做 bundle 的 A/B 测试都行。另外，更新链路也不再受 Expo 服务可用性或下一次调价的影响。</p>

<h2 class="relative group">整体架构
    <div id="整体架构" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%95%b4%e4%bd%93%e6%9e%b6%e6%9e%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>一共四个部分：Django 后端负责下发更新清单、保存元数据；Tigris 存放 bundle 和资源文件本体；一个发布脚本负责导出、上传、登记更新；再加上移动应用本身，把更新地址指向我的服务器而不是 Expo 的。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Mobile App    │
</span></span><span class="line"><span class="cl">│  (expo-updates) │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 1. Request manifest
</span></span><span class="line"><span class="cl">         │    (with headers: platform, runtime-version)
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│  Django Server  │
</span></span><span class="line"><span class="cl">│  /api/expo-     │◄─── 2. Query DB for latest update
</span></span><span class="line"><span class="cl">│   updates/      │
</span></span><span class="line"><span class="cl">│   manifest/     │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 3. Generate presigned URLs
</span></span><span class="line"><span class="cl">         │
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Tigris S3     │
</span></span><span class="line"><span class="cl">│  (Asset Files)  │◄─── 4. App downloads bundles directly
</span></span><span class="line"><span class="cl">└─────────────────┘</span></span></code></pre></div></div>

<h2 class="relative group">具体实现
    <div id="具体实现" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%b7%e4%bd%93%e5%ae%9e%e7%8e%b0" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">两个模型
    <div id="两个模型" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%a4%e4%b8%aa%e6%a8%a1%e5%9e%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>一切都建立在 <code>ExpoUpdate</code> 和 <code>ExpoUpdateAsset</code> 之上。前者保存每次更新的元数据：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdate</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">choices</span><span class="o">=</span><span class="p">[(</span><span class="s2">&#34;ios&#34;</span><span class="p">,</span> <span class="s2">&#34;iOS&#34;</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;android&#34;</span><span class="p">,</span> <span class="s2">&#34;Android&#34;</span><span class="p">)],</span>
</span></span><span class="line"><span class="cl">        <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">is_active</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">JSONField</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">TextField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">created_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">        <span class="p">]</span></span></span></code></pre></div></div>
<p>这里有几个刻意的设计。<code>runtime_version</code> 对应 <code>app.json</code> 里的 <code>runtimeVersion</code>，客户端只会下载和自己运行时版本一致的更新，所以这个字段很关键。iOS 和 Android 的 bundle 不一样，所以分开存。<code>is_active</code> 是回滚开关：把有问题的更新停用，客户端就会退回上一个版本。<code>manifest_data</code> 直接把完整的 Expo Updates v1 协议清单存成 JSON，下发的时候取出来就行。</p>
<p><code>ExpoUpdateAsset</code> 记录每个文件：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateAsset</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="s2">&#34;assets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">hash</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">content_type</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_extension</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_path</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">500</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_size</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span></span></span></code></pre></div></div>
<p>资源用 SHA-256 哈希来引用，天然不可变、好缓存，同一个资源可以被多次更新复用。</p>

<h3 class="relative group">清单接口
    <div id="清单接口" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b8%85%e5%8d%95%e6%8e%a5%e5%8f%a3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>应用和服务器真正打交道的地方是 <code>/api/expo-updates/manifest/</code>，实现的是 <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates v1 协议</a>：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;get&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;manifest&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">manifest</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Extract required headers</span>
</span></span><span class="line"><span class="cl">    <span class="n">protocol_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PROTOCOL_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PLATFORM&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_RUNTIME_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Validate protocol version</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">protocol_version</span> <span class="o">!=</span> <span class="s2">&#34;1&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Unsupported protocol version: </span><span class="si">{</span><span class="n">protocol_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="n">status</span><span class="o">=</span><span class="mi">400</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Find latest active update for this runtime + platform</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">order_by</span><span class="p">(</span><span class="s2">&#34;-created_at&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># No update available - client uses embedded bundle</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">update</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span><span class="p">[</span><span class="s2">&#34;expo-protocol-version&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate presigned URLs for all assets</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">build_manifest</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># fills in presigned asset URLs</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">manifest_data</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span></span></span></code></pre></div></div>
<p>有三个细节值得注意。没有更新时返回 204，应用会继续用内置的 bundle。清单里的资源地址是预签名 URL，应用直接从 Tigris 的 CDN 下载，不用整个流经 Django。响应头里的 <code>expo-protocol-version</code> 不能省，客户端真的会检查。</p>

<h3 class="relative group">发布更新
    <div id="发布更新" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8f%91%e5%b8%83%e6%9b%b4%e6%96%b0" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>发布靠一条 Django 管理命令，外面再包一层 shell 脚本。管理命令负责读取 <code>expo export</code> 的输出、给每个资源算 SHA-256 哈希、把 bundle 和资源并行传到 Tigris、写数据库记录，需要的话还会往桶里放一份用于生产同步的导入 JSON。精简到关键部分大致是这样：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.db</span> <span class="kn">import</span> <span class="n">transaction</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">publish</span><span class="p">(</span><span class="n">platform</span><span class="p">,</span> <span class="n">runtime_version</span><span class="p">,</span> <span class="n">manifest</span><span class="p">,</span> <span class="n">files</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Upload the bundle and assets in parallel (keys are content hashes)</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">list</span><span class="p">(</span><span class="n">executor</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">files</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Swap the active update in one transaction</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">manifest_data</span><span class="o">=</span><span class="n">manifest</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span></span></span></code></pre></div></div>
<p>平时真正敲的是外面这层脚本：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Publish to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Dry run to validate</span>
</span></span><span class="line"><span class="cl">./publish.sh --dry-run</span></span></code></pre></div></div>
<p>它会在导出前加载生产环境变量，平台可以只发一个也可以两个都发，还顺带处理下一节要讲的生产同步。</p>

<h3 class="relative group">同步到生产环境
    <div id="同步到生产环境" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%90%8c%e6%ad%a5%e5%88%b0%e7%94%9f%e4%ba%a7%e7%8e%af%e5%a2%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>我不想把生产环境的凭证放在自己笔记本上，所以生产发布分两步：先在本地发布（资源传到 Tigris，同时生成一份元数据的 JSON 快照），再带着 S3 路径调用生产环境的 API，让服务器自己把元数据导进去：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;post&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;import-update&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">import_update</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Authenticate via Bearer token</span>
</span></span><span class="line"><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_AUTHORIZATION&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="s2">&#34;Bearer &#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">settings</span><span class="o">.</span><span class="n">IMPORT_TOKEN</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Download the import JSON from the bucket</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_key</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;s3_key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">obj</span><span class="p">[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Import into the production database</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">update</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="nb">id</span><span class="o">=</span><span class="n">data</span><span class="p">[</span><span class="s2">&#34;id&#34;</span><span class="p">],</span> <span class="n">defaults</span><span class="o">=</span><span class="p">{</span><span class="o">...</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">asset_data</span> <span class="ow">in</span> <span class="n">data</span><span class="p">[</span><span class="s2">&#34;assets&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">            <span class="n">ExpoUpdateAsset</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Clean up the import JSON</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;update_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">update</span><span class="o">.</span><span class="n">id</span><span class="p">)})</span></span></span></code></pre></div></div>

<h3 class="relative group">让应用指向自己的服务器
    <div id="让应用指向自己的服务器" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%ae%a9%e5%ba%94%e7%94%a8%e6%8c%87%e5%90%91%e8%87%aa%e5%b7%b1%e7%9a%84%e6%9c%8d%e5%8a%a1%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>在 <code>app.json</code> 里配置更新地址和运行时版本：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expo&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;runtimeVersion&#34;</span><span class="p">:</span> <span class="s2">&#34;1.0.0&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updates&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://api.example.com/api/expo-updates/manifest/&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p><code>runtimeVersion</code> 必须在应用和服务器之间严格对齐。改了原生代码或者升级了 Expo SDK，就把运行时版本加一，然后针对新版本重新发布更新。</p>

<h2 class="relative group">Tigris 存储
    <div id="tigris-存储" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tigris-%e5%ad%98%e5%82%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>Tigris 是 S3 兼容的对象存储，比 AWS S3 便宜得多，还自带全球边缘缓存。考虑到用户要下载好几 MB 的 bundle，这一点很实在。Django 侧的配置：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># settings.py</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BUCKET_NAME&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ENDPOINT_URL_S3</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_ENDPOINT_URL_S3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ACCESS_KEY_ID</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_ACCESS_KEY_ID&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_SECRET_ACCESS_KEY</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_SECRET_ACCESS_KEY&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_REGION</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_REGION&#34;</span><span class="p">,</span> <span class="s2">&#34;auto&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>客户端就是普通的 boto3：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">create_s3_client</span><span class="p">(</span><span class="n">endpoint_url</span><span class="p">,</span> <span class="n">region</span><span class="p">,</span> <span class="n">access_key</span><span class="p">,</span> <span class="n">secret_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;s3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">endpoint_url</span><span class="o">=</span><span class="n">endpoint_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">region_name</span><span class="o">=</span><span class="n">region</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_access_key_id</span><span class="o">=</span><span class="n">access_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_secret_access_key</span><span class="o">=</span><span class="n">secret_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>下载走预签名 URL，应用直接从 CDN 拉取：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">presigned_url</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;get_object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">bucket_name</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">3600</span><span class="p">,</span>  <span class="c1"># 1 hour</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>

<h2 class="relative group">安全
    <div id="安全" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%89%e5%85%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>清单接口是故意不做鉴权的，因为应用在用户登录之前就需要拿更新。导入接口则完全不同：它能写生产数据库，所以要求共享密钥，并用常数时间比较来防时序攻击：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">IMPORT_TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;IMPORT_TOKEN&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Constant-time comparison prevents timing attacks</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">IMPORT_TOKEN</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span></span></span></code></pre></div></div>
<p>资源完整性是这套设计白送的：所有资源都按 SHA-256 哈希校验，被篡改的资源直接失败。预签名 URL 一小时过期，bundle 也就不会被长期盗链。</p>

<h2 class="relative group">性能
    <div id="性能" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%80%a7%e8%83%bd" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><code>(runtime_version, platform, is_active, -created_at)</code> 这个联合索引保证清单查询不会随着更新数量增长变慢：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span></span></span></code></pre></div></div>
<p>发布脚本用线程池并行上传资源：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">futures</span> <span class="o">=</span> <span class="p">{</span><span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">asset</span><span class="p">):</span> <span class="n">asset</span> <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">assets</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">future</span> <span class="ow">in</span> <span class="n">as_completed</span><span class="p">(</span><span class="n">futures</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Track progress</span></span></span></code></pre></div></div>
<p>以一次 50 个资源左右的典型更新为例，发布时间从大约 2 分钟缩到了 15 秒。分发这边什么都不用做，Tigris 会自动把资源缓存到离用户近的边缘节点。</p>

<h2 class="relative group">日常流程
    <div id="日常流程" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%97%a5%e5%b8%b8%e6%b5%81%e7%a8%8b" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">开发时
    <div id="开发时" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%bc%80%e5%8f%91%e6%97%b6" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Make code changes in the app</span>
</span></span><span class="line"><span class="cl">git commit -am <span class="s2">&#34;Fix bug&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Publish OTA update to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Test on device</span>
</span></span><span class="line"><span class="cl"><span class="c1"># App automatically downloads and applies update</span></span></span></code></pre></div></div>

<h3 class="relative group">发布到生产
    <div id="发布到生产" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8f%91%e5%b8%83%e5%88%b0%e7%94%9f%e4%ba%a7" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Monitor</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Check Django admin for update records</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Verify assets in Tigris dashboard</span></span></span></code></pre></div></div>

<h3 class="relative group">回滚
    <div id="回滚" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%9b%9e%e6%bb%9a" aria-label="锚点">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Mark problematic update as inactive in Django admin</span>
</span></span><span class="line"><span class="cl"><span class="c1"># or via management shell:</span>
</span></span><span class="line"><span class="cl">python manage.py shell
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; from updates.models import ExpoUpdate
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; <span class="nv">bad_update</span> <span class="o">=</span> ExpoUpdate.objects.get<span class="o">(</span><span class="nv">id</span><span class="o">=</span><span class="s2">&#34;uuid-here&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.is_active <span class="o">=</span> False
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.save<span class="o">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Clients will now receive the previous active update</span></span></span></code></pre></div></div>

<h2 class="relative group">成本
    <div id="成本" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%88%90%e6%9c%ac" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个应用大约有 500 个活跃用户。Tigris 上，约 200 MB 的历史更新每月大概 0.02 美元，更新下载带来的每月约 50 GB 流出流量大概 1 美元。Django 这边跑在原本就承载 API 的服务器上，没有额外成本。加起来每月一美元左右。同样的用量放到 EAS Update 上，一年大概要 300 到 500 美元，所以这套东西基本上立刻就回本了。</p>

<h2 class="relative group">监控与调试
    <div id="监控与调试" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%9b%91%e6%8e%a7%e4%b8%8e%e8%b0%83%e8%af%95" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>没什么花哨的。ViewSet 会把每个清单请求记进日志：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Manifest request: platform=</span><span class="si">{</span><span class="n">platform</span><span class="si">}</span><span class="s2">, runtime=</span><span class="si">{</span><span class="n">runtime_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Django admin 就是事实上的控制台，把模型注册进去，查看、筛选都很方便：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@admin.register</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateAdmin</span><span class="p">(</span><span class="n">admin</span><span class="o">.</span><span class="n">ModelAdmin</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_display</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;created_at&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_filter</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">search_fields</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;description&#34;</span><span class="p">]</span></span></span></code></pre></div></div>
<p>客户端这边，<code>expo-updates</code> 会告诉你它看到了什么：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="o">*</span> <span class="kr">as</span> <span class="nx">Updates</span> <span class="kr">from</span> <span class="s1">&#39;expo-updates&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">Updates</span><span class="p">.</span><span class="nx">checkForUpdateAsync</span><span class="p">().</span><span class="nx">then</span><span class="p">(</span><span class="nx">update</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Update available:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">isAvailable</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Manifest:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">manifest</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>

<h2 class="relative group">踩过的坑
    <div id="踩过的坑" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%b8%a9%e8%bf%87%e7%9a%84%e5%9d%91" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">运行时版本不匹配
    <div id="运行时版本不匹配" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%bf%90%e8%a1%8c%e6%97%b6%e7%89%88%e6%9c%ac%e4%b8%8d%e5%8c%b9%e9%85%8d" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>最常见的故障也最安静：客户端只下载和自己运行时版本一致的更新。装机版本还在 <code>1.0.0</code>，你却按 <code>1.0.1</code> 发布，那就什么都收不到，也没有任何报错。运行时版本要和构建保持同步，只在原生代码变化时才递增。</p>

<h3 class="relative group">createdAt 时间戳
    <div id="createdat-时间戳" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#createdat-%e6%97%b6%e9%97%b4%e6%88%b3" aria-label="锚点">#</a>
    </span>
    
</h3>
<p><code>expo-updates</code> 客户端会拿清单的 <code>createdAt</code> 和内置 bundle 的 <code>commitTime</code> 比较，只有 <code>createdAt</code> 更新的才会被应用。ViewSet 里用数据库时间戳覆盖它：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">manifest_data</span><span class="p">[</span><span class="s2">&#34;createdAt&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">update</span><span class="o">.</span><span class="n">created_at</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">&#34;%Y-%m-</span><span class="si">%d</span><span class="s2">T%H:%M:%S.</span><span class="si">%f</span><span class="s2">Z&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>本地开发时，如果你在发布 OTA <em>之后</em>又构建了二进制包，就重新发布一次 OTA，保证时间戳更新。</p>

<h3 class="relative group">清单里的资源 <code>key</code>
    <div id="清单里的资源-key" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b8%85%e5%8d%95%e9%87%8c%e7%9a%84%e8%b5%84%e6%ba%90-key" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>清单中每个资源的 <code>key</code> 字段是 <code>expo-updates</code> 用来做缓存的，它必须是确定性哈希（比如文件名的 MD5），不能是随机 UUID 或随便一个字符串。搞错了，客户端可能无法正确缓存和读取资源，更新会在第一次成功加载之后悄悄失效。感谢读者 Raphael Mutschler 指出这个问题。在找到原因之前，他的更新恰好只成功过一次。</p>

<h3 class="relative group"><code>expoClient</code> 里的应用配置
    <div id="expoclient-里的应用配置" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#expoclient-%e9%87%8c%e7%9a%84%e5%ba%94%e7%94%a8%e9%85%8d%e7%bd%ae" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>如果应用用到 <code>Linking</code>、<code>Constants</code> 或其他在运行时读取应用配置的东西，清单的 <code>extra.expoClient</code> 字段里就必须带上应用配置。少了它，应用第一次可能启动正常，关掉之后再打开就崩溃或干脆打不开。原因是 <code>expo-updates</code> 会用 OTA 的清单替换内置清单，<code>expoClient</code> 一缺，这些 API 依赖的配置就没了。这个坑也是 Raphael Mutschler 发现的。</p>

<h3 class="relative group">清理旧资源
    <div id="清理旧资源" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%b8%85%e7%90%86%e6%97%a7%e8%b5%84%e6%ba%90" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>旧更新会在 Tigris 里越积越多。目前我还是手动清理：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># Delete updates older than 30 days</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">cutoff</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">old_updates</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">created_at__lt</span><span class="o">=</span><span class="n">cutoff</span><span class="p">,</span> <span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">old_updates</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete assets from S3</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">update</span><span class="o">.</span><span class="n">assets</span><span class="o">.</span><span class="n">all</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete DB records</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span><span class="o">.</span><span class="n">delete</span><span class="p">()</span></span></span></code></pre></div></div>
<p>按理说应该放进定时任务，我还没顾上。</p>

<h2 class="relative group">接下来想加的
    <div id="接下来想加的" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%8e%a5%e4%b8%8b%e6%9d%a5%e6%83%b3%e5%8a%a0%e7%9a%84" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><em>（2026 年 9 月：这三样 Patch 现在都有了，还不止，见<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >迁移记录</a>。）</em></p>

<h3 class="relative group">灰度发布
    <div id="灰度发布" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e7%81%b0%e5%ba%a6%e5%8f%91%e5%b8%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>加一个 <code>rollout_percentage</code> 字段，就能先放量给一部分用户：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">rollout_percentage</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the manifest view:</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span> <span class="o">&lt;</span> <span class="mi">100</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Hash user ID and check if they&#39;re in rollout group</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_hash</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">user_id</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">(),</span> <span class="mi">16</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">user_hash</span> <span class="o">%</span> <span class="mi">100</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>  <span class="c1"># No update</span></span></span></code></pre></div></div>

<h3 class="relative group">区分预发环境
    <div id="区分预发环境" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%8c%ba%e5%88%86%e9%a2%84%e5%8f%91%e7%8e%af%e5%a2%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>加一个 <code>environment</code> 字段，预发构建和生产就能收到不同的更新：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">environment</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Client sends environment in custom header</span>
</span></span><span class="line"><span class="cl"><span class="n">environment</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_X_UPDATE_ENVIRONMENT&#34;</span><span class="p">,</span> <span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">environment</span><span class="o">=</span><span class="n">environment</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span></span></span></code></pre></div></div>

<h3 class="relative group">下载统计
    <div id="下载统计" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e4%b8%8b%e8%bd%bd%e7%bb%9f%e8%ae%a1" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>再加一个小模型，就能认真回答“这次更新到底有没有人收到”：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateDownload</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">null</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">downloaded_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span></span></span></code></pre></div></div>

<h2 class="relative group">写在最后
    <div id="写在最后" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%86%99%e5%9c%a8%e6%9c%80%e5%90%8e" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>整套系统就是两个 Django 模型、几个视图、一个发布脚本，外加每月一美元的基础设施。比我动手前预想的要小，而且从那以后一直安安静静地支撑着这个应用的两个平台。</p>
<p>上面的代码是从那个生产应用简化出来的示例，按自己的项目改改就能用。读者 Raphael Mutschler 还发布了一个独立实现 <a href="https://github.com/mutschler/expo-ota-server"  target="_blank" rel="noreferrer">expo-ota-server</a>，想从能直接跑的代码开始的话可以去看看。</p>

<h3 class="relative group">如果你不想自己写服务器
    <div id="如果你不想自己写服务器" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%a6%82%e6%9e%9c%e4%bd%a0%e4%b8%8d%e6%83%b3%e8%87%aa%e5%b7%b1%e5%86%99%e6%9c%8d%e5%8a%a1%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这篇写完之后出现了两样东西，不用 Django 也能覆盖同样的地盘。第一个是上面提到的 Raphael 的 <code>expo-ota-server</code>。第二个是 <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Codemagic Patch</a>：<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">开源</a>，通过 Docker Compose 安装跑在你自己的机器上，走的是 CodePush 协议而不是 Expo Updates 协议，所以你要把 <code>expo-updates</code> 换成它的 SDK，把 <code>eas update</code> 换成 <code>cmpatch release-react</code>。它能用 S3 兼容存储，所以本文这样的 Tigris 桶也照样能用。</p>
<p>它开箱就带着我在“接下来想加的”里列出来、却一直没做的那些：Staging 和 Production 两套 deployment、按百分比灰度、每个 release 的下载和安装数字、强制更新、二进制 patch、代码签名，以及新 bundle 在应用报告就绪之前崩溃时设备端的回滚。代价是，在任何人收到 Patch 更新之前先要发一个新的原生二进制，因为 SDK 得进到应用里；还有一台要跑的服务器，取代了一条管理命令。</p>
<p>我把自己的一个应用，连同已提交的原生工程和真实的发布流水线，从本文这套方案迁到了 Patch。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-13-moving-to-codemagic-patch/" >那篇记录</a>写了哪些顺利、哪些不顺，以及到底有多难。Codemagic 赞助了那项工作，并对它做了一轮事实审阅，不是编辑审阅。</p>
<hr>
<p><em>完整规范见 <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates Protocol Specification</a>。</em></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-22-self-hosted-ota-updates/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
