<?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>GitHub Actions · 仁才德</title>
    <link>https://jared.lynskey.co.nz/zh-cn/tags/github-actions/</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/github-actions/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>用 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>
