<?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>CI/CD · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/ci/cd/</link>
    <description>关于软件工程与研发团队领导力的短文——绩效评估、招聘与团队扩张、CI/CD、DevOps 监控、智能体 AI。写于首尔。</description>
    <generator>Hugo</generator>
    <language>zh-CN</language>
    <copyright>© 2026 仁才德</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/zh-cn/tags/ci/cd/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>pytest 加速：用 pytest-testmon 只跑受代码改动影响的测试</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-26-pytest-testmon/</guid>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Django</category>
      <category>pytest</category>
      <category>Testing</category>
      <category>Test Impact Analysis</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Claude Code</category>
      <category>AI Agents</category>
      <category>Efficiency</category>
      <description>pytest-testmon 只跑改动能触及的测试，依据是覆盖率记录而不是猜测。把它加进 2,248 个测试的 Django 套件、它需要的包装脚本，以及 Python 3.14 的一个坑。</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a> 是一个 pytest 插件，只跑你的改动可能影响到的测试。它借助 coverage.py 观察每个测试执行了哪些代码行，并为测试碰过的每个代码块存一个校验和。下次运行时，它把这些校验和与磁盘上的代码对比，只跑代码块有变化的测试，再加上上次失败的测试，所以它顺带也干了 <code>pytest --lf</code> 的活。</p>
<p>这类技术统称测试影响分析（test impact analysis），而 testmon 是在 Python 里免费、开源地用上它的一种办法。它完全不依赖 git：它对比的是上次运行时看到的代码，提交没提交都一样。</p>
<p>这周我把它加进了我那个集市应用的 Python 测试里，原因并不是为了快而快。</p>
<figure><video
    class="my-0 rounded-md"
    controls playsinline preload="none"
    poster="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film-poster.jpg" style="background:url('data:image/webp;base64,UklGRmYAAABXRUJQVlA4IFoAAADwBACdASogABIAPw1wqUqmIyGYDAQAYxDE9IAAS3igrsLc+giUXH/mz758faBcoAAA/vVl8+gZ7GYAPuvC1znoo64X/UaaVXzZOS4gxmDEyDUzHvkl6JAAAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="讲述这篇文章的一段短片">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film.webm" type="video/webm">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_en.vtt" srclang="en" label="English">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ja.vtt" srclang="ja" label="日本語">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ko.vtt" srclang="ko" label="한국어">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_vi.vtt" srclang="vi" label="Tiếng Việt">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_zh-cn.vtt" srclang="zh-cn" label="简体中文" default>
  </video><figcaption>这篇文章的约八分钟视频版。旁白是英文，配有中文字幕。</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="#%e4%b8%ba%e4%bb%80%e4%b9%88%e8%a6%81%e5%8a%a0%e5%ae%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>一个功能需求通常很小：一个界面、一个接口、一条要换个做法的规则。跑整个测试套件，让每个需求都变得比它本身更大。这个套件有 2,248 个 Django 测试，四个进程跑大约三分半，单进程要 11 分钟，而每次编辑都会把它整个跑一遍，尽管其中几乎全部检查的都是这个需求根本没碰过的东西。只要有不相干的测试失败，或者运行单纯慢了点，功能就得等着。拖住它的，是功能需求之外的情况，而那个拖住它的东西，正是我自己的测试套件。</p>
<p>另一条路也好不到哪儿去：不跑全量，猜哪个测试文件覆盖了这次改动，偶尔猜错。这样是快，但不精确，而我发出去的每一个未经测试的改动，都是一个要做两遍的功能需求。</p>
<p>我等的测试应该恰好是这次改动能触及的那些，不多也不少。testmon 给我的这组测试来自记录，而不是猜测。全量套件照样在跑，只不过是在 CI 里，不会拖住我接下来手上的任何事。</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%e6%80%8e%e4%b9%88%e5%86%b3%e5%ae%9a%e8%b7%91%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>testmon 把每个 Python 文件切成代码块：模块的顶层、每个类、每个函数，嵌套函数的函数体会从外层代码块里切出去。它为每个代码块存一个校验和，从不存代码本身。测试运行时，coverage.py 记录它执行的代码行，testmon 记下这些行属于哪些代码块。没有静态分析，也不靠 import 去猜。一个测试依赖的，恰好就是它跑过的那些代码块。</p>
<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="c1"># pricing.py</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">subtotal</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">item</span><span class="o">.</span><span class="n">price</span> <span class="o">*</span> <span class="n">item</span><span class="o">.</span><span class="n">quantity</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">)</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">discount</span><span class="p">(</span><span class="n">total</span><span class="p">,</span> <span class="n">code</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;WELCOME10&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">total</span> <span class="o">*</span> <span class="mf">0.9</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">total</span></span></span></code></pre></div></div>
<p>一个只调用 <code>subtotal()</code> 的测试，依赖的是模块顶层和 <code>subtotal</code> 的函数体。<code>discount()</code> 随你怎么改写，testmon 都不会重跑这个测试，因为它从没执行过 <code>discount</code> 里的任何一行。而在 <code>subtotal</code> 里哪怕只改一个字符，它就会再跑一次，即使这个改动根本不可能改变结果。testmon 不会在副作用上耍聪明：你执行过的代码块变了，你就得跑。</p>
<p>它还会追踪代码周围的东西。记录里包括 Python 版本，以及已安装的包及其版本。它不看环境变量：同一份代码要在两个 settings 模块下跑的话，得用 <code>--testmon-env</code>（第 8 步）给它们起名字，否则它们共用一份记录。升级一下 Django，所有碰过 Django 的东西都会再次受影响，而在一个 Django 应用里，那就是全部。</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%e7%9c%8b%e4%b8%8d%e5%88%b0%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>testmon 追踪的是测试执行的 Python 代码。测试读取的其他文件它不追踪：模板、JSON、文本，磁盘上的任何别的东西。经网络访问的服务它也不追踪。而在测试开始前只跑一次的东西，它没法归到某个具体测试头上。</p>
<p>放在 Django 项目里，首先是迁移：它们只在创建测试数据库时跑一次，所以没有哪个测试的记录里包含它们（有新迁移时，下面的包装脚本会重建测试数据库）。其次是设置和启动代码，它们在 pytest 启动时运行，不属于任何一个测试。再就是模板和数据文件：模板不是 Python，所以改了模板，渲染它的测试一个都不会重跑。</p>
<p>我的测试套件还有几样自己特有的。有些测试读取一个 JSON fixture 文件，移动应用的测试也共用它。有些检查构建出来的 Web bundle，还有些检查同一个仓库里生成的静态网站的输出。改了其中任何一个，testmon 都会高高兴兴地告诉你什么都不用跑。所以下面的教程会用一个脚本把 testmon 包起来，这个脚本会留意这些文件，只要其中一个改了，就全部跑一遍。</p>

<h2 class="relative group">怎么把它加进 Django 项目
    <div id="怎么把它加进-django-项目" 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%8e%e4%b9%88%e6%8a%8a%e5%ae%83%e5%8a%a0%e8%bf%9b-django-%e9%a1%b9%e7%9b%ae" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>以下步骤假定你用 pytest 和 <a href="https://pytest-django.readthedocs.io/"  target="_blank" rel="noreferrer">pytest-django</a>，依赖用 uv 管理。用 pip 的话，把 <code>uv add --dev</code> 换成 <code>pip install</code>，去掉 <code>uv run</code>。</p>
<p><strong>1. 安装。</strong></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">uv add --dev pytest-testmon</span></span></code></pre></div></div>
<p><strong>2. 确保 pytest 能跑这套测试。</strong> testmon 是 pytest 插件，所以在 <code>python manage.py test</code> 下它什么都不做。pytest-django 需要知道设置模块，以及你的测试所用的文件命名模式：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># pytest.ini</span>
</span></span><span class="line"><span class="cl"><span class="k">[pytest]</span>
</span></span><span class="line"><span class="cl"><span class="na">DJANGO_SETTINGS_MODULE</span> <span class="o">=</span> <span class="s">myproject.settings</span>
</span></span><span class="line"><span class="cl"><span class="na">python_files</span> <span class="o">=</span> <span class="s">tests.py test_*.py *_tests.py</span>
</span></span><span class="line"><span class="cl"><span class="na">python_classes</span> <span class="o">=</span> <span class="s">Test* *Tests *TestCase</span></span></span></code></pre></div></div>
<p>如果这套测试一直只在 Django 自带的 runner 下跑过，先在 pytest 下跑一遍，把不一致的地方修好，再加 testmon。不然你就得同时调试两件事。</p>
<p><strong>3. 别把记录放进 git。</strong> testmon 会在 pytest 根目录写入 <code>.testmondata</code>。它是一个 SQLite 数据库，所以旁边还会出现 <code>-shm</code> 和 <code>-wal</code> 文件，下面的脚本还会再加一个时间戳文件。一个模式就能把它们全部覆盖：</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"># .gitignore
</span></span><span class="line"><span class="cl">.testmondata*</span></span></code></pre></div></div>
<p><strong>4. 先把整套测试记录一遍。</strong> testmon 在看过每个测试运行之前，什么都选不出来：</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">uv run pytest --testmon</span></span></code></pre></div></div>
<p>在全新的检出里，第一次运行就是单进程跑完整个套件。在我这里，2,248 个测试大约要 12 分钟。从那以后，同一条命令就只跑受影响的测试。什么都没改的话，它什么都不跑。</p>
<p>有两个坑。在 testmon 2.2.0 及更早版本上，它需要 pytest 的 cache 插件：我第一次尝试时带着 <code>-p no:cacheprovider</code>，结果以 <code>KeyError: 'lf'</code> 崩掉了（2.3.0 会把缺失的 <code>--lf</code> 当作未设置）。另外，在 Python 3.14 或更高版本上，记录之前要先设置 <code>COVERAGE_CORE=ctrace</code>。原因在后面的“看起来全绿的那个 bug”一节里解释。这是下面脚本里最重要的一行。</p>
<p>更新（11 月 7 日）：11 月 2 日发布的 testmon 2.3.0 会自己强制使用 coverage 的 C tracer，所以在这个版本上，这行 export 只是多一道保险。如果你用的是 2.2.0 或更早的版本，就留着它。而故意弄坏点什么来验证的那一步，不管哪个版本都要做。</p>
<p><strong>5. 为它看不到的文件包一层。</strong> 正是这一步，让它可以放心地天天用。脚本保留一个时间戳文件，记的是上次运行开始的时间。只要有任何 testmon 追踪不到的文件比这个时间戳新，就全部跑一遍并重新记录。新的迁移只会让它重建测试数据库。其余情况就让 testmon 来选。删减后的示例：</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/test-changed.sh: the tests a change can affect, or all when in doubt.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh            # affected tests</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh --all      # everything, re-recorded</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh -k orders  # anything else goes to pytest</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$0</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">/..&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># coverage&#39;s C tracer: from Python 3.14 coverage defaults to sys.monitoring,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># which reports a line only the first time a process runs it, so testmon</span>
</span></span><span class="line"><span class="cl"><span class="c1"># would credit each line to one test. Changing this means --all.</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">COVERAGE_CORE</span><span class="o">=</span>ctrace
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">STAMP</span><span class="o">=</span>.testmondata.started
</span></span><span class="line"><span class="cl"><span class="nv">full</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="nv">rebuild</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="s2">&#34;--all&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span>true<span class="p">;</span> <span class="nb">shift</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="o">[</span> ! -f .testmondata <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> ! -f <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;No record yet: running everything once.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Changed since the last run started, and not traceable per test:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># anything that isn&#39;t Python, plus settings and conftest.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">newer</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>find myapp myproject fixtures pytest.ini <span class="se">\
</span></span></span><span class="line"><span class="cl">        -type f -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> ! -path <span class="s1">&#39;*/migrations/*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        <span class="se">\(</span> ! -name <span class="s1">&#39;*.py&#39;</span> -o -path <span class="s1">&#39;myproject/settings*&#39;</span> -o -name <span class="s1">&#39;conftest.py&#39;</span> <span class="se">\)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        ! -path <span class="s1">&#39;*/__pycache__/*&#39;</span> <span class="p">|</span> head -5<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$newer</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        <span class="nb">echo</span> <span class="s2">&#34;Changed, and not traceable per test:&#34;</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">&#39;  %s\n&#39;</span> <span class="nv">$newer</span>
</span></span><span class="line"><span class="cl">        <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A new migration needs a fresh test database, not a full run.</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span> -n <span class="s2">&#34;</span><span class="k">$(</span>find myapp -path <span class="s1">&#39;*/migrations/*.py&#39;</span> -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">|</span> head -1<span class="k">)</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">rebuild</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Stamp the start, so an edit made while the tests run counts next time.</span>
</span></span><span class="line"><span class="cl"><span class="nv">started</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>mktemp .testmondata.run.XXXXXX<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --create-db alone deletes the database when the run ends; with</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --reuse-db as well, the rebuilt one is kept for next time.</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rebuild</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$full</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon-noselect --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">status</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">uv run pytest <span class="s2">&#34;</span><span class="si">${</span><span class="nv">args</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> -q <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nv">status</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 0: passed, or nothing selected. 1: some failed (still recorded).</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 5: nothing collected at all.</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> in 0<span class="p">|</span>1<span class="p">|</span>5<span class="o">)</span> mv <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">;;</span> *<span class="o">)</span> rm -f <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="m">5</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>这里面有两个细节值得解释。<code>--reuse-db</code> 是 pytest-django 的参数，用来在两次运行之间保留测试数据库。一旦一次运行只要几秒，这一点就很要紧：每次重新创建数据库，花的时间可能比测试本身还长。全量运行，或者有新迁移之后的那次运行，会再加上 <code>--create-db</code>，让数据库用上当前的 schema。<code>--reuse-db</code> 要和它一起留着：单独用 <code>--create-db</code> 时，pytest-django 会在运行结束时删掉数据库，下一次运行就得从头再建一遍。</p>
<p>还有，当 testmon 把所有测试都取消选中时，运行的退出码是 0，所以“什么都没改”算作通过。退出码 5 是 pytest 的 &ldquo;no tests collected&rdquo;（没有收集到测试），脚本把它也当作没问题。</p>
<p>注意清单里没有什么：<code>pyproject.toml</code> 和 lock 文件。我的第一版只要 lock 文件一变就全部跑一遍，结果这个慢吞吞的兜底大多数日子都会触发，整件事比以前还糟。testmon 本身就会追踪已安装包的版本，用不着帮这个忙。</p>
<p><strong>6. 给它单独的数据库和缓存。</strong> 如果同一台机器上还有别的东西在跑测试，比如第二个终端、一个编程 agent 或者 <code>manage.py test</code>，就让脚本指向它自己的测试数据库和它自己的缓存编号，这样保留下来的数据库永远不会撞上别人的运行：</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="nb">export</span> <span class="nv">DATABASE_URL</span><span class="o">=</span><span class="s2">&#34;postgresql://myapp:myapp@localhost:5432/myapp_changed&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">REDIS_URL</span><span class="o">=</span><span class="s2">&#34;redis://localhost:6379/8&#34;</span></span></span></code></pre></div></div>
<p><strong>7. 每次运行前清空那个缓存。</strong> 这一条我是吃了亏才学到的。子集运行开始出现 <code>KeyError</code>，全量运行却没有。有些分类标签的缓存没有过期时间，于是一次运行保留下来的测试数据库，碰上了更早一次运行留下、还热着的缓存。在脚本开头清掉缓存，问题就解决了：</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">uv run python -c <span class="s2">&#34;import os, redis; redis.Redis.from_url(os.environ[&#39;REDIS_URL&#39;]).flushdb()&#34;</span></span></span></code></pre></div></div>
<p>这正是 testmon 在做它文档里说过会做的事：每次跑不同的子集，会暴露出那些只是因为之前跑过什么才通过的测试。修复应该放在测试的 setup 里，而不是 testmon 里。</p>
<p><strong>8. 记住选择出错时用的参数。</strong></p>
<ul>
<li><code>--testmon-noselect</code> 跑所有测试，但把可能失败的排在前面。当你同时传了 <code>-k</code>、<code>-m</code>、<code>--lf</code> 或某个测试的 node ID 时，testmon 会自动切换到这个模式。</li>
<li><code>--testmon-forceselect</code> 只跑既受影响、又匹配你的 <code>-k</code> 或 <code>-m</code> 的测试。</li>
<li><code>--testmon-nocollect</code> 使用已有的记录，但不往里添加。在调试器下，或者 coverage 已经在运行时，会强制使用这个模式。</li>
<li><code>--no-testmon</code> 在它写进了 <code>addopts</code> 时，用来关掉一次运行。</li>
<li><code>--testmon-env=&lt;name&gt;</code> 在一个文件里保存多份独立的记录，适合要在两个设置模块下跑的测试套件。</li>
</ul>
<p>如果项目还有 JavaScript 那一侧，Jest 自带了同类功能。<code>jest --changedSince=main</code> 会跑那些 import 了自 <code>main</code> 以来改动过的任何东西的测试，它跟的是 import，而不是覆盖率。我把它加进了应用的 <code>package.json</code>，叫 <code>npm run test:changed</code>，这样两边是同一个习惯。改了一个工具模块，它从应用的 121 个测试文件里选中了 7 个。</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%e8%83%bd%e7%9c%81%e4%b8%8b%e5%a4%9a%e5%b0%91" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>包装脚本就位、并修好下一节说的那个 bug 之后，我计了一下时，包括启动时间：</p>
<table>
	<thead>
			<tr>
					<th>改了什么</th>
					<th style="text-align: right">跑的测试数</th>
					<th style="text-align: right">耗时</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>什么都没改</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0 s</td>
			</tr>
			<tr>
					<td>改了一处首页横幅背后的代码</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5 s</td>
			</tr>
			<tr>
					<td>撤销那处改动</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7 s</td>
			</tr>
			<tr>
					<td>整个套件，作为对照（4 个进程）</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">约 3½ min</td>
			</tr>
	</tbody>
