<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>CI/CD · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/ci/cd/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/ci/cd/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>pytest-testmon：コードの変更が影響するテストだけを実行してpytestを高速化する</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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="日本語" default>
    <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="简体中文">
  </video><figcaption>この記事を約8分の映像にしました。ナレーションは英語で、日本語字幕が付きます。</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="#%e3%81%aa%e3%81%9c%e5%85%a5%e3%82%8c%e3%81%9f%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>機能のリクエストは、たいてい小さなものです。画面が一つ、エンドポイントが一つ、挙動を変えたいルールが一つ。ところがテストスイートを丸ごと実行すると、そのどれもが実際より大きくなっていました。スイートはDjangoのテストが2,248件で、4プロセスで約3分半、1プロセスだと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="#%e4%bd%95%e3%82%92%e5%ae%9f%e8%a1%8c%e3%81%99%e3%82%8b%e3%81%8b%e3%81%ae%e6%b1%ba%e3%82%81%e6%96%b9" 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のバージョンと、インストール済みのパッケージとそのバージョンが含まれます。環境変数は見ていません。同じコードを二つの設定モジュールで動かすなら、<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="#%e8%a6%8b%e3%81%88%e3%81%aa%e3%81%84%e3%82%82%e3%81%ae" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>testmonが追跡するのは、テストが実行するPythonのコードです。テストが読むそれ以外のファイル、つまりテンプレートやJSON、テキストなど、ディスク上にあるほかのものは追跡しません。ネットワーク越しに呼び出すサービスも追跡しません。そして、テストが始まる前に一度だけ実行されるものを、特定のテストに結びつけることもできません。</p>
<p>Djangoのプロジェクトでいえば、まずマイグレーションです。テスト用データベースを作るときに一度だけ実行されるので、どのテストの記録にも含まれません（後で紹介するラッパーは、新しいマイグレーションがあるとテスト用データベースを作り直します）。次に設定と起動時のコードで、pytestの起動中に実行されるので、どのテストにも属しません。そしてテンプレートとデータファイルです。テンプレートはPythonではないので、編集しても、それを描画するテストは一つも再実行されません。</p>
<p>私のスイートには、ほかにもいくつかあります。モバイルアプリのテストと共有しているフィクスチャのJSONファイルを読むテスト。ビルドしたWebのバンドルを確認するテスト。同じリポジトリで生成している静的サイトの出力を確認するテスト。どれを変えても、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="#django%e3%81%ae%e3%83%97%e3%83%ad%e3%82%b8%e3%82%a7%e3%82%af%e3%83%88%e3%81%ab%e5%85%a5%e3%82%8c%e3%82%8b%e6%96%b9%e6%b3%95" 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には、settingsモジュールと、テストが使っているファイル名のパターンを教える必要があります。</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のランナーでしかスイートを実行したことがないなら、testmonを入れる前に一度pytestで実行し、違いが出たところを直しておきましょう。そうしないと、二つの問題を同時にデバッグする羽目になります。</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>まっさらなチェックアウトでは、この最初の実行は1プロセスでのスイート全体の実行になります。私の環境では、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>を設定してください。理由は、この後の「成功に見えたバグ」で説明します。以下のスクリプトで、いちばん大事な一行です。</p>
<p>追記（11月7日）：11月2日にリリースされたtestmon 2.3.0は、coverageのCトレーサーを自分で強制するようになったので、このバージョンではこの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>を足します。そのときも<code>--reuse-db</code>は外さないでください。<code>--create-db</code>だけだと、pytest-djangoは実行の終わりにデータベースを削除してしまい、次の実行でまた一から作ることになります。</p>
<p>もう一つは終了コードです。testmonがすべてのテストを選択から外したとき、実行は0で終わるので、「何も変わっていない」は成功として扱われます。終了コード5はpytestの「テストが一つも収集されなかった」という意味で、スクリプトはこれも問題なしとして扱います。</p>
<p>リストに入っていないものにも注目してください。<code>pyproject.toml</code>とロックファイルです。最初のバージョンはロックファイルが変わるたびに全部を実行していて、そのせいで遅いフォールバックがほぼ毎日発動し、入れる前より悪くなっていました。testmonはインストール済みのパッケージのバージョンを自分で追跡しているので、手助けはいりません。</p>
<p><strong>6. 専用のデータベースとキャッシュを持たせる。</strong> 同じマシンでほかにもテストを実行するもの、たとえば二つ目のターミナルやコーディングエージェント、<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が、ドキュメントに書いてあるとおりのことをしているだけです。毎回違うサブセットを実行すると、前に何が実行されたかのおかげで通っていたにすぎないテストがあぶり出されます。直すべきはテストのセットアップで、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>は、一回の実行だけtestmonを無効にします。<code>addopts</code>に入れているときに使います。</li>
<li><code>--testmon-env=&lt;name&gt;</code>は、一つのファイルの中に記録を別々に持ちます。二つのsettingsモジュールで実行するスイート向けです。</li>
</ul>
<p>プロジェクトにJavaScriptの側もあるなら、Jestには同じような機能が最初から入っています。<code>jest --changedSince=main</code>は、<code>main</code>以降に変わったものをimportしているテストを実行します。たどるのはcoverageではなく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="#%e3%81%a9%e3%82%8c%e3%81%a0%e3%81%91%e7%af%80%e7%b4%84%e3%81%a7%e3%81%8d%e3%82%8b%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ラッパーを入れた後、次のセクションで書くバグを直してから、起動時間込みで計ってみました。</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½分</td>
			</tr>
	</tbody>
</table>
<p>ふだんの編集なら並列実行のスイートより約25倍速く、何も変わっていなければ約200倍速くなります。</p>
<p>記録も、もっと広い範囲で同じことを示しています。スイートが実行する、テスト以外のPythonファイル307個のうち、中央値にあたるファイルを変えたときに届くのはテスト22件、テスト時間にして約8秒です。反対の端にはいくつかのコアモジュールがあります。ほぼすべてのテストがこれらを実行するので、変更すると約1,500件のテストに届き、testmonはスイートの大半を再実行します。これは失敗ではなく、正しい答えです。これらの数字はファイルごとの上限です。testmonはブロック単位で動くので、関数を一つ変えれば、その関数を実行したテストだけが選ばれます。</p>
<p>コストが表に出るのは、ラッパーがフォールバックしたときです。全体の実行は最初と同じ1プロセスでの記録なので約12分かかり、置き換える前の並列実行のスイートより遅くなります。1プロセスなのは、testmonをpytest-xdistと組み合わせてまだ試していないからです。xdistは1.4から対応しているので、次に測るべきはそこでしょう。だからこそ、フォールバックのきっかけになるファイルのリストは、正直でいられる範囲でできるだけ短くしておくべきです。</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%90%e5%8a%9f%e3%81%ab%e8%a6%8b%e3%81%88%e3%81%9f%e3%83%90%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最初に出た数字は、これよりも良いものでした。一か所の編集でテスト6件、3.3秒。でもそれは間違っていて、画面のどこにもそうは書いてありませんでした。</p>
<p>Python 3.14から、coverage.pyはデフォルトで新しいコアを使います。インタプリタの<code>sys.monitoring</code>の上に作られたものです。このコアがある行を報告するのは、プロセスがその行を初めて実行したときだけです。testmonはどのテストがどの行を実行したかの記録をcoverageから作るので、各行は、たまたま最初にそれを実行したテスト一つだけのものとして記録され、ほかのどのテストにも結びつきませんでした。8件のテストが共有している行を編集しても、testmonはそのうち1件しか選びません。</p>
<p>気づいたのは、この仕組みをそのまま真似した、私の別の小さなプロジェクトでした。そちらに導入する作業の一環として、わざと一行を壊して結果を比べたところ、数字が合わなかったのです。こちらのスイートに戻って、同じ確認をしました。ホーム画面のバナーのコードで、スライドのタイトルの一行を壊します。素のpytestでは8件のテストが失敗しました。testmonが選んだのは4件です。壊れた残りの4件は一度も実行されず、出力のどこにも、それらが外されたことは書いてありませんでした。</p>
<p>直すのは一行で、<code>export COVERAGE_CORE=ctrace</code>です。これでcoverageは昔ながらのCトレーサーに戻ります。トレーサーが違えば記録も違うので、記録し直しました。2,248件のテストで12分3秒です。その後、同じように壊す編集をしたら、壊れた11件のテストがすべて実行されました。</p>
<p>追記（11月7日）：11月2日リリースのtestmon 2.3.0は、プロセス内でもサブプロセスでもCトレーサーを自分で設定するようになったので、新規に入れる場合はこのexportはもう要りません。次の段落のチェックは、今でも要ります。</p>
<p>この記事で何よりも先に伝えたい教訓はこれです。選ぶテストが少なすぎるテストセレクタは、成功とまったく同じに見えます。テストは少なく、実行は速く、すべて緑。だから導入するときは、共有されている何かをわざと壊し、素のスイートとセレクタの両方を実行して、同じテストが失敗するかを確かめてください。そして、Pythonやcoverage.pyのバージョンが変わるたびに、もう一度やってください。</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="#%e3%82%b3%e3%83%bc%e3%83%87%e3%82%a3%e3%83%b3%e3%82%b0%e3%82%a8%e3%83%bc%e3%82%b8%e3%82%a7%e3%83%b3%e3%83%88%e3%81%ae%e5%87%ba%e7%95%aa" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>そのリポジトリの変更を、もう全部自分でしているわけではありません。小さな変更の多くはコーディングエージェント（私の場合はClaude Code）がしていて、良いエージェントは編集のたびにテストを実行します。11分かかるスイートを前にすると、エージェントにも私と同じ二つの悪い選択肢しかありません。待つか、どのテストファイルが関係するかを当て推量するかです。</p>
<p>そこで、リポジトリのエージェント向けの指示に、作業中のテストはこのラッパースクリプトで実行するのを基本とし、全体を実行するときは<code>--all</code>を付ける、と書きました。エージェントも私と同じ正確なテストの集合を、編集のたびに数秒で得られます。ステップ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="#ci%e3%81%ab%e3%81%af%e3%82%82%e3%81%a3%e3%81%a8%e7%b2%97%e3%81%84%e3%82%82%e3%81%ae%e3%82%92%e5%85%a5%e3%82%8c%e3%81%9f%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>testmonにはCIモードがありません。ランナーの上でもノートPCの上と同じように動きます。作者は、CIのランナー間で<code>.testmondata</code>を共有したりキャッシュしたりするのは、自分たちのホスティングサービスであるtestmon.netに比べてずっと効果が薄いと書いています。キャッシュした記録は上で挙げた点でどれも見えないままですし、私のCIはDjangoのランナーを4プロセスで動かしてスイートを実行しています。なので、testmonは入れませんでした。</p>
<p>代わりにCIに入れたのは、同じ考え方をファイル単位にしたものです。チェックのワークフローはまず小さなジョブでpushの差分を取り、リポジトリのどちら側に触れたかを判断します。PythonのAPIか、モバイルアプリか、両方か、どちらでもないか。これまでCIは、ドキュメントだけのものも含めて、pushのたびにPythonとアプリの両方のジョブを実行していました。今は、ドキュメントだけのpushなら何も実行せず、アプリだけへのpushならDjangoのスイートを飛ばします。実際のコミット5件に対してこのジョブを試しに走らせてみたところ、ドキュメントだけのコミットでは何も実行されず、アプリだけのコミットではPythonのジョブが、バックエンドだけのコミットではアプリのジョブが飛ばされました。削って例にしたものがこちらです。</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ファイルが見えませんが、パスのフィルタも、リストに書かない限り見えません。そこでパターンには共有のフィクスチャ、ビルドしたバンドル、生成したサイトを明示的に書き、編集する人にはリストを実態に合わせておくよう頼むコメントを添えています。ファイル単位のやり方は大雑把ですが、失敗するときは安全な方向に失敗します。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%85%a5%e3%82%8c%e3%82%8b%e4%be%a1%e5%80%a4%e3%81%af%e3%81%82%e3%82%8b%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>実行に数分かかるスイートなら、あります。本当のコストは見えないものの方で、ラッパースクリプトはそのためにあります。それでも、testmonが活きるのはノートPCでの開発ループです。CIはPython側が変わるたびに今もスイート全体を実行していて、デプロイの前に私が信頼しているのはその実行です。</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/ja/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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はすべてのpushをクリーンなマシンでビルドしてテストし、CDは通ったものを繰り返せる一つの手順でリリースします。GitHub Actionsでは<code>.github/workflows/</code>の下に置くYAMLファイルがそれで、私のものはわざと薄くしています。チェックは手元のノートPCで打つのと同じコマンドで、デプロイはどれもGitHubがなくても同じように動くスクリプトかCLIです。</p>
<p>GitHub ActionsでDocker Composeを動かす方法を探してここに来たなら、先に結論を。私はそれを2年やって、やめました。いまのCIは同じPostgresとValkeyのイメージを<code>services:</code>として宣言し、同じ<code>uv run …</code>と<code>npm run …</code>をランナー上で直接実行します。Composeは開発者が手元で動かすものに戻りました。以下はその理由と、実際のファイルです。</p>
<p>2024年に<a href="https://jared.lynskey.co.nz/ja/posts/2024/2024-06-02-simplifyingcicd/" >CI/CDのロジックはプラットフォーム固有のYAMLではなくDocker Composeに</a>という記事を書きました。ビルドのロジックはComposeに置き、CIツールにはそれを呼ぶことだけをさせる、という内容です。2年たって、実際にリリースに使っているリポジトリを全部見直しました。ワークフローがあるのは4つで、コミッターはどれも私一人です。そして、そのワークフローのうち<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="#%e5%8b%95%e3%81%8b%e3%81%97%e3%81%a6%e3%81%84%e3%82%8b%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%82%92%e4%b8%80%e3%81%a4%e3%81%ae%e8%a1%a8%e3%81%ab" 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>すべてのpush、ブランチを問わず</td>
					<td><code>hugo --gc</code>がビルドできること</td>
					<td>Cloudflare Pages：<code>main</code>は本番、ほかのブランチはプレビューURL</td>
					<td>なし。pushがそのままリリース</td>
			</tr>
			<tr>
					<td>私が作っているマーケットプレイスアプリのAPI</td>
					<td>デプロイスクリプト、手動</td>
					<td><code>main</code>へのpushごとにリント、型チェック、Djangoのテスト</td>
					<td>Fly.io、ローリングデプロイ</td>
					<td>私</td>
			</tr>
			<tr>
					<td>同じアプリのフロントエンド：Web、OTAアップデート、ストア向けビルド</td>
					<td>リリース用スクリプトとローカルのストア向けビルド、手動</td>
					<td><code>main</code>へのpushごとにTypeScriptとJest</td>
					<td>オブジェクトストレージ、OTAアップデートのサービス、二つのアプリストア</td>
					<td>私</td>
			</tr>
			<tr>
					<td>pyhtml2text（私のオープンソースのPythonパッケージ）</td>
					<td>すべてのpush、プルリクエスト、タグ</td>
					<td>15のビルドターゲット向けのwheel、その大半でテスト</td>
					<td>PyPI、タグを打ったとき</td>
					<td>タグ</td>
			</tr>
	</tbody>
</table>
<p>教科書の言葉でいえば、二つのHugoサイトは継続的デプロイメントで、アプリ（私が作っているマーケットプレイス兼コミュニティアプリ）は継続的インテグレーションにコマンド一つのリリースを足した継続的デリバリーです。チェックは何かがおかしいと教えてくれるだけで、デプロイを止めはしません。これは意図してそうしています。OTAアップデートは対象のバイナリバージョンを使っているすべての端末に届くので、マージの副作用ではなく判断であるべきです。そしてAPIはデータベースのマイグレーションが走る場所です。残り二つのDjangoアプリには、ワークフローがそもそもありません。</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="#ci%e3%81%ae%e5%81%b4%e3%83%af%e3%83%bc%e3%82%af%e3%83%95%e3%83%ad%e3%83%bc%e4%b8%80%e3%81%a4%e3%82%b8%e3%83%a7%e3%83%96%e4%ba%8c%e3%81%a4" 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>二つのジョブは並行して走ります。これまでに成功した35回の実行で、アプリのジョブの中央値は約7分、Pythonのジョブは約12分半です。</p>
<p><code>services:</code>ブロックが、Composeファイルの移った先です。これは次の節で書きます。環境変数はいくつかとフラグ一つだけです。ほかはすべて開発者が<code>docker compose up</code>で得る開発用のデフォルトに落ちるので、テストにシークレットは一つも要りません。本当にそうか確かめるために、まさにその環境だけで、ほかに何もない状態でテストの一部を走らせました。</p>
<p>CIは手元のノートPCや本番イメージと同じインタープリタ、つまり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>インタープリタを揃えた甲斐は、ワークフローを入れて8日後に出ました。10月19日に3.15のJITコンパイラを有効にしたところ、有効にして最初の実行でテストが一つ落ちたのです。アプリが保存のたびに通るコードパスのごく普通の<code>yield from</code>が、<code>AttributeError: 'list' object has no attribute 'send'</code>を投げていました。同じコミットをJITなしで走らせると通ります。私のMacはarm64で一度もこれに当たらず、ランナーは本番マシンと同じx86_64です。JITはCIでもDockerfileでも再び無効にしてあり、このバグが本番に届くことはありませんでした。これはpython/cpython#155823で、3.15.0のリリース後の10月13日に上流で修正されています。</p>
<p>実は最初の実行も落ちていて、こちらの理由はもっと地味です。Expoは<code>expo start</code>のときに<code>expo-env.d.ts</code>を書き出し、このファイルはgitignoreされています。なので私が使ってきた作業コピーにはどれもあり、まっさらなチェックアウトにはありませんでした。TypeScriptは、ローカルでは一度も出たことのないエラーを五つ出しました。そのファイルを書き出すステップが対処です。</p>
<p><code>concurrency</code>は、同じブランチに新しいpushが来ると前の実行を取り消します。これまでに取り消された12回は、すべてこれによるものです。コミット前のフック（pre-commit）がリンターを走らせ、PythonかTypeScriptがステージされていれば型チェッカーも走らせるので、ミスの大半はpushまで行きません。</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%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab%e3%81%af%e3%81%a9%e3%81%93%e3%81%b8%e8%a1%8c%e3%81%a3%e3%81%9f%e3%81%8b" 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は同じイメージをサービスコンテナとして宣言し、開発者が打つのと同じコマンド、<code>uv run …</code>と<code>npm run …</code>をランナー上で直接実行します。</p>
<p>2024年の記事と比べると、いちばん大事にしていた部分は残りました。CIツールは薄く、チェックもデプロイも自分で実行できるコマンドです。残らなかったのは、CIからComposeを呼ぶやり方です。</p>
<p>アプリのCIはDockerイメージをまったくビルドしません。イメージはデプロイのときにFlyのビルダーが一度だけビルドし、CIはコードが大丈夫かどうかだけ答えればよく、ツールチェーンがキャッシュされていればそれは6秒のインストールで済みます。それにComposeファイルは作業コピーをバインドマウントし、データを投入しますが、それはテストの実行がすることではありません。CI用に別のComposeファイルが必要になっていたはずで、それは同じサービスの二つ目の定義を手で揃え続けるということです。</p>
<p>そして2024年の例には、当時気づかなかったバグがありました。<code>docker compose build</code>はイメージをビルドするだけで、サービスの<code>command</code>は実行しません。なので、あのファイルの<code>npm test</code>はCIで一度も走っておらず、ワークフローはテストが通ろうが落ちようが緑でした。</p>
<p>あの記事がいちばん心配していたリスク、つまりCIベンダーの乗り換えは、結局起きませんでした。実際に起きた移行は反対側でした。このブログとクライアントのサイトがホスティング先をCloudflare Pagesに移し、ブログのほうは45行のワークフローを一つ新しく書くだけで済みました。ビルドがもともとコマンド一つだったからです。それから、私が警告していた300行のワークフロー。一つあります。pyhtml2textのものです。wheelをLinux、macOS、Windowsのランナーで、それぞれ複数のアーキテクチャ向けにビルドしなければならないので長いのですが、だからこそプラットフォーム固有の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="#%e3%82%ad%e3%83%a3%e3%83%83%e3%82%b7%e3%83%a5%e3%81%be%e3%81%9a%e3%82%b9%e3%83%86%e3%83%83%e3%83%97%e3%81%ae%e6%89%80%e8%a6%81%e6%99%82%e9%96%93%e3%82%92%e8%aa%ad%e3%82%80" 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>ジョブ</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のキャッシュは200KBほどで、setup-uvがダウンロードし直せば済むものを整理してしまうからです。npmのほうは400MB近くあります。時間がかかっているのはテストで、テストはすでに4並列で走っています。実行を速くしたいなら、キャッシュではなくテストスイートに手を入れます。</p>
<p>ブログは正反対です。Hugoが処理した画像はgitignoreされた<code>resources/_gen</code>にあるので、新しいランナーのたびにカバー、本文の画像、プレースホルダーを全部エンコードし直していました。後で載せるワークフローは<code>actions/cache</code>でこれを復元します。キーは画像そのもののハッシュで、合わなければいちばん新しい古いキャッシュで代用します。Hugoは足りない分しか処理しないからです。キャッシュ導入前の最後の8回では、ビルドのステップに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を置いたフォルダーを漏らしていました。それぞれが自分の依存関係とビルド成果物を抱えています。そのせいで27万7千ファイル、7.7GBが、デプロイのたびにビルダーへ送られていました。許可リストに書き直したいま、ビルドコンテキストは40MBほどです。パターンの例はこうです。</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="#%e3%82%b7%e3%83%bc%e3%82%af%e3%83%ac%e3%83%83%e3%83%88%e3%81%af%e3%81%a7%e3%81%8d%e3%82%8b%e3%81%a0%e3%81%91%e5%b0%91%e3%81%aa%e3%81%84%e5%a0%b4%e6%89%80%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>シークレットは三か所にあります。GitHub Actionsのシークレットはワークフローがすることのためのもので、二つのHugoリポジトリにはそれぞれ<code>CLOUDFLARE_API_TOKEN</code>が一つあるだけです。Flyのシークレットはアプリの実行時設定で、<code>flyctl secrets set</code>で入れ、Actionsを経由することはありません。そしてノートPCのgitignoreされた<code>.env</code>に、デプロイスクリプトがCloudflareのキャッシュを消すときに使うトークンがあります。</p>
<p>PyPIにはシークレットがそもそも要りません。pyhtml2textはトラステッドパブリッシングを使っています。GitHubがワークフローに発行する短命のOIDCトークンをPyPIが受け付けるので、リリースのジョブは<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">デプロイ：pushごとに、スクリプトで、タグで
    <div id="デプロイpushごとにスクリプトでタグで" 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="#%e3%83%87%e3%83%97%e3%83%ad%e3%82%a4push%e3%81%94%e3%81%a8%e3%81%ab%e3%82%b9%e3%82%af%e3%83%aa%e3%83%97%e3%83%88%e3%81%a7%e3%82%bf%e3%82%b0%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hugoサイトはpushのたびにデプロイ
    <div id="hugoサイトはpushのたびにデプロイ" 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%e3%82%b5%e3%82%a4%e3%83%88%e3%81%afpush%e3%81%ae%e3%81%9f%e3%81%b3%e3%81%ab%e3%83%87%e3%83%97%e3%83%ad%e3%82%a4" 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月にパッケージマネージャーのアップグレードでノートPCのHugoが0.168に上がりましたが、これはテーマが対応する最新版より一つ新しいものです。デプロイは0.165のままで、何も起きませんでした。</p>
