<?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 · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/ota-updates/</link>
    <description>Những câu chuyện ngắn về kỹ thuật phần mềm và lãnh đạo đội ngũ kỹ sư — performance review, tuyển dụng và mở rộng team, CI/CD, giám sát DevOps và AI agent. Viết từ Seoul.</description>
    <generator>Hugo</generator>
    <language>vi</language>
    <copyright>© 2026 Nhân Tài Đức</copyright>
    <lastBuildDate>Wed, 11 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/ota-updates/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>Self host ngay trên laptop: public localhost miễn phí bằng Cloudflare Tunnel</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/self-host-behind-cloudflare-tunnel/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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 là gì và có miễn phí không? Cách public localhost ra Internet với tên miền HTTPS thật, không mở cổng nào, dùng allowlist trong ingress và Docker Compose.</description>
      <content:encoded><![CDATA[<p>Ý tưởng nào của tôi cũng bắt đầu giống nhau: nó chạy trên laptop, nó chạy được, và tôi muốn người khác xem. Một thời gian dài, bước tiếp theo luôn là một hóa đơn cloud. Một máy chủ, một cơ sở dữ liệu, một bucket, một tên miền trỏ vào đó, và một khoản phí hằng tháng cho thứ mà chỉ ba người ngó tới. Với một dự án phụ, hay tháng đầu tiên của một dự án thật, đó là sai thứ tự: bạn trả tiền cho production trước khi biết liệu có production hay không.</p>
<p>Bài này đi theo thứ tự ngược lại. Dịch vụ vẫn chạy trên chính cái máy đã dựng nên nó, và một Cloudflare Tunnel cho nó một tên miền HTTPS công khai. Không có gì trên laptop lắng nghe Internet: không chuyển tiếp cổng (port forwarding), không chỉnh router, không lộ địa chỉ IP. Máy chủ động gọi ra Cloudflare, và Cloudflare gửi request ngược xuống qua chính kết nối đó. Tunnel thì miễn phí, còn chứng chỉ TLS, chống DDoS và cache đi kèm với zone Cloudflare mà có lẽ bạn đã có sẵn.</p>
<p>Ví dụ ở đây là thật. Một <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/" >sản phẩm của tôi</a> chạy trên Fly.io, và ứng dụng di động của nó nhận bản cập nhật JavaScript qua mạng (OTA) từ backend (Expo Updates tự host). Tôi muốn có một bản sao thứ hai của máy chủ cập nhật đó chạy trên Mac cho các bản build phát triển, còn production thì giữ nguyên. Các kết quả bên dưới lấy từ buổi chiều hôm đó, kể cả đoạn nó không chạy.</p>

<h2 class="relative group">Cloudflare Tunnel thực ra là gì
    <div id="cloudflare-tunnel-thực-ra-là-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-th%e1%bb%b1c-ra-l%c3%a0-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một máy chủ bình thường ngồi chờ kết nối. Muốn chạm tới laptop từ Internet, bạn phải mở một cổng trên router, chuyển tiếp nó về laptop, rồi cầu mong nhà mạng cấp cho bạn một địa chỉ IPv4 công khai (ở nhiều nơi, kể cả chỗ tôi, thì không). Sau đó mọi thứ lắng nghe trên cổng ấy đều bị mọi máy quét trên Internet chạm tới, cả ngày.</p>
<p>Tunnel đảo ngược chuyện đó. <code>cloudflared</code> chạy cạnh ứng dụng của bạn và mở các kết nối đi ra tới edge của Cloudflare, bốn kết nối tới hai trung tâm dữ liệu gần nhất. Một bản ghi DNS trong zone của bạn trỏ một tên miền vào tunnel. Khi có request tới tên miền đó, Cloudflare kết thúc TLS rồi chuyển request xuống một trong các kết nối ấy. <code>cloudflared</code> sau đó giao nó cho dịch vụ cục bộ mà cấu hình của bạn chỉ định.</p>
<figure class="diagram">
  <div role="img" aria-label="Một request đi qua HTTPS tới Cloudflare, qua các quy tắc của nó, tới điểm cuối của tunnel, rồi xuống một kết nối đi ra mà cloudflared đã mở từ máy bạn; cloudflared gửi nó tới API, edge, hoặc trả 404 tùy theo tên miền và đường dẫn"><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">Internet</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">Máy của bạn</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="19">Điện thoại / trình duyệt</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, quy tắc redirect, 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">Điểm cuối tunnel</text>
<rect class="d-group" x="820" y="110" width="400" height="360" rx="30"/>
<rect class="d-box" x="850" y="290" width="160" height="80" rx="14"/>
<text class="d-label" x="930" y="338" text-anchor="middle" font-size="22">cloudflared</text>
<rect class="d-box" x="1050" y="160" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="202" text-anchor="middle" font-size="20">api:8000</text>
<rect class="d-box" x="1050" y="290" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="332" text-anchor="middle" font-size="20">edge:8081</text>
<rect class="d-box" x="1050" y="380" width="140" height="70" rx="14"/>
<text class="d-label" x="1120" y="422" text-anchor="middle" font-size="20">404</text>
<path class="d-line" d="M280,250 L428,190" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="350" y="200" text-anchor="middle" font-size="18">HTTPS</text>
<path class="d-line" d="M560,220 L560,288" marker-end="url(#tu-ah)"/>
<path class="d-line" d="M848,330 L692,330" marker-end="url(#tu-ah)"/>
<text class="d-sub" x="770" y="315" text-anchor="middle" font-size="16">QUIC đi ra</text>
<text class="d-sub" x="770" y="360" text-anchor="middle" font-size="16">(không mở cổng)</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">quy tắc ingress: allowlist tên miền + đường dẫn</text>
</svg>
</div><figcaption>Máy chỉ kết nối đi ra. Các quy tắc của zone Cloudflare chạy trước tunnel; các quy tắc ingress quyết định thứ gì chạm tới được.</figcaption>
</figure>

<p>Về bảo mật, điểm quan trọng là router của bạn không có quy tắc chiều vào nào và địa chỉ IP của bạn không bao giờ xuất hiện trong DNS. Ai quét đường mạng nhà bạn cũng chẳng thấy gì, vì không có gì trên đó đang lắng nghe.</p>

<h2 class="relative group">Thiết lập, từ đầu đến cuối
    <div id="thiết-lập-từ-đầu-đến-cuối" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#thi%e1%ba%bft-l%e1%ba%adp-t%e1%bb%ab-%c4%91%e1%ba%a7u-%c4%91%e1%ba%bfn-cu%e1%bb%91i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bạn cần một tên miền trên Cloudflare (gói miễn phí là đủ) và cài <code>cloudflared</code> (<code>brew install cloudflared</code> trên Mac).</p>
<p><strong>1. Đăng nhập một lần.</strong> Lệnh này mở trình duyệt, bạn chọn zone, và nó lưu một chứng chỉ cho phép máy này tạo tunnel và bản ghi DNS trong zone đó:</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. Tạo tunnel.</strong> Tôi để mọi thứ của dự án này trong một thư mục riêng, vì tệp credentials chính là danh tính của tunnel: ai có nó đều phục vụ được lưu lượng cho các tên miền của bạn.</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. Trỏ tên miền vào nó.</strong> Mỗi tên miền một lệnh, tạo một CNAME có proxy tới <code>&lt;tunnel-id&gt;.cfargotunnel.com</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 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. Viết các quy tắc ingress.</strong> Đây là phần quyết định về bảo mật, nên nó có hẳn một mục riêng bên dưới.</p>
<p><strong>5. Chạy nó.</strong> Ứng dụng của tôi vốn đã chạy bằng Docker Compose, nên <code>cloudflared</code> chỉ là thêm một container trên cùng mạng. Nó gọi ứng dụng bằng tên service, và ứng dụng hoàn toàn không cần publish cổng nào ra máy host:</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>Bốn kết nối tới Seoul, và laptop đã có mặt trên Internet, nhìn từ ngoài vào. <code>restart: unless-stopped</code> đưa nó trở lại sau khi khởi động lại máy, ngay khi Docker chạy.</p>

<h2 class="relative group">Mở đường dẫn, đừng mở cả máy chủ
    <div id="mở-đường-dẫn-đừng-mở-cả-máy-chủ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#m%e1%bb%9f-%c4%91%c6%b0%e1%bb%9dng-d%e1%ba%abn-%c4%91%e1%bb%abng-m%e1%bb%9f-c%e1%ba%a3-m%c3%a1y-ch%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là lỗi tôi suýt mắc, và tôi đoán là lỗi phổ biến nhất. Cấu hình dễ nhất chỉ có một dòng: <code>hostname → http://localhost:8000</code>. Như thế là công khai <strong>toàn bộ máy chủ phát triển</strong>.</p>
<p>Máy chủ phát triển không được làm ra cho việc đó. Máy của tôi chạy với <code>DEBUG=True</code>, hiện đầy đủ traceback và settings mỗi khi có lỗi. Nó còn chạy với xác thực bị tắt (<code>AUTH_MODE=disabled</code>), nên bearer token nào cũng đăng nhập bạn thành một user test. Trên <code>localhost</code> thì vậy là đúng ý, còn trên một tên miền công khai thì là thảm họa. Máy của bạn chắc cũng có thứ tương tự: một trang admin với mật khẩu mặc định, một endpoint nạp dữ liệu mẫu, một debug toolbar, hay một API tin người gọi vì xưa nay chỉ có bạn gọi nó.</p>
<p>Vì vậy cấu hình tunnel là một danh sách cho phép (allowlist). <code>cloudflared</code> so mỗi request với các quy tắc ingress theo thứ tự, theo tên miền và theo một biểu thức chính quy trên đường dẫn. Quy tắc khớp đầu tiên thắng, và quy tắc cuối cùng hứng mọi thứ còn lại:</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>Endpoint phát hành nằm trên cùng máy chủ, dưới cùng tiền tố (<code>/api/expo-updates/import-update/</code>), và nó không có trong danh sách. Trang admin hay phần còn lại của API cũng không. Những request đó không bao giờ ra khỏi Cloudflare.</p>
<p>Bạn không cần phải tin regex của mình; <code>cloudflared</code> sẽ cho bạn biết một URL khớp quy tắc nào:</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>Tôi sẽ đưa bước kiểm tra này vào một script và chạy mỗi lần cấu hình thay đổi. Nó mất vài giây, còn một regex khớp rộng hơn ý bạn là kiểu lỗi mà bạn chỉ biết được qua người khác.</p>
<p>Thêm hai nguyên tắc nữa:</p>
<ul>
<li><strong>Thứ bạn mở ra phải tự xác thực.</strong> Các endpoint OTA phục vụ dữ liệu công khai, chỉ đọc. Check-in của thiết bị được ký bằng khóa riêng của từng thiết bị. Không cái nào dựa vào mạng để giữ an toàn. Nếu muốn cho ai đó xem một trang không có đăng nhập, hãy đặt <a href="https://developers.cloudflare.com/cloudflare-one/policies/access/"  target="_blank" rel="noreferrer">Cloudflare Access</a> trước tên miền đó (miễn phí tới 50 người dùng) thay vì tự dựng trang đăng nhập.</li>
<li><strong>Đừng để tunnel chạy ngầm như một phần quen thuộc của quy trình dev.</strong> Chạy nó có chủ đích, cho đúng tên miền bạn cần, và tắt khi xong việc. <code>docker compose stop cloudflared</code> gỡ mọi thứ khỏi Internet chỉ bằng một lệnh.</li>
</ul>

<h2 class="relative group">Cú redirect nuốt mất tên miền của tôi
    <div id="cú-redirect-nuốt-mất-tên-miền-của-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%ba-redirect-nu%e1%bb%91t-m%e1%ba%a5t-t%c3%aan-mi%e1%bb%81n-c%e1%bb%a7a-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tunnel đã kết nối, các quy tắc đã kiểm tra, tôi thử từ bên ngoài:</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>Đường dẫn nào cũng trả về cùng một mã 301, kể cả những đường dẫn mà tunnel lẽ ra phải trả 404. Nghĩa là request chưa bao giờ tới được <code>cloudflared</code>. Header <code>Location</code> ghi <code>https://www.example.com/</code>, và header <code>server: cloudflare</code> chốt lại: chính Cloudflare đang tự trả lời.</p>
<p>Tôi tự gây ra chuyện này. Mấy tháng trước tôi thêm một redirect rule ở cấp zone, &ldquo;mọi subdomain khác → www&rdquo;, làm <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >hố chặn cho các máy quét dò những subdomain bịa ra</a>. Redirect rule chạy trong pipeline xử lý request của Cloudflare trước khi request được gửi tới origin, mà tunnel thì cũng chỉ là một origin. <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >Chính quy tắc đó đã tóm các môi trường preview của tôi</a> trước khi Workers kịp thấy chúng. Cách sửa vẫn như lần trước: thêm một ngoại lệ vào biểu thức của quy tắc.</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>Nếu tên miền tunnel của bạn trả về một redirect hay một trang lỗi của Cloudflare thay vì ứng dụng của bạn, hãy kiểm tra <strong>Rules → Redirect Rules</strong>, Page Rules và mọi WAF custom rule của zone trước khi đi debug tunnel. <code>cloudflared</code> ghi log mọi request nó chuyển tiếp, nên log trống nghĩa là request chưa đi được tới đó.</p>

<h2 class="relative group">Những gì nó không làm được
    <div id="những-gì-nó-không-làm-được" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-g%c3%ac-n%c3%b3-kh%c3%b4ng-l%c3%a0m-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<ul>
<li><strong>UDP từ Internet công khai.</strong> Tên miền tunnel công khai chỉ mang HTTP(S) (cùng WebSocket và gRPC). Thiết bị của sản phẩm tôi bình thường check-in bằng CoAP trên UDP, và thứ đó không đi qua tunnel được; các thiết bị trên bản sao cục bộ chuyển sang check-in qua HTTPS, mỗi lần tốn thêm vài giây. Nếu ý tưởng của bạn là game server hay VoIP, tunnel không phải công cụ phù hợp.</li>
<li><strong>Uptime.</strong> Đây là laptop của bạn. Gập máy là site sập; Cloudflare hiện trang lỗi 530. Với một bản demo, một bản beta mười người, hay một bản staging thì không sao. Với thứ gì có người phụ thuộc vào thì không ổn.</li>
<li><strong>Những thứ đã đóng cứng vào bản build.</strong> URL cập nhật của ứng dụng di động là cấu hình native, nên chỉ những bản build tạo với URL của tunnel mới dùng nó. Các bản build trên store của tôi vẫn nói chuyện với production, và đó là lý do thử cách này là an toàn.</li>
</ul>

<h2 class="relative group">Chi phí: Cloudflare Tunnel có miễn phí không?
    <div id="chi-phí-cloudflare-tunnel-có-miễn-phí-không" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#chi-ph%c3%ad-cloudflare-tunnel-c%c3%b3-mi%e1%bb%85n-ph%c3%ad-kh%c3%b4ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một zone Cloudflare gói miễn phí, tunnel, chứng chỉ của nó và các bản ghi DNS: 0 đô. Máy thì vốn đã chạy sẵn. Chi phí thật duy nhất là giờ bạn chính là đội lo uptime.</p>
<p>Thứ nó tiết kiệm cho bạn là giai đoạn giữa &ldquo;chạy được trên máy tôi&rdquo; và &ldquo;đáng bỏ tiền ra host&rdquo;. Ngay chiều hôm đó bạn đã có thể gửi link cho một tester, một designer hay khách hàng đầu tiên. Nếu không ai quan tâm, bạn chẳng mất gì. Nếu có, chuyển sang một host thật chỉ là một thay đổi: trỏ CNAME sang chỗ khác.</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>Thay máy chủ cập nhật Expo tự dựng bằng Codemagic Patch</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Self-Hosted</category>
      <category>Mobile Development</category>
      <category>CI/CD</category>
      <description>Tôi chuyển một ứng dụng Expo thật, kèm cả dự án native đã commit, từ expo-updates sang Codemagic Patch: bộ công nghệ chạy cục bộ, quá trình di chuyển, một lỗi do chính tôi gây ra rồi sửa qua mạng (over the air), và chuyện khôi phục (rollback) trông ra sao khi một bản phát hành thật sự bị sập.</description>
      <content:encoded><![CDATA[<blockquote><p><strong>Minh bạch.</strong> Codemagic đã trả tiền để tôi thực hiện đợt di chuyển này và viết về nó. Họ được đọc duyệt một lượt để kiểm tra tính chính xác về mặt sự kiện, chứ không phải về giọng văn, và không có chi tiết nào trong bài bị sửa theo yêu cầu của họ. Chỗ nào tôi gặp vấn đề và đã báo cáo, tôi nói rõ; nếu tới lúc bạn đọc bài này mà vấn đề đó đã được sửa, thì bài cũng ghi là đã sửa.</p>
</blockquote><blockquote><p><strong>Cập nhật, ngày 23 tháng 9 năm 2026.</strong> Đội ngũ phát triển của Codemagic đã lần lượt xử lý những vấn đề tôi báo cáo. Phần lớn giờ đã được sửa, trong đó có lỗi phát hiện phiên bản trên iOS, các lượt cài bị đếm trùng, lần chạy đầu tiên im lặng và lệnh gỡ lỗi ồn ào; tài liệu giờ cũng đã đề cập tới dự án native đã commit và ngân sách số lần mở trước khi khôi phục khi sập. Ở từng chỗ trong bài, tôi đều ghi chú lại vấn đề nào đã được sửa, còn vài vấn đề vẫn chưa xử lý xong thì tôi giữ nguyên như cũ.</p>
</blockquote><blockquote><p><strong>Cập nhật, ngày 16 tháng 10 năm 2026.</strong> Lần nâng cấp native đầu tiên kể từ khi chuyển sang Patch (Expo SDK 58) đã dựng ra một bản release Android không hề có cấu hình Patch nào bên trong, và không có gì báo lỗi cả. Chuyện gì đã xảy ra và cách sửa chỉ bằng một tệp nằm ở phần <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/#l%e1%ba%a7n-n%c3%a2ng-c%e1%ba%a5p-native-%c4%91%e1%ba%a7u-ti%c3%aan" >Lần nâng cấp native đầu tiên</a>.</p>
</blockquote><p>Hồi tháng Hai, tôi có viết về chuyện <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/" >tự dựng máy chủ cập nhật cho Expo</a>. Hệ thống đó vẫn chạy tốt. maru, ứng dụng cộng đồng tôi đang vận hành, từ đó tới nay nhận mọi bản sửa lỗi qua mạng (over the air) thông qua nó, trên cả hai nền tảng, với chi phí khoảng một đô mỗi tháng.</p>
<p>Nhưng điều đó cũng có nghĩa là tôi phải tự gánh từng mảnh của nó: các bảng cơ sở dữ liệu, một endpoint API, một lệnh quản trị, một script phát hành, một khóa bí mật dùng chung, và một loạt cái bẫy mà tôi học được qua từng bản cập nhật hỏng. Khi ngồi xuống lên kế hoạch cho đợt di chuyển này, tôi mới phát hiện là script kia đã in ra dòng &ldquo;Sent 0 push notifications&rdquo; sau mỗi lần phát hành lên production, ngay từ ngày tôi viết nó, vì nó đọc một trường mà máy chủ này chưa bao giờ trả về. Chẳng ai để ý, và người ít để ý nhất chính là tôi. Tự vận hành một thứ gì đó thì đại khái là sẽ thành ra như vậy.</p>
<p>Nên khi Codemagic hỏi tôi có muốn thử <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>, phiên bản <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">mã nguồn mở</a>, tự vận hành được (self-hosted) của họ theo mô hình CodePush, tôi đồng ý với một điều kiện: phải là một ứng dụng thật chứ không phải bản demo. Ký mã (signing) thật, quy trình phát hành thật, dự án native đã commit sẵn với những chỗ sửa tay bên trong, đủ cả. Phần lớn những người rời EAS Update đều đang mang theo một ứng dụng như thế, chứ không phải một template mới tinh.</p>
<p>Dưới đây là toàn bộ quá trình, giữ nguyên cả những chỗ còn xù xì.</p>

<h2 class="relative group">Patch là gì
    <div id="patch-là-gì" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-l%c3%a0-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Patch gồm một máy chủ do bạn tự vận hành và một SDK bạn nhúng vào ứng dụng. Phía máy chủ có một API, một tiến trình chạy nền (worker), Postgres, kho lưu trữ đối tượng (object storage) tương thích S3, và một bảng điều khiển (dashboard) trên web, tất cả được đóng gói thành một bộ Docker Compose. Phía máy khách là <code>@codemagic/react-native-patch</code>, một TurboModule cho iOS và Android kèm plugin cấu hình cho Expo, cộng thêm một công cụ dòng lệnh (CLI) tên <code>cmpatch</code> để đóng gói (bundle) JavaScript của bạn và phát hành nó.</p>
<p>Sau khi đọc mã nguồn, có hai quyết định thiết kế khiến tôi chú ý.</p>
<p>Thứ nhất, ứng dụng không bao giờ hỏi API xem có bản cập nhật hay không. Khi một bản phát hành được công bố, worker ghi các manifest dạng JSON thuần vào kho lưu trữ đối tượng, và ứng dụng chỉ việc tải chúng về. Một lần kiểm tra cập nhật chỉ là hai yêu cầu GET tĩnh tới kho lưu trữ hoặc CDN; API chỉ nhận số liệu (metrics). Endpoint cũ của tôi trước đây nằm ngay trên đường đi của mọi lần mở ứng dụng. Với Patch thì chuyện đó không thể xảy ra.</p>
<p>Thứ hai, mỗi bản phát hành đều ghi rõ phiên bản nhị phân (binary) chính xác mà nó dành cho, và CLI tính ra một dấu vân tay (fingerprint) của dự án native rồi từ chối phát hành JavaScript lên một bản nhị phân có mã native không khớp. Máy chủ của tôi thì chỉ có một chuỗi phiên bản và rất nhiều hy vọng.</p>

<h2 class="relative group">Chạy thử trên laptop trước đã
    <div id="chạy-thử-trên-laptop-trước-đã" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ch%e1%ba%a1y-th%e1%bb%ad-tr%c3%aan-laptop-tr%c6%b0%e1%bb%9bc-%c4%91%c3%a3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tài liệu hướng dẫn bắt đầu bằng một bộ đánh giá chạy cục bộ (local evaluation stack), và đó đúng là chỗ nên bắt đầu.</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 cài xong trong hai giây. Còn cả bộ thì lần đầu mất 3 phút 14 giây, phần lớn là để dựng image của máy chủ và bảng điều khiển ngay trên máy, và ở chế độ không tương tác thì nó không in ra bất cứ thứ gì trong suốt quãng đó, trông y hệt như bị treo. (Chỗ này nay đã được sửa: mỗi bước giờ in ra một dòng tiến độ.) Rồi bốn container khỏe mạnh hiện lên, tất cả đều gắn vào localhost: bảng điều khiển ở cổng 8080, API ở 3000, Postgres, và MinIO ở 9100. Nó khéo léo tránh những cổng mà bộ công nghệ của tôi đang dùng sẵn.</p>
<p>Ở chế độ này, đăng nhập bị tắt, và trang nào cũng có một cảnh báo là đừng đưa nó ra ngoài Internet.</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="Trang đăng nhập của chế độ đánh giá cục bộ, với email điền sẵn và cảnh báo rằng xác thực đã bị tắt"
      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>Chế độ đánh giá cục bộ thay đăng nhập bằng GitHub bằng một email điền sẵn.</figcaption>
</figure>

<p>Bộ này có sẵn dữ liệu mẫu cho một ứng dụng tên Example Data, rất đáng xem trước khi bạn phát hành bất cứ thứ gì, vì nó cho thấy bảng điều khiển dùng để làm gì. Các con số trong hai ảnh chụp màn hình tiếp theo là dữ liệu mẫu đó, không phải người dùng thật.</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="Lịch sử phát hành của ứng dụng mẫu Example Data, với một bản canary 10% và số lượt thành công, thất bại của từng bản"
      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>Dữ liệu demo có sẵn: lịch sử phát hành với một bản canary 10%, phiên bản đích, và số lượt thành công, thất bại của từng bản.</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="Số liệu triển khai của ứng dụng mẫu: phân bố phiên bản, mức độ tiếp nhận theo thời gian, kết quả cập nhật"
      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>Dữ liệu demo có sẵn: các thiết bị thật sự đang chạy bản nào, và việc cài đặt diễn ra ra sao.</figcaption>
</figure>

<p>Để so sánh, bảng điều khiển của tôi trước đây là một danh sách các bản cập nhật trong trang quản trị, kèm một ô đánh dấu &ldquo;đang bật&rdquo;. Tôi hoàn toàn không biết có bao nhiêu điện thoại đã thật sự nhận được bản cập nhật.</p>
<p>Đăng nhập cho CLI là một luồng trên trình duyệt đàng hoàng, dùng PKCE và một callback về localhost, và trang phê duyệt cho bạn biết tài khoản nào đang yêu cầu, cùng với việc mã sẽ hết hạn sau chừng một phút.</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="Bảng điều khiển hỏi có phê duyệt một lượt đăng nhập từ CLI hay không"
      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 yêu cầu bảng điều khiển phê duyệt cho terminal.</figcaption>
</figure>


<h2 class="relative group">Trỏ nó vào một ứng dụng thật
    <div id="trỏ-nó-vào-một-ứng-dụng-thật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tr%e1%bb%8f-n%c3%b3-v%c3%a0o-m%e1%bb%99t-%e1%bb%a9ng-d%e1%bb%a5ng-th%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Patch muốn mỗi nền tảng là một ứng dụng riêng, mỗi ứng dụng được tạo sẵn hai môi trường triển khai (deployment) Staging và Production. Tôi tạo <code>maru-ios</code> trên bảng điều khiển và <code>maru-android</code> bằng <code>cmpatch app create</code>, chủ yếu để thử cả hai cách. Hộp thoại hiện ra sau khi tạo ứng dụng là màn hình hữu ích nhất của cả sản phẩm cho một đợt di chuyển: cả hai khóa triển khai (deployment key), hai URL mà SDK cần, và một ghi chú rằng khóa triển khai không phải là thông tin bí mật.</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="Hộp thoại báo đã tạo ứng dụng, hiển thị khóa triển khai Staging, Production và các URL cho SDK"
      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>Mọi thứ SDK cần, gói gọn trên một màn hình.</figcaption>
</figure>

<p>Hai điểm nhỏ. Hồi đó <code>cmpatch init</code> chỉ liên kết dự án với những ứng dụng đã tồn tại, nên nó không thể là lệnh đầu tiên, dù hướng dẫn di chuyển có ngụ ý thế nào đi nữa. Điểm này nay đã được sửa: ở chế độ tương tác, lệnh này giờ tự tạo sẵn một ứng dụng cho mỗi nền tảng. Và hộp thoại đưa URL của API là <code>http://localhost:8080</code>, trong khi tài liệu và CLI thì nói 3000. Cả hai đều chạy được, vì mỗi cổng đều phục vụ cả hai thứ, nhưng bạn sẽ thắc mắc đấy.</p>
<p>Sau đó tôi chạy <code>cmpatch doctor</code> trên ứng dụng còn nguyên chưa sửa gì, và nó tìm ra một vấn đề thật sự trong ba giây. Nó báo phiên bản nhị phân iOS là 2.0.0. Trong khi ứng dụng đang ở 2.5.1.</p>
<p>CLI tìm mọi tệp <code>Info.plist</code> bên dưới <code>ios/</code>, và nếu không tệp nào có chữ &ldquo;test&rdquo; trong đường dẫn, nó lấy tệp đầu tiên theo thứ tự bảng chữ cái. maru có một tiện ích mở rộng WidgetKit mà thư mục của nó lại xếp trước thư mục của chính ứng dụng, nên CLI lấy nhầm <code>Info.plist</code> của tiện ích mở rộng. Tệp plist đó đã cũ, vẫn còn ghi 2.0.0, đây là lỗi của tôi, nhưng hậu quả thì rất khó chịu: cơ chế tự phát hiện sẽ nhắm mọi bản phát hành iOS vào một phiên bản mà không điện thoại nào đang chạy, và sẽ chẳng có lỗi nào bắn ra. Bản cập nhật đơn giản là không bao giờ tới nơi, đúng kiểu thất bại mà tôi đã cảnh báo trong bài về chuyện tự dựng máy chủ. Trên Android thì <code>versionName</code> được đọc đúng. Cách khắc phục là truyền <code>--target-binary-version</code> hoặc <code>--plist-file</code>, và script phát hành của tôi giờ truyền phiên bản một cách tường minh, sau khi kiểm tra rằng <code>app.config.js</code> và cả hai dự án native đều thống nhất với nhau. Về sau, cũng chính lỗi này khiến bước kiểm tra manifest của <code>doctor</code> thất bại trên một môi trường triển khai đang chạy hoàn toàn bình thường, vì nó đi tìm manifest của phiên bản 2.0.0. Sau khi tôi sửa tệp plist cũ kia, <code>doctor</code> vượt qua cả 24 bước kiểm tra.</p>
<p>Codemagic đã sửa lỗi này. Giờ CLI đọc phiên bản từ tệp <code>Info.plist</code> của target ứng dụng chứ không phải của tiện ích mở rộng, và nếu có nhiều hơn một target có thể là ứng dụng chính, <code>cmpatch</code> sẽ hỏi bạn chọn cái nào.</p>

<h2 class="relative group">Phía JavaScript
    <div id="phía-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="#ph%c3%ada-javascript" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Gỡ <code>expo-updates</code> ra và thêm SDK vào chỉ là thay đổi hai dòng. Còn thay thế những gì ứng dụng vốn làm với các bản cập nhật thì phải suy nghĩ nhiều hơn.</p>
<p>maru kiểm tra cập nhật khi mở ứng dụng và mỗi khi quay lại từ chế độ nền (foreground), tối đa mười lăm phút một lần. Nếu có bản mới, nó tải về rồi đề nghị khởi động lại; bạn từ chối thì bản đó sẽ được áp dụng ở lần khởi động nguội (cold start) tiếp theo. Trong phần Cài đặt còn có nút kiểm tra thủ công, cho biết bạn đang chạy gói mã (bundle) nào, và trình báo cáo lỗi gắn nhãn mọi báo cáo bằng bản cập nhật mà nó xuất phát.</p>
<p>Điều đầu tiên tôi học được từ mã nguồn SDK là nó tra cứu mô-đun native ngay lúc import bằng <code>TurboModuleRegistry.getEnforcing</code>, hàm này sẽ ném lỗi nếu mô-đun không tồn tại. maru còn có cả bản web, và một lệnh import ở cấp cao nhất sẽ làm sập luôn ứng dụng web. Nên mọi thứ đi qua một mô-đun nhỏ nạp SDK theo kiểu lười (lazy), kèm một bản song sinh <code>.web.ts</code> trả về rỗng, cách này cũng giữ SDK hoàn toàn nằm ngoài gói mã 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>Bản thân bước kiểm tra tự động thì chủ yếu là <code>sync()</code>, hàm này không bao giờ ném lỗi mà trả về một trạng thái:</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>Mặc định của SDK cho các bản phát hành bắt buộc (mandatory) là <code>IMMEDIATE</code>, tức là nạp lại JavaScript ngay bất kể người dùng đang ở đâu. Tôi đổi thành áp dụng vào lần ứng dụng quay lại từ chế độ nền tiếp theo, vì mất một bài viết đang gõ dở là một lỗi còn tệ hơn lợi ích của hầu hết các bản sửa. Vào thời điểm đó, <code>InstallMode</code> là một kiểu hợp chuỗi (string union), chứ không phải đối tượng enum như ví dụ trong tài liệu gợi ý; giờ nó đã được export dưới dạng một đối tượng hằng, nên <code>InstallMode.ON_NEXT_RESTART</code> chạy đúng như tài liệu viết.</p>

<h2 class="relative group">Phía native, khi bạn đã commit ios/ và android/
    <div id="phía-native-khi-bạn-đã-commit-ios-và-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="#ph%c3%ada-native-khi-b%e1%ba%a1n-%c4%91%c3%a3-commit-ios-v%c3%a0-android" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hướng dẫn di chuyển bảo chạy <code>npx expo prebuild --clean</code> sau khi thêm plugin cấu hình. Tôi chạy nó trong một bản checkout riêng, và nó xóa các ảnh đi kèm, tệp cấu hình bảo mật mạng của Android, tệp kê khai quyền riêng tư (privacy manifest) của iOS và <code>Podfile.lock</code>, đồng thời hoàn tác một bản sửa cho công cụ chạy kiểm thử trong <code>build.gradle</code> cùng vài trăm dòng của <code>Info.plist</code>. Bỏ <code>--clean</code> đi thì Expo vẫn dọn sạch cả hai thư mục.</p>
<p>Đây không phải lỗi của Patch. maru khởi đầu là một ứng dụng Expo dạng quản lý (managed) rồi dần có thêm những tùy biến native, nên <code>ios/</code> và <code>android/</code> được commit vào kho và EAS dựng chúng nguyên trạng. Nhưng có rất nhiều ứng dụng trông giống như vậy, và hồi đó hướng dẫn không hề cảnh báo bạn. Giờ thì có rồi: hướng dẫn di chuyển cho Expo tách thành hai hướng, chạy prebuild cho dự án có thư mục native được sinh tự động, và liệt kê chính xác những thay đổi cần áp dụng bằng tay cho dự án đã commit thư mục native.</p>
<p>Hóa ra những gì plugin thật sự thay đổi lại đủ nhỏ để làm bằng tay. Trên iOS, một dòng import và một dòng trong <code>AppDelegate.swift</code>:</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>Trên Android, một dòng import và một tham số trong <code>MainApplication.kt</code> (đây là dạng của React Native 0.82 trở lên; các phiên bản cũ hơn thì ghi đè <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>Cộng thêm ba giá trị cấu hình cho mỗi nền tảng và việc gỡ bỏ các thiết lập <code>expo-updates</code> cũ: 13 dòng thêm vào và 20 dòng xóa đi trên cả hai dự án.</p>
<p>Plugin ghi những giá trị cấu hình đó dưới dạng hằng (literal) vào lúc prebuild, mà với dự án native đã commit thì điều đó có nghĩa là mọi bản dựng (build) đều bị gắn cứng vào một môi trường triển khai duy nhất. Tôi muốn bản dựng xem trước (preview) đi vào Staging và bản dựng cho cửa hàng đi vào Production từ cùng một dự án, nên các giá trị này được lấy từ môi trường dựng thay vì ghi cứng. Trong <code>Info.plist</code>, Xcode tự mở rộng các thiết lập dựng:</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>Và trong <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>Các khóa sau đó nằm trong từng hồ sơ dựng (build profile) của <code>eas.json</code>, ngay cạnh các giá trị <code>EXPO_PUBLIC_</code>. Giá trị rỗng nghĩa là SDK lặng lẽ không làm gì cả, với bản dựng cục bộ thì không sao, nhưng với bản dựng cho cửa hàng thì là thảm họa, nên <code>app.config.js</code> giờ sẽ ném lỗi nếu một bản dựng EAS preview hoặc production thiếu chúng, hoặc vẫn còn trỏ về localhost.</p>

<h2 class="relative group">Bản cập nhật đầu tiên
    <div id="bản-cập-nhật-đầu-tiên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt-%c4%91%e1%ba%a7u-ti%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cho bản phát hành đầu tiên, tôi đổi dòng tiêu đề của màn hình onboarding, thứ bạn thấy được mà không cần đăng nhập, rồi phát hành:</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>Mất 30 giây, phần lớn là Metro và Hermes. Worker của máy chủ công bố manifest 0.6 giây sau khi tải lên xong. Bản cập nhật là một tệp tarball nén 7.3 MB, so với 11 MB gồm gói mã Hermes cộng tài nguyên mà cách kết xuất (export) cũ của tôi tạo ra.</p>
<p>Có một chỗ phải làm cho đúng ở đây: gói mã JavaScript nhúng thẳng các giá trị <code>EXPO_PUBLIC_</code> vào lúc dựng, nên bản cập nhật phải được dựng với đúng những giá trị của bản nhị phân mà nó sẽ đáp xuống, nếu không bạn sẽ đẩy đi một đoạn JavaScript nói chuyện với sai backend. Script của tôi nạp chúng từ đúng hồ sơ <code>eas.json</code> đã dùng để dựng bản nhị phân đó. Tài liệu về CI của Patch giờ cũng đã nói rõ điểm này, kèm theo lưu ý rằng bản thân <code>release-react</code> không đọc hồ sơ EAS.</p>
<p>Rồi tôi mở lại ứng dụng. Nhật ký (log) của thiết bị cho thấy toàn bộ lần kiểm tra cập nhật chỉ là hai yêu cầu tới kho lưu trữ đối tượng, <code>meta.json</code> và <code>2.5.1/manifest.json</code>. SDK ghi một sự kiện <code>Downloaded</code> ba giây sau khi mở, xếp bản cập nhật vào hàng chờ, và ứng dụng đề nghị khởi động lại.</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="Ứng dụng mở lại trên gói mã đi kèm, đề nghị cập nhật, rồi khởi động lại vào bản phát hành 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>Mở lại, hộp thoại của chính ứng dụng khi sync() trả về update-installed, rồi bản phát hành v1 giao qua mạng.</figcaption>
</figure>


<h2 class="relative group">Lỗi là của tôi
    <div id="lỗi-là-của-tôi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%97i-l%c3%a0-c%e1%bb%a7a-t%c3%b4i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Sau đó tôi nhìn vào bảng điều khiển. Một trình giả lập (simulator) đã cài bản phát hành v1, mà nó lại hiện hai lượt cài thành công và hai người dùng đang hoạt động.</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="Môi trường Staging hiển thị hai người dùng hoạt động và hai lượt thành công cho một thiết bị"
      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>Một trình giả lập, bị đếm hai lần.</figcaption>
</figure>

<p>SDK lưu trạng thái và các sự kiện số liệu đang chờ gửi dưới dạng tệp trong container của ứng dụng, nên việc truy vết khá dễ. Có hai sự kiện <code>Success</code> với dấu thời gian (timestamp) giống hệt nhau nhưng id khác nhau. Hook của tôi xác nhận gói mã đang chạy bằng <code>notifyAppReady()</code> ngay khi được gắn (mount), rồi gọi <code>sync()</code> ngay sau đó, mà <code>sync()</code> thì tự gọi <code>notifyAppReady()</code> bên trong. Hai lời gọi chồng lên nhau, mỗi lời gọi đều thấy bản phát hành đang ở trạng thái chờ, và mỗi lời gọi đều ghi lại sự kiện. Máy chủ có khử trùng lặp theo id sự kiện, nhưng hai sự kiện này có id khác nhau, nên cả hai đều được đếm, và những bản ghi trùng đó nằm lại vĩnh viễn.</p>
<p>Cách sửa ở phía tôi là chỉ có một lần xác nhận dùng chung cho mỗi lần mở ứng dụng, mọi thứ khác đều phải chờ nó. SDK cũng có thể tự phòng ngừa các lời gọi chồng chéo, và tôi đã đề xuất điều đó. Giờ SDK đã làm vậy: các lời gọi <code>notifyAppReady()</code> chồng lên nhau, kể cả lời gọi nằm bên trong <code>sync()</code>, sẽ dùng chung một lần xác nhận, nên mỗi lượt cài chỉ được ghi nhận một lần. Tài liệu tham khảo của SDK giờ cũng ghi rõ rằng <code>sync()</code> tự xác nhận trạng thái sẵn sàng, nên ứng dụng nào đã dùng nó thì không cần gọi <code>notifyAppReady()</code> riêng nữa.</p>
<p>Phần thú vị là chuyện đẩy bản sửa đi. Đây là thay đổi thuần JavaScript, nên nó được phát hành thành bản v2, và vì thiết bị đang ở v1 có thể nhận một bản vá nhị phân (binary diff), v2 tải về dưới dạng bản vá 543 KB thay vì gói 7.3 MB, tức khoảng 7% kích thước. Sau khi khởi động lại: một lượt tải, một lượt cài, một lượt thành công, một thiết bị hoạt động.</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="Lịch sử phát hành cho thấy v1 với số liệu bị thổi phồng và v2 với số liệu đúng"
      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>Số liệu trùng của v1 vẫn còn đó; v2 đếm đúng.</figcaption>
</figure>


<h2 class="relative group">Cố tình làm hỏng
    <div id="cố-tình-làm-hỏng" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%e1%bb%91-t%c3%acnh-l%c3%a0m-h%e1%bb%8fng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khôi phục (rollback) là tính năng tôi quan tâm nhất, vì hệ thống cũ của tôi không có cơ chế nào trên thiết bị cả. Một bản cập nhật hỏng sẽ làm sập mọi lần mở ứng dụng cho tới khi tôi để ý và bỏ đánh dấu một ô.</p>
<p>Nên tôi phát hành bản v3 ném lỗi trước lần kết xuất giao diện (render) đầu tiên, nghĩa là nó không bao giờ gọi được <code>notifyAppReady()</code>. Ứng dụng tải về, đề nghị khởi động lại, rồi chết ngay về màn hình chính.</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="Màn hình chính của trình giả lập sau khi ứng dụng sập vì bản phát hành lỗi">
      <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 sập ngay lúc mở.</figcaption>
</figure>

<p>Lần mở tiếp theo lại khởi động v3 và lại sập, điều này làm tôi bất ngờ, vì tài liệu nói rằng gói mã nào sập trước khi gọi <code>notifyAppReady()</code> sẽ được khôi phục. Mã nguồn giải thích: SDK cho phép ba lần mở chưa được xác nhận trước khi kết luận là sập, vì một lần mở chưa xác nhận có thể chỉ là iOS đã diệt một tiến trình khỏe mạnh trước khi JavaScript kịp chạy. Lần mở thứ tư thì ứng dụng quay lại 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="Ứng dụng chạy lại v2 sau khi tự động khôi phục">
      <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>Lần mở thứ tư: đã quay về v2 mà không ai phải động vào máy chủ.</figcaption>
</figure>

<p>Thiết bị sau đó tự báo cáo thất bại này dưới dạng <code>crash_rollback</code>, và bảng điều khiển ghi nhận nó cho v3. Đó là một thiết kế hợp lý, nhưng cần nói thẳng ý nghĩa của nó: một bản phát hành hỏng khiến mỗi người dùng phải chịu ba lần mở ứng dụng bị sập trước khi hồi phục, và vào thời điểm đó tài liệu không nhắc tới con số này. Giờ thì tài liệu đã ghi, và ngân sách này cũng cấu hình được, qua <code>CodemagicPatchMaxLaunchAttempts</code> ở phía native hoặc tùy chọn <code>maxLaunchAttempts</code> của plugin Expo. Đó chính là lý do nên đẩy lên Staging trước, rồi mới phát hành theo từng đợt (rollout) nhỏ lên Production.</p>
<p>Chặn một bản phát hành hỏng cho tất cả những người còn lại chỉ là một lệnh, và mất chưa tới một giây:</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>Nó không viết lại lịch sử. Nó công bố lại bản phát hành trước đó thành một bản mới (v4, được đánh dấu là bản khôi phục của v2), nên bản đang gây sập vẫn nằm trong danh sách với số lượt thất bại của nó, đây là cách ghi chép trung thực. Bảng điều khiển làm điều tương tự qua một hộp thoại.</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="Khôi phục Staging từ bảng điều khiển: bản phát hành trước được công bố lại thành 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>Khôi phục từ bảng điều khiển: v2 quay lại thành v4, còn v3 vẫn nằm trong danh sách.</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="Lịch sử phát hành sau khi khôi phục, với v4 được đánh dấu là bản khôi phục và v3 ghi nhận một lượt thất bại"
      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 là bản khôi phục; v3 vẫn giữ lượt thất bại của nó.</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="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Android có hai bất ngờ, đều không phải do Patch. Quy tắc keep cho R8 mà tôi thêm cho SDK nằm trong <code>app.config.js</code>, thứ chỉ tới được dự án native thông qua prebuild, nên bản dựng release đầu tiên của tôi vô tình trở thành bài kiểm thử xem SDK có sống sót qua R8 mà không cần quy tắc đó hay không. Kết quả là có: R8 đổi tên toàn bộ các lớp của nó, mà bản cập nhật vẫn tải, cài và khôi phục bình thường. Còn trình giả lập (emulator), vừa khởi động nguội xong trong khi Gradle vẫn đang chiếm bộ nhớ, chậm đến mức bài kiểm thử đầu tiên của tôi bỏ cuộc 45 giây trước khi SDK tải xong bản cập nhật mà nó đã bắt đầu tải.</p>
<p>Bản v1 được áp dụng, với một lượt thành công và một thiết bị hoạt động, còn bản v2 gây sập thì được khôi phục sau cùng một ngân sách ba lần mở như trên.</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="Ứng dụng Android hiển thị dòng tiêu đề được giao qua mạng"
      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, đã quay về v1 sau lần khôi phục của chính nó.</figcaption>
</figure>


<h2 class="relative group">Vượt ra ngoài kịch bản suôn sẻ
    <div id="vượt-ra-ngoài-kịch-bản-suôn-sẻ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c6%b0%e1%bb%a3t-ra-ngo%c3%a0i-k%e1%bb%8bch-b%e1%ba%a3n-su%c3%b4n-s%e1%ba%bb" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bản cập nhật tới nơi và khôi phục được là phần lõi, nhưng một đội (team) sống với những tính năng còn lại, nên tôi đi qua từng cái một.</p>
<p>Phát hành theo từng đợt là tất định (deterministic), điểm này tôi thích. Một thiết bị được nhận bản mới nếu tám chữ số hex đầu của <code>md5(deviceId + &quot;-&quot; + releaseLabel)</code>, lấy modulo 100, nhỏ hơn tỷ lệ phát hành, nên với một bản phát hành nhất định, cùng một chiếc điện thoại sẽ luôn được nhận hoặc luôn không được nhận. Tôi tính ra trình giả lập của mình rơi vào nhóm 91 cho bản phát hành kế tiếp, công bố nó ở mức 25%, và trình giả lập đúng là không nhận được gì. Nâng lên 95% bằng <code>cmpatch release patch</code>, mất chưa tới một giây, thì nó cập nhật ở lần mở tiếp theo.</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="Nâng mức phát hành của một bản từ 25% lên 95% trong bảng điều khiển">
      <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>Nâng một đợt phát hành một phần từ 25% lên 95%, không cần công bố lại.</figcaption>
</figure>

<p>Rồi bản phát hành tiếp theo của tôi thất bại với mã 409: &ldquo;deployment has an active rollout below 100 percent&rdquo;. Mỗi môi trường triển khai chỉ được phép có một đợt phát hành một phần tại một thời điểm, nên bạn phải hoàn tất, tắt hoặc khôi phục bản canary trước khi đẩy bất cứ thứ gì khác. Điều đó hợp lý, và có ghi trong tài liệu, nhưng một quy trình (pipeline) tự phát hành sau mỗi lần merge sẽ đâm phải nó ngay giữa một đợt canary. Từ sau khi tôi báo cáo, CLI đã giải thích rõ ở chỗ này: nếu bạn đang bị chặn bởi một đợt phát hành một phần còn dang dở, nó cho biết chờ cũng vô ích và hướng dẫn cách hoàn tất hoặc tắt đợt đó; còn nếu có một tác vụ phát hành khác vẫn đang chạy, nó bảo bạn chờ bằng <code>release inspect --wait</code>. Tài liệu giờ cũng có riêng một phần về việc thực hiện các thay đổi phát hành theo tuần tự.</p>
<p>Bản phát hành bắt buộc dùng <code>mandatoryInstallMode</code> của SDK. Với thiết lập của tôi là áp dụng vào lần quay lại tiếp theo, ứng dụng tải bản cập nhật về và hiện hộp thoại, và khi tôi lờ hộp thoại đi, đưa ứng dụng xuống nền rồi mở lại, phiên bản mới đơn giản là đã ở đó.</p>
<p>Tắt một bản phát hành không chỉ là ngừng cung cấp nó. Ngay khi tôi tắt bản mới nhất, các manifest trỏ về bản phát hành mới nhất còn đang bật, và các thiết bị đang chạy bản bị tắt được đề nghị một bản vá để quay về đó. Tắt hết mọi bản cho một phiên bản nhị phân thì đích của manifest trở thành null, và SDK đưa ứng dụng về gói mã đi kèm ban đầu. Hook cập nhật của tôi lúc đầu không hiện hộp thoại cho trường hợp này, nên giờ tôi đã bổ sung.</p>
<p>Chuyển (promote) từ Staging lên Production mất chưa tới một giây và dùng lại đúng gói đã được kiểm thử, cùng mã băm (hash) và ghi chú, thay vì dựng lại. Lệnh này cũng nhận tỷ lệ phát hành, nên &ldquo;chuyển lên 10% của Production&rdquo; chỉ là một lệnh.</p>
<p>Dựng và phát hành cũng có thể tách thành hai bước riêng. <code>cmpatch bundle</code> tạo ra một tệp <code>.cmpatch</code>, và tùy chọn &ldquo;Bundle upload&rdquo; trên bảng điều khiển sẽ đọc nó trước khi tải lên: nền tảng, phiên bản đích, dấu vân tay, trạng thái ký, kích thước và công cụ đóng gói. Cách này hợp với mô hình CI dựng tệp, còn một người khác quyết định khi nào phát hành.</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="Tải một tệp .cmpatch lên trong bảng điều khiển: nó đọc nền tảng, phiên bản, dấu vân tay và kích thước, rồi cảnh báo dấu vân tay không khớp">
      <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>Tải gói lên: bảng điều khiển đọc tệp, rồi cơ chế bảo vệ bằng dấu vân tay lên tiếng.</figcaption>
</figure>

<p>Đó cũng là chỗ tôi thật sự va phải cơ chế bảo vệ bằng dấu vân tay. Sau khi sửa tệp plist cũ của widget, máy chủ từ chối bản phát hành tiếp theo với mã 409, vì dấu vân tay của dự án native không còn khớp với cái nó đã ghi nhận cho 2.5.1. Trên Android, chỉ sửa một dòng chú thích (comment) trong <code>app.config.js</code> cũng đủ, vì Expo đưa cả cấu hình ứng dụng vào dấu vân tay. Bảng điều khiển hiện cùng kiểm tra đó dưới dạng cảnh báo với cả hai dấu vân tay và một nút &ldquo;Upload anyway&rdquo;. Trong cả hai trường hợp, phần native trên bản nhị phân đã cài không hề thay đổi, nên bỏ qua cảnh báo là đúng. Nhưng trong thực tế, câu trả lời trung thực gần như luôn là tăng phiên bản và dựng bản mới cho cửa hàng, và script phát hành của tôi giờ nói đúng như vậy khi chuyện này xảy ra.</p>
<p>Ký mã dùng RS256: CLI ký mã băm của gói bằng một khóa riêng RSA, và SDK đối chiếu với khóa công khai được biên dịch sẵn vào bản nhị phân. Khóa được đưa vào dưới dạng một dòng base64 duy nhất, khớp với cách tiếp cận qua thiết lập dựng của tôi, và tôi đã kiểm thử trên cả hai nền tảng. Khi ứng dụng đã có khóa, một bản phát hành chưa ký bị từ chối trước cả khi tải về. Khi bật &ldquo;require code signing&rdquo; cho ứng dụng, máy chủ từ chối một lần phát hành chưa ký với thông báo lỗi nêu rõ cách sửa và dẫn liên kết tới hướng dẫn. Bản phát hành đã ký thì cài được, và SDK ghi nhận chữ ký là đã xác minh. Nó cũng từ chối một bản phát hành cũ chưa ký mà máy chủ quay về khi tôi tắt bản đã ký. Điểm cần lưu ý là khóa công khai phải có mặt trong bản nhị phân đầu tiên bạn phát hành, vì bản cập nhật không thể thêm nó vào được.</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="Chi tiết bản phát hành hiển thị chữ ký là 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>Một bản phát hành đã ký.</figcaption>
</figure>

<p>Phần đội nhóm và CI cũng được lo. Thành viên nhận một trong bốn vai trò áp dụng toàn đội (owner, admin, developer, viewer), và lời mời sẽ chờ cho tới khi người đó đăng nhập lần đầu. Token truy cập cá nhân (personal access token) chạy được với thư mục home trống, truyền qua <code>--token</code> hoặc qua biến môi trường, và danh sách token cho biết mỗi token được dùng lần cuối khi nào. Tôi cũng đã viết một workflow CI chạy thủ công, chạy cùng script phát hành đó trên một runner Linux với khóa ký lấy từ secret, dù tôi chưa chạy nó, vì nó cần máy chủ thật.</p>
<p>Một công cụ mà hồi đó tôi sẽ bỏ qua là <code>cmpatch debug ios</code>. Nó phát trực tiếp nhật ký hệ thống được lọc theo chữ &ldquo;OTA&rdquo; không phân biệt hoa thường, mà chuỗi này khớp cả với &ldquo;Rotation&rdquo;, nên một lần mở ứng dụng kéo dài hai mươi giây cho ra cả màn hình thông điệp của SpringBoard mà không có dòng nào từ ứng dụng. Lỗi này đã được sửa: giờ nó chỉ hiện những dòng mang dấu hiệu nhận biết của SDK. Bản thân SDK cũng không ghi nhật ký nhiều, chủ yếu là lỗi và cảnh báo trong bản dựng phát triển, nên đừng ngạc nhiên nếu luồng nhật ký khá im ắng. Còn cảnh báo <code>getpwuid_r</code> mà bạn có thể thấy là đến từ <code>xcrun simctl</code>, không phải từ Patch.</p>

<h2 class="relative group">Rốt cuộc thì khó tới mức nào
    <div id="rốt-cuộc-thì-khó-tới-mức-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#r%e1%bb%91t-cu%e1%bb%99c-th%c3%ac-kh%c3%b3-t%e1%bb%9bi-m%e1%bb%a9c-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Codemagic hỏi ý kiến của tôi, nên đây là ý kiến đó, nói thẳng nhất có thể.</p>
<p>Chạy thử thì dễ. Hai lệnh, vài phút, và bạn có máy chủ thật, một bảng điều khiển với dữ liệu demo, và một CLI trỏ được vào ứng dụng của chính bạn. Tôi đánh giá đây là phần tốt nhất trong giờ đầu tiên.</p>
<p>Tích hợp SDK là việc ở mức vừa phải, và phần lớn cái khó nằm ở dự án của chính bạn chứ không phải ở Patch. Với một ứng dụng Expo mới tinh có thư mục native được sinh tự động, thì đúng là chỉ cần plugin cộng vài dòng thay thế các lời gọi <code>expo-updates</code>. Với một ứng dụng như maru, có dự án native đã commit, một tiện ích mở rộng widget, một bản web, R8 và các hồ sơ dựng, thì công việc nằm ở những chỗ rìa. Tôi phải áp dụng các thay đổi native bằng tay để tránh prebuild, đưa cấu hình về lúc dựng để bản Staging và Production có thể khác nhau, và giữ SDK tránh xa bản web. Tôi cũng phải đảm bảo JavaScript trong bản cập nhật được dựng với cùng môi trường như bản nhị phân. Không việc nào khó khi đã hiểu. Lúc tôi làm thì phần lớn những điều đó chưa có trong tài liệu, dù giờ hướng dẫn cho dự án native đã commit và chuyện môi trường lúc đóng gói đều đã được bổ sung.</p>
<p>Dùng hằng ngày thì tốt. Phát hành là một lệnh và chừng nửa phút. Phát hành theo từng đợt, chuyển môi trường, tắt và khôi phục đều dưới một giây từ CLI hoặc bảng điều khiển, và thông báo lỗi nói rõ đã sai gì và phải làm gì. Các cơ chế bảo vệ của máy chủ (kiểm tra dấu vân tay, mỗi lần chỉ một canary, ký mã) nghiêm ngặt theo cách đã hơn một lần ngăn tôi làm sai.</p>
<p>Vận hành nó trên production là cam kết lớn hơn. Đó là một bộ Docker Compose với Postgres và kho lưu trữ đối tượng mà bạn phải tự lưu trữ, sao lưu và nâng cấp, cộng thêm hai tên miền và một ứng dụng OAuth. Với một đội đã quen vận hành máy chủ thì đó là một buổi chiều yên ả. Với một đội dùng EAS chính vì không muốn dính tới máy chủ, đây mới là chi phí thật của việc chuyển đổi, hơn cả phần việc với SDK.</p>
<p>Tính theo thời gian thực, tôi cài CLI lúc 12:29 và có bản cập nhật đầu tiên áp dụng trên trình giả lập lúc 13:34, trong đó nửa tiếng là một lần dựng Xcode bị treo vì lý do không liên quan tới Patch. Mọi thứ trong bài này, trên cả hai nền tảng, đều xong trước giữa buổi chiều, chưa tính máy chủ thật.</p>
<p>Những thứ ngốn của tôi nhiều thời gian nhất, theo thứ tự: <code>expo prebuild --clean</code> xóa sạch dự án native đã commit, rồi việc tìm cách nối mọi thứ bằng tay; lỗi phát hiện phiên bản trên iOS, thứ suýt gửi bản cập nhật tới một phiên bản không ai chạy; các lượt cài bị đếm trùng, lỗi do chính tôi gây ra nhưng ai cũng dễ lặp lại, vì <code>sync()</code> tự gọi <code>notifyAppReady()</code>; và ngân sách ba lần mở khi sập chưa được ghi trong tài liệu, thứ làm tôi bất ngờ giữa chừng một bài kiểm thử. Sau đó là những thứ đúng nhưng cần lên kế hoạch: cơ chế bảo vệ dấu vân tay kích hoạt sau những chỉnh sửa vô hại gần với native, mỗi môi trường triển khai chỉ một canary tại một thời điểm, và các tác vụ vòng đời không được chồng lên nhau, điều này quan trọng ngay khi một script làm nhiều việc liên tiếp. Phần còn lại đều nhỏ: lần thiết lập đầu tiên chạy im lặng, một lệnh gỡ lỗi ồn ào, và vài ví dụ trong tài liệu hồi đó không khớp với mã.</p>
<p>Tôi đã viết toàn bộ những điều này, kèm chi tiết, gửi cho Codemagic, và tới ngày 23 tháng 9 thì phần lớn đã được sửa: phát hiện phiên bản, các lần xác nhận chồng lên nhau, cảnh báo về prebuild, ngân sách số lần mở khi sập, lần thiết lập im lặng, lệnh gỡ lỗi, các ví dụ về <code>InstallMode</code>, và thông báo lỗi khi bị một tác vụ phát hành hoặc một bản canary chặn đường. Những gì còn tồn đọng thì nhỏ hơn: bảng điều khiển hiển thị API ở <code>:8080</code> trong khi mọi chỗ khác đều ghi <code>:3000</code>, SDK ném lỗi ngay lúc import trên web và trong Jest, và vài chỗ còn thiếu trong tài liệu.</p>

<h2 class="relative group">Đưa lên production
    <div id="đưa-lên-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%c6%b0a-l%c3%aan-production" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mọi thứ ở trên đều chạy trên bộ đánh giá. Production là cùng ứng dụng và cùng script đó trỏ vào một máy chủ thật, và đây là kế hoạch cho maru.</p>
<!-- EDITOR NOTE: phần này là quy trình dự kiến. Thay bằng những gì thật sự đã xảy ra (nơi lưu trữ, thời gian, thứ gì hỏng) sau khi chuyển đổi thật. -->
<p>Cách vận hành máy chủ được hỗ trợ chính thức là cài bằng Docker Compose: một máy Linux mở cổng 80 và 443, hai tên miền (một cho API và bảng điều khiển, một cho tải xuống), và một ứng dụng GitHub OAuth để đăng nhập, không có nó thì máy chủ không chịu khởi động. Trình cài đặt lo phần còn lại, kể cả 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>Nó ghi ra một tệp <code>.env.selfhost</code> chứa các khóa bí mật của cơ sở dữ liệu và kho lưu trữ, thứ nên nằm trong trình quản lý mật khẩu của bạn.</p>
<p>Nhưng bạn không bắt buộc phải dùng cơ sở dữ liệu và kho lưu trữ đi kèm, và đó chính là điểm khiến nó vừa với một bộ công nghệ như của tôi. API của maru đã chạy trên Fly.io và tệp của nó nằm trên Tigris, mà Patch chạy được trên cả hai. Bên dưới tệp Compose, máy chủ là một container chạy ở <code>MODE=all</code> (API và worker phát hành gộp chung) cần một cơ sở dữ liệu Postgres, kho lưu trữ tương thích S3 và một URL HTTPS công khai cho các tệp. Tức là một ứng dụng Fly dựng từ Dockerfile trong kho mã (chưa có image được công bố, nên <code>fly deploy</code> sẽ tự dựng), một cơ sở dữ liệu Fly Postgres, và một bucket 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>Hai điều cần lưu ý. Chạy ngoài Compose được ghi trong kho mã như tài liệu tham khảo, chứ không phải cách được hỗ trợ trong bản mã nguồn mở đầu tiên này, nên trên Fly bạn phải tự lo nâng cấp và sao lưu. Tôi cũng chưa triển khai theo cách đó; đây là kế hoạch, chưa phải báo cáo. Và nếu bạn đã đọc bài của tôi về <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >cơ chế thu về 0 của Fly</a>, hãy giữ ít nhất một máy luôn chạy. Điện thoại không bao giờ phải chờ API khi kiểm tra cập nhật, vì manifest lấy thẳng từ kho lưu trữ, nhưng nếu không thì mọi lần tải số liệu lên và mọi tác vụ phát hành sẽ bắt đầu bằng một lần khởi động nguội.</p>
<p>Tigris là phần tôi hào hứng nhất. Đó là nơi tệp của maru vốn đã nằm, và nó đặt một CDN phía trước cái URL mà mọi lần mở ứng dụng đều gọi tới. Nếu làm theo cách này, hãy để ý việc lưu đệm (cache) manifest: với bộ điều hợp (adapter) phân phối theo URL gốc đơn giản, Patch trả manifest với <code>no-cache</code>, vì nó không thể xóa bộ nhớ đệm trên CDN, và một manifest bị lưu đệm theo TTL riêng sẽ tiếp tục phục vụ một bản phát hành mà bạn vừa khôi phục xong. Tài liệu tham khảo cấu hình giờ đã ghi rõ điều này ở mục <code>MANIFEST_CACHE_CONTROL</code>, và trang tài liệu cũng đã có phần về kho lưu trữ tương thích S3.</p>
<p>Trên máy chủ thật, bạn tạo lại các ứng dụng, đưa khóa mới vào hồ sơ <code>preview</code> và <code>production</code> trong <code>eas.json</code> (khóa Staging và khóa Production), và tạo một token để phát hành:</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>Nếu muốn ký mã, hãy quyết định ngay từ bây giờ: tạo cặp khóa cho production, giữ khóa riêng làm secret trong CI, đưa khóa công khai vào các hồ sơ dựng, và bật &ldquo;require code signing&rdquo; cho cả hai ứng dụng. Bản nhị phân đầu tiên có Patch cũng cần một số phiên bản mới, vì các bản nhị phân đang nằm trên cửa hàng vẫn chạy <code>expo-updates</code>. Dựng nó bằng hồ sơ preview cho TestFlight và kiểm thử nội bộ trên Play trước, rồi kiểm tra xem các khóa có thật sự vào được <code>Info.plist</code> đã dựng và tài nguyên Android hay chưa, trước khi đưa cho bất kỳ ai. Đó là mắt xích duy nhất mà các bài kiểm thử cục bộ của tôi chưa chạm tới, vì tôi đã xuất biến môi trường bằng tay. Sau đó phát hành lên Staging, xác nhận mỗi người thử nghiệm có một lượt tải và một lượt thành công, rồi mới dựng bản cho cửa hàng.</p>
<p>Bản phát hành lên production thì bắt đầu nhỏ rồi tăng dần:</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>Trong lúc đó, máy chủ cũ vẫn giữ nguyên. Mọi bản 2.5.1 mà người dùng chưa cập nhật vẫn kiểm tra ở đó, và bản sửa nóng (hotfix) cho họ vẫn đi qua script cũ. Khi nhật ký cho thấy những yêu cầu đó đã dừng hẳn, thì máy chủ cũ, script phát hành và khóa bí mật của nó đều có thể bỏ đi.</p>

<h2 class="relative group">Lần nâng cấp native đầu tiên
    <div id="lần-nâng-cấp-native-đầu-tiên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%ba%a7n-n%c3%a2ng-c%e1%ba%a5p-native-%c4%91%e1%ba%a7u-ti%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><em>Bổ sung ngày 16 tháng 10 năm 2026.</em></p>
<p>Từ đầu tháng 10, Patch đã đảm nhận việc phân phối các bản cập nhật production của maru. Bản phát hành native đầu tiên kể từ đó là đợt chuyển lên Expo SDK 58: React Native 0.88, và trên Android là một phiên bản chính (major) mới của plugin Gradle. Thay đổi native không thể gửi qua mạng, nên đó là một bản nhị phân mới, 2.6.0, và script phát hành của tôi từ chối gửi bản cập nhật cho 2.6.0 cho tới khi bản nhị phân đó tồn tại. Cả hai nền tảng đều dựng trơn tru ngay lần đầu.</p>
<p>Phần production ở trên dặn hãy kiểm tra xem các khóa có thật sự vào được <code>Info.plist</code> đã dựng và tài nguyên Android hay chưa, trước khi đưa bản dựng cho bất kỳ ai. Lần này tôi đã kiểm tra thật, và chính bước kiểm tra đó là lý do phần này tồn tại. Bản dựng iOS có đủ bốn thiết lập của Patch. Bản dựng Android thì không có cái nào:</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>Lệnh đó in ra bốn khóa với bản dựng 2.5.11 và không in gì với 2.6.0. Vấn đề không nằm ở biến môi trường: tác vụ <code>generateReleaseResValues</code> của Gradle ghi đủ cả bốn. Chúng bị xóa ở bước sau bởi bộ thu gọn tài nguyên (resource shrinker) của bản release, thứ mà trong Android Gradle Plugin 9 đã trở nên mạnh tay hơn nhiều. Cùng một ứng dụng mà số tài nguyên giảm từ 9,240 xuống 7,116. SDK đọc cấu hình theo tên bằng <code>getIdentifier</code>, thứ mà bộ thu gọn không nhìn thấy, nên nó coi bốn chuỗi đó là không dùng tới và xóa đi. Một chuỗi bị thiếu được đọc thành rỗng, và rỗng nghĩa là &ldquo;không có cập nhật&rdquo;. Sẽ không có gì thất bại cả. Android 2.6.0 sẽ cài được, chạy bình thường và không bao giờ nhận được bản cập nhật nào, và dấu hiệu duy nhất là trên bảng điều khiển không có thiết bị Android 2.6.0 nào.</p>
<p>Cách sửa là một tệp tài nguyên bảo bộ thu gọn giữ lại bốn chuỗi đó:</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>Đừng đặt tên nó là <code>keep.xml</code>: tác vụ đóng gói của React Native đã tự sinh một tệp <code>raw/keep.xml</code> cho các tài nguyên JavaScript của bạn (đó là lý do hình ảnh và phông chữ vẫn sống sót), và hai tệp sẽ đụng nhau. Chỗ hợp lý hơn cho quy tắc này là chính SDK, vì tệp keep của một thư viện được gộp vào ứng dụng một cách tự động, và chỉ cần một cảnh báo khi bản dựng release khởi động mà không có khóa triển khai là trường hợp im lặng này sẽ trở nên dễ thấy. Đây là cùng kiểu thất bại với lỗi phát hiện phiên bản trên iOS ở phần trước: không có gì báo lỗi, và bản cập nhật cứ thế không bao giờ tới.</p>
<p>Cũng bước kiểm tra đó còn lộ ra vấn đề thứ hai chẳng liên quan gì tới Patch, và nó đáng biết vì một lý do khác. Bản dựng release Android sập ngay khi mở, tới hai lần, vì hai lý do riêng biệt nằm trong chính các mô-đun của Expo. Đầu tiên, mô-đun widget của Expo không đăng ký được các lớp native của nó, nên ứng dụng chết trước khi bất kỳ dòng JavaScript nào kịp chạy. Sửa xong lỗi đó, ứng dụng chạy được tới lúc khởi động JavaScript rồi dừng lại, vì ba mô-đun đã chuyển sang hệ thống mô-đun mới của Expo chưa bao giờ được đăng ký. Cơ chế khôi phục khi sập của Patch không giúp được gì trong cả hai trường hợp, vì không có bản cập nhật nào để khôi phục. Nó bảo vệ bạn khỏi một bản cập nhật hỏng, chứ không phải khỏi một bản nhị phân hỏng. Có Patch hay không thì một lần nâng cấp native vẫn phải được cài và mở thử trên một thiết bị thật hoặc trình giả lập trước khi lên cửa hàng.</p>
<p>Cũng từ đợt thử nghiệm đó còn một chuyện nhỏ hơn. Khi <code>sync()</code> thất bại, nó trả về <code>&quot;error&quot;</code> và giữ kín lý do, trừ khi ứng dụng đang chạy ở chế độ phát triển, nên một bản dựng release chỉ có thể ghi vào nhật ký rằng đã có gì đó trục trặc. Giờ hook của tôi gọi <code>checkForUpdate()</code> một lần sau mỗi lần sync thất bại và ghi lại lỗi mà nó ném ra, và nhờ vậy tôi mới biết một bản dựng cho trình giả lập cục bộ có các URL Patch rỗng (<code>unsupported URL</code>), chứ không phải máy chủ bị hỏng. Sẽ có ích nếu lỗi gần nhất cũng đọc được trong bản dựng release.</p>
<p>Danh sách kiểm tra cho một bản phát hành native của tôi giờ có thêm ba dòng mà trước đây không có: trích xuất các thiết lập Patch từ tệp <code>.ipa</code> và <code>.aab</code> đã dựng, so sánh khóa triển khai với hồ sơ dựng, và chạy chính bản dựng release, không phải bản dựng debug, trước khi nộp.</p>

<h2 class="relative group">Điều tôi muốn nói với ai đang trả hóa đơn cho Expo
    <div id="điều-tôi-muốn-nói-với-ai-đang-trả-hóa-đơn-cho-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="#%c4%91i%e1%bb%81u-t%c3%b4i-mu%e1%bb%91n-n%c3%b3i-v%e1%bb%9bi-ai-%c4%91ang-tr%e1%ba%a3-h%c3%b3a-%c4%91%c6%a1n-cho-expo" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn đang trả tiền cho EAS Update vì thấy tự dựng máy chủ có vẻ quá nhiều việc, thì Patch là một câu trả lời thật sự: bạn vẫn phải vận hành một máy chủ, nhưng đó là một bộ Compose và một trình cài đặt, chứ không phải mã mà bạn phải tự bảo trì. So với thứ tôi đã xây, bạn có thêm phát hành theo từng đợt, số lượt cài và thất bại của từng bản, bản vá nhị phân, cơ chế bảo vệ bằng dấu vân tay, ký mã, khôi phục khi sập ngay trên thiết bị, và một nút khôi phục. Tôi chưa từng xây được cái nào trong số đó.</p>
<p>Cái giá phải trả: một bản nhị phân mới trước khi bất kỳ ai nhận được bản cập nhật qua Patch, vì SDK phải có trong ứng dụng; một máy chủ phải giữ cho luôn được nâng cấp và sao lưu; mặc định là ba lần mở ứng dụng bị sập cho mỗi người dùng khi một bản phát hành hỏng đủ nặng; và một ít việc làm tay nếu dự án native của bạn đã commit. Nó cũng còn non. Đây là phiên bản 0.3.0, và tôi gặp một lỗi phát hiện phiên bản, một lệnh gỡ lỗi ồn ào và vài chỗ thiếu trong tài liệu, cộng thêm một lỗi do chính tôi gây ra mà SDK lẽ ra có thể ngăn được. Từ đó tới nay, lỗi phát hiện phiên bản và lệnh gỡ lỗi đều đã được sửa, phần lớn chỗ thiếu trong tài liệu đã được lấp, và SDK giờ đã tự phòng ngừa đúng cái sai mà tôi mắc phải. Không điều nào trong số đó cản trở đợt di chuyển, và tất cả đều nhìn thấy được, trên CLI, trên bảng điều khiển, hoặc ngay trong các tệp của SDK trên thiết bị, điều mà tôi không thể nói về hệ thống cũ của mình.</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>Tự host cập nhật OTA cho Expo, không cần EAS: dựng máy chủ bằng Django và Tigris</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/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>Cập nhật OTA của Expo hoạt động ra sao, và cách tôi tự host nó mà không cần EAS Update: một máy chủ Django REST nói đúng giao thức Expo Updates, gói mã nằm trên Tigris S3, chi phí vận hành chỉ khoảng một đô mỗi tháng.</description>
      <content:encoded><![CDATA[<blockquote><p><strong>Cập nhật, ngày 13 tháng 9 năm 2026.</strong> Sau khi bài này lên, Codemagic đã ra mắt <a href="https://patch.codemagic.io/"  target="_blank" rel="noreferrer sponsored">Patch</a>, một máy chủ OTA <a href="https://github.com/codemagic-ci-cd/codemagic-patch"  target="_blank" rel="noreferrer sponsored">mã nguồn mở</a>, tự vận hành được (self-hosted), theo mô hình CodePush. Nếu bạn tìm tới đây vì không muốn tự viết máy chủ, thì giờ đã có một lựa chọn thật sự: tôi đã <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >chuyển một ứng dụng từ hệ thống bên dưới sang Patch</a> và viết lại quá trình đó, ngoài ra còn có một ghi chú ngắn ở phần <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/#t%e1%bb%95ng-k%e1%ba%bft" >Tổng kết</a>. Minh bạch: Codemagic đã trả tiền để tôi thực hiện đợt di chuyển đó và viết về nó, và họ đề nghị nhắc tới ở đây. Phần mã trong bài sau đó đã được rút gọn thành các ví dụ ngắn.</p>
</blockquote><p>Với ứng dụng báo giá mà tôi đang làm, các bản sửa lỗi được đẩy thẳng qua mạng (over the air): phần mã JavaScript mới về tận điện thoại người dùng mà không phải chờ App Store duyệt. Thật ra EAS Update, dịch vụ do chính Expo vận hành, sẵn sàng lo việc này giúp bạn. Nhưng tôi vẫn tự làm một bản riêng bằng Django REST Framework và Tigris S3, và từ đó tới giờ, mọi bản cập nhật cho cả iOS lẫn Android đều đi qua hệ thống này.</p>
<p>Cơ chế cập nhật OTA của Expo như sau: thư viện <code>expo-updates</code> trong ứng dụng gửi yêu cầu lấy manifest tới một máy chủ cập nhật, kèm theo nền tảng (platform) và phiên bản runtime (runtime version) trong header. Máy chủ trả về bản cập nhật mới nhất còn tương thích, cùng URL của gói mã (bundle) JavaScript và các tài nguyên đi kèm; ứng dụng tải chúng về và chạy gói mã mới ở lần khởi động kế tiếp. EAS Update là máy chủ do Expo vận hành cho giao thức đó, nhưng thực ra máy chủ nào nói đúng giao thức cũng dùng được, và tôi đã tự làm một cái như thế.</p>
<p>Trong bài này, tôi sẽ đi qua toàn bộ hệ thống: các mô hình dữ liệu (model), endpoint trả về manifest, quy trình phát hành (publish), và cả những cái bẫy mà phải tự vận hành thì bạn mới gặp.</p>

<h2 class="relative group">Vì sao lại tự vận hành?
    <div id="vì-sao-lại-tự-vận-hành" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-l%e1%ba%a1i-t%e1%bb%b1-v%e1%ba%adn-h%c3%a0nh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với tôi thì lý do chính là tiền. EAS Update tính phí theo mức sử dụng, nên khi bạn đẩy bản cập nhật thường xuyên cho một lượng người dùng ngày càng đông, chi phí sẽ cộng dồn lên. Trong khi đó, dịch vụ lưu trữ tương thích S3 như Tigris thì gần như miễn phí. Cũng có vài lý do khác, biết đâu còn quan trọng hơn với bạn. Một số ngành bắt buộc mọi tài nguyên (asset) của ứng dụng phải nằm trong hạ tầng của chính công ty. Tự nắm máy chủ cũng có nghĩa là tự nắm luồng cập nhật: nếu sản phẩm cần, bạn có thể phát hành dần cho từng nhóm người dùng, hay thử nghiệm A/B với các gói mã (bundle) khác nhau. Và quy trình cập nhật của bạn không còn phụ thuộc vào chuyện dịch vụ của Expo có ổn định hay không, hay lần tới họ sẽ đổi bảng giá ra sao.</p>

<h2 class="relative group">Các thành phần kết nối với nhau thế nào
    <div id="các-thành-phần-kết-nối-với-nhau-thế-nào" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1c-th%c3%a0nh-ph%e1%ba%a7n-k%e1%ba%bft-n%e1%bb%91i-v%e1%bb%9bi-nhau-th%e1%ba%bf-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Hệ thống gồm bốn thành phần: một máy chủ Django trả về manifest của bản cập nhật và lưu siêu dữ liệu (metadata); Tigris chứa các tệp gói mã và tài nguyên thật; một script phát hành lo phần kết xuất (export), tải lên và đăng ký bản cập nhật; và cuối cùng là chính ứng dụng di động, được cấu hình trỏ về máy chủ của tôi thay vì máy chủ của 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">Bắt tay vào làm
    <div id="bắt-tay-vào-làm" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%aft-tay-v%c3%a0o-l%c3%a0m" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Hai mô hình dữ liệu
    <div id="hai-mô-hình-dữ-liệu" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#hai-m%c3%b4-h%c3%acnh-d%e1%bb%af-li%e1%bb%87u" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Mọi thứ đều xoay quanh hai mô hình <code>ExpoUpdate</code> và <code>ExpoUpdateAsset</code>. Mô hình thứ nhất lưu siêu dữ liệu của từng bản cập nhật:</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>Trong mô hình này có vài chỗ tôi cố ý thiết kế như vậy. <code>runtime_version</code> tương ứng với <code>runtimeVersion</code> trong <code>app.json</code>, và trường này gánh khá nhiều việc: máy khách chỉ tải những bản cập nhật có cùng phiên bản runtime (runtime version) với mình. iOS và Android được lưu thành hai bản ghi riêng, vì gói mã của hai nền tảng khác nhau. <code>is_active</code> chính là cơ chế khôi phục về phiên bản trước (rollback): chỉ cần tắt bản cập nhật bị lỗi là máy khách quay về bản trước đó. Còn <code>manifest_data</code> lưu nguyên manifest theo giao thức Expo Updates v1 dưới dạng JSON, nên về sau muốn trả manifest ra thì chỉ cần tra một lần là xong.</p>
<p><code>ExpoUpdateAsset</code> thì theo dõi từng tệp riêng lẻ:</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>Tài nguyên được định danh bằng mã băm (hash) SHA-256, nên nội dung của chúng không bao giờ đổi và có thể lưu đệm (cache) thoải mái. Một tài nguyên dùng chung cho bao nhiêu bản cập nhật cũng được.</p>

<h3 class="relative group">Endpoint manifest
    <div id="endpoint-manifest" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#endpoint-manifest" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p><code>/api/expo-updates/manifest/</code> là nơi ứng dụng và máy chủ thật sự trao đổi với nhau. Endpoint này được xây dựng theo <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">giao thức 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>Ở đây có ba chi tiết cần để ý. Thứ nhất, phản hồi (response) 204 nghĩa là “không có bản cập nhật nào”, và ứng dụng cứ thế chạy tiếp với gói mã nhúng sẵn. Thứ hai, URL của tài nguyên trong manifest là URL ký sẵn (presigned URL), nên ứng dụng tải thẳng từ CDN của Tigris chứ không phải truyền mọi thứ qua Django. Thứ ba, header <code>expo-protocol-version</code> trong phản hồi là bắt buộc, vì máy khách sẽ kiểm tra giá trị này.</p>

<h3 class="relative group">Phát hành bản cập nhật
    <div id="phát-hành-bản-cập-nhật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%c3%a1t-h%c3%a0nh-b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phần phát hành là một lệnh quản trị (management command) của Django, bên ngoài bọc thêm một shell script. Lệnh này đọc đầu ra của <code>expo export</code>, tính mã băm SHA-256 cho từng tài nguyên, tải song song gói mã và tài nguyên lên Tigris, ghi bản ghi vào cơ sở dữ liệu, và nếu cần thì đặt thêm vào bucket một tệp JSON dùng để nhập (import) dữ liệu, phục vụ việc đồng bộ lên môi trường production. Rút gọn lại thì phần quan trọng trông như sau:</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>Còn thứ tôi thật sự gõ hằng ngày là script bọc ngoài:</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>Script này nạp các biến môi trường production trước khi kết xuất, chạy được cho một hoặc cả hai nền tảng, và lo luôn phần đồng bộ lên production mà tôi sẽ nói ngay bên dưới.</p>

<h3 class="relative group">Đồng bộ lên production
    <div id="đồng-bộ-lên-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%e1%bb%93ng-b%e1%bb%99-l%c3%aan-production" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Tôi không muốn để sẵn thông tin đăng nhập (credential) của production trên laptop, nên việc triển khai lên production được chia làm hai bước. Đầu tiên là phát hành từ môi trường cục bộ (local): tài nguyên được đẩy lên Tigris, kèm một bản chụp siêu dữ liệu dạng JSON. Sau đó gọi một endpoint API trên production, kèm theo đường dẫn S3, để máy chủ tự nhập siêu dữ liệu về:</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">Trỏ ứng dụng về máy chủ của bạn
    <div id="trỏ-ứng-dụng-về-máy-chủ-của-bạn" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tr%e1%bb%8f-%e1%bb%a9ng-d%e1%bb%a5ng-v%e1%bb%81-m%c3%a1y-ch%e1%bb%a7-c%e1%bb%a7a-b%e1%ba%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trong <code>app.json</code>, bạn khai báo URL cập nhật và phiên bản runtime:</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>Chỗ này phải thật chặt chẽ: <code>runtimeVersion</code> ở ứng dụng và ở máy chủ lúc nào cũng phải khớp nhau. Mỗi khi thay đổi phần mã native hoặc nâng cấp Expo SDK, hãy tăng phiên bản runtime rồi phát hành bản cập nhật mới cho phiên bản đó.</p>

<h2 class="relative group">Lưu trữ trên Tigris
    <div id="lưu-trữ-trên-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="#l%c6%b0u-tr%e1%bb%af-tr%c3%aan-tigris" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tigris là dịch vụ lưu trữ đối tượng (object storage) tương thích S3, rẻ hơn AWS S3 khá nhiều, lại có sẵn bộ nhớ đệm tại biên (edge cache) trên toàn cầu. Điểm này rất đáng giá khi người dùng phải tải về những gói mã nặng vài MB. Cấu hình phía Django như sau:</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>Còn phần khởi tạo client S3 thì chỉ là boto3 như bình thường:</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>Mọi lượt tải đều đi qua URL ký sẵn, nên ứng dụng lấy tệp thẳng từ 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">Bảo mật
    <div id="bảo-mật" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#b%e1%ba%a3o-m%e1%ba%adt" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Endpoint manifest được cố ý để không cần xác thực, vì ứng dụng phải nhận được bản cập nhật ngay cả khi chưa có ai đăng nhập. Endpoint nhập dữ liệu thì khác hẳn. Endpoint này ghi được vào cơ sở dữ liệu production, nên bắt buộc phải có một khóa bí mật dùng chung (shared secret), và phép so sánh khóa phải chạy trong thời gian không đổi (constant-time) để chặn tấn công định thời (timing attack):</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>Tính toàn vẹn của tài nguyên thì thiết kế đã lo sẵn: tệp nào cũng được đối chiếu với mã băm SHA-256 của nó, nên tài nguyên nào bị chỉnh sửa sẽ bị loại ngay. Ngoài ra, URL ký sẵn hết hạn sau một giờ, nên không ai dùng liên kết trực tiếp (hotlink) tới gói mã của bạn mãi được.</p>

<h2 class="relative group">Giữ cho hệ thống chạy nhanh
    <div id="giữ-cho-hệ-thống-chạy-nhanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%e1%bb%af-cho-h%e1%bb%87-th%e1%bb%91ng-ch%e1%ba%a1y-nhanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nhờ chỉ mục kết hợp (composite index) trên <code>(runtime_version, platform, is_active, -created_at)</code>, truy vấn lấy manifest vẫn nhanh dù bản cập nhật có tích lại nhiều đến đâu:</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>Script phát hành tải tài nguyên lên song song:</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>Với một bản cập nhật điển hình có khoảng 50 tài nguyên, thời gian phát hành giảm từ chừng 2 phút xuống còn 15 giây. Khâu phân phối thì không phải làm gì cả: Tigris tự lưu đệm tài nguyên ở các điểm biên (edge location) gần người dùng của bạn.</p>

<h2 class="relative group">Quy trình làm việc hằng ngày
    <div id="quy-trình-làm-việc-hằng-ngày" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#quy-tr%c3%acnh-l%c3%a0m-vi%e1%bb%87c-h%e1%ba%b1ng-ng%c3%a0y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Trong lúc phát triển
    <div id="trong-lúc-phát-triển" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#trong-l%c3%bac-ph%c3%a1t-tri%e1%bb%83n" aria-label="Cọc dấu">#</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">Đưa lên production
    <div id="đưa-lên-production" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#%c4%91%c6%b0a-l%c3%aan-production" aria-label="Cọc dấu">#</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">Khôi phục về phiên bản trước
    <div id="khôi-phục-về-phiên-bản-trước" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#kh%c3%b4i-ph%e1%bb%a5c-v%e1%bb%81-phi%c3%aan-b%e1%ba%a3n-tr%c6%b0%e1%bb%9bc" aria-label="Cọc dấu">#</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">Chi phí thực tế
    <div id="chi-phí-thực-tế" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#chi-ph%c3%ad-th%e1%bb%b1c-t%e1%ba%bf" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng này hiện có khoảng 500 người dùng hoạt động. Trên Tigris, ~200 MB các bản cập nhật tích lũy từ trước tới giờ tốn chừng $0.02/tháng, còn ~50 GB lưu lượng tải ra (egress) mỗi tháng từ các lượt tải bản cập nhật thì khoảng $1.00. Phần Django chạy ké trên chính máy chủ đang chạy API của tôi, nên không phát sinh thêm đồng nào. Tính tròn thì tổng cộng là một đô mỗi tháng. Với mức sử dụng tương tự, EAS Update sẽ rơi vào khoảng $300–500/năm, nên có thể nói hệ thống này hoàn vốn gần như ngay lập tức.</p>

<h2 class="relative group">Giám sát và gỡ lỗi
    <div id="giám-sát-và-gỡ-lỗi" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%c3%a1m-s%c3%a1t-v%c3%a0-g%e1%bb%a1-l%e1%bb%97i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phần này chẳng có gì cầu kỳ. Viewset ghi nhật ký (log) mọi yêu cầu lấy manifest:</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>Trên thực tế, Django admin chính là bảng điều khiển. Chỉ cần đăng ký các mô hình là bạn xem và lọc được mọi thứ:</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>Còn ở phía máy khách, <code>expo-updates</code> sẽ cho bạn biết nó đang thấy gì:</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">Những cái bẫy
    <div id="những-cái-bẫy" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-c%c3%a1i-b%e1%ba%aby" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Lệch phiên bản runtime
    <div id="lệch-phiên-bản-runtime" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#l%e1%bb%87ch-phi%c3%aan-b%e1%ba%a3n-runtime" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Lỗi hay gặp nhất cũng là lỗi khó phát hiện nhất: máy khách chỉ tải những bản cập nhật khớp với phiên bản runtime của mình. Nếu ứng dụng đã cài đang ở runtime <code>1.0.0</code> mà bạn lại phát hành cho <code>1.0.1</code>, thì sẽ chẳng có bản cập nhật nào về máy, mà cũng chẳng có lỗi nào báo ra. Hãy giữ phiên bản runtime đồng bộ với các bản dựng (build), và chỉ tăng nó khi phần mã native thay đổi.</p>

<h3 class="relative group">Dấu thời gian createdAt
    <div id="dấu-thời-gian-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="#d%e1%ba%a5u-th%e1%bb%9di-gian-createdat" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Thư viện <code>expo-updates</code> phía máy khách so sánh <code>createdAt</code> trong manifest với <code>commitTime</code> của gói mã nhúng sẵn, và chỉ áp dụng bản cập nhật nào có <code>createdAt</code> mới hơn. Trong viewset, tôi ghi đè giá trị này bằng dấu thời gian (timestamp) lấy từ cơ sở dữ liệu:</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>Khi phát triển trên máy cục bộ, nếu bạn dựng lại bản nhị phân của ứng dụng <em>sau khi</em> đã phát hành OTA, hãy phát hành lại bản OTA đó để dấu thời gian của nó mới hơn.</p>

<h3 class="relative group">Giá trị <code>key</code> của tài nguyên trong manifest
    <div id="giá-trị-key-của-tài-nguyên-trong-manifest" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#gi%c3%a1-tr%e1%bb%8b-key-c%e1%bb%a7a-t%c3%a0i-nguy%c3%aan-trong-manifest" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Trường <code>key</code> trên mỗi tài nguyên trong manifest là thứ <code>expo-updates</code> dùng để lưu đệm, và giá trị này phải là một mã băm tất định (MD5 của tên tệp là được), chứ không phải UUID ngẫu nhiên hay một chuỗi tùy ý. Làm sai chỗ này thì máy khách có thể không lưu đệm hoặc không lấy lại được tài nguyên đúng cách, và bản cập nhật sẽ âm thầm hỏng sau lần tải thành công đầu tiên. Cảm ơn bạn đọc Raphael Mutschler đã chỉ ra lỗi này: bản cập nhật bên anh ấy chỉ chạy được đúng một lần, rồi anh mới tìm ra nguyên nhân.</p>

<h3 class="relative group">Cấu hình ứng dụng trong <code>expoClient</code>
    <div id="cấu-hình-ứng-dụng-trong-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="#c%e1%ba%a5u-h%c3%acnh-%e1%bb%a9ng-d%e1%bb%a5ng-trong-expoclient" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Nếu ứng dụng của bạn dùng <code>Linking</code>, <code>Constants</code>, hay bất cứ thứ gì đọc cấu hình ứng dụng (app config) lúc chạy, thì trường <code>extra.expoClient</code> trong manifest phải chứa cấu hình đó. Thiếu trường này, lúc đầu ứng dụng có thể vẫn mở lên bình thường, nhưng sau khi bị đóng thì bị sập (crash) hoặc không chịu mở lại nữa. Lý do là <code>expo-updates</code> thay manifest nhúng sẵn bằng manifest OTA, và nếu thiếu <code>expoClient</code> thì các API kia mất luôn phần cấu hình mà chúng dựa vào. Lỗi này cũng do Raphael Mutschler phát hiện.</p>

<h3 class="relative group">Dọn dẹp tài nguyên
    <div id="dọn-dẹp-tài-nguyên" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#d%e1%bb%8dn-d%e1%ba%b9p-t%c3%a0i-nguy%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Các bản cập nhật cũ cứ thế chất đống trên Tigris. Hiện tại tôi vẫn dọn bằng tay:</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>Rõ ràng đoạn này nên được đưa vào một tác vụ chạy định kỳ (scheduled task). Chỉ là tôi chưa có thời gian làm.</p>

<h2 class="relative group">Những thứ tôi muốn làm tiếp
    <div id="những-thứ-tôi-muốn-làm-tiếp" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#nh%e1%bb%afng-th%e1%bb%a9-t%c3%b4i-mu%e1%bb%91n-l%c3%a0m-ti%e1%ba%bfp" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><em>(Tháng 9 năm 2026: Patch giờ đã có sẵn cả ba thứ này và hơn thế nữa; xem <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >bài viết về đợt di chuyển</a>.)</em></p>

<h3 class="relative group">Phát hành theo từng đợt
    <div id="phát-hành-theo-từng-đợt" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%c3%a1t-h%c3%a0nh-theo-t%e1%bb%abng-%c4%91%e1%bb%a3t" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Chỉ cần thêm một trường <code>rollout_percentage</code> là có thể phát hành bản cập nhật cho một phần người dùng trước:</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">Tách riêng bản cập nhật cho staging
    <div id="tách-riêng-bản-cập-nhật-cho-staging" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%c3%a1ch-ri%c3%aang-b%e1%ba%a3n-c%e1%ba%adp-nh%e1%ba%adt-cho-staging" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Thêm một trường <code>environment</code> thì các bản dựng cho môi trường staging có thể nhận bản cập nhật khác với production:</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">Thống kê lượt tải
    <div id="thống-kê-lượt-tải" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#th%e1%bb%91ng-k%c3%aa-l%c6%b0%e1%bb%a3t-t%e1%ba%a3i" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Và một mô hình nhỏ nữa sẽ trả lời đàng hoàng câu hỏi “rốt cuộc đã có ai nhận được bản này chư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="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">Tổng kết
    <div id="tổng-kết" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#t%e1%bb%95ng-k%e1%ba%bft" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Toàn bộ hệ thống chỉ gồm hai mô hình và vài view của Django, một script phát hành, và một đô mỗi tháng tiền hạ tầng. Ít hơn nhiều so với tôi nghĩ lúc bắt đầu, và từ đó tới nay nó vẫn lặng lẽ làm tốt việc của mình cho ứng dụng trên cả hai nền tảng.</p>
<p>Phần mã ở trên là các ví dụ đã được rút gọn từ chính ứng dụng đang chạy production đó, nên bạn cứ điều chỉnh cho hợp với dự án của mình. Còn nếu muốn bắt đầu từ một thứ đã chạy được sẵn, bạn đọc Raphael Mutschler cũng đã công bố một phiên bản độc lập của riêng anh ấy tại <a href="https://github.com/mutschler/expo-ota-server"  target="_blank" rel="noreferrer">expo-ota-server</a>.</p>

<h3 class="relative group">Nếu bạn không muốn tự viết máy chủ
    <div id="nếu-bạn-không-muốn-tự-viết-máy-chủ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#n%e1%ba%bfu-b%e1%ba%a1n-kh%c3%b4ng-mu%e1%bb%91n-t%e1%bb%b1-vi%e1%ba%bft-m%c3%a1y-ch%e1%bb%a7" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Từ lúc tôi viết bài này, đã có hai thứ xuất hiện làm được cùng việc mà không cần tới Django. Thứ nhất là <code>expo-ota-server</code> của Raphael ở trên. Thứ hai là <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">mã nguồn mở</a>, chạy trên máy của chính bạn qua một bộ cài Docker Compose, và nói giao thức CodePush thay vì Expo Updates, nên bạn thay <code>expo-updates</code> bằng SDK của nó và thay <code>eas update</code> bằng <code>cmpatch release-react</code>. Nó dùng được kho lưu trữ tương thích S3, nên một bucket Tigris như trong bài này cũng chạy được.</p>
<p>Ngay từ đầu, nó đã có sẵn những thứ tôi liệt kê ở mục &ldquo;Những thứ tôi muốn làm tiếp&rdquo; mà chưa bao giờ xây: môi trường triển khai staging và production riêng, phát hành theo tỷ lệ phần trăm, số lượt tải và cài của từng bản, cập nhật bắt buộc, bản vá nhị phân, ký mã (code signing), và khôi phục (rollback) ngay trên thiết bị khi gói mã mới bị sập trước khi ứng dụng kịp báo sẵn sàng. Cái giá phải trả là một bản nhị phân native mới trước khi bất kỳ ai nhận được bản cập nhật qua Patch, vì SDK phải có trong ứng dụng, và một máy chủ phải vận hành thay cho một lệnh quản trị.</p>
<p>Tôi đã chuyển một ứng dụng của chính mình, với dự án native đã commit và quy trình phát hành thật, từ hệ thống trong bài này sang Patch. <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-13-moving-to-codemagic-patch/" >Bài viết đó</a> kể lại những gì chạy tốt, những gì không, và thật sự thì khó tới mức nào. Codemagic tài trợ cho phần việc đó và có một lượt đọc duyệt về tính chính xác của sự kiện, chứ không phải duyệt biên tập.</p>
<hr>
<p><em>Muốn đọc toàn bộ đặc tả, bạn tham khảo <a href="https://docs.expo.dev/technical-specs/expo-updates-1/"  target="_blank" rel="noreferrer">tài liệu đặc tả giao thức Expo Updates</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>