</table>
<p>日常的一次编辑，比并行跑整个套件快大约 25 倍；什么都没改的时候，快大约两百倍。</p>
<p>这份记录从更宽的角度说明了同一件事。在测试套件跑的 307 个非测试 Python 文件中，改动中位数的那个文件会波及 22 个测试，测试时间大约 8 秒。另一头是几个核心模块：几乎每个测试都会跑到它们，所以改了那里会波及 1,500 个左右的测试，testmon 会重跑套件的大部分。这才是正确的答案，不是失败。这些数字是按文件算的上限：testmon 按代码块工作，所以改了一个函数，只会选中跑过那个函数的测试。</p>
<p>代价出现在包装脚本退回全量运行的时候。一次全量运行和第一次一样，是单进程的记录，大约 12 分钟，比它取代的那个并行套件还慢。之所以是单进程，只是因为我还没试过把 testmon 和 pytest-xdist 一起用；它从 1.4 起就支持 xdist，下一个该量的就是这个。所以触发全量运行的文件清单，应该在不自欺的前提下尽量短。</p>

<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="#%e7%9c%8b%e8%b5%b7%e6%9d%a5%e5%85%a8%e7%bb%bf%e7%9a%84%e9%82%a3%e4%b8%aa-bug" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>我最早的数字比这些好看：改一处，6 个测试，3.3 秒。它们是错的，而屏幕上没有任何东西提示这一点。</p>
<p>从 Python 3.14 开始，coverage.py 默认使用一个新的核心，建立在解释器的 <code>sys.monitoring</code> 之上。这个核心只在一个进程第一次运行某一行时报告它。testmon 关于哪个测试跑了哪一行的记录，是从 coverage 得来的，所以每一行都只记在碰巧第一个跑到它的测试头上，别的测试一概没有。改一行八个测试共用的代码，testmon 只会选中其中一个。</p>
<p>发现它，是在我另一个更小的项目上，那个项目照搬了这套配置。在那里引入它的步骤之一，就是故意弄坏一行代码、对比结果，而数字对不上。回到这个套件，我做了同样的检查。我弄坏了首页横幅代码里的一行，也就是一张幻灯片的标题。直接跑 pytest，报告了 8 个失败的测试。testmon 选中了 4 个。另外 4 个坏掉的测试根本没跑，输出里也没有任何地方说它们被漏掉了。</p>
<p>修复只要一行，<code>export COVERAGE_CORE=ctrace</code>，让 coverage 回到经典的 C tracer。换了 tracer，记录也就不一样了，所以我重新记录了一遍：2,248 个测试，12 分 3 秒。之后，同样那种弄坏代码的改动，把它弄坏的 11 个测试全都跑了。</p>
<p>更新（11 月 7 日）：11 月 2 日发布的 testmon 2.3.0 现在会自己设置 C tracer，进程内和子进程里都会，所以新装的话不再需要这行 export。下一段里的检查仍然需要。</p>
<p>这是我最想先传下去的一条教训。一个选得太少的测试选择器，看起来和成功一模一样：测试更少，跑得更快，全是绿的。所以搭一个这样的东西时，要故意弄坏某个共用的东西，分别跑原本的完整套件和选择器，确认它们失败的是同一批测试。之后每当 Python 或 coverage.py 升级版本，再做一遍。</p>

<h2 class="relative group">编程 agent 在哪里用得上
    <div id="编程-agent-在哪里用得上" 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%bc%96%e7%a8%8b-agent-%e5%9c%a8%e5%93%aa%e9%87%8c%e7%94%a8%e5%be%97%e4%b8%8a" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>那个仓库里的改动，已经不全是我自己做的了。一个编程 agent，我用的是 Claude Code，做了很多小改动，而一个好的 agent 每次编辑后都会跑测试。面对一个 11 分钟的测试套件，它的两个坏选择和我当初一样：等，或者猜哪个测试文件要紧。</p>
<p>所以仓库里给 agent 的说明现在写着：工作时跑测试，默认就用这个包装脚本，要全量运行就加 <code>--all</code>。每次编辑后，agent 都能在几秒内拿到和我一样精确的那组测试。这也是第 6 步要给脚本单独的数据库和缓存的原因：忙的时候，同一台机器上不止一个会话在跑测试，而保留下来的数据库绝不能撞上别人的运行。</p>
<p>这些都不需要 testmon 提供任何 AI 专属的东西。还是同一份记录、同一套选择。只不过，当做改动的那一方改得非常多时，它就更重要了。</p>

<h2 class="relative group">为什么 CI 用的是更粗的版本
    <div id="为什么-ci-用的是更粗的版本" 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-ci-%e7%94%a8%e7%9a%84%e6%98%af%e6%9b%b4%e7%b2%97%e7%9a%84%e7%89%88%e6%9c%ac" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>testmon 没有 CI 模式。它在 runner 上和在笔记本上表现一样，而它的作者写道，在 CI runner 之间共享或缓存 <code>.testmondata</code>，效果远不如他们的托管服务 testmon.net。缓存下来的记录还会带着上面说的所有盲区，再加上我的 CI 是用 Django 自带的 runner、四个进程跑这套测试的，所以我没在 CI 里用 testmon。</p>