<p>そして、ビルドそのものがテストです。9月14日、あるブランチのプレビューデプロイが3回続けて失敗しました。そのブランチの記事が、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="#%e3%82%a2%e3%83%97%e3%83%aa%e3%81%aeapi%e3%81%af%e3%82%b9%e3%82%af%e3%83%aa%e3%83%97%e3%83%88%e3%81%a7%e3%83%87%e3%83%97%e3%83%ad%e3%82%a4" 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>が数えるのは動いているマシンだけですが、アプリは暇なときに動いているマシンを一台も置きません（サスペンドしたマシンは1秒ほどで復帰します）。そのため、起きていた一台が寝ているマシンと同じバッチで置き換えられ、後ろに温まったマシンが一台もない、ということが起きていました。9月28日にオリジンへプローブを送り続けて測ったところ、素の<code>flyctl deploy</code>ではタイムアウトが7回と7.3秒の空白があり、先にビルドしてロールアウト開始時にはマシンを一台起こしておくスクリプトのデプロイでは、2,514リクエストで失敗はゼロでした。</p>
<p>スクリプトの残りは、エラーの通知が届くチームのチャットチャンネルにデプロイの印を残します。エラーが固まって出たとき、直前に何が変わったかと照らし合わせられるようにです。それからCloudflareのキャッシュを消し、マシンを温めておきます。Webフロントエンドはイメージをまったく通りません。それは<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-14-web-releases-without-redeploying/" >フロントエンドとバックエンドを別々にデプロイする</a>に書きました。</p>
<p>マイグレーションはパイプラインのステップではありません。Flyの<code>release_command</code>が私の環境では固まってしまったので、新しいイメージで最初に起動したマシンがロックを取ってマイグレーションし、ほかのマシンは待ちます。そのため、どのマイグレーションもまだ動いているコードと両立しなければなりません。カラムはあるデプロイで追加し、次のデプロイで使うのをやめ、三回目のデプロイで削除します。</p>
<p>このスクリプトに、私のノートPCでなければならない部分は特にありません。ただ、ノートPCにしかないファイルがいくつかあって、それが縛りになっています。Cloudflareの認証情報と、最後に何をデプロイしたかの記録で、まっさらなランナーにはこれがありません。コミットしていない変更もデプロイしてしまい、チャットでそう知らせますが、Webのリリース用スクリプトは汚れた作業ツリーを拒否します。</p>
<p><a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >ブランチごとのプレビュー環境</a>で私は、これを手でやるのは自分一人なら構わないが、二人目の開発者からはgitで何かが起きたから起きるようにしなければならない、と書きました。アプリの開発者はまだ一人です。あの記事のブランチ環境のワークフローは、作ったときのブランチにそのまま置いてあります。</p>
<p>OTAのワークフローは手動専用です。公開は判断であるべきですし、アップデートのサービスは部分ロールアウトが開いている間は新しいリリースを拒むので、pushトリガーではカナリアのたびに引っかかってしまいます。このワークフローは一度も実行されたことがありません。これまでのOTAアップデートはすべてノートPCから、ワークフローが呼ぶのと同じスクリプトで出しています。薄いワークフローにする理由は一言でこれです。リリースをどう始めても、同じスクリプトです。</p>

<h3 class="relative group">pyhtml2textはタグでリリース
    <div id="pyhtml2textはタグでリリース" 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%e3%81%af%e3%82%bf%e3%82%b0%e3%81%a7%e3%83%aa%e3%83%aa%e3%83%bc%e3%82%b9" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>リリースのジョブです。パッケージ名は一般的なものにしてあります。</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>リリースのジョブはすべてのビルドジョブを待ち、実際に公開するのはタグのときだけです。手動実行はビルドとアテステーションまではしますが、何もアップロードしません。<code>actions/attest</code>はどのワークフローが各wheelをビルドしたかを記録し、<code>uv publish --trusted-publishing always</code>はパスワードの代わりにOIDCトークンでアップロードします。パッケージそのものについては<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-10-18-pyhtml2text/" >閉じられたプルリクエスト、フォーク、そして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%9c%9f%e3%83%af%e3%83%bc%e3%82%af%e3%83%95%e3%83%ad%e3%83%bc%e3%81%af%e3%83%99%e3%82%b9%e3%83%88%e3%82%a8%e3%83%95%e3%82%a9%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アプリには定期ワークフローが一つあります。Flyがベースラインより多くのマシンを起動していないかを見るチェックで、放っておけば請求書で初めて気づくことです。通知はわざと失敗することで行います。定期実行が失敗すると、GitHubが所有者にメールを送るからです。</p>
