<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>OTA Updates · 仁才徳</title>
    <link>https://jared.lynskey.co.nz/ja/tags/ota-updates/</link>
    <description>ソフトウェアエンジニアリングと開発組織のリーダーシップについて。評価、採用とチームの拡大、CI/CD、DevOps モニタリング、エージェンティック AI。ソウルより。</description>
    <generator>Hugo</generator>
    <language>ja</language>
    <copyright>© 2026 仁才徳</copyright>
    <lastBuildDate>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/ja/tags/ota-updates/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>ノート PC から無料で公開: Cloudflare Tunnel の設定方法と安全性</title>
      <link>https://jared.lynskey.co.nz/ja/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/ja/posts/2026/self-host-behind-cloudflare-tunnel/</guid>
      <pubDate>Wed, 11 Nov 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Cloudflare</category>
      <category>Cloudflare Tunnel</category>
      <category>cloudflared</category>
      <category>Self-Hosting</category>
      <category>Expose Localhost</category>
      <category>Docker Compose</category>
      <category>Django</category>
      <category>Expo</category>
      <category>OTA Updates</category>
      <category>Security</category>
      <category>Dev Costs</category>
      <description>自宅のマシンをポートを開けずにインターネットに公開。Cloudflare Tunnel とは何か、Docker Compose での設定方法、ingress の許可リストによる安全性、そして本当に無料なのかを、Mac から OTA 更新を配信した実例で解説します。</description>
      <content:encoded><![CDATA[<p>私のアイデアはいつも同じように始まります。ノート PC で動いて、ちゃんと動いて、誰かに見せたくなる。しばらくの間、その次の一歩は決まってクラウドの請求書でした。マシン、データベース、バケット、それを指すドメイン、そして三人しか見ないものに毎月かかる料金。サイドプロジェクトや、本気のプロジェクトでも最初の一か月なら、これは順番が逆です。本番があるかどうかもわからないうちに、本番の代金を払っているのですから。</p>
<p>この記事はその逆の順番の話です。サービスは作ったマシンの上で動き続け、Cloudflare Tunnel がそこに公開の HTTPS ホスト名を与えます。ノート PC の上でインターネットに向けて待ち受けているものは何もありません。ポートフォワーディングも、ルーターの設定も、むき出しの IP アドレスもなし。マシンのほうから Cloudflare に接続し、Cloudflare はその接続を通してリクエストを送り返してきます。トンネルは無料で、TLS 証明書、DDoS 対策、キャッシュは、おそらく既に持っている Cloudflare のゾーンに付いてきます。</p>
<p>例は実際のものです。私の<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-07-22-my-current-stack/" >プロダクトのひとつ</a> は Fly.io で動いていて、そのモバイルアプリはバックエンドから JavaScript の OTA(無線)更新を受け取ります(セルフホストの Expo Updates)。本番はそのままにして、開発ビルド用にその更新サーバーの二つ目のコピーを自分の Mac で動かしたかったのです。以下の出力は、うまくいかなかった部分も含めて、その日の午後のものです。</p>

<h2 class="relative group">Cloudflare Tunnel とは実際のところ何なのか
    <div id="cloudflare-tunnel-とは実際のところ何なのか" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cloudflare-tunnel-%e3%81%a8%e3%81%af%e5%ae%9f%e9%9a%9b%e3%81%ae%e3%81%a8%e3%81%93%e3%82%8d%e4%bd%95%e3%81%aa%e3%81%ae%e3%81%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>普通のサーバーは接続を待ちます。インターネットからノート PC に届くようにするには、ルーターでポートを開け、それをノート PC に転送し、ISP がパブリックな IPv4 アドレスをくれることを祈るしかありません(多くの地域では、私のところも含めて、くれません)。そうなると、そのポートで待ち受けているものはすべて、インターネット中のスキャナーから一日中届くことになります。</p>
<p>トンネルはこれを逆にします。<code>cloudflared</code> はアプリの隣で動き、Cloudflare のエッジへ外向きの接続を開きます。近くの二つのデータセンターへ四本です。ゾーンの DNS レコードがホスト名をトンネルに向けます。そのホスト名へのリクエストが届くと、Cloudflare が TLS を終端し、リクエストをその接続のどれか一本に流します。あとは <code>cloudflared</code> が、設定で指定したローカルのサービスに渡します。</p>
<figure class="diagram">
  <div role="img" aria-label="リクエストは HTTPS で Cloudflare に届き、そのルールを通ってトンネルの終端へ、そして cloudflared が自分のマシンから開いた外向きの接続を下っていく。cloudflared はホスト名とパスに応じて API、エッジ、または 404 に振り分ける"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 520" aria-hidden="true" focusable="false">
<defs><marker id="tu-ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="d-head" d="M0,0 L10,5 L0,10 z"/></marker></defs>
<text class="d-title" x="160" y="60" text-anchor="middle" font-size="30">インターネット</text>
<text class="d-title" x="560" y="60" text-anchor="middle" font-size="30">Cloudflare</text>
<text class="d-title" x="1020" y="60" text-anchor="middle" font-size="30">自分のマシン</text>
<rect class="d-box" x="40" y="200" width="240" height="100" rx="14"/>
<text class="d-label" x="160" y="245" text-anchor="middle" font-size="24">スマホ / ブラウザ</text>
<text class="d-sub" x="160" y="278" text-anchor="middle" font-size="18">ota.example.com</text>
<rect class="d-panel" x="400" y="110" width="320" height="300" rx="30"/>
<rect class="d-box" x="430" y="140" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="188" text-anchor="middle" font-size="19">TLS・リダイレクトルール・WAF</text>
<rect class="d-box" x="430" y="290" width="260" height="80" rx="14"/>
<text class="d-label" x="560" y="338" text-anchor="middle" font-size="22">トンネルの終端</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">外向きの QUIC</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">（開いたポートなし）</text>
<path class="d-line" d="M1010,315 L1048,200" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,330 L1048,325" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M1010,345 L1048,410" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="1020" y="500" text-anchor="middle" font-size="18">ingress ルール: ホスト名 + パスの許可リスト</text>
</svg>
</div><figcaption>マシンは外に向かって接続するだけ。Cloudflare のゾーンのルールはトンネルより先に動き、何に届くかは ingress ルールが決める。</figcaption>
</figure>

<p>セキュリティの面で大事なのは、ルーターに受信ルールがひとつもなく、IP アドレスが DNS に一度も出てこないことです。自宅の回線をスキャンしても何も見つかりません。そこで待ち受けているものが何もないからです。</p>

<h2 class="relative group">設定方法、最初から最後まで
    <div id="設定方法最初から最後まで" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e8%a8%ad%e5%ae%9a%e6%96%b9%e6%b3%95%e6%9c%80%e5%88%9d%e3%81%8b%e3%82%89%e6%9c%80%e5%be%8c%e3%81%be%e3%81%a7" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>必要なのは Cloudflare に載せたドメイン(無料プランで十分)と、インストール済みの <code>cloudflared</code>(Mac なら <code>brew install cloudflared</code>)です。</p>
<p><strong>1. 一度だけログインする。</strong> ブラウザが開くのでゾーンを選ぶと、このマシンがそのゾーンにトンネルと DNS レコードを作れるようにする証明書が保存されます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel login</span></span></code></pre></div></div>
<p><strong>2. トンネルを作る。</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">mkdir -p ~/.cloudflared/myapp <span class="o">&amp;&amp;</span> <span class="nb">cd</span> ~/.cloudflared/myapp
</span></span><span class="line"><span class="cl">cloudflared tunnel create --credentials-file ./creds.json myapp-local
</span></span><span class="line"><span class="cl">chmod <span class="m">600</span> creds.json</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Tunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
</span></span><span class="line"><span class="cl">Created tunnel myapp-local with id 1ade030c-…</span></span></code></pre></div></div>
<p><strong>3. ホスト名をトンネルに向ける。</strong> ホスト名ごとにコマンドを一回実行すると、<code>&lt;tunnel-id&gt;.cfargotunnel.com</code> へのプロキシ済み CNAME が作られます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local ota.example.com
</span></span><span class="line"><span class="cl">cloudflared tunnel route dns myapp-local checkin-local.example.com</span></span></code></pre></div></div>
<p><strong>4. ingress ルールを書く。</strong> セキュリティにとって肝心な部分なので、下で節をひとつ設けます。</p>
<p><strong>5. 動かす。</strong> 私のアプリは既に Docker Compose で動いているので、<code>cloudflared</code> は同じネットワーク上のコンテナがひとつ増えるだけです。アプリにはサービス名で届くので、アプリ側はホストにポートを公開する必要すらありません。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># compose.tunnel.yml, used alongside the project&#39;s docker-compose.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cloudflared</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/cloudflared:2026.10.7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">tunnel --config /etc/cloudflared/config.yml run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">${HOME}/.cloudflared/myapp:/etc/cloudflared:ro</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">restart</span><span class="p">:</span><span class="w"> </span><span class="l">unless-stopped</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">api, edge]</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflared</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">INF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
</span></span><span class="line"><span class="cl">INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quic</span></span></code></pre></div></div>
<p>ソウルへの接続が四本。これでノート PC は、外から見てインターネット上にいます。<code>restart: unless-stopped</code> のおかげで、再起動しても Docker が立ち上がればすぐに戻ってきます。</p>

<h2 class="relative group">サーバーではなくパスを公開する
    <div id="サーバーではなくパスを公開する" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%b5%e3%83%bc%e3%83%90%e3%83%bc%e3%81%a7%e3%81%af%e3%81%aa%e3%81%8f%e3%83%91%e3%82%b9%e3%82%92%e5%85%ac%e9%96%8b%e3%81%99%e3%82%8b" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>ここは私が危うくやりかけた間違いで、おそらく一番よくある間違いです。手軽な設定は一行で済みます。<code>hostname → http://localhost:8000</code>。これで公開されるのは<strong>開発サーバー丸ごと</strong>です。</p>
<p>開発サーバーはそんな使い方を想定して作られていません。私のものは <code>DEBUG=True</code> で動いていて、エラーが起きれば完全なトレースバックと設定が表示されます。認証も切ってあり(<code>AUTH_MODE=disabled</code>)、どんな Bearer トークンでもテストユーザーとしてログインできます。<code>localhost</code> ならまさにそれで正しく、公開ホスト名の上では大惨事です。あなたのところにも似たようなものがあるはずです。デフォルトパスワードのままの管理画面、データ投入用のエンドポイント、デバッグツールバー、あるいは呼ぶのが自分だけだからと呼び出し元を信用している API。</p>
<p>だからトンネルの設定は許可リストにします。<code>cloudflared</code> は各リクエストを ingress ルールと上から順に、ホスト名とパスの正規表現で照合します。最初に一致したものが勝ち、最後のルールが残りすべてを受け止めます。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">tunnel</span><span class="p">:</span><span class="w"> </span><span class="l">myapp-local</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">credentials-file</span><span class="p">:</span><span class="w"> </span><span class="l">/etc/cloudflared/creds.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">ingress</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># OTA: the two read-only endpoints the phone calls, and the files they link.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/api/expo-updates/(manifest|status)/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">ota.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/media/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://api:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Device check-ins, authenticated by each device&#39;s own key.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">hostname</span><span class="p">:</span><span class="w"> </span><span class="l">checkin-local.example.com</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">^/c/$</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http://edge:8081</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Everything else, including /admin/ and the publish endpoint: 404 at the edge.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">service</span><span class="p">:</span><span class="w"> </span><span class="l">http_status:404</span></span></span></code></pre></div></div>
<p>公開用のエンドポイントは同じサーバーの同じ接頭辞の下(<code>/api/expo-updates/import-update/</code>)にありますが、リストには入っていません。管理画面も、API の残りの部分も同じです。そうしたリクエストは Cloudflare から先へは出ていきません。</p>
<p>自分の正規表現を信じる必要はありません。URL がどのルールに一致するかは <code>cloudflared</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">cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/</span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/        service: http://api:8000
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/import-update/   service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                            service: http_status:404
</span></span><span class="line"><span class="cl">https://ota.example.com/media/expo-updates/x.js           service: http://api:8000
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                      service: http://edge:8081
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/health                  service: http_status:404</span></span></code></pre></div></div>
<p>このチェックはスクリプトにして、設定を変えるたびに走らせるのがおすすめです。数秒で終わりますし、意図より広く一致する正規表現は、他人に指摘されて初めて気づく類の間違いです。</p>
<p>経験則をあと二つ。</p>
<ul>
<li><strong>公開するものは自分で認証すること。</strong> OTA のエンドポイントが配るのは公開の読み取り専用データです。デバイスのチェックインはデバイスごとの鍵で署名されています。どちらも安全をネットワークに頼っていません。ログインのないページを誰かに見せたいなら、ログインを作るのではなく、そのホスト名の前に <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a> を置きましょう(50 ユーザーまで無料)。</li>
<li><strong>トンネルを開発ワークフローの裏で常時動かさないこと。</strong> 意図したホスト名のために意図して動かし、終わったら止めます。<code>docker compose stop cloudflared</code> のコマンド一発で、すべてがインターネットから消えます。</li>
</ul>

<h2 class="relative group">ホスト名を飲み込んだリダイレクト
    <div id="ホスト名を飲み込んだリダイレクト" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%83%9b%e3%82%b9%e3%83%88%e5%90%8d%e3%82%92%e9%a3%b2%e3%81%bf%e8%be%bc%e3%82%93%e3%81%a0%e3%83%aa%e3%83%80%e3%82%a4%e3%83%ac%e3%82%af%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-text" data-lang="text"><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/status/?…   301
</span></span><span class="line"><span class="cl">https://ota.example.com/api/expo-updates/manifest/   301
</span></span><span class="line"><span class="cl">https://ota.example.com/admin/                       301
</span></span><span class="line"><span class="cl">https://checkin-local.example.com/c/                 301</span></span></code></pre></div></div>
<p>どのパスも同じ 301 を返しました。トンネルなら 404 を返すはずのものまでです。つまりリクエストは <code>cloudflared</code> に届いていません。<code>Location</code> ヘッダーは <code>https://www.example.com/</code> で、<code>server: cloudflare</code> ヘッダーで決まりでした。Cloudflare が自分で応答していたのです。</p>
<p>原因は自分でした。何か月も前に、<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >でっち上げのサブドメインを歩き回るスキャナー向けのシンクホール</a>として、ゾーンレベルのリダイレクトルール「その他のサブドメイン → www」を追加していました。リダイレクトルールは Cloudflare のリクエスト処理の中で、リクエストがオリジンに送られる前に動きます。そしてトンネルはただのオリジンです。<a href="https://jared.lynskey.co.nz/ja/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >同じルールが私のプレビュー環境も捕まえて</a>、Workers に届く前に止めていました。対処も前回と同じで、ルールの式に例外をもうひとつ足すだけです。</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">… and not http.host in {&#34;ota.example.com&#34; &#34;checkin-local.example.com&#34;}</span></span></code></pre></div></div>
<p>トンネルのホスト名が、アプリではなく Cloudflare のリダイレクトやエラーページを返してくるなら、トンネルをデバッグする前に、そのゾーンの <strong>Rules → Redirect Rules</strong>、Page Rules、WAF のカスタムルールを確認してください。<code>cloudflared</code> は転送したリクエストをすべてログに残すので、ログが空ならリクエストはそこまで来ていません。</p>

<h2 class="relative group">できないこと
    <div id="できないこと" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%81%a7%e3%81%8d%e3%81%aa%e3%81%84%e3%81%93%e3%81%a8" aria-label="アンカー">#</a>
    </span>
    
</h2>
<ul>
<li><strong>公開インターネットからの UDP。</strong> トンネルの公開ホスト名が運ぶのは HTTP(S)(と WebSocket、gRPC)です。私のプロダクトのデバイスは普段 UDP 上の CoAP でチェックインしますが、それはトンネルを通れません。ローカルのコピーにつながるデバイスは HTTPS のチェックインにフォールバックし、そのたびに数秒余計にかかります。アイデアがゲームサーバーや VoIP なら、トンネルは向いていません。</li>
<li><strong>稼働率。</strong> ノート PC です。蓋を閉じればサイトは落ち、Cloudflare が 530 のエラーページを出します。デモや十人規模のベータ、ステージング用のコピーならそれで構いません。誰かが頼りにしているものには向きません。</li>
<li><strong>ビルドに焼き込まれるもの。</strong> モバイルアプリの更新 URL はネイティブの設定なので、トンネルの URL で作ったビルドだけがそれを使います。ストア版のビルドは本番と話し続けるので、だからこそこの構成は安心して試せました。</li>
</ul>

<h2 class="relative group">コスト: Cloudflare Tunnel は無料?
    <div id="コスト-cloudflare-tunnel-は無料" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%e3%82%b3%e3%82%b9%e3%83%88-cloudflare-tunnel-%e3%81%af%e7%84%a1%e6%96%99" aria-label="アンカー">#</a>
    </span>
    
</h2>
<p>無料プランの Cloudflare ゾーン、トンネル、その証明書、DNS レコード、合わせて 0 ドル。マシンはもともと動いていました。本当のコストは、今やあなたが稼働率の担当チームになる、ということだけです。</p>
<p>その代わりに省けるのは、「自分のマシンでは動く」と「お金を払ってホストする価値がある」の間の段階です。テスターやデザイナー、最初のお客さんに、その日の午後のうちにリンクを送れます。誰も興味を示さなければ、失ったものは何もありません。興味を示してくれたら、本物のホストへの移行は変更ひとつで済みます。CNAME を別の場所に向けるだけです。</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/self-host-behind-cloudflare-tunnel/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>自作の 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>
  </channel>
</rss>