<p>CI 拿到的，是同一个思路的文件级版本。检查工作流现在先跑一个小 job，对这次推送做 diff，判断它碰到了仓库的哪一半：Python API、移动应用、两者都有，或者都没有。在这之前，每次推送 CI 都会把 Python 和应用两个 job 都跑一遍，只改文档的推送也不例外。现在只改文档的推送什么都不跑，只改应用的推送会跳过 Django 测试套件。我拿五个真实的提交试跑了这个 job：只改文档的那个什么都没跑，只改应用的那个跳过了 Python job，只改后端的那个跳过了应用 job。删减后的示例：</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">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">changes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">outputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">python</span><span class="p">:</span><span class="w"> </span><span class="l">${{ steps.diff.outputs.python }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">diff</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">BEFORE</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.event.before }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HEAD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.sha }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          FILES=&#34;$(git diff --name-only &#34;$BEFORE&#34; &#34;$HEAD&#34;)&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          # Python&#39;s inputs include the non-Python files its tests read.
</span></span></span><span class="line"><span class="cl"><span class="sd">          PY=&#39;^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">          if printf &#39;%s\n&#39; &#34;$FILES&#34; | grep -qE &#34;$PY&#34;; then
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=true&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          else
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=false&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          fi</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="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">changes</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">needs.changes.outputs.python == &#39;true&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span></span></span></code></pre></div></div>
<p>真实的版本在没有可比较的范围时，也会全部跑一遍：新分支、强制推送（force push），或者手动运行。</p>
<p>盲区还是同一个，只是换了个形式。testmon 看不到 JSON 文件，路径过滤器也看不到，除非你把它列出来。所以这个模式里明确写出了共享的 fixture、构建出来的 bundle 和生成的网站，还加了一条注释，请改它的人让这份清单保持如实。文件级的版本更粗糙，但它出错时偏向安全的一边：Python 那边只要有任何改动，就跑整个套件。</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%80%bc%e5%be%97%e5%90%97" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>对一个要跑好几分钟的测试套件来说，值得。真正的代价在于它看不到的东西，而那正是包装脚本的用处。即便如此，testmon 该待的地方是笔记本上的开发循环。只要 Python 那边有改动，CI 照样跑整个套件，部署前我信任的是那一次运行。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>GitHub Actions CI 里的 Docker Compose：我换成了什么，以及现在在跑的流水线</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>我 2026 年在 GitHub Actions 上跑的 CI/CD：CI 里用服务容器取代 Docker Compose，缓存按每步耗时来选，部署全是脚本。</description>
      <content:encoded><![CDATA[<p>CI/CD 流水线，就是一次改动从 <code>git push</code> 走到生产环境的自动化路径：CI 在干净的机器上构建并测试每一次推送，CD 用一个可重复的步骤把通过的东西发布出去。在 GitHub Actions 上它是 <code>.github/workflows/</code> 下的一个 YAML 文件，我的流水线故意做得很薄：检查就是我在笔记本上敲的那些命令，每一种部署都是脚本或 CLI，离开 GitHub 也照样能跑。</p>
<p>如果你是来找「怎么在 GitHub Actions 里跑 Docker Compose」的：我这么干了两年，然后停了。现在 CI 把同样的 Postgres 和 Valkey 镜像声明为 <code>services:</code>，直接在 runner 上跑同样的 <code>uv run …</code> 和 <code>npm run …</code>；Compose 回归成开发者本地跑的东西。下面是原因，连同文件。</p>
<p>2024 年我写过一篇<a href="https://jared.lynskey.co.nz/zh-cn/posts/2024/2024-06-02-simplifyingcicd/" >用 Docker Compose 做 CI/CD，别把逻辑写进平台专属的 YAML</a>，意思是构建逻辑放进 Compose，CI 工具只负责调用它。两年后，我把自己真正用来发布的仓库全部翻了一遍。有工作流的仓库一共四个，提交者都只有我一个人，而这些工作流里，已经没有一个在调用 <code>docker compose</code> 了。原则留下来了，做法没有。下面是取而代之的东西：删减过的示例文件、GitHub 记录下的每一步耗时，以及那些没有 CI/CD 示意图期望的那么自动化的部分。</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%88%91%e8%b7%91%e7%9a%84%e6%89%80%e6%9c%89%e6%b5%81%e6%b0%b4%e7%ba%bf%e4%b8%80%e5%bc%a0%e8%a1%a8" aria-label="锚点">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>流水线</th>
					<th>由谁触发</th>
					<th>CI 检查什么</th>
					<th>发布到哪里</th>
					<th>谁来发布</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>这个博客和一个客户的 Hugo 网站</td>
					<td>每次推送，任何分支</td>
					<td><code>hugo --gc</code> 能否构建</td>
					<td>Cloudflare Pages：<code>main</code> 是生产，其他分支得到预览 URL</td>
					<td>没人；推送就是发布</td>
			</tr>
			<tr>
					<td>我那个集市应用的 API</td>
					<td>一个部署脚本，手动运行</td>
					<td>每次推送到 <code>main</code> 时跑代码检查、类型检查和 Django 测试</td>
					<td>Fly.io，滚动部署</td>
					<td>我</td>
			</tr>
			<tr>
					<td>同一个应用的前端：Web、OTA 更新、商店构建</td>
					<td>几个发布脚本和本地商店构建，手动</td>
					<td>每次推送到 <code>main</code> 时跑 TypeScript 和 Jest</td>
					<td>对象存储、一个 OTA 更新服务，以及两家应用商店</td>
					<td>我</td>
			</tr>
			<tr>
					<td>pyhtml2text，我的开源 Python 包</td>
					<td>每次推送、Pull Request 和 tag</td>
					<td>15 个构建目标的 wheel，其中大部分会跑测试</td>
					<td>PyPI，打 tag 时</td>
					<td>tag</td>
			</tr>
	</tbody>
</table>
<p>用教科书的话说，两个 Hugo 网站是持续部署；而那个应用（我做的一个集市兼社区应用）是持续集成加上一条命令就能完成的发布，也就是持续交付：检查会告诉我哪里不对，但不会拦着我部署。这是有意为之。一次 OTA 更新会送到所有装着目标二进制版本的手机上，所以发布它应该是一个决定，而不是合并的副作用；而 API 是数据库迁移运行的地方。我另外两个 Django 应用，压根没有工作流。</p>

<h2 class="relative group">CI 这一半：一个工作流，两个 job
    <div id="ci-这一半一个工作流两个-job" 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="#ci-%e8%bf%99%e4%b8%80%e5%8d%8a%e4%b8%80%e4%b8%aa%e5%b7%a5%e4%bd%9c%e6%b5%81%e4%b8%a4%e4%b8%aa-job" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个应用的 CI 只有一个文件，<code>.github/workflows/checks.yml</code>。10 月 11 日之前，连它都没有。下面是删减后的示例，看个大致样子：</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"># Lint, types and tests on every push and pull request to main.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Nothing here deploys.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checks</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">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</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">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">checks-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><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">postgres</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">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;5432:5432&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-cmd &#34;pg_isready -U myapp&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-interval 5s --health-timeout 5s --health-retries 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</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">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;6379:6379&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DEBUG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;True&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql://myapp:myapp@localhost:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">redis://localhost:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PYTHON_JIT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0&#34;</span><span class="w">   </span><span class="c"># match production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">enable-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv sync --locked</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run ruff check .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run mypy myapp/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</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="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-node@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">node-version</span><span class="p">:</span><span class="w"> </span><span class="m">24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache</span><span class="p">:</span><span class="w"> </span><span class="l">npm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">frontend/package-lock.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Expo writes this on `expo start` and it is gitignored,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># so a fresh checkout doesn&#39;t have it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">printf &#39;/// &lt;reference types=&#34;expo/types&#34; /&gt;\n&#39; &gt; expo-env.d.ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm run typecheck</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx jest --ci</span></span></span></code></pre></div></div>
<p>两个 job 并行运行。到目前为止成功的 35 次运行里，应用 job 的中位数大约七分钟，Python job 大约十二分半。</p>
<p><code>services:</code> 这一块，就是 Compose 文件搬去的地方，下一节细说。环境变量只有寥寥几个，外加一个开关；其余的一切，都会回落到开发者 <code>docker compose up</code> 时拿到的开发默认值，所以测试不需要任何密钥。为了确认真是这样，我只用这套环境、别的什么都不带，跑了一部分测试。</p>
<p>CI 用的解释器和我的笔记本、生产镜像是同一个，也就是 uv 自己构建的 Python 3.15.0，因为 <code>uv sync</code> 会读 <code>.python-version</code>，并下载正是那个版本。（我真实的文件里还给 setup-uv 传了一个 <code>python-version-file</code> 输入，其实什么都没做：setup-uv v6 没有这个输入，每次运行都带着一条这样说的警告。写这篇文章时我才发现。）</p>
<p>解释器一致的好处，在工作流上线八天后就显出来了。10 月 19 日我打开了 3.15 的 JIT 编译器，开启后的第一次运行就有一个测试失败：一句再普通不过的 <code>yield from</code>，在每次保存都会经过的代码路径上，抛出了 <code>AttributeError: 'list' object has no attribute 'send'</code>。同一个提交关掉 JIT 就能通过。我的 Mac 是 arm64，从没碰到过它；而 runner 是 x86_64，和生产机器一样。JIT 现在在 CI 和 Dockerfile 里都重新关掉了，这个 bug 从没到过生产环境。这就是 python/cpython#155823，上游在 10 月 13 日、也就是 3.15.0 发布之后修掉了它。</p>
<p>其实第一次运行也失败了，原因更无聊。Expo 在 <code>expo start</code> 时会生成 <code>expo-env.d.ts</code>，而这个文件被 gitignore 了，所以我用过的每个工作副本里都有它，全新的检出里却没有。TypeScript 报了五个本地从没出现过的错误。补上生成这个文件的那一步，就修好了。</p>
<p><code>concurrency</code> 会在同一分支来了更新的推送时，取消之前的运行；到目前为止被取消的十二次运行，全都是这么结束的。另外还有一个提交前钩子（pre-commit），会跑代码检查，暂存了 Python 或 TypeScript 文件时再跑类型检查，所以大部分错误根本走不到推送那一步。</p>

<h2 class="relative group">Docker Compose 文件去哪儿了
    <div id="docker-compose-文件去哪儿了" 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="#docker-compose-%e6%96%87%e4%bb%b6%e5%8e%bb%e5%93%aa%e5%84%bf%e4%ba%86" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>下面是这个应用的 <code>docker-compose.yml</code> 里 CI 关心的那部分，同样是示例。真实文件里服务更多，环境变量也多得多。</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">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">valkey</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">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</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="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</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">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</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">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</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="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</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">python manage.py runserver 0.0.0.0:8000</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">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</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></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</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">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">postgres_data:</span></span></span></code></pre></div></div>
<p>把它和 <code>checks.yml</code> 里的 <code>services:</code> 放在一起看，是同样的两个镜像：Postgres 17（这里是 Alpine 版，CI 里是 Debian 版）和 <code>valkey/valkey:8-alpine</code>。现在两者的关系就是这样：Compose 文件是开发者跑的东西；CI 把同样的镜像声明为服务容器，然后直接在 runner 上跑开发者跑的那些命令，<code>uv run …</code> 和 <code>npm run …</code>。</p>
<p>和 2024 年那篇对照，我最在意的部分留了下来：CI 工具依然很薄，检查和部署都是我自己就能跑的命令。没留下来的，是让 CI 去调用 Compose。</p>
<p>这个应用的 CI 根本不构建 Docker 镜像；镜像在部署时由 Fly 的构建机构建一次，CI 只需要回答代码好不好，工具链有缓存的话，这只要六秒的安装。另外，Compose 文件会把工作副本 bind mount 进去、灌入种子数据，这些都不是一次测试运行该做的事。所以 CI 本来还得有一份自己的 Compose 文件，也就是同一组服务的第二份描述，靠手工保持同步。</p>
<p>还有，2024 年那个例子里有个我当时没发现的 bug。<code>docker compose build</code> 只构建镜像，从不运行服务的 <code>command</code>，所以那个文件里的 <code>npm test</code> 在 CI 里一次都没跑过，工作流不管测试过没过都是绿的。</p>
<p>那篇文章最担心的风险，也就是换 CI 供应商，始终没有发生。真正发生的迁移在另一头：这个博客和那个客户的网站换了托管，搬到了 Cloudflare Pages，博客这边只是新写了一个 45 行的工作流，因为构建本来就只是一条命令。至于我当年警告过的三百行工作流？我确实有一个，pyhtml2text 的。它之所以长，是因为 wheel 必须分别在 Linux、macOS 和 Windows 的 runner 上、为多种架构构建，也正因为如此，这是唯一一个平台专属 YAML 才是对的工具的活儿。</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%bc%93%e5%ad%98%e5%85%88%e7%9c%8b%e6%af%8f%e4%b8%80%e6%ad%a5%e7%9a%84%e8%80%97%e6%97%b6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>GitHub 记录了每一步的耗时，用 <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code> 就能拿到。下面是这个应用最近一次成功的运行，10 月 20 日：</p>
<table>
	<thead>
			<tr>
					<th>Job</th>
					<th>步骤</th>
					<th>秒</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>启动 Postgres 和 Valkey</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>，缓存已热</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Django 测试</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>，缓存已热</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>两个依赖缓存都开着，而且都是便宜的那部分。GitHub 为这个仓库保存的 uv 缓存只有 200 KB 左右，因为 setup-uv 会清掉 uv 重新下载就行的东西；npm 的接近 400 MB。时间花在测试上，而测试已经四路并行了。想让运行更快的话，我会去改测试套件，而不是缓存。</p>
<p>博客的情况正好相反。Hugo 处理过的图片放在被 gitignore 的 <code>resources/_gen</code> 里，所以每台新的 runner 都要把封面、正文图片和占位图全部重新编码一遍。下文的工作流用 <code>actions/cache</code> 把它们恢复回来，键是图片本身的哈希，对不上时退回到最新的旧缓存，因为 Hugo 只处理缺的部分。加缓存之前的最后八次运行，构建这一步要 33 到 76 秒；之后的 26 次是 6 到 20 秒，只有一次例外，94 秒。而且同一个提交还加了占位图和第二种卡片尺寸。</p>
<p>这个应用的镜像在 Fly 的构建机上构建，依赖文件先于代码复制进去，uv 的下载缓存放在 BuildKit 的缓存挂载里（<code>RUN --mount=type=cache,target=/root/.cache/uv</code>）。不过最大的改进并不是缓存。旧的 <code>.dockerignore</code> 是黑名单，漏掉了一个放额外 git worktree 的文件夹，每个 worktree 都带着自己的依赖和构建产物，结果每次部署都有 27.7 万个文件、7.7 GB 被传到构建机。改写成白名单之后，构建上下文大约 40 MB。这种写法的示例：</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"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</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%af%86%e9%92%a5%e6%94%be%e5%9c%a8%e5%b0%bd%e9%87%8f%e5%b0%91%e7%9a%84%e5%9c%b0%e6%96%b9" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>密钥放在三个地方。GitHub Actions 的密钥用于工作流要做的事，两个 Hugo 仓库各自只有一个，<code>CLOUDFLARE_API_TOKEN</code>。Fly 的密钥是应用运行时的配置，用 <code>flyctl secrets set</code> 设置，从不经过 Actions。最后，我笔记本上被 gitignore 的 <code>.env</code> 里，放着部署脚本清 Cloudflare 缓存用的令牌。</p>
<p>PyPI 完全不需要密钥。pyhtml2text 用的是可信发布（trusted publishing）：PyPI 接受 GitHub 签发给工作流的短期 OIDC 令牌，所以发布 job 只申请 <code>id-token: write</code>，仓库的密钥页面是空的。没有可泄露的，也没有要轮换的。</p>
<p>令牌只给它需要的权限。这个应用在 GitHub 上的 Fly 令牌，服务于一个只列出机器的定时检查，所以是用 <code>fly tokens create readonly</code> 创建的。Pages 部署只申请 <code>contents: read</code> 和 <code>deployments: write</code>，别无其他。</p>
<p>输入一律经过 <code>env:</code>，绝不直接塞进脚本。OTA 工作流接收自由填写的发布说明。如果在 <code>run:</code> 里写成 <code>${{ inputs.notes }}</code>，文本会在 bash 读取之前原样贴进脚本，于是一条带引号和分号的说明就成了代码。作为环境变量传进去，它就始终只是数据。这种写法的示例：</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="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">OTA_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.OTA_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_PLATFORM</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.platform }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_ROLLOUT</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.rollout }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_NOTES</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.notes }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          args=(&#34;$INPUT_PLATFORM&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_ROLLOUT&#34; ] &amp;&amp; args+=(--rollout &#34;$INPUT_ROLLOUT&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_NOTES&#34; ] &amp;&amp; args+=(--notes &#34;$INPUT_NOTES&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          ./scripts/publish-ota.sh &#34;${args[@]}&#34;</span></span></span></code></pre></div></div>
<p>有些密钥干脆不放在 GitHub 上。有一份定时报告需要一个对应用登录后端有管理员权限的密钥，所以它从定时工作流搬到了我 Mac 上的一个定时任务。工作流仍然留着作为手动备用，只是会略过需要那个密钥的部分。</p>

<h2 class="relative group">部署：每次推送、靠脚本、按 tag
    <div id="部署每次推送靠脚本按-tag" 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%83%a8%e7%bd%b2%e6%af%8f%e6%ac%a1%e6%8e%a8%e9%80%81%e9%9d%a0%e8%84%9a%e6%9c%ac%e6%8c%89-tag" aria-label="锚点">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hugo 网站每次推送都部署
    <div id="hugo-网站每次推送都部署" 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="#hugo-%e7%bd%91%e7%ab%99%e6%af%8f%e6%ac%a1%e6%8e%a8%e9%80%81%e9%83%bd%e9%83%a8%e7%bd%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>这是博客工作流的示例版本，省掉了第一次运行时创建 Pages 项目的那一步：</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">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</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">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</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">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.165.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PAGES_PROJECT</span><span class="p">:</span><span class="w"> </span><span class="l">my-site</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">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">peaceiris/actions-hugo@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">hugo-version</span><span class="p">:</span><span class="w"> </span><span class="l">${{ env.HUGO_VERSION }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">extended</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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="w">      </span><span class="c"># Processed images are gitignored; restore them so Hugo only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># re-encodes what is new. An older cache still helps.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/cache@v6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</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">resources/_gen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">hugo-resources-${{ hashFiles(&#39;content/**/*.jpg&#39;, &#39;content/**/*.png&#39;, &#39;assets/img/**&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restore-keys</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            hugo-resources-</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="w">      </span><span class="c"># A multilingual site has no root 404.html; without one, Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># answers unknown paths with index.html and a 200.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          cp public/en/404.html public/404.html</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="w">      </span><span class="c"># ...</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="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.CLOUDFLARE_ACCOUNT_ID }}</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">pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}</span></span></span></code></pre></div></div>
<p><code>--branch=${{ github.ref_name }}</code> 让 <code>main</code> 成为生产，其他每个分支都得到预览 URL。那个客户网站的工作流和它是同一个结构，只是在构建前多了一行 <code>rm -rf public</code>，因为那边的 <code>public/</code> 是提交进仓库的，不这样做的话，已删除的页面会一直留在上传内容里。</p>
<p>Hugo 的版本只在这里固定，别处都不固定。10 月份一次包管理器升级把我笔记本上的 Hugo 升到了 0.168，比主题支持的最新版本还高一个。部署一直停在 0.165，毫无察觉。</p>
<p>而构建本身就是测试。9 月 14 日，某个分支的预览部署连续失败了三次：那个分支上的一篇文章，用 Hugo 的 <code>ref</code> 链接到了一篇还是草稿的文章。CI 和生产一样，构建时不包含草稿，所以这个链接什么都指不到，Hugo 以 <code>REF_NOT_FOUND</code> 停了下来。在此期间，<code>main</code> 照常部署。</p>

<h3 class="relative group">应用的 API 靠脚本部署
    <div id="应用的-api-靠脚本部署" 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%ba%94%e7%94%a8%e7%9a%84-api-%e9%9d%a0%e8%84%9a%e6%9c%ac%e9%83%a8%e7%bd%b2" aria-label="锚点">#</a>
    </span>
    
</h3>
<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="nv">IMAGE_LABEL</span><span class="o">=</span><span class="s2">&#34;deploy-</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">-</span><span class="k">$(</span>date +%Y%m%d%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build first, then wake a spare machine, then roll out exactly that image.</span>
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --build-only --push --image-label <span class="s2">&#34;</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsS --retry <span class="m">5</span> https://myapp.fly.dev/health &gt; /dev/null
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --image <span class="s2">&#34;registry.fly.io/myapp:</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>以及 <code>fly.toml</code> 里对应的几行：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">deploy</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="c"># max_unavailable only counts machines that are running,</span>
</span></span><span class="line"><span class="cl">  <span class="c"># which is why the script wakes one first.</span>
</span></span><span class="line"><span class="cl">  <span class="nx">strategy</span> <span class="p">=</span> <span class="s2">&#34;rolling&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">max_unavailable</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>镜像只构建一次，以提交和时间组成的标签推到 Fly 的镜像仓库，再按这个标签部署，所以滚动上线的正是构建出来的那个东西。</p>
<p>让它做到零停机的，是先唤醒一台备用机器。<code>max_unavailable = 1</code> 只计算正在运行的机器，而这个应用空闲时一台运行中的机器都不留（挂起的机器大约一秒就能恢复）。所以以前那台唯一醒着的机器，会和睡着的机器在同一批里被替换掉，身后没有一台热着的机器。9 月 28 日，我用探针持续请求源站：直接 <code>flyctl deploy</code> 时出现了 7 次超时和一段 7.3 秒的空白；而用脚本部署时，先构建、在滚动开始前就有一台机器醒着，2,514 个请求一个都没失败。</p>
<p>脚本的其余部分，会在错误告警所在的团队聊天频道里留下部署标记，这样一阵错误涌来时，可以对照刚刚改了什么；然后清掉 Cloudflare 缓存，再把机器预热一遍。Web 前端完全不经过镜像，这部分我写在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-14-web-releases-without-redeploying/" >前后端分开部署：不重新部署 API 也能发布 Web 前端</a>里。</p>
<p>迁移不是流水线的一个步骤。Fly 的 <code>release_command</code> 在我这里会卡住，所以由第一台用新镜像启动的机器拿锁并执行迁移，其他机器等着。因此每次迁移都必须兼容仍在运行的代码：一次部署里加列，下一次部署不再使用它，第三次再删掉。</p>
<p>这个脚本里没有哪一处非得在我的笔记本上不可，但有几个只在本地的文件把它留在了那里：Cloudflare 凭据，以及一份最后部署了什么的记录，全新的 runner 上不会有它们。它还会把未提交的改动也一起部署，并在聊天频道里说明这一点，而 Web 的发布脚本会拒绝不干净的工作区。</p>
<p>在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >每个分支一个预览环境</a>里我写过，这些事手工来做，只有你一个人时没问题；从第二个开发者开始，它就必须因为 git 里发生了什么而发生。这个应用现在还是我一个开发者。那篇文章里的分支环境工作流，还留在我当初搭建它们的那个分支上。</p>
<p>OTA 工作流只能手动运行，因为发布应该是一个决定，也因为在有部分发布（partial rollout）进行中时，更新服务会拒绝新的发布，换成推送触发的话，每次金丝雀发布都会撞上。这个工作流一次都没跑过。到目前为止，每一次 OTA 更新都是从我的笔记本上，通过工作流调用的同一个脚本发出去的。保持工作流轻薄的理由，一句话就是这个：不管发布从哪里开始，跑的都是同一个脚本。</p>

<h3 class="relative group">pyhtml2text 按 tag 发布
    <div id="pyhtml2text-按-tag-发布" 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="#pyhtml2text-%e6%8c%89-tag-%e5%8f%91%e5%b8%83" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>发布 job 如下，包名换成了通用的名字：</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="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>发布 job 会等所有构建 job 完成，只有 tag 才会真正发布；手动运行会构建并生成证明（attestation），但什么都不上传。<code>actions/attest</code> 记录每个 wheel 是由哪个工作流构建的，<code>uv publish --trusted-publishing always</code> 用 OIDC 令牌而不是密码来上传。这个包本身，我写在<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-18-pyhtml2text/" >一个被关闭的 Pull Request、一个 fork 和一个 PyPI 包</a>里。</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%9a%e6%97%b6%e5%b7%a5%e4%bd%9c%e6%b5%81%e5%8f%aa%e6%98%af%e5%b0%bd%e5%8a%9b%e8%80%8c%e4%b8%ba" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这个应用有一个定时工作流，检查 Fly 启动的机器有没有超过基线，不检查的话，第一次发现会是在账单上。它靠故意失败来报警，因为定时运行失败时 GitHub 会给仓库所有者发邮件。</p>
<p>它设的是 <code>cron: '*/15 * * * *'</code>，一天 96 次。可过去两周里，它每天只跑了三到八次，两次运行之间间隔的中位数大约三小时四十分。GitHub 文档给出的唯一解释是负载：Actions 忙的时候，定时运行可能会延迟，忙到一定程度还可能被丢掉。对持续的扩容来说，几个小时足够了。对于必须在几分钟内报警的东西，GitHub 的 cron 不是监控。</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%88%91%e4%bc%9a%e9%97%ae%e4%b8%80%e6%9d%a1%e6%b5%81%e6%b0%b4%e7%ba%bf%e7%9a%84%e5%87%a0%e4%b8%aa%e9%97%ae%e9%a2%98" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>不管你是在搭新的，还是在审视现有的：</p>
<ol>
<li>如果 GitHub Actions 停摆一整天，你还能发布吗？不能的话，你的发布逻辑就在 YAML 里，2024 年那篇依然适用。</li>
<li>CI 跑的解释器、CPU 架构和数据库大版本，和生产一致吗？我的 CI 能抓到一个编译器 bug，只是因为 runner 和生产都是 x86_64，而我的笔记本不是。</li>
<li>全新的检出能通过吗？通不过的话，通常是那些自动生成、又被 gitignore 的文件在作怪。</li>
<li>一次运行的时间到底花在哪儿？加缓存之前，先看每一步的耗时。</li>
<li>每个密钥放在几个地方？它真的需要放在 GitHub 上吗？</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>每个分支一个预览环境：用 Fly.io 和 Cloudflare 终结 staging 争夺战</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</guid>
      <pubDate>Sat, 19 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>DevOps</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>PostgreSQL</category>
      <category>Redis</category>
      <category>Django</category>
      <category>Database Migrations</category>
      <category>Branching</category>
      <category>Preview Environments</category>
      <description>怎样在 Fly.io 和 Cloudflare 上给每个 PR 一个预览环境：打一个标签，用分支名的哈希把共享的 Postgres、Redis、bucket 和主机名切开，PR 关闭就拆掉。</description>
      <content:encoded><![CDATA[<p>要给每个分支一个预览环境，我在 Fly.io 上没有把基础设施整套复制，而是共享着切开用。往 PR 上打一个 <code>env</code> 标签，GitHub Actions 工作流就跑一个脚本，脚本从分支名推导出一个名字（slug 加一段短哈希），盖在 Fly 应用、从模板克隆的 Postgres 数据库、Redis 键前缀、bucket 前缀、新生成的密钥和 Cloudflare 通配符下的主机名上。每次 push 都会更新它，PR 关闭或合并就拆掉，漏掉的由每晚的清理任务收拾，从此不用再问“现在有人在用 staging 吗？”。</p>
<p>想象一个小团队在做同一个应用。两个开发者也好，五个也好；不是分工不同的专家，而是各自在自己的分支上把一个功能从数据库迁移、经过 API、一直做到移动端界面，端到端地负责的人。这是运转团队的好方式，而从第二个开发者加入起，它就有一个反复出现的故障：&ldquo;现在有人在用 staging 吗？&ldquo;这条消息。</p>
<p>消息背后是一个做完了功能、想给测试或设计看、却看不了的开发者：staging 上跑着第二个开发者的分支，dev 上跑着第三个的，而那个分支两天前把登录搞坏了，之后就再没重新部署过。几个人，两个共享环境，一条聊天线程在负责排班。</p>
<p>这不是人的问题，是拓扑问题。每个开发者都把环境当作&quot;我的分支变成真的&quot;的地方，于是环境在替 merge 干活，一次一个分支，其他所有人排队等着。</p>
<p>我的技术栈在生产中的样子写在 <a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a> 里：Fly.io 最便宜的机器上跑的 Django，连接池后面的 Postgres，给缓存和 Channels 层用的 Redis，独立的 WebSocket 应用，一个后台 worker，放文件的兼容 S3 的 bucket，一个托管的认证服务，前面挡着 Cloudflare，还有一个和这一切通信的移动应用。这篇文章讲的是我怎么把这套栈变成这样一种东西：任何分支，开发者往 PR 上打一个标签，几分钟后就拿到一份带 HTTPS 的、公开可访问的完整副本，不用求别人，也没人替他开通，而且不需要第二个 Postgres 集群、第二个 Redis 或第二个 bucket。基础设施保持共享。变化的是每一种共享资源都学会了按名字切片，而名字来自分支。这里面没有任何东西预设团队规模：两个开发者和二十个开发者用的是同一套设计，会增长的只有应用列表，而那由清扫器来处理。我在一个下午里把它搭起来，同时跑了三个分支，在两个没预料到的地方把它弄坏，又拆掉了；下面的输出都来自那次运行。</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%e4%b8%80%e4%b8%aa%e5%85%b1%e4%ba%ab%e7%8e%af%e5%a2%83%e6%92%91%e4%b8%8d%e8%bf%87%e4%b8%80%e4%b8%aa%e5%bc%80%e5%8f%91%e8%80%85" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>共享的 dev 环境里烙着一个假设：所有人想看的&quot;当前代码版本&quot;只有一个。对单个开发者，或者对齐步推进同一条变更流的团队来说，这是对的。第二个开发者按另一个时间表开始做功能的那一刻，它就不对了；而在端到端负责制下，每个功能都是这样。从那以后，环境里的一切都成了争抢对象：</p>
<ul>
<li><strong>部署的代码。</strong> 最后部署的人赢。其他人的工作在重新部署前都看不见，而重新部署又会抹掉前一个人的。</li>
<li><strong>数据库 schema。</strong> 当每个开发者都拥有自己功能的数据模型时，咬得最狠的就是这一条。第一个分支加了一列。一小时后部署的第二个分支不知道有这列，ORM 开始在 insert 上报错。或者两个分支都有一个 <code>0042_*</code> 迁移，共享数据库落到一个和谁的迁移历史都对不上的状态，然后有人把它重置，顺手删掉了第三个开发者花一下午准备的测试数据。</li>
<li><strong>缓存。</strong> 一个分支改了序列化器，结果缓存到了另一个分支代码读取的同一个 Redis 键下。由此产生的 bug 报告莫名其妙，每个都要耗掉一上午。</li>
<li><strong>第三方集成。</strong> 一个 OAuth 回调，一个 webhook URL，一个推送证书。谁的分支部署着，谁就收到事件。</li>
<li><strong>测试人员。</strong> 他们只能测已经部署的东西，所以是按环境碰巧被谁占用的顺序、而不是功能就绪的顺序，一次测一个。</li>
</ul>
<p>团队的应对是预约制：一条聊天线程、一条置顶消息、一个 <code>/claim staging</code> 机器人。那是一把锁，而锁在唯一能展示你工作的地方上，就是锁在交付速度上。两个开发者的话，只是烦人。三个的话，顺利的一周还能忍。加一个第四人，或者某个功能因为要和设计师来回三天而一直霸着 staging，就忍不了了。</p>
<p>人们最先想到的办法是再加一个共享环境，&ldquo;dev2&rdquo; 或者 &ldquo;uat&rdquo;。这能争取几周。队伍只是变成了两条道。</p>
<p>真正的解法是意识到 dev 和 staging 从来就不是环境。它们是<em>某人分支的当前状态，附带一个 URL</em>。如果它们是这样的东西，那它们的正确数量就是团队此刻在意的分支数，正确寿命就是分支的寿命。</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%a8%a1%e5%9e%8b%e5%88%86%e6%94%af%e5%b0%b1%e6%98%af%e7%8e%af%e5%a2%83" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>目标状态是这样。</p>
<table>
	<thead>
			<tr>
					<th>环境</th>
					<th>数量</th>
					<th>由谁创建</th>
					<th>由谁销毁</th>
					<th>存活时间</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>分支环境（原来的&quot;dev&rdquo;）</td>
					<td>每个活跃分支一个</td>
					<td>分支的 PR 上的 <code>env</code> 标签，之后每次 push；没有 PR 的 spike，就手动跑同一个 <code>./scripts/env up</code></td>
					<td>删除分支，外加每晚的清扫器</td>
					<td>几天</td>
			</tr>
			<tr>
					<td>Staging</td>
					<td>每个发布候选一个</td>
					<td>从 <code>main</code> 打 tag 时由 CI</td>
					<td>同一镜像晋升到生产后由 CI</td>
					<td>几小时</td>
			</tr>
			<tr>
					<td>生产</td>
					<td>一个</td>
					<td>你，一次</td>
					<td>没人</td>
					<td>永远</td>
			</tr>
	</tbody>
</table>
<p>再看一遍表里谁创建、谁销毁：每一项要么是 git 事件，要么是时钟。git 就是控制平面。应该存在的环境集合直接由存在的分支集合决定，自动化唯一的工作就是让现实和它保持一致：PR 上的标签创建，之后每次 push 更新，关闭或合并 PR 销毁，每晚一个任务把前三者漏掉的收拾干净。也不是每个分支都会有环境：标签就是闸门，开发者点一下，所以备份用的 push 或机器人的依赖升级不花一分钱。没有人在开通环境。不是拿着工单的 DevOps 工程师，不是你在聊天里喊的机器人，也不是你每次都要提示一遍的 AI agent。第十个开发者的第一个分支拿到环境的方式，和第一个开发者的一模一样：PR 被打上了标签。如果还得去找一个人，那就没有扩展，只是队伍换了个地方排。</p>
<p>让它成立的规则只有一条：<strong>环境触及的每一种资源都按分支命名，且是确定性的。</strong> 不按开发者，不按工单，也不按那个没人开 PR 之前根本不存在的 PR 号。按分支，因为分支才是开发者实际打交道的东西，也因为名字确定，就意味着在同一分支上第二次运行 <code>./scripts/env up</code> 会找到已经建好的环境，而不是再建一个。</p>
<p>名字是给人看的 slug 加上保证唯一的短哈希：</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">branch</span><span class="o">=</span><span class="k">$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span>              <span class="c1"># feature/payments-retry</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">hash</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> sha256sum <span class="p">|</span> cut -c1-6<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">hash</span><span class="si">}</span><span class="s2">&#34;</span>                                 <span class="c1"># feature-payments-retr-3f9a1c</span></span></span></code></pre></div></div>
<p><code>feature-payments-retr-3f9a1c</code> 就是环境。这个字符串会成为 Fly 应用名、Postgres 数据库名（连字符换成下划线）、Redis 键前缀、bucket 的对象前缀、错误追踪工具里的 environment，以及主机名最左边的标签。知道分支名的任何人都能算出来。什么都不用查。</p>
<p>最后那句话就是设计本身。没有环境注册表，没有协调者。<code>env up</code>、<code>env down</code>、负责路由主机名的 Cloudflare Worker，以及每晚的清扫器，各自独立地从分支重新算出名字，靠构造保证一致。尤其是 Cloudflare，它什么都不协调：Worker 是一个从主机名到 Fly 应用的纯函数，它永远不知道某个环境被创建或销毁了。应用存在，请求就落地；不存在，Fly 用自己的错误来回答。</p>
<p>由此得出三件事。</p>
<p>第一，&ldquo;谁在用 staging&quot;不再是个问题。有 <code>feature-payments-retr-3f9a1c-dev.example.com</code>，有 <code>fix-login-redirect-8b21e0-dev.example.com</code>，各归在那个分支上的人所有。</p>
<p>第二，staging 从垃圾场变成了对生产的诚实彩排。它用即将上生产的那个镜像构建，对着几分钟前从生产形态快照克隆出来的数据库跑。那里能跑通，生产里剩下的唯一变量就是生产数据。</p>
<p>第三，每个开发者获得了权力，却不需要任何共享资源的 root。分支环境的爆炸半径就是那个分支环境。第四个新人入职第一天想往自己分支上部署什么都行，最坏的结果是自己的分支坏了。</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">fly.io/dashboard/my-org</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_e5b19353521f0ce4.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="该组织的 Fly.io 控制台：三个功能分支应用（两个已 suspended），旁边是生产应用和共享的预览 Postgres、Redis"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_a766ad7bf75a9514.webp"></span></div>
  <figcaption>三个分支起来后的组织应用列表。三个里有两个在部署一分钟后就已经 suspended；共享的预览 Postgres 和 Redis 在旁边。下面那几行是生产应用。</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="#%e7%89%a9%e7%90%86%e4%b8%8a%e5%85%b1%e4%ba%ab%e9%80%bb%e8%be%91%e4%b8%8a%e9%9a%94%e7%a6%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>&ldquo;每个分支一个环境&quot;的诱人版本是整套复制：每人一个 Postgres 集群、一个 Redis、一个 bucket。干净，但是笔坏买卖。一个 Fly Postgres 集群是一台机器加一个卷加一次恢复；一个 Redis 又是一台机器；你会为每个环境等上几分钟，并为十来个闲置数据库付钱。清扫器要清理的东西也从一种变成四种。</p>
<p>取而代之的是，每种共享服务为整个预览组织只配置一次，按小号生产的规格来，每个环境以 <code>$ENV</code> 为键分到一片。这是我想贴在墙上的表：</p>
<table>
	<thead>
			<tr>
					<th>资源</th>
					<th>共享的东西</th>
					<th>每个环境独有的</th>
					<th>靠什么保证</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>计算</td>
					<td>Fly 组织 <code>myapp-preview</code></td>
					<td>一个 Fly 应用 <code>myapp-$ENV</code>，一台机器</td>
					<td>Fly：应用是隔离单位</td>
			</tr>
			<tr>
					<td>密钥</td>
					<td>Fly 的按应用密钥存储</td>
					<td>应用自己的密钥；<code>SECRET_KEY</code>、<code>ENCRYPTION_KEY</code> 和 <code>ENV_TOKEN</code> 是按环境<em>生成</em>的，不是复制的</td>
					<td>Fly：密钥属于应用，机器只能读自己的</td>
			</tr>
			<tr>
					<td>Postgres</td>
					<td>一个 Fly Postgres 集群 <code>myapp-preview-pg</code></td>
					<td>一个数据库 <code>env_feature_payments_retr_3f9a1c</code>，从 <code>seed_template</code> 克隆</td>
					<td>Postgres：数据库是硬边界，不能跨库查询</td>
			</tr>
			<tr>
					<td>Redis</td>
					<td>一个共享的 Redis</td>
					<td>缓存和 Channels 层上的键前缀 <code>feature-payments-retr-3f9a1c:</code></td>
					<td>Django 缓存的 <code>KEY_PREFIX</code>，channel layer 的 <code>prefix</code></td>
			</tr>
			<tr>
					<td>文件</td>
					<td>一个兼容 S3 的 bucket <code>myapp-preview</code></td>
					<td>对象前缀 <code>feature-payments-retr-3f9a1c/</code></td>
					<td>存储后端的 <code>location</code>；预签名 URL 本来就限定到单个键</td>
			</tr>
			<tr>
					<td>主机名</td>
					<td>区域里已有的、已代理的 <code>*.example.com</code> 记录</td>
					<td>最左边的标签 <code>&lt;env&gt;-dev</code></td>
					<td><code>*-dev.example.com/*</code> 上的一条 Cloudflare Worker 路由</td>
			</tr>
			<tr>
					<td>认证</td>
					<td>认证服务上的一个 dev 项目</td>
					<td>无；用户身份是有意共享的（见下文）</td>
					<td>授权域名 <code>example.com</code></td>
			</tr>
			<tr>
					<td>错误与日志</td>
					<td>错误追踪工具，Fly 的日志</td>
					<td>所有东西都打上 <code>environment=$ENV</code> 标签</td>
					<td>配置</td>
			</tr>
	</tbody>
</table>
<p>其中两行值得多说一句。</p>
<p><strong>Postgres：用数据库，不用 schema。</strong> 切分单个集群的另一种做法是每个环境一个 schema，按连接设置 <code>search_path</code>。把 <code>-c search_path=...</code> 塞进连接的 <code>options</code> 里，Django 是能做到的，但事务模式下的连接池会丢弃启动参数，除非你专门告诉它别丢，而即便如此，池里的每个连接也终究只能属于一个环境。这是&quot;为什么我的查询打到了别的表&quot;这类 bug 的源头，而每个环境一个数据库根本不存在这个问题。数据库是硬边界，<code>CREATE DATABASE ... TEMPLATE</code> 几秒钟建一个，<code>DROP DATABASE</code> 把它删得一干二净。分支环境不走连接池，直接连 Postgres 的 5433 端口，和生产里的后台 worker 一样；连接池是在生产级并发下才值回票价的，而分支环境永远见不到那种并发。</p>
<p><strong>密钥：生成，不共享。</strong> 弄一套 dev 密钥复制到每个环境里当然省事。别这么干。<code>env up</code> 脚本为每个环境生成新的 <code>SECRET_KEY</code>（会话签名）、<code>ENCRYPTION_KEY</code>（所有静态加密的东西）和 <code>ENV_TOKEN</code>（移动端出示的 bearer 令牌，下文详述），只存在那个 Fly 应用的密钥存储里。这意味着一个分支的会话 cookie 在另一个分支上一文不值，泄露的预览令牌只能打开一个预览，开发者不特意去找就永远看不到这些值。如果你把密钥放在单独的密钥管理工具里而不只是 Fly 里，规则一样：路径是 <code>myapp/preview/$ENV/</code>，清扫器会连同环境一起删掉这条路径。真正跨环境<em>共享</em>的密钥只有共享基础设施本身的凭据（Postgres 管理员 URL、Redis URL、bucket 密钥），而这些放在 CI 里，不在任何环境里。</p>
<p>Redis 那一行最容易让人怀疑，所以做了测试。从一个环境里面设一个缓存键，从另一个读，再列出共享 Redis 里的所有键：</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">$ fly ssh console -a myapp-feature-profile-tagl-fa8832 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; cache.set(&#39;who-am-i&#39;, &#39;tagline-branch&#39;, 600)\&#34;&#34;
</span></span><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-webs-dcb2e7 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; print(cache.get(&#39;who-am-i&#39;))\&#34;&#34;
</span></span><span class="line"><span class="cl">None
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ # every key in the shared Redis
</span></span><span class="line"><span class="cl">  feature-profile-tagl-fa8832:1:who-am-i
</span></span><span class="line"><span class="cl">  ...</span></span></code></pre></div></div>
<p>同一个 Redis，同一个键名，另一个环境什么都看不到。</p>
<p>这篇文章剩下的部分，就是把 <code>$ENV</code> 盖到那八行上的脚本，以及让它保持诚实的规则。</p>

<h2 class="relative group"><code>./scripts/env up</code>
    <div id="scriptsenv-up" 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="#scriptsenv-up" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>这就是整个思路——一个开发者在自己分支上运行的脚本。CI 运行的是同一个脚本；没有会各自漂移的 CI 专用路径。笔记本上只需要 <code>fly</code> 和 <code>git</code>，别的都不要：SQL 通过 <code>fly ssh</code> 送到 Postgres 应用，环境的 Redis 键和 bucket 对象在销毁前由环境自己的机器删除，所以笔记本除了一个被 gitignore 的 env 文件之外，不持有任何共享 Redis 或 bucket 的凭据。下面只留了要紧的几行：</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/env up|down|name|url: one environment per branch on shared preview infrastructure</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">PG_APP</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_PG_APP</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview-pg</span><span class="si">}</span><span class="p">;</span> <span class="nv">DOMAIN</span><span class="o">=</span><span class="si">${</span><span class="nv">BASE_DOMAIN</span><span class="k">:-</span><span class="nv">example</span><span class="p">.com</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="si">${</span><span class="nv">BRANCH</span><span class="k">:-$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;main has staging, not a branch environment&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span><span class="p">;</span> <span class="nv">slug</span><span class="o">=</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="si">${</span><span class="nv">ENV_OVERRIDE</span><span class="k">:-</span><span class="si">${</span><span class="nv">slug</span><span class="si">}</span><span class="p">-</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> shasum -a <span class="m">256</span> <span class="p">|</span> cut -c1-6<span class="k">)</span><span class="si">}</span>   <span class="c1"># the sweeper passes the name</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP</span><span class="o">=</span><span class="s2">&#34;myapp-</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">DB</span><span class="o">=</span><span class="s2">&#34;env_</span><span class="si">${</span><span class="nv">ENV</span><span class="p">//-/_</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">-dev.</span><span class="si">${</span><span class="nv">DOMAIN</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">pg<span class="o">()</span> <span class="o">{</span> fly ssh console -a <span class="s2">&#34;</span><span class="nv">$PG_APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;psql postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@localhost:5433/postgres -At -c \&#34;</span><span class="nv">$1</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">exists<span class="o">()</span> <span class="o">{</span> fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -q <span class="s2">&#34;\&#34;Name\&#34;: *\&#34;</span><span class="nv">$APP</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">up<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;== </span><span class="nv">$branch</span><span class="s2"> -&gt; </span><span class="nv">$ENV</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  exists <span class="o">||</span> fly apps create <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span>                         <span class="c1"># idempotent</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="k">$(</span>pg <span class="s2">&#34;SELECT 1 FROM pg_database WHERE datname=&#39;</span><span class="nv">$DB</span><span class="s2">&#39;&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span> <span class="o">||</span> pg <span class="s2">&#34;CREATE DATABASE </span><span class="nv">$DB</span><span class="s2"> TEMPLATE seed_template&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">have</span><span class="o">=</span><span class="k">$(</span>fly secrets list -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4 <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  gen<span class="o">()</span> <span class="o">{</span> grep -qx <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$have</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">=</span><span class="k">$(</span>openssl rand -hex 32<span class="k">)</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>   <span class="c1"># generated once, never rotated by a later `up`</span>
</span></span><span class="line"><span class="cl">  <span class="o">{</span> gen SECRET_KEY<span class="p">;</span> gen ENCRYPTION_KEY<span class="p">;</span> gen ENV_TOKEN
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;ENV_NAME=</span><span class="nv">$ENV</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;ALLOWED_HOSTS=</span><span class="nv">$HOST</span><span class="s2">,</span><span class="nv">$APP</span><span class="s2">.fly.dev&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;DATABASE_URL=postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@</span><span class="si">${</span><span class="nv">PG_APP</span><span class="si">}</span><span class="s2">.flycast:5432/</span><span class="si">${</span><span class="nv">DB</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... plus the shared Redis URL and bucket credentials</span>
</span></span><span class="line"><span class="cl">  <span class="o">}</span> <span class="p">|</span> fly secrets import -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --stage
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  fly deploy -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --config fly.preview.toml --remote-only --ha<span class="o">=</span><span class="nb">false</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --build-arg <span class="nv">GIT_SHA</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">&#34;</span> --build-arg <span class="nv">GIT_BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">down<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^myapp-<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;refusing to destroy </span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> exists<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    fly machine start -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">true</span>                 <span class="c1"># it may be suspended</span>
</span></span><span class="line"><span class="cl">    fly ssh console -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;python manage.py teardown_env&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;warning: cleanup failed; the sweeper will catch it&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    fly apps destroy <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --yes
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  pg <span class="s2">&#34;DROP DATABASE IF EXISTS </span><span class="nv">$DB</span><span class="s2"> WITH (FORCE)&#34;</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> in up<span class="o">)</span> up <span class="p">;;</span> down<span class="o">)</span> down <span class="p">;;</span> name<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$ENV</span><span class="s2">&#34;</span> <span class="p">;;</span> url<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span></span></span></code></pre></div></div>
<p>演示时我是从笔记本上直接跑的，而不是 push，因为这三个分支是一次性的，我不想让它们留在仓库历史里，也想盯着输出一行行出来。团队里用的时候没人会敲这条命令：下文的标签工作流跑的就是这个脚本，接下来的输出就是会落在 Actions 日志里的那些。这是它第一次在承载这套工具本身的分支上运行时打印的内容：</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">$ ./scripts/env up
</span></span><span class="line"><span class="cl">== feature/branch-environments -&gt; feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">New app created: myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">CREATE DATABASE
</span></span><span class="line"><span class="cl">Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
</span></span><span class="line"><span class="cl">==&gt; Building image
</span></span><span class="line"><span class="cl">image size: 139 MB
</span></span><span class="line"><span class="cl">✔ Machine 1a2b3c4d5e6f78 [app] update finished: success
</span></span><span class="line"><span class="cl">https://feature-branch-envir-c65cb4-dev.example.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s https://feature-branch-envir-c65cb4-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">{&#34;environment&#34;: &#34;feature-branch-envir-c65cb4&#34;, &#34;branch&#34;: &#34;feature/branch-environments&#34;, &#34;commit&#34;: &#34;a1b2c3d4&#34;, &#34;region&#34;: &#34;syd&#34;}</span></span></code></pre></div></div>
<p>从命令到 URL 大约两分半，大部分是镜像构建。再提交一次后在同一分支上第二次 <code>up</code>，没有打印 &ldquo;New app created&rdquo;，也没有 &ldquo;CREATE DATABASE&rdquo;，暂存了同样的密钥名而没有重新生成已生成的那些，然后重新构建：同一个 URL，新的提交。</p>
<p>里面的几个选择各值得一句话。</p>
<p><strong><code>fly.preview.toml</code> 不是 <code>fly.toml</code>。</strong> 生产把 API、WebSocket 服务器和后台 worker 作为独立的 Fly 应用和进程组来跑，各按自己的指标伸缩，那正是技术栈那篇的全部论点。分支环境不需要伸缩；它需要的是一台机器。所以预览配置只有一个进程：容器的入口脚本多了一个预览模式，先跑迁移，再启动<em>一个</em>开着 WebSocket 的 ASGI 服务器。Channels 本来就在同一个 ASGI 应用里同时路由 HTTP 和 WebSocket 流量，所以一个进程就能服务整个应用。那个模式下同步视图按 thread-sensitive 方式运行，差不多一次一个请求——正是我在生产里放弃的那笔交易，而在这里恰恰合适，因为测试分支的人不会察觉。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># fly.preview.toml (the app name comes from `fly deploy -a`)</span>
</span></span><span class="line"><span class="cl"><span class="nx">app</span> <span class="p">=</span> <span class="s1">&#39;placeholder&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">primary_region</span> <span class="p">=</span> <span class="s1">&#39;syd&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">processes</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">app</span> <span class="p">=</span> <span class="s2">&#34;/app/entrypoint.sh preview&#34;</span>   <span class="c"># migrate, then one ASGI server for HTTP and WebSockets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">env</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DJANGO_SETTINGS_MODULE</span> <span class="p">=</span> <span class="s1">&#39;myapp.settings&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DEBUG</span> <span class="p">=</span> <span class="s1">&#39;False&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">internal_port</span> <span class="p">=</span> <span class="mi">8000</span>
</span></span><span class="line"><span class="cl">  <span class="nx">force_https</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="p">[[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">    <span class="nx">path</span> <span class="p">=</span> <span class="s1">&#39;/healthz&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">headers</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">      <span class="nx">Host</span> <span class="p">=</span> <span class="s1">&#39;127.0.0.1&#39;</span>               <span class="c"># the app&#39;s own hostname isn&#39;t known to a static file</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">size</span> <span class="p">=</span> <span class="s1">&#39;shared-cpu-1x&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;1gb&#39;</span></span></span></code></pre></div></div>
<p><strong><code>auto_stop_machines = &quot;suspend&quot;</code>，<code>min_machines_running = 0</code>。</strong> 午饭后没人打开过的分支环境应该一分钱不花。用 suspend 而不是 stop，得到的是我<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-09-09-scale-to-zero-504s/" >追查 504</a>之后落到的约 3 秒恢复，而不是 30 秒冷启动，而在这里就算 30 秒也能接受。三个演示环境里有两个在部署完成一分钟后就已经在 <code>fly apps list</code> 里显示 <code>suspended</code> 了。</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">fly.io/apps/myapp-feature-profile-tagl-fa8832</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d2f15e465075b4eb.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="一个分支应用的 Fly.io 概览：SYD 的一台 shared-cpu-1x 机器，一个进程组，自己的 *.fly.dev 主机名"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_381dc63e1f9d24c0.webp"></span></div>
  <figcaption>Fly 眼中的一个分支环境：一台机器，一个进程组，一个区域。没有任何东西值得伸缩。</figcaption>
</figure>

<p><strong><code>DEBUG = False</code>，和生产同一个 settings 模块。</strong> 分支环境是一个公开 URL。在所有对测试重要的方面（真 HTTPS、真 cookie、同样的健康检查）它都应该像生产，在所有可能伤到人的方面它都应该不像生产。差异不靠单独的预览 settings 文件，而是由 <code>env up</code> 设置的密钥承载：沙箱支付密钥、没有认证服务的管理员凭据，以及打开下面所有前缀的 <code>ENV_NAME</code>。</p>
<p><strong>一个设置切分一切。</strong> <code>ENV_NAME</code> 是 settings 里唯一新增的旋钮，应用在四个地方：</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">ENV_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;ENV_NAME&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">_prefix</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">/&#34;</span> <span class="k">if</span> <span class="n">ENV_NAME</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">CACHES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;KEY_PREFIX&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">ENV_NAME</span>                           <span class="c1"># every cache key</span>
</span></span><span class="line"><span class="cl"><span class="n">CHANNEL_LAYERS</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;CONFIG&#34;</span><span class="p">][</span><span class="s2">&#34;prefix&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">:asgi&#34;</span>  <span class="c1"># Channels group and channel names</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">media&#34;</span>      <span class="c1"># uploads</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;staticfiles&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">static&#34;</span></span></span></code></pre></div></div>
<p>漏掉 channels 那一处，两个分支的 WebSocket 服务器就会共用组名，一个环境里发的消息会出现在另一个环境里。那是要耗掉一天的那种 bug。</p>
<p>第五处是事后读共享 Redis 才发现的。504 那篇里的启动锁——让每个镜像只有一台机器跑迁移、其他机器等待的机制——原本是以镜像引用为键的。从同一个提交构建的两个分支环境有同一个镜像。第二个会找到第一个的 &ldquo;done&rdquo; 标记，然后跳过它自己对自己那个尚未迁移的数据库的迁移。现在环境名是键的一部分，键也住在前缀下面。这是共享基础设施设计会不断产出的那类问题：所有隐含地&quot;按部署&quot;的东西都得显式地变成按环境，而在你去看之前，你不知道什么是隐含的。</p>
<p><strong>数据库是克隆的，不是从空库迁移的。</strong> 下一节。</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%b0%e6%8d%ae%e5%ba%93%e5%87%a0%e7%a7%92%e5%85%8b%e9%9a%86%e5%87%ba%e6%9d%a5%e7%9a%84%e6%a8%a1%e6%9d%bf" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>分支环境的数据库有三种可能。和所有人共享——这正是把我们带到这里的东西。空库，从零迁移再用 fixture 填充：确定性强，但 fixture 从来比不上真实形态的数据，而且永远陈旧十八个月。或者克隆一份生产形态的快照：真实的行数，fixture 假定总是有值的那些列里真实的 null，在空表上瞬间完成、在真表上要跑四十分钟的迁移会现出原形。</p>
<p>选第三种，第二种保留为只在 CI 跑的检查。而让第三种快到可以每个分支都做一次的，是一个大多数人忘了存在的 Postgres 功能：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="c1">-- Nightly, on the preview cluster. Restore last night&#39;s prod backup into seed_raw,
</span></span></span><span class="line"><span class="cl"><span class="c1">-- run the anonymiser, then freeze it as a template nobody can connect to.
</span></span></span><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">seed_raw</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="n">pg_database</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">datistemplate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span><span class="w"> </span><span class="n">datallowconn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">datname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;seed_template&#39;</span><span class="p">;</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="c1">-- Per branch, in the time it takes to copy the files:
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">env_feature_payments_retr_3f9a1c</span><span class="w"> </span><span class="k">TEMPLATE</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span></span></span></code></pre></div></div>
<p><code>CREATE DATABASE ... TEMPLATE</code> 是集群内的文件级复制。几个 GB 只要几秒，而不是 <code>pg_restore</code> 的几分钟。被克隆时模板不能有打开的连接，<code>datallowconn = false</code> 强制了这一点，这也是为什么夜间任务往 <code>seed_raw</code> 里构建、最后再改名，而不是直接往活着的模板上恢复。</p>
<p>演示里模板是手工建的，而不是来自生产 dump：用 <code>fly proxy</code> 打一条通向预览集群的隧道，<code>manage.py migrate</code>，然后用仓库自带的填充命令生成一份不大但贴近真实的数据。十九兆。三个分支起来时，集群里出现了它的三个克隆，每个 19 MB，每个不到一秒：</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">$ fly ssh console -a myapp-preview-pg -C &#34;psql ... -c &#39;\l&#39;&#34;
</span></span><span class="line"><span class="cl">             datname             | template | size
</span></span><span class="line"><span class="cl">---------------------------------+----------+-------
</span></span><span class="line"><span class="cl"> env_feature_branch_envir_c65cb4 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_tagl_fa8832 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_webs_dcb2e7 | f        | 19 MB
</span></span><span class="line"><span class="cl"> seed_template                   | t        | 19 MB</span></span></code></pre></div></div>
<p>预览集群是一个非托管的 Fly Postgres 节点，<code>shared-cpu-1x</code> 带 1 GB 卷，每月大约两美元。分支环境通过私有网络的 <code>.flycast</code> 连它；没有任何东西是公开的。</p>
<p>匿名化不是可选项。生产数据在 <code>-dev</code> 主机名上可达的那一刻，它就必须不再是生产数据：姓名、邮箱、电话、地址、自由文本、支付引用、令牌，全部在改名之前覆盖掉。匿名化失败，前一晚的模板保留，有人会收到消息。这是件小活，也正是让整个方案能被回答隐私问卷的那个人接受的那件活。</p>
<p>入口脚本在每个镜像首次启动时运行 <code>migrate</code>，所以分支自己的迁移会叠加在模板之上。这是&quot;我的迁移在生产形态的数据上能不能跑&quot;的第一次诚实测试，而且发生在任何人打开 URL 之前。</p>

<h2 class="relative group">主机名：一个通配符、一个 Worker，以及文档没告诉我的两件事
    <div id="主机名一个通配符一个-worker以及文档没告诉我的两件事" 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%bb%e6%9c%ba%e5%90%8d%e4%b8%80%e4%b8%aa%e9%80%9a%e9%85%8d%e7%ac%a6%e4%b8%80%e4%b8%aa-worker%e4%bb%a5%e5%8f%8a%e6%96%87%e6%a1%a3%e6%b2%a1%e5%91%8a%e8%af%89%e6%88%91%e7%9a%84%e4%b8%a4%e4%bb%b6%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>每个环境都需要一个公开的 HTTPS 主机名，而&quot;公开&quot;正是重点：拿着手机的测试、另一个网络里的设计师、不肯装 VPN 的产品经理。Fly 免费给每个应用一个 <code>myapp-$ENV.fly.dev</code>，光靠它也能用。但 <code>*.fly.dev</code> 主机名有三个问题：发出去难看，前面没有 Cloudflare 所以你在生产上的所有防护都不生效，而如果想放到自己的域名下，就得每个环境一条 DNS 记录加一张证书，清扫器又多一样要清的东西。</p>
<p>把这些都去掉的版本，是一个通配符加一个 Cloudflare Worker。我原计划用 <code>*.dev.example.com</code>。两件事挡住了它，而且在你做同样的计划之前，两件都值得知道。</p>
<p><strong>Universal SSL 只覆盖一层通配符。</strong> Cloudflare 的免费证书签发给 <code>example.com</code> 和 <code>*.example.com</code>。它不覆盖 <code>*.dev.example.com</code>；二级通配符需要每月 10 美元的 Advanced Certificate Manager。对 <code>feature-branch-envir-c65cb4.dev.example.com</code> 的第一个请求在我的任何东西运行之前就死于 SSL 握手失败。解法是把环境留在第一层标签里：<code>feature-branch-envir-c65cb4-dev.example.com</code>。它被已经存在的证书覆盖，也被已经存在的、已代理的 <code>*.example.com</code> 记录覆盖——就是我为<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>设的那个黑洞记录。所以一个环境完全不需要 DNS。</p>
<p><strong>Redirect Rules 在 Workers 之前执行。</strong> 路由配好之后，每个 <code>-dev</code> 主机名都在 Worker 没有运行的情况下 301 到了 www 首页。黑洞是一条区域级的重定向规则，&ldquo;其他任何子域名 → www&rdquo;，而 Cloudflare 的重定向阶段在 Workers 阶段之前执行。规则需要加一个子句：<code>and not ends_with(http.host, &quot;-dev.example.com&quot;)</code>。部署脚本在 <code>up</code> 时加上、<code>down</code> 时去掉，因为手工改过的规则正是会被遗忘的东西。</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">dash.cloudflare.com/…/example.com/rules/redirect-rules/…/edit</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_38e5e373828b8373.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Cloudflare 控制台里的重定向规则，表达式以 -dev 例外结尾"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_71d546da5b5668b6.webp"></span></div>
  <figcaption>脚本动过手之后的黑洞重定向。区域下的一切仍然弹回 www，只有以 <code>-dev.example.com</code> 结尾的主机名会漏过去交给 Worker。</figcaption>
</figure>

<p>DNS 这边就是那条本来就在的记录：</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">dash.cloudflare.com/…/example.com/dns/records</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_2c615b9e52444abd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="按通配符过滤的区域 DNS 记录：一条区域通配符的已代理 A 记录，指向 192.0.2.1"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_952422604a6c8304.webp"></span></div>
  <figcaption>唯一涉及的 DNS 记录，而且早于这篇文章。一个指向占位地址的已代理通配符：Cloudflare 应答，接下来的事交给规则和 Workers。</figcaption>
</figure>

<p>这两件事解决后，路由就是 <code>*-dev.example.com/*</code> 上的一个 Worker：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// dev-router.js: &lt;env&gt;-dev.example.com -&gt; myapp-&lt;env&gt;.fly.dev
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DEV_SUFFIX</span> <span class="o">=</span> <span class="s2">&#34;-dev.example.com&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ENV_NAME</span> <span class="o">=</span> <span class="sr">/^[a-z0-9-]+-[0-9a-f]{6}$/</span><span class="p">;</span>   <span class="c1">// slug-hash, as scripts/env names them
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">url</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">endsWith</span><span class="p">(</span><span class="nx">DEV_SUFFIX</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="s2">&#34;not a dev hostname&#34;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">env</span> <span class="o">=</span> <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="nx">DEV_SUFFIX</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">ENV_NAME</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">env</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="sb">`no such environment: </span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span> <span class="o">=</span> <span class="sb">`myapp-</span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">.fly.dev`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">upstream</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Request</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">request</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">upstream</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Forwarded-Host&#34;</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="s2">&#34;&#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">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">upstream</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="nx">res</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Robots-Tag&#34;</span><span class="p">,</span> <span class="s2">&#34;noindex, nofollow&#34;</span><span class="p">);</span>   <span class="c1">// a half-finished branch is not for search engines
</span></span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Dev-Environment&#34;</span><span class="p">,</span> <span class="nx">env</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">out</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="p">};</span></span></span></code></pre></div></div>
<p>Cloudflare 在通配符上终结 TLS，Worker 从标签算出 Fly 主机名，到机器那一跳由 Fly 自己的 <code>*.fly.dev</code> 证书覆盖。WebSocket 升级原样穿过 <code>fetch</code>。而由于路由在区域上，Cloudflare 为生产做的一切在这里都可用：WAF 规则、限速、bot 防护模式，以及 Access。</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">dash.cloudflare.com/…/example.com/workers</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_9996f094fe4379cd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="该区域的 Cloudflare Workers Routes 页面：一条路由 *-dev.example.com/*，绑定到 dev-router Worker"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_78420fc686a1e6e3.webp"></span></div>
  <figcaption>一个环境在 Cloudflare 侧的全部：一个路由模式，一个 Worker。这个控制台里任何地方都没有出现环境的名字。</figcaption>
</figure>

<p>三个分支同时在线，一个 Worker，以及每个主机名的应答：</p>
<table>
	<thead>
			<tr>
					<th>主机名</th>
					<th>HTTP</th>
					<th>路由到</th>
					<th><code>branch</code></th>
					<th><code>commit</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>feature-branch-envir-c65cb4-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-branch-envir-c65cb4.fly.dev</code></td>
					<td><code>feature/branch-environments</code></td>
					<td><code>a1b2c3d4</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-tagl-fa8832-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-tagl-fa8832.fly.dev</code></td>
					<td><code>feature/profile-tagline</code></td>
					<td><code>e5f6a7b8</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-webs-dcb2e7-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-webs-dcb2e7.fly.dev</code></td>
					<td><code>feature/profile-website</code></td>
					<td><code>c9d0e1f2</code></td>
			</tr>
			<tr>
					<td><code>not-an-env-dev.example.com</code></td>
					<td>404</td>
					<td>无；Worker 拒绝了这个标签</td>
					<td></td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>每个 200 都带着 Worker 加上的 <code>X-Robots-Tag: noindex, nofollow</code> 和 <code>X-Dev-Environment: &lt;env&gt;</code>。那个 404 是 Worker 自己给的，根本没问过 Fly。</p>
<p>Cloudflare Access 是把&quot;公开&quot;变成&quot;该到的人到得了&quot;的东西。在 <code>*-dev.example.com</code> 上建一个应用，策略是&quot;任何 @example.com 邮箱&rdquo;，浏览器访客看一次登录页，之后就再也不用想它。设计师、PM 和用笔记本的测试就都覆盖了。演示里我没加；那些环境带着 <code>noindex</code> 在线了一小时，上面除了种子数据什么都没有。</p>
<p>它覆盖不了移动端，因为原生应用完不成 Access 的登录跳转。两条路。Access 支持服务令牌（一对 <code>CF-Access-Client-Id</code> / <code>CF-Access-Client-Secret</code> 请求头）绕过登录，移动应用的开发菜单可以为 dev 主机名带上一对。或者更简单，为 <code>/api/</code> 加一条 Access 放行，让 Django 拒绝任何既没有已登录用户、也没有该环境 <code>ENV_TOKEN</code>——就是 <code>env up</code> 专门为这个环境生成的那个——的请求。无论哪种，移动端这边都是一个带两个字段的开发菜单页，基础 URL 和令牌，测试人员已经装好的应用现在就和那个分支通信了。不需要新构建。<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-02-22-self-hosted-ota-updates/" >空中更新（OTA）通道</a>继续指向应用构建时的那个；API 基础 URL 是运行时设置，不是构建时设置，本来就该如此。</p>
<p>认证是唯一有意共享的共享服务。身份不分环境；测试人员希望在每个分支上都用同一个账号登录。认证服务上一个授权域名里有 <code>example.com</code> 的 dev 项目覆盖所有 <code>-dev</code> 主机名，每个环境都对着它验证 ID 令牌。每个环境数据库里的用户表都是模板用户的副本，所以模板里有的账号，测试人员在哪儿都有。</p>

<h2 class="relative group">用 GitHub Actions 自动化预览环境：事件进来，环境出去
    <div id="用-github-actions-自动化预览环境事件进来环境出去" 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%94%a8-github-actions-%e8%87%aa%e5%8a%a8%e5%8c%96%e9%a2%84%e8%a7%88%e7%8e%af%e5%a2%83%e4%ba%8b%e4%bb%b6%e8%bf%9b%e6%9d%a5%e7%8e%af%e5%a2%83%e5%87%ba%e5%8e%bb" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>上面的一切手工来做，只有你一个人时没问题。一旦有了第二个开发者，它就必须因为 git 里发生了什么而发生，而不是因为有人记得。整套自动化是四个工作流、一个仓库设置、一个规则集和一个 GitHub environment，其中每一个都把某个 git 事件映射到对开发者手工运行的同一个 <code>scripts/env</code> 的调用。没有会漂移的 CI 专用路径。</p>
<table>
	<thead>
			<tr>
					<th>Git 事件</th>
					<th>运行什么</th>
					<th>做什么</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>PR 被打上 <code>env</code> 标签</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>创建该分支的环境，在 PR 上贴出 URL</td>
			</tr>
			<tr>
					<td>向已打标签的 PR 的分支 push</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>原地更新</td>
			</tr>
			<tr>
					<td>标签被移除、PR 关闭或合并</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>环境清理自己那一片，然后被销毁</td>
			</tr>
			<tr>
					<td>删除分支</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>兜底：手动拉起、从来没开过 PR 的分支；仓库设置 <em>delete branch on merge</em> 让合并也触发它</td>
			</tr>
			<tr>
					<td>向其他任何分支 push</td>
					<td>什么都不做</td>
					<td>备份用的 push 或一个 spike，在有人想看之前不花一分钱</td>
			</tr>
			<tr>
					<td>向 <code>main</code> 的 pull request</td>
					<td><code>migrations-check.yml</code></td>
					<td>合入 <code>main</code> 后迁移图只有一个叶子；从空库能应用完整链条</td>
			</tr>
			<tr>
					<td>push <code>v*</code> 标签</td>
					<td><code>release.yml</code></td>
					<td>由标签生成 staging 环境；评审人批准后把同一镜像晋升到生产并销毁 staging</td>
			</tr>
			<tr>
					<td>每晚 02:00</td>
					<td><code>sweep-envs.yml</code> → <code>scripts/sweep-envs</code></td>
					<td>销毁分支已不存在或闲置一周的一切</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">标签、push 与 delete 工作流
    <div id="标签push-与-delete-工作流" 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%a0%87%e7%ad%bepush-%e4%b8%8e-delete-%e5%b7%a5%e4%bd%9c%e6%b5%81" aria-label="锚点">#</a>
    </span>
    
</h3>
<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"># .github/workflows/branch-env.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">branch environment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">types</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">labeled, unlabeled, synchronize, closed]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">delete</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">inputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="nt">branch to bring up without a PR, required</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </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">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">env-${{ github.head_ref || github.event.ref || inputs.branch }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</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">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">FLY_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FLY_PREVIEW_TOKEN }}  </span><span class="w"> </span><span class="c"># org-scoped deploy token; a separate preview org keeps it away from prod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PG_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.PREVIEW_PG_PASSWORD }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># ... the shared Redis URL and bucket credentials, the same way</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">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">up</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># the gate: a PR carrying the `env` label, or someone asking for a branch by hand</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      github.event_name == &#39;workflow_dispatch&#39; ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp; github.event.action != &#39;closed&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">        &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">ref</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">BRANCH</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/env up &amp;&amp; echo &#34;url=$(scripts/env url)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">marocchino/sticky-pull-request-comment@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">header</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Branch environment: ${{ steps.env.outputs.url }}&#34;</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="w">  </span><span class="nt">down</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># label removed, labelled PR closed or merged, or the branch itself deleted</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp;
</span></span></span><span class="line"><span class="cl"><span class="sd">        ((github.event.action == &#39;closed&#39; &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;)) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">         (github.event.action == &#39;unlabeled&#39; &amp;&amp; github.event.label.name == &#39;env&#39;))) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;delete&#39; &amp;&amp; github.event.ref_type == &#39;branch&#39;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;${{ github.head_ref || github.event.ref }}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>闸门是 <code>env</code> 标签，这也是我最想据理力争的部分。没有闸门，每个分支都要花掉一个应用和一份数据库克隆：某人为了备份笔记本而 push 的分支、明天就会被 squash 的 spike、机器人凌晨三点开的依赖升级。所以光 push 什么都不发生，光开 PR 也什么都不发生。给 PR 加上 <code>env</code>，环境就起来；标签在的时候每次 push 都更新它；移除标签、关闭 PR 或合并，它就下线。选择加入仍然是一个 GitHub 事件，而不是向某个人提请求，而且这一下点击是由唯一知道有没有人要看的那个人做的。置顶评论就是用户界面：给 PR 打标签，一条带 URL 的评论出现，可以发给任何人，而且每次 push 都在原地更新，不会堆叠。还没有 PR 就想拉起来的分支交给 <code>workflow_dispatch</code>，其余的交给笔记本上的 <code>./scripts/env up</code>。</p>
<p>还有两种闸门值得知道。分支名过滤（<code>branches: ['feature/**', 'fix/**']</code>）不需要 PR，但每个功能分支都要付钱。&ldquo;所有非草稿 PR&quot;最自动，如果你的团队开 PR 开得晚、只在准备好时才开，那是正确的选择。我选标签，是因为它把花不花钱的决定交给了有上下文的人，也因为它可以收回而不用关掉任何东西。一个我喜欢的副作用：来自 fork 的 <code>pull_request</code> 运行拿不到 secrets，所以 fork 拉不起环境。</p>
<p><code>concurrency</code> 比看起来更重要。对同一分支的一连串快速 push 会取消之前的部署而不是让它们赛跑；对同一应用同时跑两个 <code>fly deploy</code>，结局是其中一个输掉。</p>
<p>有过 PR 的东西，不管合没合并，拆除都由 <code>closed</code> 负责。<code>delete</code> 事件是给用 <code>workflow_dispatch</code> 拉起来、最终也没开 PR 的分支兜底的，而它只在分支真的被删除时才触发。那是一个仓库设置，<em>Automatically delete head branches</em>，默认关闭。打开之后，点 Merge 会关闭 PR 并删除分支，两个事件中的任何一个单独都会跑 <code>env down</code>。合并按钮就是拆除。</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">gh api -X PATCH repos/org/myapp -f <span class="nv">delete_branch_on_merge</span><span class="o">=</span>true</span></span></code></pre></div></div>
<p><code>FLY_PREVIEW_TOKEN</code> 是这个文件里危险的一行。它能创建和销毁应用，如果 <code>$APP</code> 变成了分支环境之外的任何东西，它就能销毁生产。两道防护，我两道都留着：令牌限定在一个单独的 Fly 组织里，从字面上就看不到生产组织（演示里为了省去给新组织绑定账单的步骤，我用了生产组织，只靠第二道防护）；以及 <code>down()</code> 拒绝任何不匹配 <code>slug-hash</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%88%86%e6%94%af%e7%8e%af%e5%a2%83%e6%97%a0%e6%b3%95%e6%9b%bf%e8%87%aa%e5%b7%b1%e5%81%9a%e7%9a%84%e6%a3%80%e6%9f%a5" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>一个分支只看得见自己的迁移，所以真正要紧的检查跑在 pull request 上，针对合入 <code>main</code> 之后的状态，也是 <code>main</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"># .github/workflows/migrations-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">migrations</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</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">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><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">postgres</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">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="nt">ci, POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">ci }</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;5432:5432&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span>--<span class="l">health-cmd pg_isready --health-interval 5s --health-retries 10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgres://postgres:ci@localhost:5432/ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">ci-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-python@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">python-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;3.14&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">pip install -r requirements.txt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Merge main into the branch (a conflict here fails the check too)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.email ci@example.com &amp;&amp; git config user.name ci
</span></span></span><span class="line"><span class="cl"><span class="sd">          git merge --no-edit origin/main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">One leaf in the migration graph</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py makemigrations --check --dry-run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">The full chain applies from nothing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py migrate --noinput</span></span></span></code></pre></div></div>
<p>三十秒。正是它把 <code>0042</code> / <code>0042</code> 的碰撞从发布日的意外变成了第二个开发者 PR 上的一个红叉；下面的迁移一节会展示它真的触发。</p>

<h3 class="relative group"><code>main</code> 上的规则
    <div id="main-上的规则" 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="#main-%e4%b8%8a%e7%9a%84%e8%a7%84%e5%88%99" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>如果分支能活一个月，或者有人直接往 <code>main</code> 上 push，上面的一切都撑不住。这些规则与其说是保护，不如说是为了让环境保持小、让迁移保持新：</p>
<ul>
<li><strong><code>main</code> 受保护且随时可部署。</strong> 只通过 PR 合并，且 <code>migrations</code> 检查<em>针对当前的 <code>main</code></em> 是绿的（严格状态检查，所以上周绿过的 PR 在 <code>main</code> 前进后要重跑）。线性历史，禁止 force-push，禁止删除。</li>
<li><strong>功能分支要短。</strong> 开了两周的分支，环境落后 <code>main</code> 两周，迁移和别人碰撞的概率也多了两周。一个月的功能藏在开关后面分块合入 <code>main</code>。长寿的是开关，不是分支。</li>
<li><strong>一个分支，一个环境，一个数据库。</strong> 命名已经强制了。</li>
<li><strong>只有发布标签能上生产。</strong> 镜像从标签构建一次；staging 和生产跑的是那个镜像，不重新构建。</li>
<li><strong>动了 <code>migrations/</code> 的 PR 由另外两人之一评审。</strong> 见下文。</li>
</ul>
<p>写成规则集，用 <code>gh api -X POST repos/org/myapp/rulesets --input ruleset-main.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;name&#34;</span><span class="p">:</span> <span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="nt">&#34;target&#34;</span><span class="p">:</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="nt">&#34;enforcement&#34;</span><span class="p">:</span> <span class="s2">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;conditions&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;ref_name&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;include&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;~DEFAULT_BRANCH&#34;</span><span class="p">],</span> <span class="nt">&#34;exclude&#34;</span><span class="p">:</span> <span class="p">[]</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;rules&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;deletion&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;non_fast_forward&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_linear_history&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;pull_request&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;required_approving_review_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;dismiss_stale_reviews_on_push&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_review_thread_resolution&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_status_checks&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;strict_required_status_checks_policy&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_status_checks&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="p">{</span> <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="s2">&#34;check&#34;</span> <span class="p">}</span> <span class="p">]</span> <span class="p">}</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="p">}</span></span></span></code></pre></div></div>
<p>一个诚实的褶皱。这个仓库是 GitHub Free 计划下的私有仓库，而在 Free 上，分支保护和规则集只对公开仓库可用；API 的回答是 <code>403 Upgrade to GitHub Pro or make this repository public</code>。<code>delete_branch_on_merge</code> 设置、environment 和工作流在 Free 上都能用。所以对于私有 Free 仓库上的小团队，上面的规则集是一种约定：<code>migrations</code> 工作流照样跑、照样变红，但没有东西会把合并按钮变灰。这是一个每月 4 美元的决定，我会在第二个人能合并的那一刻就做。</p>
<p>没有 <code>develop</code> 分支，没有 <code>release/*</code> 分支。Git-flow 的设计前提是集成昂贵、必须成批做。分支环境让集成变得便宜，成批的必要就消失了。</p>

<h3 class="relative group">Staging 是标签的环境，生产是一次晋升
    <div id="staging-是标签的环境生产是一次晋升" 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="#staging-%e6%98%af%e6%a0%87%e7%ad%be%e7%9a%84%e7%8e%af%e5%a2%83%e7%94%9f%e4%ba%a7%e6%98%af%e4%b8%80%e6%ac%a1%e6%99%8b%e5%8d%87" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>Staging 复用整套机制。<code>main</code> 上的一个 <code>v*</code> 标签用分支名 <code>staging/v1.42.0</code> 运行 <code>scripts/env up</code>，命名把它变成 <code>staging-v1-42-0-&lt;hash&gt;</code>：同样从模板克隆的数据库，同样闲置即 suspend 的机器，同样的主机名方案。区别在于之后发生的事：</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"># .github/workflows/release.yml (abridged)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;v*&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">staging</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env up</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">echo &#34;image=$(fly image show -a &#34;myapp-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)&#34; --json | jq -r &#39;…&#39;)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production       </span><span class="w"> </span><span class="c"># required reviewer on this environment = the promotion gate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.api.toml --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.ws.toml  --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p><code>production</code> 作业等待一个设有必要评审人的 GitHub environment。有人看过 staging，点 Approve，生产就按引用收到 staging 跑过的<em>同一个镜像</em>，不是重新构建。最后一步销毁 staging，因为它的任务完成了。如果没人批准，发布候选就是被否了，而当它的&quot;分支&rdquo;（<code>staging/v1.42.0</code> 从未在 origin 上存在过）通不过存活检查时，清扫器会把 staging 清掉。没有清扫器能核实的理由，任何东西都不许存在。</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%af%8f%e6%99%9a%e7%9a%84%e6%b8%85%e6%89%ab%e5%99%a8" aria-label="锚点">#</a>
    </span>
    
</h3>
<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"># .github/workflows/sweep-envs.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 14 * * *&#39;</span><span class="w">     </span><span class="c"># 02:00 NZST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sweep</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/sweep-envs</span></span></span></code></pre></div></div>
<p><code>fetch-depth: 0</code> 是因为清扫器需要每个远程分支及其最后提交日期，而不是 Actions 默认检出的那一个提交。<code>workflow_dispatch</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%bc%80%e5%8f%91%e8%80%85%e5%ae%9e%e9%99%85%e8%a6%81%e5%81%9a%e7%9a%84%e4%ba%8b" aria-label="锚点">#</a>
    </span>
    
</h3>
<p>push 一个分支，开 PR，想让人看的时候打上 <code>env</code> 标签。清单就这么长。三分钟内 PR 上出现一个 URL，每次 push 更新，PR 合并或关闭后消失。如果和队友撞了，迁移检查会比任何人都先告诉他们。没人去抢 staging，因为没有 staging 可抢；没人去拆任何东西，因为合并就是拆除。</p>
<p>而搭起这套东西的人，之后要为每个分支做什么？什么都不用。共享的 Postgres、Redis 和 bucket 开通一次，密钥放进 Actions 一次，此后反复要做的事只剩周一看一眼清扫器的日志。没有工单队列，没有要喊的机器人，没有要提示的对象。</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%8d%e4%bf%a1%e4%bb%bb%e6%8b%86%e9%99%a4%e7%9a%84%e6%8b%86%e9%99%a4" aria-label="锚点">#</a>
    </span>
    
</h2>
<p><code>env down</code> 是常规情况，有意思的地方在于<em>谁</em>来清理。Redis 键和 bucket 对象属于共享服务，而跑 <code>down</code> 的笔记本两者的凭据都不需要，因为环境自己的机器已经有了，也已经知道自己的前缀。所以 <code>down</code> 先把 suspend 的机器唤醒，在上面跑一条小小的清理命令，然后才销毁应用、删除数据库。环境自己收拾自己：</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">$ BRANCH=feature/profile-website ./scripts/env down
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-webs-dcb2e7/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-webs-dcb2e7
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s -o /dev/null -w &#39;%{http_code}\n&#39; https://feature-profile-webs-dcb2e7-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">530</span></span></code></pre></div></div>
<p>那个 530 再次印证了关于协调的那一点：Worker 仍在路由这个主机名，Fly 那边什么都没有，而没有人需要通知 Cloudflare。</p>
<p>这条清理命令没有 <code>ENV_NAME</code> 就拒绝运行，因为前缀为空时它会把整个共享 bucket 清空。它之所以做成应用里的一条命令、而不是 shell 脚本里的三行，全部理由就是这道防护。</p>
<p>环境照样会泄漏。GitHub Actions 故障期间有分支被删除。开发者为了做个试验在笔记本上跑了 <code>env up</code>，从来没 push。<code>fly apps destroy</code> 偶发失败，被 <code>|| true</code> 吞掉。三个月后有四十个应用，和一张让人问&quot;发生了什么&quot;的账单。</p>
<p>所以有第二道机制，它不信任第一道：上表里那个每晚的清扫器。它列出所有 <code>myapp-*</code> 应用，弄清每个属于哪个分支，销毁分支已不存在或七天没有 push 的那些。闲置一周的分支不需要一个活着的 URL；下一次 push 两分钟就把它重建出来。哈希不可逆，所以清扫器为每个活着的分支算出环境名，销毁不在这个集合里的所有应用，再删掉后面没有应用的任何 <code>env_*</code> 数据库：</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="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/sweep-envs: nightly</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">TTL_DAYS</span><span class="o">=</span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="k">:-</span><span class="nv">7</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">cutoff</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> TTL_DAYS*86400 <span class="k">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">git fetch --prune --quiet origin
</span></span><span class="line"><span class="cl"><span class="nv">live</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span><span class="s1">$&#39;\t&#39;</span> <span class="nb">read</span> -r ts branch<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="o">((</span> ts &gt;<span class="o">=</span> cutoff <span class="o">))</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">live</span><span class="o">+=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nv">BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> scripts/env name<span class="k">)</span><span class="s2">&#34;</span><span class="s1">$&#39;\n&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> &lt; &lt;<span class="o">(</span>git <span class="k">for</span>-each-ref --format<span class="o">=</span><span class="s1">&#39;%(committerdate:unix)%09%(refname:short)&#39;</span> refs/remotes/origin <span class="p">|</span> sed <span class="s1">&#39;s#^\([0-9]*\)\torigin/#\1\t#&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">apps</span><span class="o">=</span><span class="k">$(</span>fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;myapp-[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> app in <span class="nv">$apps</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">env</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">app</span><span class="p">#myapp-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="k">continue</span>       <span class="c1"># not a branch environment (e.g. myapp-preview-pg)</span>
</span></span><span class="line"><span class="cl">  grep -qx <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$live</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;sweeping </span><span class="nv">$app</span><span class="s2">: branch gone or idle &gt; </span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="si">}</span><span class="s2">d&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ENV_OVERRIDE</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> scripts/env down <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ...then any env_* database with no app: DROP DATABASE.</span></span></span></code></pre></div></div>
<p>演示的分支从来没 push 过（就是上面那次笔记本上的运行），所以在清扫器看来它们全都不存在了；有人从笔记本上拉起来、之后再也没 push 的 spike，一周后正是这个状态。它把剩下的两个拆了，包括 tagline 环境写下的 Redis 键：</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">$ ./scripts/sweep-envs
</span></span><span class="line"><span class="cl">sweeping myapp-feature-branch-envir-c65cb4: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-branch-envir-c65cb4:*
</span></span><span class="line"><span class="cl">bucket: deleted 322 objects under feature-branch-envir-c65cb4/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">sweeping myapp-feature-profile-tagl-fa8832: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 3 keys under feature-profile-tagl-fa8832:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-tagl-fa8832/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-tagl-fa8832
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ fly apps list --org myapp-preview | grep myapp-
</span></span><span class="line"><span class="cl"> myapp-preview-pg │ myapp-preview │ deployed</span></span></code></pre></div></div>
<p>（一件小事咬了我一口：macOS 自带的是 bash 3，没有关联数组。清扫器的第一版用了关联数组，在我的笔记本上死在 <code>declare -A</code> 上。上面这版用换行分隔的列表，在哪儿都能跑。）</p>
<p>费用是令人愉快的部分。suspend 的机器只付 rootfs，每月几美分。数据库是已经付过钱的卷上的 19 MB。Redis 用的是按量付费的套餐，每十万条命令 20 美分，在分支测试的量级上四舍五入等于零。bucket 前缀按存了多少付。共享 Postgres 节点是唯一的固定项，每月约两美元。二十个活跃分支的花费大致等于一台常开的 <code>shared-cpu-1x</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="#%e8%b7%a8%e5%88%86%e6%94%af%e7%9a%84%e8%bf%81%e7%a7%bb%e6%b2%a1%e6%9c%89%e5%85%b1%e4%ba%ab%e6%95%b0%e6%8d%ae%e5%ba%93%e6%9d%a5%e9%a2%84%e8%ad%a6" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>当每个开发者端到端负责一个功能时，每个分支里都有一个迁移。这让本节成为重要的一节。</p>
<p>离开共享环境世界时，人们会漏掉一件事。所有人都往同一个 dev 数据库部署时，迁移冲突会立刻、痛苦地浮现，但它们<em>浮现了</em>。一个迁移跑了，下一次部署失败了，有人当天下午修好。每个分支一个数据库之后，每个迁移在自己的环境里都完美运行，两个迁移第一次相遇是在第二个合入 <code>main</code> 的时候。做得不小心，冲突就从便宜的 dev 挪到了不便宜的发布。</p>
<p>四条规则，全都能由 CI 强制，所以没人需要记住它们。</p>
<p><strong>1. 针对合入 <code>main</code> 后的状态，迁移图有两个叶子时 CI 失败。</strong> <code>makemigrations --check</code> 能抓到模型和迁移不一致，但要紧的冲突是两个分支都加了 <code>0042_*</code>。要对着合入 <code>main</code> 的分支检查，而不是分支本身：</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">git fetch origin main
</span></span><span class="line"><span class="cl">git merge --no-commit --no-ff origin/main <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;merge conflict&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">python manage.py migrate --plan 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -q <span class="s2">&#34;Conflicting migrations&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div></div>
<p>这一个是我故意让它发生的。三个演示分支里有两个，<code>feature/profile-tagline</code> 和 <code>feature/profile-website</code>，各用一个迁移给用户资料模型加了一个字段，每个环境在启动时应用了自己的。新列只出现在该出现的地方：</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">env_feature_branch_envir_c65cb4      (no new column)
</span></span><span class="line"><span class="cl">env_feature_profile_tagl_fa8832      tagline
</span></span><span class="line"><span class="cl">env_feature_profile_webs_dcb2e7      website</span></span></code></pre></div></div>
<p>两个迁移都是 <code>0042_*</code>。两个分支互相看不见对方的，所以两个环境都不会告诉你有问题。把一个合入另一个，检查就会说：</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">$ git merge --no-commit --no-ff feature/profile-tagline
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
</span></span><span class="line"><span class="cl">  (0042_profile_tagline, 0042_profile_website in accounts).
</span></span><span class="line"><span class="cl">To fix them run &#39;python manage.py makemigrations --merge&#39;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --merge --noinput
</span></span><span class="line"><span class="cl">Created new merge migration accounts/migrations/0043_merge_20260919_0324.py
</span></span><span class="line"><span class="cl">    dependencies = [
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_tagline&#39;),
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_website&#39;),
</span></span><span class="line"><span class="cl">    ]</span></span></code></pre></div></div>
<p>第二位作者在自己的分支上、自己的环境里、不打扰任何人地运行那个 <code>--merge</code>，下一次 <code>env up</code> 就把 <code>0043</code> 叠加到他们的克隆上。（这个例子里 git 还先报了文本冲突，因为两个字段加在了模型文件的同一行。很真实，而且值得知道：文本冲突在解决之前会遮住迁移冲突。）</p>
<p><strong>2. 迁移要向后兼容上一个发布的代码。</strong> 部署过程中有一个新 schema 与旧代码共存的窗口，机器回滚时则反过来。加列要可空或带默认值，绝不一步改名，绝不删掉当前发布仍在读的东西：先加，部署，回填，切代码，在后续发布里再删。expand and contract。它在这里<em>更</em>重要的原因是，一次性环境让部署更频繁，你待在那个窗口里的时间更多。按标签的 staging 就是彩排它的地方。</p>
<p><strong>3. CI 还会往空数据库里从零迁移。</strong> 模板克隆测试的是&quot;我的迁移能不能应用在真实数据上&rdquo;。空库运行测试的是&quot;整条链从零还能不能跑通&rdquo;，能抓到那种假定前一个数据迁移已经填了什么的迁移。三十秒，抓住一类否则要等到下一台新笔记本才暴露的 bug。</p>
<p><strong>4. schema 变更要有第二双眼睛。</strong> 小团队不需要 <code>CODEOWNERS</code> 文件，但需要一条规则：动了 <code>migrations/</code> 的 PR 合并前由一位队友评审，而评审人的工作不是检查 SQL。是知道自己或团队里的其他人这两周是否在动同一张表，并把它说出来。超过十来个开发者，这份知识就装不进一个人的脑袋了，那时给 <code>migrations/</code> 加一条 <code>CODEOWNERS</code> 才算物有所值。没有任何工具能替代这一点。机械性的东西工具都抓了，这就是件两分钟的活，也是旧共享环境世界里唯一值得保留的部分：两个人发现自己即将相撞的那一刻。</p>

<h2 class="relative group">版本标识，让 URL 说出它在跑什么
    <div id="版本标识让-url-说出它在跑什么" 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%89%88%e6%9c%ac%e6%a0%87%e8%af%86%e8%ae%a9-url-%e8%af%b4%e5%87%ba%e5%ae%83%e5%9c%a8%e8%b7%91%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>只有一个共享环境时，&ldquo;staging 上是什么&quot;是个在聊天里问的问题。有很多个时，它必须是环境的属性，否则每个 bug 报告都从考古开始。</p>
<ul>
<li><strong>每个镜像携带提交 SHA，应用把它暴露出来。</strong> <code>fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...</code> 把它们作为环境变量烙进镜像，<code>/api/version/</code> 连同环境名和区域一起返回（见上面的输出）。听到&quot;feature-profile-tagl-fa8832 上坏了&rdquo;，任何人第一件事就是确认它跑的是自己以为的那个提交。第一个演示环境是在工具提交之前一个提交的工作树上部署的，端点如实说了：它报的是上一个提交，不是我预期的 SHA。</li>
<li><strong><code>ENVIRONMENT=$ENV</code> 在每一行日志里，也是错误追踪工具的 environment 标签。</strong> 分支的错误不会污染生产的错误追踪，一键就能过滤。</li>
<li><strong>分支有 SHA，发布有标签。</strong> <code>feature-payments-retr-3f9a1c</code> 永远没有版本号；它有提交。staging 和生产有 <code>v1.42.0</code>。</li>
<li><strong>移动端携带的是最低 API 版本，不是 URL。</strong> 开发菜单里的 URL 字段是测试人员把真机指向某个分支的方式，带有 API schema 版本的版本端点则是应用判断对方是否可理解的方式。</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="#%e8%bf%99%e8%a7%a3%e5%86%b3%e4%b8%8d%e4%ba%86%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>它解决不了只有放在一起才有意义的两个功能。分支环境展示一个分支。答案是把两者都藏在开关后面合入 <code>main</code>，然后看 <code>main</code>，可以用一个跟随 <code>main</code> 的常驻环境（<code>main-dev.example.com</code>，每次合并重新部署）来做。这是我唯一会保留的长期非生产环境，没人往它<em>上</em>部署；它是用来看下一个发布会是什么样的。</p>
<p>它解决不了那些要求注册回调、又不接受通配符的提供商。我用的认证服务接受父域名，所以 <code>example.com</code> 覆盖所有 <code>-dev</code> 分支主机名。有些 OAuth 和支付提供商不接受，那就保留一个共享主机名，由 Worker 路由到最后认领它的那个环境。那是一把小锁，锁在一件小事上，比把一切都锁住好得多。</p>
<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%88%91%e4%bc%9a%e5%9c%a8%e4%bd%a0%e7%9a%84%e7%8e%af%e5%a2%83%e9%87%8c%e6%a3%80%e6%9f%a5%e4%bb%80%e4%b9%88" aria-label="锚点">#</a>
    </span>
    
</h2>
<p>如果你有共享的 dev 或 staging，还有一条预约它的聊天线程：</p>
<ol>
<li>一个做完的分支，要等多久才能让作者以外的人看到它在运行？超过一小时，你就在用交付时间为这把锁付费，而在小团队里，那是团队里很大一部分人在等。</li>
<li>共享数据库多久重置一次，重置时谁丢了工作？</li>
<li>一个新开发者在第一天，能不能不向任何人要任何东西，就拿到一个跑着自己分支的公开 URL？无论他们得要的是什么，那就是最先该自动化的东西。</li>
<li>新环境是从真实数据开始的吗？如果从空库开始，它第一个抓不到的 bug 就是那个在空表上没事、在真表上要跑四十分钟的迁移。</li>
<li>你的非生产托管里，有没有什么东西在所有人都忘了它一个月后还会存在？有的话，先于一切写清扫器。泄漏从第一天就开始了。</li>
</ol>
<p>那个聊天里的问题消失，不是因为人们不再问，而是因为答案永远是&quot;在用啊，用我自己的。&quot;</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/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>
    <item>
      <title>用 Docker Compose 做 CI/CD，别把逻辑写进平台专属的 YAML</title>
      <link>https://jared.lynskey.co.nz/zh-cn/posts/2024/2024-06-02-simplifyingcicd/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/zh-cn/posts/2024/2024-06-02-simplifyingcicd/</guid>
      <pubDate>Sun, 02 Jun 2024 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>Docker</category>
      <category>GitHub Actions</category>
      <category>DevOps</category>
      <description>把构建和测试逻辑放进 Docker Compose 文件，而不是 GitHub Actions YAML：可移植、能在本地测试、维护起来也更省心。</description>
      <content:encoded><![CDATA[<p><em>2026 年 10 月更新：两年后，我把自己发版用的每个仓库都重新过了一遍，把这篇建议里留下来的部分写进了<a href="https://jared.lynskey.co.nz/zh-cn/posts/2026/2026-10-22-docker-compose-ci-github-actions/" >在 GitHub Actions 上搭 CI/CD 流水线</a>。原则站住了；在 CI 里跑 Compose 的那套做法，大多没留下来。</em></p>
<p>很多团队把构建和测试步骤直接写进 GitHub Actions 的 YAML 里。平时用着没问题，直到某天你想在自己的笔记本上跑一遍同样的构建，或者有人提议迁去 GitLab，才发现整条流水线已经焊死在某家供应商的配置格式上了。我现在的做法很朴素：真正的逻辑放进 Docker Compose，CI 工具只负责调用它。</p>

<h4 class="relative group">为什么我不把构建逻辑写进 Actions YAML
    <div id="为什么我不把构建逻辑写进-actions-yaml" 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%e6%88%91%e4%b8%8d%e6%8a%8a%e6%9e%84%e5%bb%ba%e9%80%bb%e8%be%91%e5%86%99%e8%bf%9b-actions-yaml" aria-label="锚点">#</a>
    </span>
    
</h4>
<p>最明显的问题是绑定。Actions 的工作流对 GitLab、Bitbucket、Jenkins 来说毫无意义，构建步骤要是写在工作流文件里，换平台就得全部重写。我还没见过哪个团队真会给这种事排期。</p>
<p>更隐蔽的问题是流水线没法在本地跑。如果验证构建改动的唯一办法是推一个提交、盯着 Actions 页面等结果，git 历史很快就会塞满 “fix ci”“fix ci again” 这样的提交。Compose 文件在我的笔记本上和在 runner 上跑起来一模一样，“在我机器上是好的” 这类争论基本也就消失了。</p>
<p>还有，YAML 会一直长。一开始是整洁的二十行，加上缓存技巧和条件判断后很快变成三百行，开发者除了 Git 还得学一门 CI 方言。一个脚本本该只做一件事，而庞大的工作流文件往往什么都揽。</p>
<p>用 Compose 之后分工就清楚了：构建和测试归 Compose 文件管，CI 平台只是在每次推送时把它跑一遍的角色。</p>

<h4 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%81%9a%e6%b3%95" aria-label="锚点">#</a>
    </span>
    
</h4>
<ol>
<li>先写一个 <code>Dockerfile</code>，让镜像的构建方式只有一个出处：</li>
</ol>
<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"># 使用官方Node.js运行时作为基础镜像</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">FROM node:14</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="c"># 设置容器中的工作目录</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">WORKDIR /app</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="c"># 将package.json和package-lock.json文件复制到容器</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY package*.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="c"># 安装依赖</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">RUN npm install</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="c"># 将其余应用程序代码复制到容器</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY . .</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="c"># 暴露应用程序运行的端口</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">EXPOSE 3000</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="c"># 定义运行应用程序的命令</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">CMD [&#34;npm&#34;, &#34;start&#34;]</span></span></span></code></pre></div></div>
<ol start="2">
<li>
<p>再写一个 <code>docker-compose.build.yaml</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="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">app</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">myprivaterepo.com/image:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</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">.:/app</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">sh -c &#34;npm install &amp;&amp; npm test&#34;</span></span></span></code></pre></div></div>
</li>
<li>
<p>先在本地跑一遍，这正是这套做法的意义所在：</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">docker compose -f docker-compose.build.yaml build</span></span></code></pre></div></div>
</li>
<li>
<p>本地跑通之后，GitHub Actions 的工作流（换成别的 CI 平台也一样）就缩成了一个编排器：</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">name</span><span class="p">:</span><span class="w"> </span><span class="l">CI/CD Pipeline</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Set up Docker Buildx</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">docker/setup-buildx-action@v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build and Test with Docker Compose</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">docker compose -f docker-compose.build.yaml build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Push the image referenced in the docker compose file</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">docker compose -f docker-compose.build.yaml push</span></span></span></code></pre></div></div>
</li>
</ol>
<p>现在 CI 工具只做两件事：构建和推送。所有值得看懂的东西都在几个文件里，任何平台、任何新同事都能直接运行。</p>

<h4 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%a8%e9%80%81%e9%95%9c%e5%83%8f" aria-label="锚点">#</a>
    </span>
    
</h4>
<p>Compose 文件还有个顺带的好处：推送镜像变得非常简单。Docker 根据镜像名判断推送目标，<code>image: myprivaterepo.com/image:latest</code> 就意味着推到 <code>myprivaterepo.com</code>。私有仓库需要访问令牌，这部分请查你所用仓库服务的说明。</p>
<p>如果你现在用 GitHub Actions 用得很顺，不必急着改。但哪天真要换供应商，迁移工作就从“移植三百行 YAML”变成“复制两条命令”。而在那天到来之前，你可以体面地在本地调试构建。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