<p>設定は<code>cron: '*/15 * * * *'</code>、1日96回です。ところがこの2週間は1日3〜8回しか動いておらず、実行間隔の中央値は3時間40分ほどでした。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="#%e3%83%91%e3%82%a4%e3%83%97%e3%83%a9%e3%82%a4%e3%83%b3%e3%81%ab%e6%8a%95%e3%81%92%e3%81%8b%e3%81%91%e3%81%9f%e3%81%84%e8%b3%aa%e5%95%8f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>これから作る人も、すでにあるものを見直している人も：</p>
<ol>
<li>GitHub Actionsが丸一日止まったら、それでもリリースできますか？ できないなら、リリースのロジックがYAMLの中にあるということで、2024年の記事がまだ当てはまります。</li>
<li>CIは本番と同じインタープリタ、同じCPUアーキテクチャ、同じデータベースのメジャーバージョンで動いていますか？ 私のCIがコンパイラのバグを捕まえられたのは、ランナーと本番がどちらもx86_64で、ノートPCがそうではなかったからです。</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 でステージング争奪戦を終わらせる</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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>PR ごとにプレビュー環境を立てる方法です。ラベルひとつでブランチ名のハッシュを Fly アプリ、Postgres、Redis、バケット、ホスト名に刻み、インフラは共有のまま分け、PR を閉じれば片付けます。</description>
      <content:encoded><![CDATA[<p>プレビュー環境とは、ブランチや PR ごとに立ち上がり、用が済めば消える使い捨ての環境のことです。Fly.io でこれを作るとき、私はインフラを丸ごと複製せず、共有のまま切り分けています。PR に <code>env</code> ラベルを付けると GitHub Actions のワークフローがスクリプトをひとつ走らせ、ブランチ名から導いた名前(スラッグと短いハッシュ)を Fly アプリ、テンプレートから複製した Postgres データベース、Redis のキー接頭辞、バケットの接頭辞、生成したシークレット、Cloudflare のワイルドカード配下のホスト名に刻みます。push のたびに更新され、PR を閉じるかマージすれば片付き、漏れたものは毎晩のスイーパーが拾うので、「ステージング使ってる人いる?」と聞く必要はもうありません。</p>
<p>ひとつのアプリを作る小さなチームを思い浮かべてください。開発者は二人でも五人でも構いません。分野ごとの専門家ではなく、それぞれが自分のブランチで、データベースのマイグレーションから API を経てモバイルアプリの画面まで、機能をひとつ端から端まで受け持つ人たちです。チームの回し方としては良いやり方で、二人目の開発者が加わった瞬間から繰り返し起きる失敗がひとつあります。「今ステージング使ってる人いる?」というメッセージです。</p>
<p>そのメッセージの裏には、機能を作り終えてテスターやデザイナーに見せたいのに見せられない開発者がいます。ステージングには二人目の開発者のブランチが載っていて、dev には三人目のブランチが載っていて、そちらは二日前にログインを壊してから再デプロイされていません。数人、共有環境が二つ、そしてスケジュール調整を引き受けているチャットのスレッド。</p>
<p>これは人の問題ではありません。トポロジーの問題です。全員が環境を「自分のブランチが本物になる場所」として扱っているので、環境がマージのやるべき仕事をブランチひとつずつ順番に肩代わりし、残りの全員が列に並んでいるのです。</p>
<p>自分のスタックの本番の形は <a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a> に書きました。Fly.io の最安マシン上の Django、コネクションプーラーの背後の Postgres、キャッシュと Channels レイヤー用の Redis、別立ての WebSocket アプリ、バックグラウンドワーカー、ファイル用の S3 互換バケット、ホスティング型の認証プロバイダー、前段の Cloudflare、そしてそれら全部と話すモバイルアプリです。この記事は、そのスタックを、どのブランチでも開発者が PR にラベルをひとつ付ければ数分のうちに HTTPS 付きの公開コピーを丸ごと手に入れられるものに変えた話です。他の誰かに頼むことも、誰かが用意してやることもありません。Postgres クラスタも Redis もバケットも、二つ目は用意しません。インフラは共有のままです。変わるのは、共有リソースのすべてが名前で切り分けられるようになることで、その名前はブランチから来ます。ここにチームの規模を前提にしたものはありません。開発者が二人でも二十人でも設計は同じで、増えるのはアプリの一覧だけ、それはスイーパーが面倒を見ます。作って、ブランチを三つ同時に動かして、予想していなかった二か所で壊して、片付けるまでを午後のうちに済ませました。以下の出力はその実行から取ったものです。</p>

<h2 class="relative group">共有環境ひとつが開発者一人分より先に伸びない理由
    <div id="共有環境ひとつが開発者一人分より先に伸びない理由" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%b1%e6%9c%89%e7%92%b0%e5%a2%83%e3%81%b2%e3%81%a8%e3%81%a4%e3%81%8c%e9%96%8b%e7%99%ba%e8%80%85%e4%b8%80%e4%ba%ba%e5%88%86%e3%82%88%e3%82%8a%e5%85%88%e3%81%ab%e4%bc%b8%e3%81%b3%e3%81%aa%e3%81%84%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>共有 dev 環境には前提がひとつ焼き込まれています。全員が見たがる「現在のコードのバージョン」がひとつだという前提です。開発者が一人のとき、あるいはチームが変更の流れをひとつ歩調を合わせて進めているときは正しい。二人目の開発者が別のスケジュールで機能に着手した瞬間に正しくなくなり、端から端までのオーナーシップではそれがすべての機能で起きます。それ以降、環境が抱えるものはすべて取り合いになります。</p>
<ul>
<li><strong>デプロイされたコード。</strong> 最後にデプロイした人が勝ちます。残りの人の作業は再デプロイするまで見えず、再デプロイすれば前の人のものが消えます。</li>
<li><strong>データベースのスキーマ。</strong> 各開発者が自分の機能のデータモデルを持つとき、いちばん強く噛みつく部分です。最初のブランチがカラムを足す。一時間後にデプロイされた二つ目のブランチはそれを知らず、ORM が insert で失敗し始める。あるいは両方のブランチが <code>0042_*</code> マイグレーションを持っていて、共有データベースが誰のマイグレーション履歴とも一致しない状態になり、誰かがリセットして、三人目が午後いっぱいかけて用意したテストデータを消す。</li>
<li><strong>キャッシュ。</strong> あるブランチのシリアライザ変更が、別のブランチのコードが読むのと同じ Redis キーの下にキャッシュされる。そこから出てくるバグ報告は不可解で、一件ごとに午前中がつぶれます。</li>
<li><strong>サードパーティ連携。</strong> OAuth コールバックがひとつ、Webhook URL がひとつ、プッシュ証明書がひとつ。誰のブランチがデプロイされていようと、そのブランチがイベントを受け取ります。</li>
<li><strong>テスター。</strong> デプロイされているものしかテストできないので、機能が準備できた順ではなく、環境がたまたま確保された順に一度にひとつずつテストします。</li>
</ul>
<p>チームは予約システムで応じます。チャットのスレッド、ピン留めメッセージ、<code>/claim staging</code> ボット。それはロックです。作業を見せられる唯一の場所にかかったロックは、デリバリー速度にかかったロックです。開発者二人なら苛立つ程度です。三人なら良い週には耐えられます。四人目が加わるか、機能ひとつがデザイナーとの三日間のやり取りでステージングを握り続ければ、耐えられなくなります。</p>
<p>人が最初に手を伸ばす対処は三つ目の共有環境、「dev2」や「uat」です。数週間は稼げます。列が二車線になっただけです。</p>
<p>本当の対処は、dev もステージングもそもそも環境ではなかったと気づくことです。あれは <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="#%e3%83%a2%e3%83%87%e3%83%ab-%e3%83%96%e3%83%a9%e3%83%b3%e3%83%81%e3%81%8c%e7%92%b0%e5%a2%83%e3%81%a7%e3%81%82%e3%82%8b" 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>ブランチ環境 (かつての「dev」)</td>
					<td>アクティブなブランチごとに一つ</td>
					<td>ブランチの PR に付く <code>env</code> ラベル、その後は push のたびに。PR のないスパイクなら同じ <code>./scripts/env up</code> を手で</td>
					<td>ブランチ削除、そして毎晩のスイーパー</td>
					<td>数日</td>
			</tr>
			<tr>
					<td>ステージング</td>
					<td>リリース候補ごとに一つ</td>
					<td><code>main</code> からタグが切られたときの 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 エージェントでもありません。十人目の開発者の最初のブランチも、一人目のブランチとまったく同じやり方で、PR にラベルが付いたという理由で環境を得ます。人に頼まなければならないなら、それはスケールしたのではなく、行列の場所が変わっただけです。</p>
<p>そしてこれを成り立たせる規則はひとつです。<strong>環境が触れるすべてのリソースは、ブランチにちなんで決定論的に名付ける。</strong> 開発者名でもチケット番号でもなく、誰かが開くまで存在しない PR 番号でもありません。ブランチです。開発者が実際に扱うのがブランチだから、そして名前が決定論的なら、同じブランチで <code>./scripts/env up</code> を二度目に実行したとき、新しい環境を作る代わりに既に作った環境を見つけられるからです。</p>
<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">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 のキー接頭辞、バケットのオブジェクト接頭辞、エラートラッカーの environment、そしてホスト名の左端のラベルになります。ブランチ名を知っている人なら誰でも計算できます。どこかを引く必要はありません。</p>
<p>その最後の一文が設計です。環境の台帳もコーディネーターもありません。<code>env up</code>、<code>env down</code>、ホスト名をルーティングする Cloudflare Worker、毎晩のスイーパーが、それぞれ独立にブランチから名前を計算し直し、構造的に一致します。特に Cloudflare は何も調整していません。Worker はホスト名から Fly アプリへの純粋関数で、環境が作られたことも消されたことも知りません。アプリがあればリクエストは届き、なければ Fly が自分のエラーで答えます。</p>
<p>ここから三つのことが出てきます。</p>
<p>第一に、「ステージング誰が使ってる」が問いでなくなります。<code>feature-payments-retr-3f9a1c-dev.example.com</code> があり、<code>fix-login-redirect-8b21e0-dev.example.com</code> があり、それぞれそのブランチにいる人のものです。</p>
<p>第二に、ステージングがゴミ捨て場ではなく本番の正直なリハーサルになります。本番に行くまさにそのイメージで、数分前に本番の形のスナップショットから複製したデータベースを相手に組まれます。そこで動けば、本番に残る変数は本番のデータだけです。</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%e7%9a%84%e3%81%ab%e3%81%af%e5%85%b1%e6%9c%89%e8%ab%96%e7%90%86%e7%9a%84%e3%81%ab%e3%81%af%e5%88%86%e9%9b%a2" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>「ブランチごとに環境」の魅力的な版は丸ごとコピーです。Postgres クラスタをひとつずつ、Redis をひとつずつ、バケットをひとつずつ。きれいですが、悪い取引です。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>、チャネルレイヤーの <code>prefix</code></td>
			</tr>
			<tr>
					<td>ファイル</td>
					<td>S3 互換バケットひとつ <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>なし。ユーザーの identity は意図的に共有 (後述)</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: スキーマではなくデータベース。</strong> クラスタひとつを切り分けるもうひとつの方法は、環境ごとにスキーマを持ち、接続ごとに <code>search_path</code> を設定することです。接続の <code>options</code> に <code>-c search_path=...</code> を押し込めば Django もやってくれますが、トランザクションモードのコネクションプーラーは指示しない限り起動時オプションを捨てますし、そうすると結局プール内のすべての接続がひとつの環境に属さなければなりません。「なぜ自分のクエリが違うテーブルに当たるのか」系のバグの温床で、環境ごとにデータベースならそもそも存在しない問題です。データベースは固い境界で、<code>CREATE DATABASE ... TEMPLATE</code> は数秒でひとつ作り、<code>DROP DATABASE</code> は何も残さず消します。ブランチ環境はプーラーを通さず 5433 番ポートで Postgres に直接つなぎます。本番のバックグラウンドワーカーと同じやり方です。プーラーは本番の同時実行数で元を取るもので、ブランチ環境がそれを見ることはありません。</p>
<p><strong>シークレット: 共有ではなく生成。</strong> dev 用シークレット一式を作って全環境にコピーすれば楽でしょう。やめましょう。<code>env up</code> スクリプトは環境ごとに新しい <code>SECRET_KEY</code>(セッション署名)、<code>ENCRYPTION_KEY</code>(保存時に暗号化するもの全部)、<code>ENV_TOKEN</code>(モバイルアプリが提示する bearer トークン、後述)を生成し、その Fly アプリのシークレットストアにだけ保存します。あるブランチのセッションクッキーは別のブランチでは無価値で、漏れたプレビュー用トークンが開くのはプレビューひとつだけで、開発者はわざわざ探さない限り値を見ることがありません。Fly だけでなく別のシークレットマネージャーにシークレットを置くなら、同じ規則が当てはまります。パスは <code>myapp/preview/$ENV/</code> で、スイーパーが環境と一緒にそのパスを消します。環境をまたいで<em>本当に</em>共有されるシークレットは共有インフラ自体の認証情報(Postgres の管理者 URL、Redis の URL、バケットのキー)だけで、それらはどの環境でもなく 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 キーとバケットのオブジェクトは消す前に環境自身のマシンが削除するので、ラップトップは gitignore された env ファイル以外に共有 Redis やバケットの認証情報を持ちません。要点になる行だけに絞ると、次のとおりです。</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> をすると、「New app created」も「CREATE DATABASE」も出ず、同じシークレット名をステージしつつ生成したものは作り直さず、リビルドしました。同じ URL、新しいコミット。</p>
<p>中の選択のいくつかは一文ずつ値します。</p>
<p><strong><code>fly.preview.toml</code> は <code>fly.toml</code> ではない。</strong> 本番は API、WebSocket サーバー、バックグラウンドワーカーを別々の Fly アプリとプロセスグループで動かし、それぞれが自分の指標でスケールするようにしていて、それがスタックの記事の主張のすべてです。ブランチ環境はスケールする必要がありません。マシンひとつであるべきです。だからプレビュー設定はプロセスがひとつです。コンテナのエントリーポイントにプレビュー用のモードを足し、マイグレーションを走らせてから WebSocket を有効にした ASGI サーバーを<em>ひとつ</em>起動します。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> 昼食以降誰も開いていないブランチ環境の費用はゼロであるべきです。stop でなく suspend なら、30 秒のコールドスタートの代わりに <a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >504 を追いかけて</a> たどり着いた 3 秒ほどの再開が得られ、ここでは 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、本物のクッキー、同じヘルスチェック)で本番のように振る舞い、誰かを傷つけうるすべての面で本番と違うべきです。プレビュー用の設定ファイルを別に用意する代わりに、差分は <code>env up</code> が設定するシークレットが運びます。サンドボックスの決済キー、認証プロバイダーの管理者認証情報なし、そして以下のすべての接頭辞を有効にする <code>ENV_NAME</code>。</p>
<p><strong>設定ひとつがすべてを切り分ける。</strong> <code>ENV_NAME</code> は設定に新しく入った唯一のつまみで、四か所に適用されます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">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 サーバーがグループ名を共有し、ある環境で送ったメッセージが別の環境に現れます。一日を食う種類のバグです。</p>
<p>五か所目は、後から共有 Redis を読んで初めて見つけました。504 の記事の起動時ロック、つまりイメージごとにマシン一台がマイグレーションを走らせる間ほかが待つ仕組みは、イメージ参照をキーにしていました。同じコミットから組まれたブランチ環境二つは同じイメージを持ちます。二つ目は一つ目の「done」マーカーを見つけて、マイグレーションされていない自分のデータベースへの自分のマイグレーションを飛ばしていたでしょう。今は環境名がキーに含まれ、そのキーは接頭辞の下に住んでいます。共有インフラの設計が生み続ける種類の問題です。暗黙のうちに「デプロイごと」だったものはすべて明示的に環境ごとにしなければならず、何が暗黙だったかは見るまで分かりません。</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="#%e3%83%87%e3%83%bc%e3%82%bf%e3%83%99%e3%83%bc%e3%82%b9-%e6%95%b0%e7%a7%92%e3%81%a7%e8%a4%87%e8%a3%bd%e3%81%95%e3%82%8c%e3%82%8b%e3%83%86%e3%83%b3%e3%83%97%e3%83%ac%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ブランチ環境のデータベースは三つのうちのどれかです。全員と共有。ここまで我々を連れてきたやり方です。空からマイグレーションしてフィクスチャでシード。決定論的ですが、フィクスチャが実物の形をしたデータに敵ったことはなく、いつも十八か月は古い。あるいは本番の形のスナップショットの複製。現実的な行数、フィクスチャが常に埋まっていると仮定していたカラムの現実的な 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> はクラスタ内のファイルレベルのコピーです。数ギガバイトなら <code>pg_restore</code> にかかる数分ではなく数秒です。複製中のテンプレートには開いた接続があってはならず、<code>datallowconn = false</code> がそれを強制します。夜間ジョブが生きているテンプレートの上にリストアするのではなく <code>seed_raw</code> に組んで最後にリネームするのもそのためです。</p>
<p>デモではテンプレートを本番ダンプからではなく手で作りました。<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 ノードひとつ、1 GB ボリューム付きの <code>shared-cpu-1x</code> で、月に二ドルほどです。ブランチ環境はプライベートネットワークの <code>.flycast</code> 経由でつなぎます。公開されているものは何もありません。</p>
<p>匿名化は省略できません。本番データが <code>-dev</code> ホスト名で届く瞬間、それは本番データであってはなりません。名前、メール、電話番号、住所、自由記述、決済参照、トークン、すべてリネームの前に上書きします。匿名化が失敗したら前夜のテンプレートが残り、誰かにメッセージが飛びます。小さな仕事で、このアプローチ全体をプライバシー質問票に答える人が受け入れられるものにする、まさにその仕事です。</p>
<p>エントリーポイントがすべてのイメージの初回起動で <code>migrate</code> を走らせるので、ブランチ自身のマイグレーションがテンプレートの上に適用されます。「自分のマイグレーションは本番の形のデータで動くか」の最初の正直なテストで、誰かが 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="#%e3%83%9b%e3%82%b9%e3%83%88%e5%90%8d-%e3%83%af%e3%82%a4%e3%83%ab%e3%83%89%e3%82%ab%e3%83%bc%e3%83%89%e3%81%b2%e3%81%a8%e3%81%a4worker-%e3%81%b2%e3%81%a8%e3%81%a4%e3%81%9d%e3%81%97%e3%81%a6%e3%83%89%e3%82%ad%e3%83%a5%e3%83%a1%e3%83%b3%e3%83%88%e3%81%8c%e6%95%99%e3%81%88%e3%81%a6%e3%81%8f%e3%82%8c%e3%81%aa%e3%81%8b%e3%81%a3%e3%81%9f%e4%ba%8c%e3%81%a4%e3%81%ae%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>すべての環境には公開の HTTPS ホスト名が必要で、「公開」が要点です。スマホを持ったテスター、別のネットワークのデザイナー、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/ja/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 が動くことなく www のトップページへ 301 で答えました。シンクホールはゾーンレベルのリダイレクトルール「その他のサブドメインはすべて → www」で、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 レコード: 192.0.2.1 を指す、ゾーンのワイルドカードのプロキシ済み A レコードがひとつ"
      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 ルール、レート制限、ボット対策モード、そして 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-router Worker に結び付いたルート *-dev.example.com/* がひとつ"
      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 は Fly に尋ねる前に Worker 自身が返したものです。</p>
<p>Cloudflare Access は「公開」を「届くべき人に届く」に変える装置です。<code>*-dev.example.com</code> にアプリケーションひとつ、「@example.com のメールなら誰でも」というポリシーひとつで、ブラウザからの訪問者はログインページを一度見て二度と気にしなくなります。ラップトップのデザイナー、PM、テスターはそれで済みます。デモには入れませんでした。環境は一時間、<code>noindex</code> を付けてシードデータ以外何もない状態で上がっていました。</p>
<p>モバイルアプリは覆えません。ネイティブアプリは Access のログインリダイレクトを完了できないからです。道は二つ。Access はログインを迂回するサービストークン(<code>CF-Access-Client-Id</code> / <code>CF-Access-Client-Secret</code> のヘッダー対)をサポートしていて、モバイルアプリの dev メニューが dev ホスト名用の一対を持てます。あるいはもっと単純に、<code>/api/</code> に Access の迂回を入れ、ログイン済みのユーザーか環境の <code>ENV_TOKEN</code>、つまり <code>env up</code> がまさにこの環境のために生成したあのトークンのどちらもないリクエストを Django が拒否するようにします。どちらでも、モバイル側はベース URL とトークンの二つのフィールドを持つ dev メニュー画面で、テスターが既にインストールしているアプリがそのブランチと話すようになります。新しいビルドは要りません。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/" >OTA(無線)更新チャネル</a>はアプリがビルドされたときのものを指したままで、API のベース URL はビルド時ではなく実行時の設定であり、元々そうあるべきでした。</p>
<p>認証は意図的に共有する唯一の共有サービスです。identity は環境ごとのものではありません。テスターはどのブランチでも同じアカウントでログインしたい。承認済みドメインに <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="#github-actions-%e3%81%a7%e3%83%97%e3%83%ac%e3%83%93%e3%83%a5%e3%83%bc%e7%92%b0%e5%a2%83%e3%82%92%e8%87%aa%e5%8b%95%e5%8c%96%e3%81%99%e3%82%8b-%e3%82%a4%e3%83%99%e3%83%b3%e3%83%88%e3%81%8c%e5%85%a5%e3%82%8a%e7%92%b0%e5%a2%83%e3%81%8c%e5%87%ba%e3%82%8b" 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 やスパイクは、誰かが見たいと言うまで費用ゼロ</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><code>v*</code> タグの push</td>
					<td><code>release.yml</code></td>
					<td>タグからステージング環境; レビュアーの承認が同じイメージを本番に昇格しステージングを消す</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="ラベルpushdelete-のワークフロー" 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="#%e3%83%a9%e3%83%99%e3%83%abpushdelete-%e3%81%ae%e3%83%af%e3%83%bc%e3%82%af%e3%83%95%e3%83%ad%e3%83%bc" 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 されるスパイク、ボットが午前三時に開いた依存関係の更新まで。だから 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 が要りませんが、すべての機能ブランチが費用を払います。「ドラフトでないすべての PR」はいちばん自動的で、チームが PR を遅く、準備できたときにだけ開くなら正しい選択です。自分がラベルを選んだのは、費用の判断を文脈を知っている人に置けることと、何も閉じずに取り消せることからです。気に入っている副作用がひとつ。フォークからの <code>pull_request</code> の実行にはシークレットが渡らないので、フォークは環境を立ち上げられません。</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="#%e3%83%96%e3%83%a9%e3%83%b3%e3%83%81%e7%92%b0%e5%a2%83%e3%81%8c%e8%87%aa%e5%88%86%e3%81%a7%e3%81%af%e3%81%a7%e3%81%8d%e3%81%aa%e3%81%84%e3%83%81%e3%82%a7%e3%83%83%e3%82%af" 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-%e3%81%ae%e8%a6%8f%e5%89%87" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ブランチが一か月生きたり、誰かが <code>main</code> に直接 push したりすれば、このどれも持ちません。規則は保護というより、環境を小さく、マイグレーションを最新に保つためのものです。</p>
<ul>
<li><strong><code>main</code> は保護され、常にデプロイ可能。</strong> <em>現在の <code>main</code> に対して</em> <code>migrations</code> チェックが緑の PR でだけマージします(strict なステータスチェックなので、先週緑だった PR は <code>main</code> が動けば再実行されます)。線形履歴、force-push 禁止、削除禁止。</li>
<li><strong>機能ブランチは短い。</strong> 二週間開いているブランチは <code>main</code> より二週間遅れた環境と、他人のものと衝突する確率が二週間分高いマイグレーションを持ちます。一か月がかりの機能はフラグの裏で小分けに <code>main</code> へマージします。長生きするのはフラグであってブランチではありません。</li>
<li><strong>ブランチひとつ、環境ひとつ、データベースひとつ。</strong> 既に命名が強制しています。</li>
<li><strong>本番に行くのはリリースタグだけ。</strong> イメージはタグから一度組まれ、ステージングと本番はそのイメージを動かします。リビルドはしません。</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">ステージングはタグの環境で、本番は昇格
    <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="#%e3%82%b9%e3%83%86%e3%83%bc%e3%82%b8%e3%83%b3%e3%82%b0%e3%81%af%e3%82%bf%e3%82%b0%e3%81%ae%e7%92%b0%e5%a2%83%e3%81%a7%e6%9c%ac%e7%95%aa%e3%81%af%e6%98%87%e6%a0%bc" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ステージングは仕組み全体を再利用します。<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 を待ちます。誰かがステージングを見て Approve を押すと、本番はステージングが動かした<em>同じイメージ</em>を参照で受け取ります。リビルドではありません。最後のステップがステージングを消すのは、役目を終えたからです。誰も承認しなければリリース候補は却下されたということで、その「ブランチ」(<code>staging/v1.42.0</code> は origin に存在したことがない)が生存チェックに落ちたときにスイーパーがステージングを片付けます。スイーパーが確認できる理由なしには何も存在できません。</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%8e%e6%99%a9%e3%81%ae%e3%82%b9%e3%82%a4%e3%83%bc%e3%83%91%e3%83%bc" 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="#%e9%96%8b%e7%99%ba%e8%80%85%e3%81%8c%e5%ae%9f%e9%9a%9b%e3%81%ab%e3%81%99%e3%82%8b%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>ブランチを push し、PR を開き、誰かに見てほしくなったら <code>env</code> ラベルを付ける。リストはそれだけです。三分以内に PR に URL が現れ、push のたびに更新され、PR がマージされるか閉じられれば消えます。チームメイトと衝突していれば、マイグレーションチェックが誰より先に教えてくれます。取り合うステージングがないので誰もステージングを取り合わず、片付けがマージそのものなので誰も何も片付けません。</p>
<p>そして、これを組んだ人がその後ブランチごとにやることは、何もありません。共有の Postgres、Redis、バケットは一度だけ用意し、シークレットは 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="#%e7%89%87%e4%bb%98%e3%81%91%e3%82%92%e4%bf%a1%e7%94%a8%e3%81%97%e3%81%aa%e3%81%84%e7%89%87%e4%bb%98%e3%81%91" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>env down</code> が通常の場合で、面白いのは<em>誰が</em>掃除するかです。Redis のキーとバケットのオブジェクトは共有サービスのもので、<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> なしでは実行を拒否します。接頭辞が空なら共有バケットの全消去になってしまうからです。そのガードこそ、これがシェルスクリプトの三行ではなくアプリ内のコマンドとして存在する理由のすべてです。</p>
<p>それでも環境は漏れます。GitHub Actions の障害中にブランチが削除される。開発者が試作のためにラップトップから <code>env up</code> を走らせ、push はしないまま。<code>fly apps destroy</code> が一時的に失敗して <code>|| true</code> が飲み込む。三か月後にアプリが四十個あり、誰かに何が起きたのかと問わせる請求書が来ます。</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 しなかったスパイクが一週間後に置かれる状態が、まさにこれです。残りの二つを、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 セント、ブランチテストの規模ではゼロに丸まります。バケットの接頭辞は保存した分だけ。共有 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="#%e3%83%96%e3%83%a9%e3%83%b3%e3%83%81%e3%82%92%e3%81%be%e3%81%9f%e3%81%90%e3%83%9e%e3%82%a4%e3%82%b0%e3%83%ac%e3%83%bc%e3%82%b7%e3%83%a7%e3%83%b3%e8%ad%a6%e5%91%8a%e3%81%97%e3%81%a6%e3%81%8f%e3%82%8c%e3%82%8b%e5%85%b1%e6%9c%89%e3%83%87%e3%83%bc%e3%82%bf%e3%83%99%e3%83%bc%e3%82%b9%e3%81%aa%e3%81%97%e3%81%a7" 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> デプロイ中には新しいスキーマと古いコードが共存する窓があり、マシンがロールバックすればその逆もあります。カラムは nullable かデフォルト付きで足し、一度にリネームせず、現在のリリースがまだ読むものは決してドロップしない。足して、デプロイして、バックフィルして、コードを切り替えて、後のリリースでドロップする。expand and contract です。ここで<em>より</em>重要になる理由は、使い捨て環境がデプロイを頻繁にし、その窓の中にいる時間が増えるからです。タグごとのステージングがそれをリハーサルする場所です。</p>
<p><strong>3. CI は空のデータベースにゼロからのマイグレーションもする。</strong> テンプレートの複製は「自分のマイグレーションが現実的なデータに適用できるか」をテストします。空からの実行は「チェーン全体がまだ無から動くか」をテストし、前のデータマイグレーションが何かを埋めたと仮定していたマイグレーションを捕まえます。三十秒で、さもなければ次の新しいラップトップを待つ類のバグを捕まえます。</p>
<p><strong>4. スキーマ変更には二つ目の目が付く。</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="#%e3%83%90%e3%83%bc%e3%82%b8%e3%83%a7%e3%83%8b%e3%83%b3%e3%82%b0url-%e3%81%8c%e4%bd%95%e3%82%92%e5%8b%95%e3%81%8b%e3%81%97%e3%81%a6%e3%81%84%e3%82%8b%e3%81%8b%e8%aa%9e%e3%82%8b%e3%82%88%e3%81%86%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>共有環境がひとつのとき、「ステージングに何が載ってる」はチャットで飛び交う質問でした。複数になれば環境の属性でなければならず、さもなければすべてのバグ報告が考古学から始まります。</p>
<ul>
<li><strong>すべてのイメージがコミット SHA を持ち、アプリがそれを公開する。</strong> <code>fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...</code> がイメージに環境変数として刻み、<code>/api/version/</code> が環境名とリージョンと一緒に返します(上の出力参照)。「feature-profile-tagl-fa8832 で壊れてる」に対して誰もが最初にするのは、思っているコミットを動かしているかの確認です。最初のデモ環境はツールがコミットされる一コミット前の作業ツリーからデプロイされていて、エンドポイントがそう言いました。返ってきたのは期待した SHA ではなく、ひとつ前のコミットでした。</li>
<li><strong><code>ENVIRONMENT=$ENV</code> がすべてのログ行とエラートラッカーの environment タグにある。</strong> ブランチのエラーが本番のエラー追跡を汚さず、ワンクリックで絞り込めます。</li>
<li><strong>ブランチは SHA を、リリースはタグを持つ。</strong> <code>feature-payments-retr-3f9a1c</code> にバージョン番号はありません。コミットがあります。ステージングと本番には <code>v1.42.0</code> があります。</li>
<li><strong>モバイルアプリは URL ではなく最小 API バージョンを持つ。</strong> dev メニューの URL フィールドがテスターが実機をブランチに向ける方法で、API スキーマバージョンを載せたバージョン確認用のエンドポイントが、アプリが理解できる相手と話しているかを知る方法です。</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="#%e3%81%93%e3%82%8c%e3%81%a7%e8%a7%a3%e6%b1%ba%e3%81%97%e3%81%aa%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>一緒でなければ意味をなさない二つの機能は解決しません。ブランチ環境はブランチひとつを見せます。答えは両方をフラグの裏でマージして <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="#%e3%81%82%e3%81%aa%e3%81%9f%e3%81%ae%e7%92%b0%e5%a2%83%e3%81%a7%e7%a2%ba%e3%81%8b%e3%82%81%e3%81%a6%e3%81%bf%e3%82%8b%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>共有の dev やステージングがあり、それを予約するチャットのスレッドがあるなら:</p>
<ol>
<li>完成したブランチが、作者以外の誰かに動いているところを見てもらえるまでどれだけ待ちますか? 一時間を超えるなら、そのロックの代金をデリバリー時間で払っていて、小さなチームならその大きな割合が待っています。</li>
<li>共有データベースはどれくらいの頻度でリセットされ、そのとき誰が作業を失いますか?</li>
<li>新しい開発者が初日に、誰にも何も頼まずに自分のブランチが動く公開 URL を手に入れられますか? 頼まなければならないものが何であれ、それが最初に自動化するものです。</li>
<li>新しい環境は現実的なデータから始まりますか? 空から始まるなら、最初に捕まえ損ねるバグは、空のテーブルでは平気で実物のテーブルでは四十分かかるマイグレーションです。</li>
<li>非本番のホスティングに、全員が忘れて一か月経ってもまだ存在するものがありますか? あるなら、何より先にスイーパーを書いてください。漏れは初日から始まります。</li>
</ol>
<p>チャットのあの質問が消えるのは、人が尋ねるのをやめたからではなく、答えがいつも「使ってますよ、自分のを」だからです。</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>自作の Expo アップデートサーバーを Codemagic Patch に置き換える</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/</guid>
      <pubDate>Sun, 13 Sep 2026 00:00:00 +0900</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Codemagic Patch</category>
      <category>CodePush</category>
      <category>セルフホスト</category>
      <category>モバイル開発</category>
      <category>CI/CD</category>
      <description>ネイティブプロジェクトごと管理している本物の Expo アプリを、expo-updates から Codemagic Patch へ移行しました。ローカルのスタック、移行作業、自分で作って OTA で直したバグ、そしてリリースが実際にクラッシュしたときのロールバックの様子まで。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>開示。</strong> この移行と記事の執筆には Codemagic から報酬を受けています。Codemagic のレビューは事実関係の確認だけで、論調には及ばず、先方の要望で書き換えた箇所はありません。問題にぶつかって報告した箇所はそう書いていますし、あなたが読む時点で直っているものは、直ったものとして書いてあります。</p>
</blockquote><blockquote><p><strong>2026年9月23日追記。</strong> 私が報告した問題には、Codemagic の開発者が順に対応してくれました。iOS のバージョン検出のバグ、二重に数えられたインストール、無音の初回セットアップ、うるさいデバッグコマンドを含めて大半はすでに修正済みで、ドキュメントにもコミット済みのネイティブプロジェクトの扱いとクラッシュ時のロールバックまでの予算が載りました。本文ではそれぞれ該当する箇所でそう書き添え、まだ残っているいくつかは元のままにしています。</p>
</blockquote><blockquote><p><strong>2026年10月16日追記。</strong> Patch に移ってから初めてのネイティブのアップグレード（Expo SDK 58）で、Patch の設定がまったく入っていない Android のリリースビルドができあがり、しかもどこもエラーを出しませんでした。何が起きたのかと、ファイル1つで済んだ修正は <a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/#%e5%88%9d%e3%82%81%e3%81%a6%e3%81%ae%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e3%82%a2%e3%83%83%e3%83%97%e3%82%b0%e3%83%ac%e3%83%bc%e3%83%89" >初めてのネイティブアップグレード</a> に書きました。</p>
</blockquote><p>2月に、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/" >Expo のアップデートサーバーを自作した話</a> を書きました。あれは今も動いています。私が運営しているコミュニティアプリの maru は、それ以来すべての OTA 修正をあのサーバー経由で、両プラットフォームに、月1ドルほどで配信してきました。</p>
<p>それは同時に、部品を全部自分で抱えているということでもあります。データベースのテーブル、API エンドポイント、管理コマンド、パブリッシュスクリプト、共有シークレット、そして壊れたアップデートを一つずつ経験して覚えたハマりどころの数々。今回の計画を立てようと腰を据えたとき、スクリプトが書いた日からずっと、本番へパブリッシュするたびに &ldquo;Sent 0 push notifications&rdquo; と出力していたことに気づきました。このサーバーが一度も返したことのないフィールドを読んでいたのです。誰も気づいていませんでした。誰よりも私が。自前で運用するというのは、だいたいこういうことになります。</p>
<p>そこで Codemagic から、CodePush を<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">オープンソース</a>でセルフホストできる形にした <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a> を試してみないかと声をかけられたとき、条件を一つ付けて引き受けました。デモではなく本物のアプリでやること。本物の署名、本物のリリース手順、手で編集した箇所のあるコミット済みのネイティブプロジェクト、その全部です。EAS Update から移行する人の多くが動かしているのは、まっさらなテンプレートではなく、そういうアプリでしょう。</p>
<p>その顛末を、粗い部分もそのままにして書きます。</p>

<h2 class="relative group">Patch とは何か
    <div id="patch-とは何か" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#patch-%e3%81%a8%e3%81%af%e4%bd%95%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Patch は、自分で動かすサーバーと、アプリに組み込む SDK でできています。サーバー側は API、バックグラウンドワーカー、Postgres、S3 互換のオブジェクトストレージ、Web ダッシュボードで、Docker Compose のスタックとして配布されています。クライアント側は <code>@codemagic/react-native-patch</code> という iOS と Android 向けの TurboModule で、Expo の config plugin が付いています。それに加えて、JavaScript をバンドルしてパブリッシュする CLI の <code>cmpatch</code> があります。</p>
<p>ソースを読んで、設計上の判断が2つ目に留まりました。</p>
<p>1つ目は、アプリが API に「アップデートはあるか」と一度も尋ねないことです。リリースがパブリッシュされるとワーカーが素の JSON マニフェストをオブジェクトストレージに書き、アプリはそれを取りに行くだけ。アップデートの確認はストレージか CDN に対する静的な GET が2回で、API が受け取るのはメトリクスだけです。私の旧エンドポイントは、アプリを起動するたびにリクエスト経路の上に乗っていました。こちらではそれが起こり得ません。</p>
<p>2つ目は、すべてのリリースが対象とするバイナリのバージョンを厳密に指定することと、CLI がネイティブプロジェクトのフィンガープリントを計算して、ネイティブコードが一致しないバイナリへ JavaScript をパブリッシュするのを拒むことです。私のサーバーにあったのは バージョン文字列1つと、たくさんの「大丈夫だろう」でした。</p>

<h2 class="relative group">まずは手元のノートパソコンで
    <div id="まずは手元のノートパソコンで" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%be%e3%81%9a%e3%81%af%e6%89%8b%e5%85%83%e3%81%ae%e3%83%8e%e3%83%bc%e3%83%88%e3%83%91%e3%82%bd%e3%82%b3%e3%83%b3%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ドキュメントはローカルの評価用スタックから始めるように案内していて、実際そこから始めるのが正解です。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">npm install -g @codemagic/patch-cli
</span></span><span class="line"><span class="cl">cmpatch selfhost local-eval up</span></span></code></pre></div></div>
<p>CLI のインストールは2秒でした。スタックのほうは初回に3分14秒かかり、大半はサーバーとダッシュボードのイメージをローカルでビルドしている時間です。非対話モードだとその間何も出力しないので、ハングしているようにしか見えません（その後修正され、今はステップごとに進捗を1行出します）。そのあと、healthy なコンテナが4つ、すべて localhost に束縛された状態で現れました。8080 にダッシュボード、3000 に API、Postgres、そして 9100 に MinIO です。私自身のスタックがすでに使っているポートは、きちんと避けてくれました。</p>
<p>このモードではサインインが無効になっていて、外部に公開するなという警告がすべてのページに出ます。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAACQBACdASogABQAPv1ko0eloqGYBgBbD8TgCzgCWNWXs6T53beCcoRI0PP/N6AA/K7n/8I/+iwVHjl71MVWujkm+9TgA0wAr+PZ+x6+cQcgCHdovZ75vexrOBrFPrvj8AA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_6ee9ddaa0c7c695e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_e5915d9b78052f15.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="ローカル評価用のサインインページ。メールアドレスが入力済みで、認証が無効だという警告が出ている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/local-eval-sign-in_hu_8ae8b3cbcb1d7ae7.webp"></span></div>
  <figcaption>ローカル評価モードでは GitHub サインインの代わりに、入力済みのメールアドレスが使われます。</figcaption>
</figure>

<p>スタックには Example Data というアプリがあらかじめ入っています。何かをパブリッシュする前に一度眺めておく価値があります。ダッシュボードが何のためにあるのかが分かるからです。次の2枚のスクリーンショットの数字はそのシードデータで、実際のユーザーではありません。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAAAQBACdASogABUAPw1uqkqmIyGYDAQAYxDE9IABUVMfRogwtMiXImNo4AD+1hmAfbeuR0QbIc0lZuw4PBjyhGJq5t/3NdR81x+BG5H496NeiObQODoB8gkWF2AAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_dfad23ca3450bc38.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_453fd7641ffbc69b.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1037"
      alt="シード済みの Example Data アプリのリリース履歴。10% のカナリアリリースと、リリースごとの成功数・失敗数が並んでいる"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-release-history_hu_30abe30dd08f2234.webp"></span></div>
  <figcaption>シードされたデモデータ: 10% のカナリア、対象バージョン、リリースごとの成功数と失敗数を含むリリース履歴。</figcaption>
</figure>

<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoQAAABXRUJQVlA4IHgAAABwBACdASogABcAPw1qrEsmIqIYDAQAYxDE9IBnsglwh49zWML+lyXzzjPEgAD+4AZDuD4GlZfO+rTnJJ+yBUjSHGV1a2gPc+tdTO7IIMf86boVjtRW8VJ7KXLfxZsJzumvn68JtLzOqetoXfqt/I/Omlo4DyEAAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_2e0ff04f94cd5a88.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_91146bb0b6c75d52.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1600" height="1155"
      alt="シード済みアプリのデプロイメントメトリクス: バージョン分布、採用の推移、アップデートの結果"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/demo-deployment-metrics_hu_1f6ae6ce53428957.webp"></span></div>
  <figcaption>シードされたデモデータ: 端末が実際に動かしているリリースと、インストールの結果。</figcaption>
</figure>

<p>比較のために言うと、私のダッシュボードは、管理画面のアップデート一覧に「有効」のチェックボックスが付いただけのものでした。実際に何台の端末がアップデートを取り込んだのか、まったく分かりませんでした。</p>
<p>CLI のログインは PKCE と localhost へのコールバックを使ったまっとうなブラウザフローで、承認ページにはどのアカウントが要求しているかと、コードが1分ほどで失効することが書いてあります。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRn4AAABXRUJQVlA4IHIAAADwBACdASogABQAPv1ioUeloqGYBgBbD8TxAFnAEsa4FDLDrochGk8HT/WQ+e1SUMAA/K7n5mOlQFSkxh0DtgfJ9tHRhVgvwu2jEApXgWL2ekdKJHkUb1NzXgNcjv6IA1JC+dF2JqE73QqxENNNeYvZMAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_ee30fa96a8ea860e.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c7af1bedbd1fa8b3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="CLI のサインインを承認するか尋ねるダッシュボード"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/cli-login-approve_hu_c267548bca14bf21.webp"></span></div>
  <figcaption>cmpatch login はダッシュボードにターミナルの承認を求めます。</figcaption>
</figure>


<h2 class="relative group">本物のアプリに向ける
    <div id="本物のアプリに向ける" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%ac%e7%89%a9%e3%81%ae%e3%82%a2%e3%83%97%e3%83%aa%e3%81%ab%e5%90%91%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Patch はプラットフォームごとに1アプリを求め、それぞれに Staging と Production のデプロイメントが作られます。<code>maru-ios</code> はダッシュボードで、<code>maru-android</code> は <code>cmpatch app create</code> で作りました。主に両方を見ておきたかったからです。アプリを作ったあとに出るダイアログは、移行にあたってこの製品でいちばん役に立つ画面です。両方のデプロイメントキー、SDK が必要とする2つの URL、そしてデプロイメントキーは秘密情報ではないという注記が載っています。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRoIAAABXRUJQVlA4IHYAAAAQBQCdASogABQAPuFWmkekIqEb9ABDDgT0gF8kHTL70/EPugF6lMUZ0A+0pWU0WRlAAPpGgpz7VeOvAMdDs/q/I00JZTDCozGF/p+yUbXDUjfdJacA2ZbVan+1M3EfSih3MXlM1+RWV02wNY/nmH4Pte3keQAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_31f66d5c03d282e7.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_ec9ad24dac45b7f9.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="アプリ作成後のダイアログ。Staging と Production のデプロイメントキーと SDK の URL が表示されている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-created-keys_hu_bd9fb369cb1053b8.webp"></span></div>
  <figcaption>SDK に必要なものが、1画面に。</figcaption>
</figure>

<p>小さなことが2つ。<code>cmpatch init</code> はすでに存在するアプリにプロジェクトを紐付けるコマンドだったので、移行ガイドがどう匂わせていようと、最初のコマンドにはなりませんでした。これはその後修正され、今は対話モードならプラットフォームごとにアプリを1つずつ自分で作ってくれます。それから、ダイアログは API の URL を <code>http://localhost:8080</code> と示すのに、ドキュメントと CLI は 3000 と言います。どちらのポートも両方を配信しているのでどちらでも動きますが、首をかしげることにはなるでしょう。</p>
<p>次に、手を付けていないアプリに対して <code>cmpatch doctor</code> を走らせたところ、3秒で本物の問題を見つけてきました。iOS のバイナリバージョンを 2.0.0 と報告したのです。アプリは 2.5.1 です。</p>
<p>CLI は <code>ios/</code> 以下のすべての <code>Info.plist</code> を探し、パスに &ldquo;test&rdquo; を含むものがなければ、アルファベット順で最初のものを取ります。maru には WidgetKit の extension があり、そのフォルダがアプリ本体のフォルダより前に並ぶので、CLI は extension の <code>Info.plist</code> を拾いました。その plist が 2.0.0 のまま古くなっていたのは私のバグですが、その影響はたちが悪い。自動検出に任せると、iOS のリリースがすべて、どの端末も動かしていないバージョンを対象にしてしまい、しかも何もエラーになりません。アップデートが単に永遠に届かないだけ。まさにセルフホストの記事で警告した失敗です。Android は <code>versionName</code> を正しく読みました。回避策は <code>--target-binary-version</code> か <code>--plist-file</code> を渡すことで、私のパブリッシュスクリプトは今、<code>app.config.js</code> と両方のネイティブプロジェクトが一致していることを確認したうえで、バージョンを明示的に渡しています。あとになって同じバグのせいで、<code>doctor</code> 自身のマニフェスト確認が、正常に動いているデプロイメントに対して失敗しました。バージョン 2.0.0 のマニフェストを探しに行ったからです。古い plist を直したら、<code>doctor</code> は24項目すべてを通過しました。</p>
<p>これは Codemagic がその後修正しました。CLI は今、extension ではなくアプリターゲットの <code>Info.plist</code> からバージョンを読みますし、アプリの候補になるターゲットが複数あれば、どれがそうなのかを <code>cmpatch</code> が尋ねてきます。</p>

<h2 class="relative group">JavaScript 側
    <div id="javascript-側" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#javascript-%e5%81%b4" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>expo-updates</code> を外して SDK を足すのは2行の変更です。アプリがアップデートに対してやっていたことを置き換えるほうは、もう少し考える必要がありました。</p>
<p>maru は起動時と、フォアグラウンドに戻るたびにアップデートを確認します。ただし最短でも15分おきです。見つかればダウンロードして再起動を提案し、断られれば次のコールドスタートで適用します。設定画面には、動作中のバンドルを表示する手動チェックもあり、エラーレポーターはすべてのレポートに、それが来たアップデートのタグを付けます。</p>
<p>SDK のソースから最初に学んだのは、インポート時に <code>TurboModuleRegistry.getEnforcing</code> でネイティブモジュールを探しに行き、モジュールがなければ例外を投げるということです。maru は Web ビルドも出荷しているので、トップレベルでインポートすると Web アプリが落ちます。そこで、SDK を遅延ロードする小さなモジュールを1つ用意し、すべてそこを通すようにしました。何も返さない <code>.web.ts</code> の双子を置いてあるので、SDK は Web バンドルから完全に外れます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="c1">// ota.ts (ota.web.ts exports a patchSdk() that always returns null)
</span></span></span><span class="line"><span class="cl"><span class="kr">import</span> <span class="p">{</span> <span class="nx">Platform</span> <span class="p">}</span> <span class="kr">from</span> <span class="s2">&#34;react-native&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">type</span> <span class="nx">PatchSdk</span> <span class="o">=</span> <span class="k">typeof</span> <span class="kr">import</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="kd">let</span> <span class="nx">sdk</span>: <span class="kt">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="o">|</span> <span class="kc">undefined</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="kd">function</span> <span class="nx">patchSdk</span><span class="p">()</span><span class="o">:</span> <span class="nx">PatchSdk</span> <span class="o">|</span> <span class="kc">null</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">!==</span> <span class="kc">undefined</span><span class="p">)</span> <span class="k">return</span> <span class="nx">sdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> <span class="p">(</span><span class="nx">Platform</span><span class="p">.</span><span class="nx">OS</span> <span class="o">===</span> <span class="s2">&#34;web&#34;</span><span class="p">)</span> <span class="k">return</span> <span class="p">(</span><span class="nx">sdk</span> <span class="o">=</span> <span class="kc">null</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="k">try</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sdk</span> <span class="o">=</span> <span class="kr">require</span><span class="p">(</span><span class="s2">&#34;@codemagic/react-native-patch&#34;</span><span class="p">)</span> <span class="kr">as</span> <span class="nx">PatchSdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nx">sdk</span> <span class="o">=</span> <span class="kc">null</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">return</span> <span class="nx">sdk</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>自動チェック自体はほぼ <code>sync()</code> です。決して例外を投げず、ステータスに解決します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ts" data-lang="ts"><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">status</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">patch</span><span class="p">.</span><span class="nx">sync</span><span class="p">({</span>
</span></span><span class="line"><span class="cl">  <span class="nx">installMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESTART&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nx">mandatoryInstallMode</span><span class="o">:</span> <span class="s2">&#34;ON_NEXT_RESUME&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="p">(</span><span class="nx">status</span> <span class="o">===</span> <span class="s2">&#34;update-installed&#34;</span><span class="p">)</span> <span class="nx">offerRestart</span><span class="p">();</span></span></span></code></pre></div></div>
<p>SDK の必須リリースのデフォルトは <code>IMMEDIATE</code> で、ユーザーがどこにいようと JavaScript をリロードします。私はアプリが次にバックグラウンドから戻ってきたときに適用するよう変えました。書きかけの投稿が消えるのは、たいていの修正の効き目より重いバグだからです。それから当時は、<code>InstallMode</code> がドキュメントの例から想像する enum オブジェクトではなく、文字列の union 型でした。今は定数オブジェクトとしてエクスポートされているので、<code>InstallMode.ON_NEXT_RESTART</code> はドキュメントどおりに動きます。</p>

<h2 class="relative group">ネイティブ側、ios/ と android/ をコミットしている場合
    <div id="ネイティブ側ios-と-android-をコミットしている場合" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e5%81%b4ios-%e3%81%a8-android-%e3%82%92%e3%82%b3%e3%83%9f%e3%83%83%e3%83%88%e3%81%97%e3%81%a6%e3%81%84%e3%82%8b%e5%a0%b4%e5%90%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>移行ガイドは、config plugin を追加したあとで <code>npx expo prebuild --clean</code> を実行するように言っています。別のチェックアウトで実行してみたところ、同梱の画像、Android のネットワークセキュリティ設定、iOS のプライバシーマニフェスト、<code>Podfile.lock</code> が消え、<code>build.gradle</code> のテストランナー向けの修正と <code>Info.plist</code> の数百行が元に戻りました。<code>--clean</code> なしでも、Expo はどのみち両方のディレクトリを空にしました。</p>
<p>これは Patch のせいではありません。maru は managed な Expo アプリとして始まり、ネイティブのカスタマイズが増えていったので、<code>ios/</code> と <code>android/</code> はコミットされていて、EAS はそれをそのままビルドします。ただ、そういうアプリはいくらでもあるのに、ガイドは警告してくれませんでした。今はしてくれます。Expo の移行ガイドには道が2つ用意されていて、生成されたネイティブプロジェクトなら prebuild、コミット済みのプロジェクトなら手で当てる変更をそのまま示してくれます。</p>
<p>plugin が実際に変える内容は、蓋を開けてみれば手作業で済む程度でした。iOS では <code>AppDelegate.swift</code> に import が1つと1行。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-swift" data-lang="swift"><span class="line"><span class="cl"><span class="k">return</span> <span class="n">CodemagicPatch</span><span class="p">.</span><span class="n">bundleURL</span><span class="p">()</span> <span class="p">??</span> <span class="n">Bundle</span><span class="p">.</span><span class="n">main</span><span class="p">.</span><span class="n">url</span><span class="p">(</span><span class="n">forResource</span><span class="p">:</span> <span class="s">&#34;main&#34;</span><span class="p">,</span> <span class="n">withExtension</span><span class="p">:</span> <span class="s">&#34;jsbundle&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>Android では <code>MainApplication.kt</code> に import が1つと引数が1つ（これは React Native 0.82 以降の形で、それより古いバージョンでは代わりに <code>getJSBundleFile()</code> をオーバーライドします）。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-kotlin" data-lang="kotlin"><span class="line"><span class="cl"><span class="nc">ExpoReactHostFactory</span><span class="p">.</span><span class="n">getDefaultReactHost</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">  <span class="n">jsBundleFilePath</span> <span class="p">=</span> <span class="nc">CodemagicPatch</span><span class="p">.</span><span class="n">getJSBundleFile</span><span class="p">(</span><span class="n">applicationContext</span><span class="p">),</span>
</span></span><span class="line"><span class="cl">  <span class="c1">// ...</span></span></span></code></pre></div></div>
<p>加えてプラットフォームごとに設定値が3つと、古い <code>expo-updates</code> の設定の削除。両プロジェクト合わせて13行の追加と20行の削除です。</p>
<p>plugin はこれらの設定値を prebuild 時にリテラルとして書き込みます。コミット済みのネイティブプロジェクトでは、ビルドごとにデプロイメントが1つハードコードされることになります。私は同じプロジェクトから、プレビュービルドは Staging に、ストア向けビルドは Production に向けたかったので、値はビルド環境から取るようにしました。<code>Info.plist</code> では Xcode がビルド設定を展開してくれます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="nt">&lt;key&gt;</span>CodemagicPatchDeploymentKey<span class="nt">&lt;/key&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;string&gt;</span>$(PATCH_IOS_DEPLOYMENT_KEY)<span class="nt">&lt;/string&gt;</span></span></span></code></pre></div></div>
<p><code>android/app/build.gradle</code> ではこうです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-groovy" data-lang="groovy"><span class="line"><span class="cl"><span class="kt">def</span> <span class="n">patchEnv</span> <span class="o">=</span> <span class="o">{</span> <span class="n">name</span> <span class="o">-&gt;</span> <span class="n">System</span><span class="o">.</span><span class="na">getenv</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="o">(</span><span class="n">findProperty</span><span class="o">(</span><span class="n">name</span><span class="o">)</span> <span class="o">?:</span> <span class="s2">&#34;&#34;</span><span class="o">)</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="n">resValue</span> <span class="s2">&#34;string&#34;</span><span class="o">,</span> <span class="s2">&#34;CodemagicPatchDeploymentKey&#34;</span><span class="o">,</span> <span class="n">patchEnv</span><span class="o">(</span><span class="s2">&#34;PATCH_ANDROID_DEPLOYMENT_KEY&#34;</span><span class="o">)</span></span></span></code></pre></div></div>
<p>キーは <code>eas.json</code> の各ビルドプロファイルに、<code>EXPO_PUBLIC_</code> の値と並べて置きます。値が空だと SDK は黙って何もしません。ローカルビルドならそれで構いませんが、ストア向けビルドでは大惨事なので、<code>app.config.js</code> は preview か production の EAS ビルドでキーが欠けていたり、localhost を向いたままだったりすると例外を投げるようにしました。</p>

<h2 class="relative group">最初のアップデート
    <div id="最初のアップデート" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%80%e5%88%9d%e3%81%ae%e3%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>最初のリリースでは、サインインしなくても見えるオンボーディングの見出しを変えて、パブリッシュしました。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./scripts/publish-update.sh ios --notes <span class="s2">&#34;Onboarding headline over the air&#34;</span></span></span></code></pre></div></div>
<p>30秒かかり、大半は Metro と Hermes です。サーバーのワーカーは、アップロード完了の0.6秒後にマニフェストをパブリッシュしました。アップデートは 7.3 MB の圧縮 tarball で、以前のエクスポートが生成していた 11 MB の Hermes バンドルとアセットに比べて小さくなっています。</p>
<p>ここで間違えてはいけないことが1つ。JavaScript バンドルはビルド時に <code>EXPO_PUBLIC_</code> の値をインライン展開するので、アップデートは着地先のバイナリと同じ値でビルドしなければなりません。さもないと、間違ったバックエンドに話しかける JavaScript を出荷することになります。私のスクリプトは、バイナリをビルドしたのと同じ <code>eas.json</code> のプロファイルから値を読み込みます。Patch の CI ドキュメントにも今はこの点が明記されていて、<code>release-react</code> 自体は EAS のプロファイルを読まないことも書かれています。</p>
<p>それからアプリを再起動しました。端末のログでは、アップデートの確認全体がオブジェクトストレージへの2リクエスト、<code>meta.json</code> と <code>2.5.1/manifest.json</code> として現れました。SDK は起動の3秒後に <code>Downloaded</code> イベントを書き、アップデートをステージングし、アプリが再起動を提案しました。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update-poster.jpg" style="background:url('data:image/webp;base64,UklGRoYBAABXRUJQVlA4IHoBAAAwCgCdASogAEYAPw1oq0omIqIWagaoYxDE9IBo3u6bOACEWXWx1HDG/AwrSOYwsJmgoDuewmmxiJfqdlf28aG4OgV3Vy0nk3lgu8Wi25BY3FxW+lY1wMNQ5gAA/uqWQwe/E/6WeZpIf2UpLel/fKpwqlcmjLMeA7F4ReOblq5eH4qIOx1w7ZF3v0mxWMH8e4hZoalFig747vzom14jgdSClOC5vthxNjGbdjdmv0JuF4Bd3uw2FZbc3Dlojmgl1fUvZqyymqEqzOWLtImgLAZjJhWZgigFmKgGd+GQ0SIKjnJHHdXOZrb/cZMTM1wrOB56DkWBsUxEsixjmVu+Vb3yqJNYglAA21u7aH9XnZ18oNyTlPWugRrsPfZ+/F1gGsmkViDzBGO0f1wXrNBJimlGcHkGkCt7LH9QNjPcy1A9c2dBEltQFPx6XWi1nm9wND9YTEPiTwnjvIduwaNn9YfPJS9b+I1LMURaw77It/AhHjT2gmi4ar24AAA=') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="同梱バンドルで再起動したアプリがアップデートを提案し、リリース v1 で再起動するまで">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-update.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>再起動、sync() が update-installed を返して出るアプリ自身のプロンプト、そして OTA で届いたリリース v1。</figcaption>
</figure>


<h2 class="relative group">バグは私のものだった
    <div id="バグは私のものだった" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%90%e3%82%b0%e3%81%af%e7%a7%81%e3%81%ae%e3%82%82%e3%81%ae%e3%81%a0%e3%81%a3%e3%81%9f" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>そこでダッシュボードを見ました。シミュレーター1台がリリース v1 をインストールしたのに、成功2件、アクティブユーザー2人と表示されています。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmgAAABXRUJQVlA4IFwAAADwAwCdASogABQAPw1wqUqmIyGYDAQAYxDE9IABUVMd2rf8uKbf68owAP7WGYB/h1ia3xpTYkbuTZlMczaiJLdSTP+17jDH1qfCOJ+8A+IA+QScXzRlnhc+zceAAA==') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_4dd1f5d2ff813ace.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_13e5d2d984250bf3.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="Staging デプロイメントが1台の端末に対してアクティブユーザー2人、成功2件を表示している"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-double-counted_hu_b455b1f0b69680b5.webp"></span></div>
  <figcaption>シミュレーター1台が、2回数えられています。</figcaption>
</figure>

<p>SDK は状態と未送信のメトリクスイベントをアプリのコンテナ内にファイルとして持っているので、追跡は簡単でした。タイムスタンプが同一で id が異なる <code>Success</code> イベントが2つありました。私のフックはマウント時に <code>notifyAppReady()</code> で動作中のバンドルを確定させ、その直後に <code>sync()</code> を呼んでいて、<code>sync()</code> は自分でも <code>notifyAppReady()</code> を呼びます。2つの呼び出しが重なり、どちらもリリースを pending と見なし、どちらもイベントを記録した。サーバーはイベント id で重複排除しますが、id が違うので両方数えられ、その重複は永久に残ります。</p>
<p>私の側の修正は、起動ごとに確認処理を1つだけ共有し、ほかはすべてそれを待つようにすることでした。SDK 自身が重なった呼び出しを防ぐこともできるはずで、そう提案しました。今はそうなっています。<code>sync()</code> の内部のものも含め、重なった <code>notifyAppReady()</code> の呼び出しは1つの確認処理を共有するので、インストールは1回だけ記録されます。SDK のリファレンスにも、<code>sync()</code> は自分で準備完了を確定させるので、<code>sync()</code> を使うアプリが別に <code>notifyAppReady()</code> を呼ぶ必要はない、と書かれるようになりました。</p>
<p>気持ちよかったのは、この修正の出荷です。JavaScript だけの変更なのでリリース v2 として出せて、v1 の端末にはバイナリ差分を送れるので、v2 は 7.3 MB のバンドルではなく 543 KB のパッチ、サイズにして約7% でダウンロードされました。再起動後: ダウンロード1件、インストール1件、成功1件、アクティブ端末1台。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAADwAwCdASogABQAPw1qqksmIqGYDAQAYxDE9IABUVMd2rfRI1O5mmXAAP7WGYB/h1ia3xpTYkbuTZlMczaiJLkoVKAckjyruR4hTL7NmaqeP1qAfIJOLnmwKkXiX44AAAA=') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_361247d6f35aa5fc.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_d07d239ec0e34973.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="リリース履歴。v1 は水増しされた数字、v2 は正しい数字"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-v1-v2_hu_dab633c48fa0be46.webp"></span></div>
  <figcaption>v1 の重複は残り、v2 は正しく数えられます。</figcaption>
</figure>


<h2 class="relative group">わざと壊す
    <div id="わざと壊す" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%8f%e3%81%96%e3%81%a8%e5%a3%8a%e3%81%99" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ロールバックは私がいちばん気にしていた機能です。以前の構成には端末側のロールバックがまったくありませんでした。悪いアップデートを出せば、私が気づいてチェックボックスを外すまで、起動のたびにクラッシュし続けたはずです。</p>
<p>そこで、最初の描画の前に例外を投げる v3 をリリースしました。つまり <code>notifyAppReady()</code> を決して呼べないリリースです。アプリはそれをダウンロードし、再起動を提案し、ホーム画面へ落ちました。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed-poster.jpg" style="background:url('data:image/webp;base64,UklGRjACAABXRUJQVlA4ICQCAADQDgCdASogAEYAPw1+rU4mJCIhKbqpIMYhieIAuzPq4M7sW3lP/kPxexnET5ASwFv8Htr8DNovWXwo2ZQynTi8ui+9q1qzPLfv97/wPQgMDj/+e/pvE9miOHoCzZC5Gv/U0oN8n33d1NMZxA4+qT/uNT35EBYJZa/w3Ts1AUToAP5vfw4j/pjBP6ZaA8LejegsfSPzEPXc0mvX+J1dMRFcTwhgh8BttzpcI4CRjUDkbGoa6UAnxK5kCSatezNL4gQf4nZeHytilXmRtp2c1xohb5/y8HBhWsukQWCAk8rKnNMQYUQJEVbasq2JgftASZSBFtiUkjtFF9K+EYtO0DOtXBWDPeHJf1TqqEHHwl4CQ1zsv9Cx+l8KPR2tUhRR8Ctso49YsumZzdxVCAKQdb9pNx77EfSYM6ALaYnzRt4qTUeUBf3ENt66RmZKq5nGflJ9lEW0oDnWjx6zoAqeJS44t1nUDDIZm1gayu9+nhUWZE+InoYr7YlMmmXX2kklVMVsT0s5xOQqPPlW4UoSUv0NgFVDavqEtwMzPqNuUIjjfwXToJhzDbgUidZqS0gSOrBna2Xj2XcOmf2KfPjtTFgKpl74NNsPw+SkvLP+XIfGRP7f4DZ3SF5r4kITD9QK1Mb4Z3frLXQeA432gyyslKobZUjhHzgpw6Lsv1z4B+zVfYG/nF6OiGZ+SPJQMFRe2LmOB4/oyDsE7meakIUBZvS1zSAAAA==') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="壊れたリリースでアプリがクラッシュしたあとのシミュレーターのホーム画面">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-crashed.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>v3 が起動時にクラッシュ。</figcaption>
</figure>

<p>次の起動では再び v3 が立ち上がり、再びクラッシュしました。これには驚きました。ドキュメントには、<code>notifyAppReady()</code> の前にクラッシュしたバンドルはロールバックされると書いてあるからです。ソースが説明してくれました。SDK は未確認の起動を3回まで許してから、クラッシュだと判断します。未確認の起動1回は、JavaScript が動く前に iOS が健全なプロセスを殺しただけかもしれないからです。4回目の起動で v2 に戻ってきました。</p>
<figure><div class="phone-frame" style="aspect-ratio: 900 / 1840; --screen-radius: 15.7% / 7.2%">
    <video
      class="phone-frame__screen"
      style="left: 5.3333%; top: 2.5%; width: 89.3333%; height: 95%"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back-poster.jpg" style="background:url('data:image/webp;base64,UklGRsABAABXRUJQVlA4ILQBAABQCgCdASogAEYAPw1mq0umIqGU2q7YYxDE9IBowO6mweBInrUPWk42PSTT65hMmOGGOXZWA/sd5q9BGdjSK2j84FuUL93nyJVQuphNZ6mwXQhv8NVWhs+Q6BIAAP74ukd3WXEH+9Z7Rrx8W8zZkkK5+g1e2Lc8Yi325om3Qy3bb7uYsUutPzhS99cwA1kptzZhyUOs2MW6lH9eP8+SPePVMJGvfSIxSO6tL43kYr+PfLzJw/oJabDsUcKslfz5t3nFCXsiVKRSWBi80g/4Y8CJZiDrEvRejhn6wF7DlE4cfLc64tC8OpWtwMjLNLzfe0pK5MjRdPbq4v5sKsY7T49CjMzJz033ddGNJ1Vmp+9decCnIDCYl2pLqRBhrM5E+D4Cw6TfkPAN/WpX0LXCs9Wr8ACcRRwajxQnbjxLU1tKX+yJyrshjPphgACyyYJKjidt2xw5zVQZcF6XykRe430a0dWMKdcBvnMqq2qBswoqRDNp4NIHmM7r87FSOG3y5ScC9wPZH2OoU+6Am86Nz+s2Tk/J+sj1gZFVQqfqXFWH4AEGF/cO+yLfluiKfZvqAOcwbgAA') center/cover no-repeat"
      width="720"
      height="1566"
      aria-label="自動ロールバック後、再び v2 で動いているアプリ">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/app-rolled-back.mp4" type="video/mp4">
    </video>
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/iphone-17-frame_hu_492bde446de4e1d1.webp" width="900" height="1840" alt="" aria-hidden="true" decoding="async">
  </div><figcaption>4回目の起動: 誰もサーバーに触らずに v2 へロールバック。</figcaption>
</figure>

<p>そして端末は自分から失敗を <code>crash_rollback</code> として報告し、ダッシュボードは v3 に対してそれを表示しました。理にかなった設計ですが、それが何を意味するかははっきり言っておく価値があります。悪いリリースは、ユーザー一人あたり3回のクラッシュ起動を代償にしてから回復するのであって、当時のドキュメントにはその回数が書かれていませんでした。今は書かれていますし、この予算は設定で変えられます。ネイティブなら <code>CodemagicPatchMaxLaunchAttempts</code>、Expo の plugin なら <code>maxLaunchAttempts</code> です。だからこそ、まず Staging、次に小さな Production ロールアウトなのです。</p>
<p>ほかの全員に対して悪いリリースを止めるのはコマンド1つで、1秒かかりませんでした。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cmpatch release rollback --app maru-ios --deployment Staging</span></span></code></pre></div></div>
<p>履歴を書き換えることはしません。前のリリースを新しいリリース（v4、v2 のロールバックとしてマーク）としてもう一度パブリッシュするので、クラッシュしたリリースは失敗数とともに一覧に残ります。これが正直な記録です。ダッシュボードからも、ダイアログで同じことができます。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRmwAAABXRUJQVlA4IGAAAABwBACdASogABQAPw1qp0emIyGYBABjEMT0gGfQEs2nvxX7EOeFbRn3H5Si4AD+1iXJGzIunVk/ewlUVdIUbFiwAGX5QAkFFcZDl2at4PkETvjCXCOVoXFdscz2ut9AAAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="ダッシュボードから Staging をロールバックする様子。前のリリースが v4 として再パブリッシュされる">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollback.mp4" type="video/mp4">
    </video></div>
  <figcaption>ダッシュボードからのロールバック: v2 が v4 として戻り、v3 は一覧に残ります。</figcaption>
</figure>

<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRmoAAABXRUJQVlA4IF4AAAAwBACdASogABQAPw1op0emIyGYBABjEMTygAFRZbu1b6Gdq9jYHm/TLAAA/tYZgH+HWJrfGlNiRvwNU+RVn1Uhl5QjE2FNRxQPa3YDnfLX9O0wDNn7xOtAkLPDgAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_6a96ca1588cc6310.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5552e433d6a65a3f.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="875"
      alt="ロールバック後のリリース履歴。v4 がロールバックとしてマークされ、v3 に失敗1件が表示されている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/staging-after-rollback_hu_5066c51b1ba693c2.webp"></span></div>
  <figcaption>v4 がロールバック。v3 には失敗が残ります。</figcaption>
</figure>


<h2 class="relative group">Android
    <div id="android" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#android" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Android には驚きが2つあり、どちらも Patch のせいではありません。SDK 向けに足した R8 の keep ルールは <code>app.config.js</code> に置いてあって、これは prebuild を通してしかネイティブプロジェクトに届きません。つまり最初のリリースビルドは、keep ルールなしで SDK が R8 を生き延びるかどうかを、意図せず試すことになりました。生き延びます。R8 はクラスをすべてリネームしましたが、アップデートのダウンロード、インストール、ロールバックは問題なく動きました。もう1つはエミュレーターで、コールドブートしたばかりで Gradle がまだメモリを握っていたせいで極端に遅く、最初のテストは、SDK がすでに始めていたダウンロードを終える45秒前に諦めてしまいました。</p>
<p>リリース v1 は適用され、成功1件、アクティブ端末1台。クラッシュする v2 は、同じ3回の起動予算のあとにロールバックされました。</p>
<figure>
  <div class="phone-frame" style="aspect-ratio: 900 / 1892; --screen-radius: 9.85% / 4.43%"><span class="phone-frame__screen phone-frame__ph" aria-hidden="true" style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%; background:url('data:image/webp;base64,UklGRsgBAABXRUJQVlA4ILwBAABQCwCdASogAEcAPw1wr06mIqIhJngMAMYhiekAzRAKJZ22yvEtm7/GFRCOt1Z6YnyGNWf+i7i0ewagB55W+rOp9DBjbctVlnDXK6lGDu9FH315DSWJUMBYkPGJY2+xw/B5QDAA/vURG5LVrYKJoO2SXy+UxQiUlovs8pxF48eKxgmVxH4hV0qd23H4fTbOXId4reHVkO/ZtsoHNdCHkv0JWiIqSQvxGntYFegdbOEkel+at+Jvs/b4yghmeIXnBH7M31JdyN1H2WvMPx2TwLZfiUh7Vs/c0xs3ET28MPX4PV32jfA2Qo55LcWZBHZVnPmnDwZLLo2Dv++B0SqBKWqbTWPQKOgzF+6YGKB7vcORmV6iyLh5aW30GEJ6lcPq8nat/61jd9i9CZePrzdLJPqGGJyPCbsjPkfhTd6BEdIQraUcOjRfSxAroG5B9nslpJZA63tGk2R93MKzS48w8pEUJKeCNc9lq7Q91gnOgYXePvVHH49kJkJCf5DnRUSgdBqJS0Dh6nG2R7OcW67l22CzkV09rMSoVJlTLhktX5d3Ximn/zbkXSgGUos1pjcQR73ncJ+50rXoCD05SAA=') center/cover no-repeat"></span>
    <img
      class="phone-frame__screen"
      style="left: 4.889%; top: 2.326%; width: 90.222%; height: 95.349%"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp"
      width="600" height="1333"
      alt="OTA で配信された見出しを表示している Android アプリ"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/android-after-v1_hu_a5ee68e09a427bed.webp">
    <img class="phone-frame__bezel nozoom" src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/pixel-8-frame_hu_52e6b4e761a590c9.webp" width="900" height="1892" alt="" aria-hidden="true" decoding="async">
  </div>
  <figcaption>Android: ロールバック後、再び v1 に。</figcaption>
</figure>


<h2 class="relative group">ハッピーパスの先
    <div id="ハッピーパスの先" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%8f%e3%83%83%e3%83%94%e3%83%bc%e3%83%91%e3%82%b9%e3%81%ae%e5%85%88" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>アップデートが届いてロールバックされることが核ではありますが、チームが日々使うのはそれ以外の機能なので、一つずつ試していきました。</p>
<p>段階的ロールアウトは決定論的で、これは気に入りました。<code>md5(deviceId + &quot;-&quot; + releaseLabel)</code> の先頭8桁の16進数を 100 で割った余りがロールアウトのパーセンテージ未満なら対象になるので、同じ端末は同じリリースに対してずっと対象か対象外のままです。私のシミュレーターは次のリリースでバケット 91 に入ると計算できたので、25% でパブリッシュしたところ、シミュレーターには正しく何も来ませんでした。<code>cmpatch release patch</code> で 95% に上げると（1秒かかりません）、次の起動で更新されました。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnIAAABXRUJQVlA4IGYAAAAQBACdASogABQAPw1qqEemIyIYBABjEMT0gGH0Es2nvxX9G/4/1g7tAAD+1iXJGzIunVcHqQBmnStoD1oAHJUjpBT6bKy8GQx+Yo2Ka5cGJlghQl6jWcyOWHRQDYW9x5JJDLafQAA=') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="ダッシュボードでリリースのロールアウトを 25% から 95% に引き上げる様子">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-rollout.mp4" type="video/mp4">
    </video></div>
  <figcaption>部分ロールアウトを、再パブリッシュせずに 25% から 95% へ。</figcaption>
</figure>

<p>すると次のリリースが 409 で失敗しました。&ldquo;deployment has an active rollout below 100 percent&rdquo;。デプロイメントごとに部分ロールアウトは1つしか許されないので、ほかの何かを出す前にカナリアを完了するか、無効化するか、ロールバックする必要があります。妥当ですし、ドキュメントにも書いてありますが、マージのたびにパブリッシュするパイプラインは、カナリアの途中でここにぶつかります。報告してからは、CLI がここで事情を説明してくれるようになりました。アクティブな部分ロールアウトが邪魔をしているなら、待っても解決しないことと、ロールアウトを完了または無効化する方法を教えてくれますし、別のリリースジョブがまだ動いているなら、<code>release inspect --wait</code> で待つように言ってくれます。ドキュメントにも、リリースの変更を順番に行うためのセクションができました。</p>
<p>必須リリースは SDK の <code>mandatoryInstallMode</code> を使います。私は次のレジューム時に適用する設定にしていたので、アプリはアップデートをダウンロードしてプロンプトを表示し、プロンプトを無視してアプリをバックグラウンドに送ってから戻すと、新しいバージョンがただそこにありました。</p>
<p>リリースの無効化は、提供をやめる以上のことをします。最新のリリースを無効化した瞬間、マニフェストはまだ有効な中でいちばん新しいリリースを指すようになり、無効化したリリースにいる端末には、そこへ戻るパッチが提供されました。あるバイナリバージョンのリリースをすべて無効化すると、マニフェストのターゲットは null になり、SDK はアプリを出荷時のバンドルに戻します。私のアップデート用フックは最初このケースでプロンプトを出していなかったので、今は出すようにしてあります。</p>
<p>Staging から Production への昇格は1秒かからず、テストしたのとまったく同じパッケージを、同じハッシュとノートのまま再利用しました。ビルドし直すのではありません。ロールアウトのパーセンテージも受け付けるので、「Production の 10% に昇格」がコマンド1つで済みます。</p>
<p>ビルドとパブリッシュを別々の手順に分けることもできます。<code>cmpatch bundle</code> が <code>.cmpatch</code> のアーティファクトを生成し、ダッシュボードの「Bundle upload」はアップロード前にそれを読み取ります。プラットフォーム、対象バージョン、フィンガープリント、署名の状態、サイズ、バンドラー。CI がアーティファクトをビルドし、いつパブリッシュするかは別の誰かが決める、という体制に向いています。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><video
      class="browser-frame__page"
      autoplay loop muted playsinline preload="metadata"
      poster="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload-poster.jpg"
      style="background:url('data:image/webp;base64,UklGRnwAAABXRUJQVlA4IHAAAAAwBACdASogABQAPw14sE+mI6IhKAgAxiGJ6QAAM7aVPPd3tO2d2B9IEAAA/tYlyRsyLp1nLc5Hh5nniWZ+UIxN4AAKrWhTSPPLnDPJV90MvTw1QAmDXcvY3C3ghY+Uo+Wdxkjji3HNEgzIPqpIwAAA') center/cover no-repeat"
      width="1280"
      height="800"
      aria-label="ダッシュボードで .cmpatch アーティファクトをアップロードする様子。プラットフォーム、バージョン、フィンガープリント、サイズを読み取り、フィンガープリントの不一致を警告する">
      <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/dashboard-upload.mp4" type="video/mp4">
    </video></div>
  <figcaption>バンドルアップロード: ダッシュボードがアーティファクトを読み取り、続いてフィンガープリントのガードが働きます。</figcaption>
</figure>

<p>フィンガープリントのガードに本格的にぶつかったのもここです。古いウィジェットの plist を直したあと、サーバーは次のリリースを 409 で拒否しました。ネイティブプロジェクトのフィンガープリントが、2.5.1 に対して記録されていたものと一致しなくなったからです。Android では <code>app.config.js</code> のコメントを編集しただけで十分でした。Expo はアプリ設定をフィンガープリントに含めるからです。ダッシュボードは同じチェックを、両方のフィンガープリントと「Upload anyway」ボタンの付いた警告として表示します。どちらの場合もインストール済みのバイナリのネイティブ部分は何も変わっていないので、上書きが正解でした。ただ実運用では、正直な答えはほぼ必ずバージョンを上げてストア向けにビルドすることで、私のパブリッシュスクリプトはこれが起きたとき、今はまさにそう伝えます。</p>
<p>コード署名は RS256 です。CLI が RSA の秘密鍵でパッケージのハッシュに署名し、SDK がバイナリにコンパイルされた公開鍵でそれを検証します。鍵は base64 の1行として渡すので、私のビルド設定方式にそのまま収まり、両プラットフォームでテストしました。鍵をアプリに入れた状態では、署名なしのリリースはダウンロード前に拒否されました。アプリで「require code signing」を有効にすると、サーバーは署名なしのパブリッシュを、対処法を示しガイドへリンクするエラーで拒否しました。署名済みのリリースはインストールされ、SDK は署名を検証済みとして記録しました。署名済みのものを無効化したときにサーバーがフォールバックした、古い署名なしのリリースも拒否しています。注意点は、公開鍵が最初に出荷するバイナリに入っていなければならないことです。アップデートで後から足すことはできません。</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">localhost:8080</span>
    </div><span class="browser-frame__shot" style="background:url('data:image/webp;base64,UklGRnAAAABXRUJQVlA4IGQAAABwBACdASogAB0APw14sE+mI6IhKAgAxiGJ6QAAM+3OuAEp3oUrP1GNW2QgAAD+1hmAf4eqLtdnP/9v1g2ZTHRXrrujcZyW6apQhqigB3MdqokDYA7HESmkf+QYJtIlWU+VYAAA') center/cover no-repeat"><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_23c3bd38e0daa226.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_2173e76e5a1b2cec.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="1264"
      alt="リリース詳細。署名が Signed, sha256 と表示されている"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/signed-release-detail_hu_f18ca5bb6112ee2a.webp"></span></div>
  <figcaption>署名済みのリリース。</figcaption>
</figure>

<p>チームと CI もカバーされています。メンバーにはチーム全体で共通の4つのロール（owner、admin、developer、viewer）のいずれかが付き、招待は相手が最初にサインインするまで保留されます。パーソナルアクセストークンは空のホームディレクトリでも動き、<code>--token</code> でも環境変数でも渡せて、トークン一覧には最後に使われた日時が出ます。Linux ランナーで同じパブリッシュスクリプトを、シークレットから取り出した署名鍵で走らせる手動実行の CI ワークフローも書きましたが、ホスト済みのサーバーが要るので、まだ実行していません。</p>
<p>当時なら使わずにおいたツールが1つ、<code>cmpatch debug ios</code> です。システムログを &ldquo;OTA&rdquo; で大文字小文字を区別せずにフィルタしてストリームしていたので &ldquo;Rotation&rdquo; にもマッチしてしまい、20秒のアプリ起動で SpringBoard のメッセージが画面いっぱいに流れ、アプリからは何も出ませんでした。これは修正済みで、今は SDK のマーカーが付いた行だけを表示します。もっとも SDK はもともとあまりログを出さず、出るのはほとんどエラーと開発ビルドでの警告なので、静かなストリームだと思っておいてください。<code>getpwuid_r</code> の警告が出ることがありますが、これは Patch ではなく <code>xcrun simctl</code> から来ています。</p>

<h2 class="relative group">実際のところ、どれくらい大変か
    <div id="実際のところどれくらい大変か" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%9f%e9%9a%9b%e3%81%ae%e3%81%a8%e3%81%93%e3%82%8d%e3%81%a9%e3%82%8c%e3%81%8f%e3%82%89%e3%81%84%e5%a4%a7%e5%a4%89%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>Codemagic から意見を求められたので、できるだけ率直に書きます。</p>
<p>試すのは簡単です。コマンド2つと数分で、本物のサーバー、デモデータ入りのダッシュボード、自分のアプリに向けられる CLI が手に入ります。最初の1時間でいちばん良かった部分だと思います。</p>
<p>SDK の組み込みはそこそこの作業で、難しさの大半は Patch ではなく自分のプロジェクトの側にあります。ネイティブフォルダを生成させたまっさらな Expo アプリなら、本当に plugin と、<code>expo-updates</code> の呼び出しを置き換える数行だけです。maru のように、コミット済みのネイティブプロジェクト、ウィジェット extension、Web ビルド、R8、ビルドプロファイルを抱えたアプリでは、作業は端のほうにありました。prebuild を避けるためにネイティブの変更を手で当て、Staging と Production のビルドが別物になるように設定をビルド時に決め、SDK を Web から遠ざける必要がありました。アップデートの JavaScript がバイナリと同じ環境でビルドされることも保証しなければなりません。理解してしまえばどれも難しくはありませんでした。私が作業した時点ではその大半がドキュメントにありませんでしたが、コミット済みのネイティブプロジェクトの手順とバンドル時の環境については、今は載っています。</p>
<p>日常の使い勝手は良いです。パブリッシュはコマンド1つと30秒ほど。ロールアウト、昇格、無効化、ロールバックは CLI からでもダッシュボードからでもすべて1秒未満で、エラーは何が悪くて何をすべきかを伝えてくれます。サーバーのガード（フィンガープリントのチェック、一度に1つのカナリア、署名）は厳格で、私が間違ったことをするのを何度も止めてくれました。</p>
<p>本番で運用するのは、より大きなコミットメントです。Postgres とオブジェクトストレージ付きの Docker Compose スタックで、ホスティング、バックアップ、アップグレードは自分でやらなければならず、ドメイン2つと OAuth アプリが必要です。すでにサーバーを運用しているチームなら静かな午後1回分。サーバーを持ちたくないからこそ EAS にいるチームにとっては、SDK の作業よりもここが乗り換えの本当のコストです。</p>
<p>実時間で言うと、12:29 に CLI をインストールし、13:34 にはシミュレーターで最初のアップデートが適用されていました。そのうち30分は、Patch と無関係な理由で止まった Xcode のビルドです。この記事にあることは両プラットフォームとも、ホスト済みのサーバーを除けば午後の半ばまでに終わっていました。</p>
<p>いちばん時間を取られたものを順に挙げると、<code>expo prebuild --clean</code> がコミット済みのネイティブプロジェクトを消してしまい、そのあと全部を手で配線する方法を考えたこと。iOS のバージョン検出のバグで、これは誰も動かしていないバージョンにアップデートを送るところでした。二重に数えられたインストールで、これは自分のせいですが、<code>sync()</code> が自分で <code>notifyAppReady()</code> を呼ぶ以上、誰でも簡単に再現してしまいます。そしてドキュメントにない3回の起動というクラッシュ予算で、テストの途中で驚かされました。その次に来るのが、正しいけれど計画が要るもの。無害なネイティブ周辺の編集のあとに発火するフィンガープリントのガード、デプロイメントごとに一度に1つのカナリア、重ねて実行できないライフサイクルジョブ。最後のはスクリプトが続けていくつか処理をした途端に効いてきます。残りは小さなものでした。無音の初回セットアップ、うるさいデバッグコマンド、当時コードと合っていなかったドキュメントの例がいくつか。</p>
<p>これらはすべて詳細とともに Codemagic に書いて渡し、9月23日の時点でその大半が直っていました。バージョン検出、重なった確認処理、prebuild についての警告、クラッシュ予算、無音のセットアップ、デバッグコマンド、<code>InstallMode</code> の例、そしてリリースジョブやカナリアが邪魔をしているときのエラーです。まだ残っているのは小さなものです。ほかがすべて <code>:3000</code> と言うなかでダッシュボードだけが API を <code>:8080</code> と表示すること、Web と Jest で SDK が import 時に例外を投げること、それにドキュメントの穴がいくつか。</p>

<h2 class="relative group">本番へ
    <div id="本番へ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%9c%ac%e7%95%aa%e3%81%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここまでの内容はすべて評価用スタックに対して動かしたものです。本番は同じアプリと同じスクリプトを本物のサーバーに向けるだけで、これが maru の計画です。</p>
<!-- 編集メモ: このセクションは手順。実際の切り替え後に、実際に起きたこと（ホスト、所要時間、壊れたもの）で置き換えること。 -->
<p>サーバーを動かすサポート対象の方法は Docker Compose でのインストールです。ポート 80 と 443 が開いた Linux マシン、ドメイン2つ（API とダッシュボード用、ダウンロード用）、そしてサインイン用の GitHub OAuth アプリ。OAuth アプリがないとサーバーは起動しません。あとはインストーラーが TLS まで含めてやってくれます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">scripts/selfhost/install.sh <span class="se">\
</span></span></span><span class="line"><span class="cl">  --api-domain updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --storage-domain storage.updates.example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --email you@example.com <span class="se">\
</span></span></span><span class="line"><span class="cl">  --github-oauth-client-id &lt;id&gt; --github-oauth-client-secret &lt;secret&gt;</span></span></code></pre></div></div>
<p>データベースとストレージのシークレットを書いた <code>.env.selfhost</code> が生成されるので、パスワードマネージャーに入れておきましょう。</p>
<p>とはいえ同梱のデータベースとストレージを使う必要はなく、それが私のようなスタックにも合う理由です。maru の API はすでに Fly.io で動いていて、ファイルは Tigris にあり、Patch はどちらでも動きます。Compose ファイルの下にあるのは <code>MODE=all</code>（API とリリースワーカーを一緒に）で動くコンテナ1つで、必要なのは Postgres のデータベース、S3 互換ストレージ、ファイル用の公開 HTTPS URL です。つまり、リポジトリの Dockerfile からビルドした Fly アプリ（公開されたイメージはまだないので <code>fly deploy</code> がビルドします）、Fly Postgres のデータベース、Tigris のバケットです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">MODE</span><span class="o">=</span>all
</span></span><span class="line"><span class="cl"><span class="nv">DATABASE_URL</span><span class="o">=</span>postgresql://…            <span class="c1"># Fly Postgres</span>
</span></span><span class="line"><span class="cl"><span class="nv">STORAGE_ADAPTER</span><span class="o">=</span>s3
</span></span><span class="line"><span class="cl"><span class="nv">S3_ENDPOINT</span><span class="o">=</span>https://fly.storage.tigris.dev
</span></span><span class="line"><span class="cl"><span class="nv">S3_REGION</span><span class="o">=</span>auto
</span></span><span class="line"><span class="cl"><span class="nv">S3_BUCKET</span><span class="o">=</span>myapp-patch
</span></span><span class="line"><span class="cl"><span class="nv">S3_FORCE_PATH_STYLE</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="nv">PUBLIC_BASE_URL</span><span class="o">=</span>https://myapp-patch.fly.storage.tigris.dev/codemagic-patch</span></span></code></pre></div></div>
<p>注意点が2つ。Compose の外で動かす方法はリポジトリに参考資料として書かれているだけで、この最初のオープンソースリリースではサポート対象の経路ではないので、Fly 上でのアップグレードとバックアップは自己責任です。私もまだその形ではデプロイしていません。これは計画であって、報告ではありません。それから、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/" >Fly のゼロスケール</a> の記事を読んだ方は、マシンを最低1台は動かしておいてください。マニフェストはストレージから直接来るので、端末がアップデートの確認で API を待つことはありませんが、そうしないとメトリクスのアップロードとリリースジョブがすべてコールドブートから始まることになります。</p>
<p>Tigris はいちばん楽しみにしている部分です。maru のファイルがすでにある場所ですし、アプリの起動のたびに叩かれる URL の前に CDN を置いてくれます。これをやるならマニフェストのキャッシュには気をつけてください。素の base-URL 配信アダプターでは、Patch は CDN をパージできないためマニフェストを <code>no-cache</code> で配信します。マニフェストが独自の TTL でキャッシュされると、ロールバックしたばかりのリリースを配り続けてしまうからです。設定リファレンスには今、この点が <code>MANIFEST_CACHE_CONTROL</code> の項に書かれていて、ドキュメントサイトでも S3 互換ストレージが扱われています。</p>
<p>ホスト済みのサーバーでもう一度アプリを作り、新しいキーを <code>eas.json</code> の <code>preview</code> と <code>production</code> プロファイルに（Staging のキーと Production のキー）入れ、パブリッシュ用のトークンを作ります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cmpatch token create --name ci</span></span></code></pre></div></div>
<p>コード署名をするなら、今決めてください。本番用の鍵ペアを生成し、秘密鍵は CI のシークレットとして保管し、公開鍵をビルドプロファイルに入れ、両アプリで「require code signing」を有効にします。Patch 入りの最初のバイナリには新しいバージョン番号も必要です。ストアにすでにあるバイナリは <code>expo-updates</code> を動かしているからです。まず preview プロファイルで TestFlight と Play の内部テスト向けにビルドし、誰かに渡す前に、キーが実際にビルド済みの <code>Info.plist</code> と Android のリソースに入ったかを確認してください。環境変数を手でエクスポートしていたので、ここだけはローカルのテストで検証できていない繋ぎ目です。それから Staging にパブリッシュし、テスター1人につきダウンロード1件と成功1件を確認して、そこで初めてストア向けにビルドします。</p>
<p>Production のリリースは小さく始めて広げます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">./scripts/publish-update.sh ios --production --rollout <span class="m">10</span>
</span></span><span class="line"><span class="cl">cmpatch release patch --app maru-ios --deployment Production --label v1 --rollout-percentage <span class="m">100</span></span></span></code></pre></div></div>
<p>その間、古いサーバーは動かしたままにします。まだアップデートしていない 2.5.1 は今もそちらを確認していますし、そのユーザー向けのホットフィックスは引き続き古いスクリプトを通ります。ログでそのリクエストが止まったのを確認できたら、古いサーバーも、そのパブリッシュスクリプトとシークレットも、すべて消せます。</p>

<h2 class="relative group">初めてのネイティブアップグレード
    <div id="初めてのネイティブアップグレード" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%88%9d%e3%82%81%e3%81%a6%e3%81%ae%e3%83%8d%e3%82%a4%e3%83%86%e3%82%a3%e3%83%96%e3%82%a2%e3%83%83%e3%83%97%e3%82%b0%e3%83%ac%e3%83%bc%e3%83%89" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><em>2026年10月16日追記。</em></p>
<p>10月初めから、maru の本番のアップデートは Patch が運んでいます。それ以降で初めてのネイティブリリースが Expo SDK 58 への移行でした。React Native 0.88、そして Android では Gradle プラグインの新しいメジャーバージョンです。ネイティブの変更は OTA では届けられないので新しいバイナリ 2.6.0 になり、私のパブリッシュスクリプトはそのバイナリが存在するまで 2.6.0 向けのアップデートを送ろうとしません。ビルドは両プラットフォームとも一発で通りました。</p>
<p>上の本番のセクションでは、誰かにビルドを渡す前に、キーが実際にビルド済みの <code>Info.plist</code> と Android のリソースに入ったかを確認するよう書きました。今回はちゃんと確認しましたし、このセクションがあるのはその確認のおかげです。iOS のビルドには Patch の設定が4つともありました。Android のビルドには1つもありませんでした。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">bundletool dump resources --bundle<span class="o">=</span>build.aab --values <span class="p">|</span> grep -A1 <span class="s2">&#34;string/Codemagic&#34;</span></span></span></code></pre></div></div>
<p>このコマンドは 2.5.11 のビルドではキーを4つ出力し、2.6.0 では何も出しませんでした。環境変数のせいではありません。Gradle の <code>generateReleaseResValues</code> は4つとも書き出しています。消しているのはその後に走るリリースのリソースシュリンカーで、Android Gradle Plugin 9 ではずっと攻撃的になりました。同じアプリでリソースが 9,240 個から 7,116 個に減っています。SDK は設定を <code>getIdentifier</code> で名前から読みますが、シュリンカーにはそれが見えないので、4つの文字列を未使用とみなして消しました。存在しない文字列は空として読まれ、空は「アップデートなし」を意味します。何も失敗しなかったはずです。Android の 2.6.0 はインストールされ、普通に動き、アップデートを一度も受け取らず、唯一の兆候はダッシュボードに Android 2.6.0 の端末が1台もないことだけだったでしょう。</p>
<p>修正は、シュリンカーに4つの文字列を残すよう伝えるリソースファイル1つです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-xml" data-lang="xml"><span class="line"><span class="cl"><span class="c">&lt;!-- android/app/src/main/res/raw/codemagic_patch_keep.xml --&gt;</span>
</span></span><span class="line"><span class="cl"><span class="nt">&lt;resources</span> <span class="na">xmlns:tools=</span><span class="s">&#34;http://schemas.android.com/tools&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="na">tools:keep=</span><span class="s">&#34;@string/CodemagicPatch*&#34;</span> <span class="nt">/&gt;</span></span></span></code></pre></div></div>
<p><code>keep.xml</code> という名前にはしないでください。React Native のバンドルタスクが JavaScript のアセット用にすでに <code>raw/keep.xml</code> を生成していて（画像やフォントが生き残ったのはそのためです）、2つがぶつかります。ライブラリの keep ファイルはアプリに自動でマージされるので、このルールの置き場所としては SDK 自体のほうがふさわしく、デプロイメントキーなしでリリースビルドが起動したときに警告が1つ出るだけでも、黙って起きていたことが目に見えるようになります。前に出てきた iOS のバージョン検出のバグと同じ種類の失敗です。何もエラーにならず、アップデートがただ届かない。</p>
<p>同じ確認で、Patch とは関係のない2つ目の問題も見つかりました。こちらは別の理由で知っておく価値があります。Android のリリースビルドは起動直後にクラッシュしました。それも2回、Expo 自身のモジュールの中にある別々の2つの原因によってです。まず、Expo のウィジェット用モジュールがネイティブクラスの登録に失敗し、JavaScript が動く前にアプリが落ちていました。それを直すと、アプリは JavaScript の起動までは進んだものの、そこで止まりました。Expo の新しいモジュールシステムに移った3つのモジュールが登録されていなかったからです。どちらにも Patch のクラッシュロールバックは役に立ちません。ロールバックすべきアップデートがないからです。守ってくれるのは悪いアップデートからであって、悪いバイナリからではありません。Patch があってもなくても、ネイティブのアップグレードはストアに出す前に実機かエミュレーターにインストールして開いてみる必要があります。</p>
<p>同じテストから、もう1つ小さなことが出てきました。<code>sync()</code> は失敗すると <code>&quot;error&quot;</code> を返し、アプリが開発モードで動いていない限り理由は教えてくれません。なのでリリースビルドでは、何かがうまくいかなかったことしかログに残せません。今は私のフックが、sync が失敗したあとに一度だけ <code>checkForUpdate()</code> を呼んで、そこで投げられたものをログに出します。そうして初めて、サーバーが壊れていたのではなく、ローカルのシミュレータービルドの Patch の URL が空だった（<code>unsupported URL</code>）とわかりました。最後のエラーがリリースビルドでも読めれば助かります。</p>
<p>ネイティブリリースのチェックリストには、以前はなかった3行が加わりました。ビルド済みの <code>.ipa</code> と <code>.aab</code> から Patch の設定をダンプすること、デプロイメントキーをビルドプロファイルと突き合わせること、そして提出前にデバッグビルドではなくリリースビルドそのものを起動すること。</p>

<h2 class="relative group">Expo の請求書を抱えている人に伝えたいこと
    <div id="expo-の請求書を抱えている人に伝えたいこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#expo-%e3%81%ae%e8%ab%8b%e6%b1%82%e6%9b%b8%e3%82%92%e6%8a%b1%e3%81%88%e3%81%a6%e3%81%84%e3%82%8b%e4%ba%ba%e3%81%ab%e4%bc%9d%e3%81%88%e3%81%9f%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>サーバーを作るのが大変そうに見えたから EAS Update にお金を払っているのなら、Patch は本物の答えです。サーバーは動かしますが、それは Compose スタックとインストーラーであって、自分でメンテナンスするコードではありません。私が作ったものと比べると、段階的ロールアウト、リリースごとのインストール数と失敗数、バイナリパッチ、フィンガープリントのガード、コード署名、端末側でのクラッシュロールバック、ロールバックボタンが手に入ります。私はどれも作っていませんでした。</p>
<p>代償は、SDK をアプリに入れる必要があるので、誰かに Patch のアップデートが届く前に新しいバイナリが要ること。アップグレードとバックアップを続けるサーバー。リリースが十分ひどければ、デフォルトでユーザー一人あたり3回のクラッシュ起動。ネイティブプロジェクトをコミットしているなら、いくらかの手作業。それに、まだ若い。今回のバージョンは 0.3.0 で、バージョン検出のバグ、うるさいデバッグコマンド、ドキュメントの穴をいくつか踏み、SDK が防げたはずのバグを自分でも作りました。バージョン検出とデバッグコマンドはその後修正され、ドキュメントの穴も大半が埋まり、私がやった間違いも今は SDK が防いでくれます。どれも移行を止めるものではなく、すべてが CLI、ダッシュボード、あるいは端末上の SDK 自身のファイルで見えていました。これは以前の構成には言えなかったことです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-13-moving-to-codemagic-patch/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>EAS Updateを使わずにExpoのOTAアップデートをセルフホスト：DjangoとTigrisでアップデートサーバーを自作する</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/</guid>
      <pubDate>Sun, 22 Feb 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>React Native</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Django</category>
      <category>Mobile Development</category>
      <category>CI/CD</category>
      <category>Self-Hosted</category>
      <category>Tigris</category>
      <category>S3</category>
      <description>ExpoのOTAアップデートの仕組みと、EAS Updateを使わずにセルフホストする方法。Expo Updatesプロトコルに対応したDjango REST Frameworkのサーバーに、バンドルの置き場はTigris S3。運用費は月1ドルほどです。</description>
      <content:encoded><![CDATA[<blockquote><p><strong>2026年9月13日追記。</strong> この記事の公開後、CodemagicがCodePush系の<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">オープンソース</a>でセルフホストできるOTAサーバー、<a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>をリリースしました。サーバーを自分で作りたくなくてここにたどり着いた方には、いまや現実的な選択肢です。私自身、その後<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/" >この記事の構成からPatchへアプリを移行</a>してその顛末を書きましたし、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-02-22-self-hosted-ota-updates/#%e3%81%8a%e3%82%8f%e3%82%8a%e3%81%ab" >おわりに</a>の下に短い補足も足してあります。開示: その移行と執筆にはCodemagicから報酬を受けていて、ここで触れることも先方からの依頼です。本文のコードは、その後短い例に絞りました。</p>
</blockquote><p>私が開発している見積もりアプリでは、バグ修正をOTA（Over-the-Air）で配信しています。JavaScriptの変更が、App Storeの審査を通さずにユーザーの端末へ届くわけです。ExpoのホストサービスであるEAS Updateを使えば同じことができるのですが、私はDjango REST FrameworkとTigris S3で自前のサーバーを作りました。以来、iOS・Androidのアップデートはすべてここから配信しています。</p>
<p>ExpoのOTAアップデートの仕組みはこうです。アプリに入っている<code>expo-updates</code>ライブラリが、プラットフォームとランタイムバージョンをヘッダーに載せて、アップデートサーバーにマニフェストを要求します。サーバーは互換性のある最新のアップデートを、そのJavaScriptバンドルとアセットのURLと一緒に返し、アプリはそれらをダウンロードして、次回の起動時から新しいバンドルで動きます。EAS Updateはこのプロトコルを実装したExpoのホストサーバーですが、同じプロトコルに対応していればどんなサーバーでも構いません。私が作ったのは、まさにそういうサーバーです。</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="#%e3%81%9d%e3%82%82%e3%81%9d%e3%82%82%e3%81%aa%e3%81%9c%e8%87%aa%e5%89%8d%e3%81%a7%e3%83%9b%e3%82%b9%e3%83%86%e3%82%a3%e3%83%b3%e3%82%b0%e3%81%99%e3%82%8b%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>私の場合、一番の理由はコストでした。EAS Updateの料金は使用量に応じて増えていくので、ユーザーが増えて更新頻度も上がるとそれなりの金額になります。一方、TigrisのようなS3互換ストレージならほとんどタダみたいなものです。ほかの理由が効いてくる場合もあるでしょう。アプリケーションのアセットをすべて自社インフラ内に置くことが求められる業界もありますし、サーバーを自分で持てばアップデートの流れを丸ごと制御できるので、特定のユーザー層への配信やバンドルのA/Bテストも自由にできます。Expoの稼働状況や次の料金改定に振り回されずに済むのも、地味に大きいところです。</p>

<h2 class="relative group">全体の構成
    <div id="全体の構成" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%85%a8%e4%bd%93%e3%81%ae%e6%a7%8b%e6%88%90" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>構成要素は4つです。アップデートのマニフェストを返しメタデータを保存するDjangoバックエンド、バンドルとアセットの実体を置くTigris、エクスポート・アップロード・登録をまとめて行うパブリッシュ用スクリプト、そしてExpoのサーバーではなく自分のサーバーを向くように設定したモバイルアプリ本体です。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Mobile App    │
</span></span><span class="line"><span class="cl">│  (expo-updates) │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 1. Request manifest
</span></span><span class="line"><span class="cl">         │    (with headers: platform, runtime-version)
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│  Django Server  │
</span></span><span class="line"><span class="cl">│  /api/expo-     │◄─── 2. Query DB for latest update
</span></span><span class="line"><span class="cl">│   updates/      │
</span></span><span class="line"><span class="cl">│   manifest/     │
</span></span><span class="line"><span class="cl">└────────┬────────┘
</span></span><span class="line"><span class="cl">         │ 3. Generate presigned URLs
</span></span><span class="line"><span class="cl">         │
</span></span><span class="line"><span class="cl">         ↓
</span></span><span class="line"><span class="cl">┌─────────────────┐
</span></span><span class="line"><span class="cl">│   Tigris S3     │
</span></span><span class="line"><span class="cl">│  (Asset Files)  │◄─── 4. App downloads bundles directly
</span></span><span class="line"><span class="cl">└─────────────────┘</span></span></code></pre></div></div>

<h2 class="relative group">実装
    <div id="実装" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e5%ae%9f%e8%a3%85" aria-label="アンカー">#</a>
    </span>
    
</h2>

<h3 class="relative group">2つのモデル
    <div id="2つのモデル" 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="#2%e3%81%a4%e3%81%ae%e3%83%a2%e3%83%87%e3%83%ab" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>すべての土台になるのが<code>ExpoUpdate</code>と<code>ExpoUpdateAsset</code>です。前者は各アップデートのメタデータを持ちます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdate</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">50</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">choices</span><span class="o">=</span><span class="p">[(</span><span class="s2">&#34;ios&#34;</span><span class="p">,</span> <span class="s2">&#34;iOS&#34;</span><span class="p">),</span> <span class="p">(</span><span class="s2">&#34;android&#34;</span><span class="p">,</span> <span class="s2">&#34;Android&#34;</span><span class="p">)],</span>
</span></span><span class="line"><span class="cl">        <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">is_active</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">BooleanField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">JSONField</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">    <span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">TextField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">created_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">            <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">        <span class="p">]</span></span></span></code></pre></div></div>
<p>設計上のポイントがいくつかあります。<code>runtime_version</code>は<code>app.json</code>の<code>runtimeVersion</code>に対応するフィールドで、クライアントは自分のランタイムバージョンに一致するアップデートしかダウンロードしないため、ここが要になります。バンドルの中身が違うので、iOSとAndroidは別レコードです。<code>is_active</code>はロールバックの仕組みで、問題のあるアップデートを無効化すればクライアントはひとつ前のものに戻ります。そして<code>manifest_data</code>にはExpo Updates v1プロトコルのマニフェストを丸ごとJSONで保存しておくので、配信時は取り出すだけで済みます。</p>
<p><code>ExpoUpdateAsset</code>は個々のファイルを管理します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateAsset</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="nb">id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">UUIDField</span><span class="p">(</span><span class="n">primary_key</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="n">uuid</span><span class="o">.</span><span class="n">uuid4</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="s2">&#34;assets&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="nb">hash</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">db_index</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">key</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">content_type</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_extension</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_path</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">500</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">file_size</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">)</span></span></span></code></pre></div></div>
<p>アセットはSHA-256ハッシュで参照するので、不変でキャッシュも効きます。同じアセットを複数のアップデートで共有することも可能です。</p>

<h3 class="relative group">マニフェストエンドポイント
    <div id="マニフェストエンドポイント" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%9e%e3%83%8b%e3%83%95%e3%82%a7%e3%82%b9%e3%83%88%e3%82%a8%e3%83%b3%e3%83%89%e3%83%9d%e3%82%a4%e3%83%b3%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>アプリとサーバーが実際にやり取りするのが<code>/api/expo-updates/manifest/</code>です。<a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates v1プロトコル</a>を実装しています。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;get&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;manifest&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">manifest</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Extract required headers</span>
</span></span><span class="line"><span class="cl">    <span class="n">protocol_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PROTOCOL_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_PLATFORM&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">runtime_version</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_EXPO_RUNTIME_VERSION&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Validate protocol version</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">protocol_version</span> <span class="o">!=</span> <span class="s2">&#34;1&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="p">{</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="sa">f</span><span class="s2">&#34;Unsupported protocol version: </span><span class="si">{</span><span class="n">protocol_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">            <span class="n">status</span><span class="o">=</span><span class="mi">400</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Find latest active update for this runtime + platform</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span><span class="o">.</span><span class="n">order_by</span><span class="p">(</span><span class="s2">&#34;-created_at&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># No update available - client uses embedded bundle</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">update</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span> <span class="o">=</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">        <span class="n">response</span><span class="p">[</span><span class="s2">&#34;expo-protocol-version&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="s2">&#34;1&#34;</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">response</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Generate presigned URLs for all assets</span>
</span></span><span class="line"><span class="cl">    <span class="n">manifest_data</span> <span class="o">=</span> <span class="n">build_manifest</span><span class="p">(</span><span class="n">update</span><span class="p">)</span>  <span class="c1"># fills in presigned asset URLs</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">manifest_data</span><span class="p">,</span> <span class="n">status</span><span class="o">=</span><span class="mi">200</span><span class="p">)</span></span></span></code></pre></div></div>
<p>ポイントは3つあります。アップデートがないときは204を返し、アプリは組み込みのバンドルをそのまま使い続けます。マニフェスト内のアセットURLは署名付きURL（presigned URL）なので、Djangoを経由せずTigrisのCDNから直接ダウンロードされます。そしてレスポンスの<code>expo-protocol-version</code>ヘッダーは省略できません。クライアントがきちんと確認しています。</p>

<h3 class="relative group">アップデートのパブリッシュ
    <div id="アップデートのパブリッシュ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%e3%83%88%e3%81%ae%e3%83%91%e3%83%96%e3%83%aa%e3%83%83%e3%82%b7%e3%83%a5" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>パブリッシュはDjangoの管理コマンドと、それを包むシェルスクリプトで行います。管理コマンドは<code>expo export</code>の出力を読み取り、各アセットのSHA-256ハッシュを計算し、バンドルとアセットをTigrisへ並列アップロードし、データベースにレコードを作り、必要ならプロダクション同期用のインポートJSONもバケットに置きます。要点だけに絞るとこうなります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">concurrent.futures</span> <span class="kn">import</span> <span class="n">ThreadPoolExecutor</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.db</span> <span class="kn">import</span> <span class="n">transaction</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">publish</span><span class="p">(</span><span class="n">platform</span><span class="p">,</span> <span class="n">runtime_version</span><span class="p">,</span> <span class="n">manifest</span><span class="p">,</span> <span class="n">files</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Upload the bundle and assets in parallel (keys are content hashes)</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="nb">list</span><span class="p">(</span><span class="n">executor</span><span class="o">.</span><span class="n">map</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">files</span><span class="p">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Swap the active update in one transaction</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">is_active</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span><span class="o">.</span><span class="n">update</span><span class="p">(</span><span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">create</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">            <span class="n">runtime_version</span><span class="o">=</span><span class="n">runtime_version</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">platform</span><span class="o">=</span><span class="n">platform</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">            <span class="n">manifest_data</span><span class="o">=</span><span class="n">manifest</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="p">)</span></span></span></code></pre></div></div>
<p>普段実際に叩くのは、ラッパースクリプトのほうです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Publish to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Dry run to validate</span>
</span></span><span class="line"><span class="cl">./publish.sh --dry-run</span></span></code></pre></div></div>
<p>エクスポート前にプロダクションの環境変数を読み込み、プラットフォームは片方でも両方でも指定でき、次に説明するプロダクション同期まで面倒を見てくれます。</p>

<h3 class="relative group">プロダクションへの同期
    <div id="プロダクションへの同期" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%97%e3%83%ad%e3%83%80%e3%82%af%e3%82%b7%e3%83%a7%e3%83%b3%e3%81%b8%e3%81%ae%e5%90%8c%e6%9c%9f" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>プロダクションの認証情報を手元のマシンに置きたくなかったので、本番へのデプロイは2段階にしました。まずローカルでパブリッシュし（アセットはTigrisへ、メタデータのJSONスナップショットも一緒に）、そのあとS3のパスを添えてプロダクションのAPIを呼び、サーバー側でメタデータを取り込んでもらいます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@action</span><span class="p">(</span><span class="n">detail</span><span class="o">=</span><span class="kc">False</span><span class="p">,</span> <span class="n">methods</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;post&#34;</span><span class="p">],</span> <span class="n">url_path</span><span class="o">=</span><span class="s2">&#34;import-update&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">import_update</span><span class="p">(</span><span class="bp">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Authenticate via Bearer token</span>
</span></span><span class="line"><span class="cl">    <span class="n">token</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_AUTHORIZATION&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span><span class="o">.</span><span class="n">removeprefix</span><span class="p">(</span><span class="s2">&#34;Bearer &#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">settings</span><span class="o">.</span><span class="n">IMPORT_TOKEN</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Download the import JSON from the bucket</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_key</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">data</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;s3_key&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">obj</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">get_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">data</span> <span class="o">=</span> <span class="n">json</span><span class="o">.</span><span class="n">loads</span><span class="p">(</span><span class="n">obj</span><span class="p">[</span><span class="s2">&#34;Body&#34;</span><span class="p">]</span><span class="o">.</span><span class="n">read</span><span class="p">())</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Import into the production database</span>
</span></span><span class="line"><span class="cl">    <span class="k">with</span> <span class="n">transaction</span><span class="o">.</span><span class="n">atomic</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">update</span><span class="p">,</span> <span class="n">_</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="nb">id</span><span class="o">=</span><span class="n">data</span><span class="p">[</span><span class="s2">&#34;id&#34;</span><span class="p">],</span> <span class="n">defaults</span><span class="o">=</span><span class="p">{</span><span class="o">...</span><span class="p">})</span>
</span></span><span class="line"><span class="cl">        <span class="k">for</span> <span class="n">asset_data</span> <span class="ow">in</span> <span class="n">data</span><span class="p">[</span><span class="s2">&#34;assets&#34;</span><span class="p">]:</span>
</span></span><span class="line"><span class="cl">            <span class="n">ExpoUpdateAsset</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">update_or_create</span><span class="p">(</span><span class="o">...</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="c1"># Clean up the import JSON</span>
</span></span><span class="line"><span class="cl">    <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">s3_key</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;update_id&#34;</span><span class="p">:</span> <span class="nb">str</span><span class="p">(</span><span class="n">update</span><span class="o">.</span><span class="n">id</span><span class="p">)})</span></span></span></code></pre></div></div>

<h3 class="relative group">アプリをサーバーに向ける
    <div id="アプリをサーバーに向ける" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%a2%e3%83%97%e3%83%aa%e3%82%92%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%81%ab%e5%90%91%e3%81%91%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>app.json</code>でアップデートURLとランタイムバージョンを設定します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;expo&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;runtimeVersion&#34;</span><span class="p">:</span> <span class="s2">&#34;1.0.0&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="nt">&#34;updates&#34;</span><span class="p">:</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;url&#34;</span><span class="p">:</span> <span class="s2">&#34;https://api.example.com/api/expo-updates/manifest/&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">}</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p><code>runtimeVersion</code>はアプリとサーバーで厳密に揃えてください。ネイティブコードを変えたときやExpo SDKを上げたときは、ランタイムバージョンを上げて、そのバージョン向けにアップデートを出し直します。</p>

<h2 class="relative group">Tigrisでのストレージ
    <div id="tigrisでのストレージ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tigris%e3%81%a7%e3%81%ae%e3%82%b9%e3%83%88%e3%83%ac%e3%83%bc%e3%82%b8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>TigrisはS3互換のオブジェクトストレージで、本家AWS S3よりかなり安く、グローバルなエッジキャッシュも込みです。数メガバイト級のバンドルをユーザーがダウンロードすることを考えると、これが効いてきます。Django側の設定は次の通りです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># settings.py</span>
</span></span><span class="line"><span class="cl"><span class="n">BUCKET_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;BUCKET_NAME&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ENDPOINT_URL_S3</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_ENDPOINT_URL_S3&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_ACCESS_KEY_ID</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_ACCESS_KEY_ID&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_SECRET_ACCESS_KEY</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_SECRET_ACCESS_KEY&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">AWS_REGION</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;AWS_REGION&#34;</span><span class="p">,</span> <span class="s2">&#34;auto&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>クライアントの生成はごく普通のboto3です。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="kn">import</span> <span class="nn">boto3</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">create_s3_client</span><span class="p">(</span><span class="n">endpoint_url</span><span class="p">,</span> <span class="n">region</span><span class="p">,</span> <span class="n">access_key</span><span class="p">,</span> <span class="n">secret_key</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">boto3</span><span class="o">.</span><span class="n">client</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">        <span class="s2">&#34;s3&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">endpoint_url</span><span class="o">=</span><span class="n">endpoint_url</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">region_name</span><span class="o">=</span><span class="n">region</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_access_key_id</span><span class="o">=</span><span class="n">access_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">        <span class="n">aws_secret_access_key</span><span class="o">=</span><span class="n">secret_key</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="p">)</span></span></span></code></pre></div></div>
<p>ダウンロードには署名付きURLを使うため、アプリはCDNから直接取得します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">presigned_url</span> <span class="o">=</span> <span class="n">s3_client</span><span class="o">.</span><span class="n">generate_presigned_url</span><span class="p">(</span>
</span></span><span class="line"><span class="cl">    <span class="s2">&#34;get_object&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">    <span class="n">Params</span><span class="o">=</span><span class="p">{</span><span class="s2">&#34;Bucket&#34;</span><span class="p">:</span> <span class="n">bucket_name</span><span class="p">,</span> <span class="s2">&#34;Key&#34;</span><span class="p">:</span> <span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="n">ExpiresIn</span><span class="o">=</span><span class="mi">3600</span><span class="p">,</span>  <span class="c1"># 1 hour</span>
</span></span><span class="line"><span class="cl"><span class="p">)</span></span></span></code></pre></div></div>

<h2 class="relative group">セキュリティ
    <div id="セキュリティ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%bb%e3%82%ad%e3%83%a5%e3%83%aa%e3%83%86%e3%82%a3" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>マニフェストエンドポイントは、あえて認証なしにしています。ログイン前のアプリにもアップデートは必要だからです。一方、インポートエンドポイントはプロダクションのデータベースに書き込めるので話が別で、共有シークレットで守り、タイミング攻撃を避けるために定数時間で比較します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">IMPORT_TOKEN</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;IMPORT_TOKEN&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Constant-time comparison prevents timing attacks</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="ow">not</span> <span class="n">hmac</span><span class="o">.</span><span class="n">compare_digest</span><span class="p">(</span><span class="n">token</span><span class="p">,</span> <span class="n">IMPORT_TOKEN</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">Response</span><span class="p">({</span><span class="s2">&#34;error&#34;</span><span class="p">:</span> <span class="s2">&#34;Invalid token&#34;</span><span class="p">},</span> <span class="n">status</span><span class="o">=</span><span class="mi">401</span><span class="p">)</span></span></span></code></pre></div></div>
<p>アセットの整合性は、この設計なら勝手についてきます。すべてSHA-256ハッシュで検証されるので、改ざんされたアセットは単に失敗するだけです。署名付きURLも1時間で失効するため、バンドルが延々と直リンクされ続ける心配はありません。</p>

<h2 class="relative group">速度まわり
    <div id="速度まわり" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%80%9f%e5%ba%a6%e3%81%be%e3%82%8f%e3%82%8a" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><code>(runtime_version, platform, is_active, -created_at)</code>の複合インデックスのおかげで、アップデートがいくら溜まってもマニフェストのクエリは速いままです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">indexes</span> <span class="o">=</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">        <span class="n">models</span><span class="o">.</span><span class="n">Index</span><span class="p">(</span><span class="n">fields</span><span class="o">=</span><span class="p">[</span><span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;-created_at&#34;</span><span class="p">])</span>
</span></span><span class="line"><span class="cl">    <span class="p">]</span></span></span></code></pre></div></div>
<p>パブリッシュ用スクリプトはアセットを並列でアップロードします。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">with</span> <span class="n">ThreadPoolExecutor</span><span class="p">(</span><span class="n">max_workers</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span> <span class="k">as</span> <span class="n">executor</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="n">futures</span> <span class="o">=</span> <span class="p">{</span><span class="n">executor</span><span class="o">.</span><span class="n">submit</span><span class="p">(</span><span class="n">upload_asset</span><span class="p">,</span> <span class="n">asset</span><span class="p">):</span> <span class="n">asset</span> <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">assets</span><span class="p">}</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">future</span> <span class="ow">in</span> <span class="n">as_completed</span><span class="p">(</span><span class="n">futures</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">        <span class="c1"># Track progress</span></span></span></code></pre></div></div>
<p>アセット50個ほどの典型的なアップデートで、パブリッシュ時間は約2分から15秒になりました。配信側でやることは何もありません。Tigrisが勝手にユーザーの近くのエッジへキャッシュしてくれます。</p>

<h2 class="relative group">日々の運用
    <div id="日々の運用" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%97%a5%e3%80%85%e3%81%ae%e9%81%8b%e7%94%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>

<h3 class="relative group">開発中
    <div id="開発中" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e9%96%8b%e7%99%ba%e4%b8%ad" aria-label="アンカー">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Make code changes in the app</span>
</span></span><span class="line"><span class="cl">git commit -am <span class="s2">&#34;Fix bug&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Publish OTA update to local environment</span>
</span></span><span class="line"><span class="cl">./publish.sh ios
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 3. Test on device</span>
</span></span><span class="line"><span class="cl"><span class="c1"># App automatically downloads and applies update</span></span></span></code></pre></div></div>

<h3 class="relative group">プロダクションへのリリース
    <div id="プロダクションへのリリース" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%97%e3%83%ad%e3%83%80%e3%82%af%e3%82%b7%e3%83%a7%e3%83%b3%e3%81%b8%e3%81%ae%e3%83%aa%e3%83%aa%e3%83%bc%e3%82%b9" aria-label="アンカー">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># 1. Publish to production</span>
</span></span><span class="line"><span class="cl">./publish.sh ios --production
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># 2. Monitor</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Check Django admin for update records</span>
</span></span><span class="line"><span class="cl"><span class="c1"># Verify assets in Tigris dashboard</span></span></span></code></pre></div></div>

<h3 class="relative group">ロールバック
    <div id="ロールバック" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%ad%e3%83%bc%e3%83%ab%e3%83%90%e3%83%83%e3%82%af" aria-label="アンカー">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="c1"># Mark problematic update as inactive in Django admin</span>
</span></span><span class="line"><span class="cl"><span class="c1"># or via management shell:</span>
</span></span><span class="line"><span class="cl">python manage.py shell
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; from updates.models import ExpoUpdate
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; <span class="nv">bad_update</span> <span class="o">=</span> ExpoUpdate.objects.get<span class="o">(</span><span class="nv">id</span><span class="o">=</span><span class="s2">&#34;uuid-here&#34;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.is_active <span class="o">=</span> False
</span></span><span class="line"><span class="cl">&gt;&gt;&gt; bad_update.save<span class="o">()</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Clients will now receive the previous active update</span></span></span></code></pre></div></div>

<h2 class="relative group">かかっている費用
    <div id="かかっている費用" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%8b%e3%81%8b%e3%81%a3%e3%81%a6%e3%81%84%e3%82%8b%e8%b2%bb%e7%94%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>このアプリのアクティブユーザーは500人ほどです。Tigrisでは、過去のアップデート約200MB分のストレージが月$0.02ほど、アップデートのダウンロードによる月約50GBのエグレスが$1.00ほど。Django側はもともとAPIを動かしているサーバーに相乗りしているので、追加コストはゼロです。全部で月1ドルといったところ。同じ規模でEAS Updateを使うと年間$300〜500ほどになるはずなので、元はほぼ即座に取れました。</p>

<h2 class="relative group">モニタリングとデバッグ
    <div id="モニタリングとデバッグ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%a2%e3%83%8b%e3%82%bf%e3%83%aa%e3%83%b3%e3%82%b0%e3%81%a8%e3%83%87%e3%83%90%e3%83%83%e3%82%b0" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>凝ったことはしていません。ViewSetがマニフェストのリクエストをすべてログに残します。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">logger</span><span class="o">.</span><span class="n">info</span><span class="p">(</span><span class="sa">f</span><span class="s2">&#34;Manifest request: platform=</span><span class="si">{</span><span class="n">platform</span><span class="si">}</span><span class="s2">, runtime=</span><span class="si">{</span><span class="n">runtime_version</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>実質的なダッシュボードはDjango adminです。モデルを登録しておけば、確認も絞り込みも一通りできます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="nd">@admin.register</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateAdmin</span><span class="p">(</span><span class="n">admin</span><span class="o">.</span><span class="n">ModelAdmin</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_display</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;created_at&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">list_filter</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;platform&#34;</span><span class="p">,</span> <span class="s2">&#34;is_active&#34;</span><span class="p">,</span> <span class="s2">&#34;runtime_version&#34;</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">    <span class="n">search_fields</span> <span class="o">=</span> <span class="p">[</span><span class="s2">&#34;description&#34;</span><span class="p">]</span></span></span></code></pre></div></div>
<p>クライアント側では、<code>expo-updates</code>が何を見ているかを教えてくれます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-typescript" data-lang="typescript"><span class="line"><span class="cl"><span class="kr">import</span> <span class="o">*</span> <span class="kr">as</span> <span class="nx">Updates</span> <span class="kr">from</span> <span class="s1">&#39;expo-updates&#39;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">Updates</span><span class="p">.</span><span class="nx">checkForUpdateAsync</span><span class="p">().</span><span class="nx">then</span><span class="p">(</span><span class="nx">update</span> <span class="o">=&gt;</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Update available:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">isAvailable</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">  <span class="nx">console</span><span class="p">.</span><span class="nx">log</span><span class="p">(</span><span class="s1">&#39;Manifest:&#39;</span><span class="p">,</span> <span class="nx">update</span><span class="p">.</span><span class="nx">manifest</span><span class="p">);</span>
</span></span><span class="line"><span class="cl"><span class="p">});</span></span></span></code></pre></div></div>

<h2 class="relative group">ハマりどころ
    <div id="ハマりどころ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%8f%e3%83%9e%e3%82%8a%e3%81%a9%e3%81%93%e3%82%8d" aria-label="アンカー">#</a>
    </span>
    
</h2>

<h3 class="relative group">ランタイムバージョンの不一致
    <div id="ランタイムバージョンの不一致" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%a9%e3%83%b3%e3%82%bf%e3%82%a4%e3%83%a0%e3%83%90%e3%83%bc%e3%82%b8%e3%83%a7%e3%83%b3%e3%81%ae%e4%b8%8d%e4%b8%80%e8%87%b4" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>一番よくある失敗が、一番静かに起きます。クライアントは自分のランタイムバージョンに一致するアップデートしかダウンロードしません。インストール済みのアプリがランタイム<code>1.0.0</code>なのに<code>1.0.1</code>向けにパブリッシュすると、何も届かず、エラーも出ません。ランタイムバージョンはビルドと同期させ、上げるのはネイティブコードが変わったときだけにしてください。</p>

<h3 class="relative group">createdAtタイムスタンプ
    <div id="createdatタイムスタンプ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#createdat%e3%82%bf%e3%82%a4%e3%83%a0%e3%82%b9%e3%82%bf%e3%83%b3%e3%83%97" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>expo-updates</code>クライアントはマニフェストの<code>createdAt</code>を組み込みバンドルの<code>commitTime</code>と比較し、<code>createdAt</code>のほうが新しいアップデートだけを適用します。ViewSetではデータベースのタイムスタンプで上書きしています。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">manifest_data</span><span class="p">[</span><span class="s2">&#34;createdAt&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">update</span><span class="o">.</span><span class="n">created_at</span><span class="o">.</span><span class="n">strftime</span><span class="p">(</span><span class="s2">&#34;%Y-%m-</span><span class="si">%d</span><span class="s2">T%H:%M:%S.</span><span class="si">%f</span><span class="s2">Z&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<p>ローカル開発では、OTAをパブリッシュした<em>あとに</em>バイナリをビルドした場合、タイムスタンプが新しくなるようにOTAを出し直してください。</p>

<h3 class="relative group">マニフェストのアセット<code>key</code>
    <div id="マニフェストのアセットkey" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%9e%e3%83%8b%e3%83%95%e3%82%a7%e3%82%b9%e3%83%88%e3%81%ae%e3%82%a2%e3%82%bb%e3%83%83%e3%83%88key" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>マニフェスト内の各アセットの<code>key</code>フィールドは<code>expo-updates</code>がキャッシュに使うもので、ランダムなUUIDや適当な文字列ではなく、決定論的なハッシュ（ファイル名のMD5など）でなければなりません。ここを間違えると、クライアントがアセットを正しくキャッシュ・取得できなくなり、最初の1回だけ成功したあとアップデートが黙って壊れます。この問題を報告してくれた読者のRaphael Mutschlerさんに感謝します。原因を突き止めるまで、彼の環境ではアップデートがちょうど1回だけ動いていたそうです。</p>

<h3 class="relative group"><code>expoClient</code>のアプリ設定
    <div id="expoclientのアプリ設定" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#expoclient%e3%81%ae%e3%82%a2%e3%83%97%e3%83%aa%e8%a8%ad%e5%ae%9a" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>Linking</code>や<code>Constants</code>など、実行時にアプリ設定を読むものを使っている場合は、マニフェストの<code>extra.expoClient</code>フィールドにアプリ設定を含める必要があります。これがないと、最初は普通に起動するのに、アプリを閉じたあと再起動できなかったりクラッシュしたりします。<code>expo-updates</code>が組み込みマニフェストをOTAのものに置き換えるため、<code>expoClient</code>が欠けているとこれらのAPIが頼りにしている設定へアクセスできなくなるのです。これもRaphael Mutschlerさんが見つけてくれました。</p>

<h3 class="relative group">アセットの掃除
    <div id="アセットの掃除" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%a2%e3%82%bb%e3%83%83%e3%83%88%e3%81%ae%e6%8e%83%e9%99%a4" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>古いアップデートはTigrisに溜まっていきます。今のところ、掃除は手作業です。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="c1"># Delete updates older than 30 days</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">datetime</span> <span class="kn">import</span> <span class="n">timedelta</span>
</span></span><span class="line"><span class="cl"><span class="kn">from</span> <span class="nn">django.utils</span> <span class="kn">import</span> <span class="n">timezone</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">cutoff</span> <span class="o">=</span> <span class="n">timezone</span><span class="o">.</span><span class="n">now</span><span class="p">()</span> <span class="o">-</span> <span class="n">timedelta</span><span class="p">(</span><span class="n">days</span><span class="o">=</span><span class="mi">30</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">old_updates</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">created_at__lt</span><span class="o">=</span><span class="n">cutoff</span><span class="p">,</span> <span class="n">is_active</span><span class="o">=</span><span class="kc">False</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">for</span> <span class="n">update</span> <span class="ow">in</span> <span class="n">old_updates</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete assets from S3</span>
</span></span><span class="line"><span class="cl">    <span class="k">for</span> <span class="n">asset</span> <span class="ow">in</span> <span class="n">update</span><span class="o">.</span><span class="n">assets</span><span class="o">.</span><span class="n">all</span><span class="p">():</span>
</span></span><span class="line"><span class="cl">        <span class="n">s3_client</span><span class="o">.</span><span class="n">delete_object</span><span class="p">(</span><span class="n">Bucket</span><span class="o">=</span><span class="n">bucket_name</span><span class="p">,</span> <span class="n">Key</span><span class="o">=</span><span class="n">asset</span><span class="o">.</span><span class="n">file_path</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Delete DB records</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span><span class="o">.</span><span class="n">delete</span><span class="p">()</span></span></span></code></pre></div></div>
<p>本来はスケジュールタスクに載せるべき処理ですが、まだ手を付けられていません。</p>

<h2 class="relative group">この先やりたいこと
    <div id="この先やりたいこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%93%e3%81%ae%e5%85%88%e3%82%84%e3%82%8a%e3%81%9f%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p><em>(2026年9月: いまではPatchがこの3つも、それ以上もやってくれます。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/" >移行の記事</a>を参照してください。)</em></p>

<h3 class="relative group">段階的ロールアウト
    <div id="段階的ロールアウト" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e6%ae%b5%e9%9a%8e%e7%9a%84%e3%83%ad%e3%83%bc%e3%83%ab%e3%82%a2%e3%82%a6%e3%83%88" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>rollout_percentage</code>フィールドを足せば、まず一部のユーザーだけに配信できます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">rollout_percentage</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">IntegerField</span><span class="p">(</span><span class="n">default</span><span class="o">=</span><span class="mi">100</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># In the manifest view:</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span> <span class="o">&lt;</span> <span class="mi">100</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Hash user ID and check if they&#39;re in rollout group</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_hash</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="n">hashlib</span><span class="o">.</span><span class="n">sha256</span><span class="p">(</span><span class="n">user_id</span><span class="o">.</span><span class="n">encode</span><span class="p">())</span><span class="o">.</span><span class="n">hexdigest</span><span class="p">(),</span> <span class="mi">16</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="n">user_hash</span> <span class="o">%</span> <span class="mi">100</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="n">update</span><span class="o">.</span><span class="n">rollout_percentage</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">Response</span><span class="p">(</span><span class="n">status</span><span class="o">=</span><span class="mi">204</span><span class="p">)</span>  <span class="c1"># No update</span></span></span></code></pre></div></div>

<h3 class="relative group">ステージング用アップデートの分離
    <div id="ステージング用アップデートの分離" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%b9%e3%83%86%e3%83%bc%e3%82%b8%e3%83%b3%e3%82%b0%e7%94%a8%e3%82%a2%e3%83%83%e3%83%97%e3%83%87%e3%83%bc%e3%83%88%e3%81%ae%e5%88%86%e9%9b%a2" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p><code>environment</code>フィールドを足せば、ステージングビルドとプロダクションで別のアップデートを配れます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">environment</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">20</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Client sends environment in custom header</span>
</span></span><span class="line"><span class="cl"><span class="n">environment</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">META</span><span class="o">.</span><span class="n">get</span><span class="p">(</span><span class="s2">&#34;HTTP_X_UPDATE_ENVIRONMENT&#34;</span><span class="p">,</span> <span class="s2">&#34;production&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">update</span> <span class="o">=</span> <span class="n">ExpoUpdate</span><span class="o">.</span><span class="n">objects</span><span class="o">.</span><span class="n">filter</span><span class="p">(</span><span class="n">environment</span><span class="o">=</span><span class="n">environment</span><span class="p">,</span> <span class="o">...</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">()</span></span></span></code></pre></div></div>

<h3 class="relative group">ダウンロードの計測
    <div id="ダウンロードの計測" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%80%e3%82%a6%e3%83%b3%e3%83%ad%e3%83%bc%e3%83%89%e3%81%ae%e8%a8%88%e6%b8%ac" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>小さなモデルをひとつ足せば、「このアップデート、実際に届いたのか？」という問いにきちんと答えられるようになります。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="k">class</span> <span class="nc">ExpoUpdateDownload</span><span class="p">(</span><span class="n">models</span><span class="o">.</span><span class="n">Model</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="n">update</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">ForeignKey</span><span class="p">(</span><span class="n">ExpoUpdate</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="o">.</span><span class="n">CASCADE</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">user_id</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">null</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">platform</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">10</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">    <span class="n">downloaded_at</span> <span class="o">=</span> <span class="n">models</span><span class="o">.</span><span class="n">DateTimeField</span><span class="p">(</span><span class="n">auto_now_add</span><span class="o">=</span><span class="kc">True</span><span class="p">)</span></span></span></code></pre></div></div>

<h2 class="relative group">おわりに
    <div id="おわりに" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%8a%e3%82%8f%e3%82%8a%e3%81%ab" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>システム全体で、Djangoのモデルが2つとビューがいくつか、パブリッシュ用スクリプトがひとつ、インフラ費用が月1ドル。始める前に想像していたよりずっと小さく収まり、以来両プラットフォームで淡々と動き続けています。</p>
<p>ここに載せたコードはそのプロダクションアプリのコードを例として簡略化したものなので、作るものに合わせて手を入れてください。読者のRaphael Mutschlerさんがスタンドアロンの実装を<a href="https://github.com/mutschler/expo-ota-server"  target="_blank" rel="noreferrer">expo-ota-server</a>として公開してくれているので、すぐ動くものから始めたい方はそちらもどうぞ。</p>

<h3 class="relative group">サーバーを自分で書きたくないなら
    <div id="サーバーを自分で書きたくないなら" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%82%92%e8%87%aa%e5%88%86%e3%81%a7%e6%9b%b8%e3%81%8d%e3%81%9f%e3%81%8f%e3%81%aa%e3%81%84%e3%81%aa%e3%82%89" aria-label="アンカー">#</a>
    </span>
    
</h3>
<p>この記事を書いたあと、Djangoなしで同じ領域をカバーするものが2つ登場しました。1つ目は、上で触れたRaphaelさんの<code>expo-ota-server</code>。2つ目は<a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Codemagic Patch</a>です。<a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">オープンソース</a>で、Docker Composeのインストールで自分のマシン上で動き、Expo UpdatesプロトコルではなくCodePushを話すので、<code>expo-updates</code>をそのSDKに、<code>eas update</code>を<code>cmpatch release-react</code>に置き換えることになります。S3互換ストレージを使えるので、この記事に出てくるようなTigrisのバケットもそのまま使えます。</p>
<p>「この先やりたいこと」に挙げて結局作らなかったものが、最初から揃っています。ステージングとプロダクションのデプロイメント、パーセンテージ指定のロールアウト、リリースごとのダウンロード数とインストール数、必須アップデート、バイナリパッチ、コード署名、そして新しいバンドルがアプリの準備完了を報告する前にクラッシュしたときの端末側でのロールバック。引き換えになるのは、SDKをアプリに入れる必要があるため誰かにPatchのアップデートが届く前に新しいネイティブバイナリが要ることと、管理コマンドの代わりに運用するサーバーが要ることです。</p>
<p>私は自分のアプリの1つを、コミット済みのネイティブプロジェクトと本物のリリースパイプラインごと、この記事の構成からPatchへ移行しました。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-13-moving-to-codemagic-patch/" >その記事</a>では、うまくいったこと、いかなかったこと、実際どれくらい大変かを書いています。この作業はCodemagicのスポンサードで、先方のレビューは事実確認のみで、編集的なものではありません。</p>
<hr>
<p><em>仕様の全文は<a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">Expo Updates Protocol Specification</a>を参照してください。</em></p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-02-22-self-hosted-ota-updates/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>CI/CDのロジックはプラットフォーム固有のYAMLではなくDocker Composeに</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2024/2024-06-02-simplifyingcicd/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/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>ビルドとテストのロジックはGitHub ActionsのYAMLではなくDocker Composeファイルへ。ポータブルで、ローカルでテストでき、メンテナンスもシンプルです。</description>
      <content:encoded><![CDATA[<p><em>2026年10月追記：2年たって、実際にリリースに使っているリポジトリを全部見直し、このアドバイスのうち何が生き残ったかを<a href="https://jared.lynskey.co.nz/ja/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="#%e3%83%93%e3%83%ab%e3%83%89%e3%83%ad%e3%82%b8%e3%83%83%e3%82%af%e3%82%92actions%e3%81%aeyaml%e3%81%ab%e6%9b%b8%e3%81%8b%e3%81%aa%e3%81%84%e7%90%86%e7%94%b1" aria-label="アンカー">#</a>
    </span>
    
</h4>
<p>わかりやすい問題はロックインです。ActionsのワークフローはGitLabにもBitbucketにもJenkinsにも通用しないので、ビルド手順がワークフローファイルの中にあると、プラットフォームを変えるときに全部書き直しです。その作業に工数を確保するチームを、私はまだ見たことがありません。</p>
<p>もうひとつ、地味に効いてくる問題があります。パイプラインをローカルで実行できないことです。ビルドの変更を試す方法が「コミットをプッシュしてActionsのタブを眺める」しかないと、gitの履歴が「fix ci」「fix ci again」「頼む」で埋まっていきます。Composeファイルなら手元でもランナー上でも同じように動くので、「自分のマシンでは動くんですけど」という議論も始まる前にほぼ消えます。</p>
<p>そしてYAMLは育ちます。最初は整った20行だったものが、キャッシュの工夫や条件分岐が加わって300行になり、開発者は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="#%e3%82%bb%e3%83%83%e3%83%88%e3%82%a2%e3%83%83%e3%83%97" 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ツールの仕事はビルドとプッシュの2つだけになりました。理解する価値のある部分はすべて、どのプラットフォームでも、新しく入ったメンバーでも、そのまま実行できるファイルの中にあります。</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="#%e3%82%a4%e3%83%a1%e3%83%bc%e3%82%b8%e3%81%ae%e3%83%97%e3%83%83%e3%82%b7%e3%83%a5" aria-label="アンカー">#</a>
    </span>
    
</h4>
<p>Composeファイルのうれしい副作用として、プッシュがとても簡単になります。Dockerはイメージ名からプッシュ先を判断します。<code>image: myprivaterepo.com/image:latest</code>なら、プッシュ先は<code>myprivaterepo.com</code>です。プライベートレジストリの場合はアクセストークンが必要なので、そこは利用しているレジストリのドキュメントを確認してください。</p>
<p>いまGitHub Actionsで満足しているなら、急いで変える必要はありません。ただ、いつかプロバイダーを乗り換える日が来たとき、移行作業が「300行のYAMLの移植」ではなく「コマンド2つのコピー」で済みます。そしてその日までは、ビルドのデバッグを手元で落ち着いてやれるわけです。</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>
