<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/">
  <channel>
    <title>CI/CD · Nhân Tài Đức</title>
    <link>https://jared.lynskey.co.nz/vi/tags/ci/cd/</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>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/vi/tags/ci/cd/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>pytest-testmon: chỉ chạy những bài kiểm thử mà thay đổi code của bạn ảnh hưởng tới</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-26-pytest-testmon/</guid>
      <pubDate>Mon, 26 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Django</category>
      <category>pytest</category>
      <category>Testing</category>
      <category>Test Impact Analysis</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Claude Code</category>
      <category>AI Agents</category>
      <category>Efficiency</category>
      <description>pytest-testmon chỉ chạy các bài kiểm thử mà thay đổi có thể chạm tới, dựa trên bản ghi coverage chứ không phỏng đoán. Thêm vào bộ 2,248 bài Django, script bọc, và một bẫy ở Python 3.14.</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a> là một plugin của pytest, chỉ chạy những bài kiểm thử mà thay đổi của bạn có thể ảnh hưởng tới. Nó dùng coverage.py để theo dõi xem mỗi bài kiểm thử thực thi những dòng code nào, rồi lưu lại mã kiểm tra (checksum) của từng khối code mà bài đó đã chạm vào. Ở lần chạy sau, nó đem các checksum này so với code đang nằm trên đĩa, và chỉ chạy những bài có khối code đã thay đổi, cộng thêm những bài thất bại ở lần trước, nên nó làm luôn cả việc của <code>pytest --lf</code>.</p>
<p>Kỹ thuật chung này gọi là phân tích tác động kiểm thử (test impact analysis), và testmon là một cách miễn phí, mã nguồn mở để có được nó trong Python. Nó cũng hoàn toàn không phụ thuộc vào git: thứ nó đem ra so là những gì nó thấy ở lần chạy trước, dù code đó đã commit hay chưa.</p>
<p>Tuần này tôi đã thêm nó vào bộ kiểm thử Python của ứng dụng chợ mà tôi đang làm, và lý do không đơn thuần là muốn chạy nhanh hơn.</p>
<figure><video
    class="my-0 rounded-md"
    controls playsinline preload="none"
    poster="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film-poster.jpg" style="background:url('data:image/webp;base64,UklGRmYAAABXRUJQVlA4IFoAAADwBACdASogABIAPw1wqUqmIyGYDAQAYxDE9IAAS3igrsLc+giUXH/mz758faBcoAAA/vVl8+gZ7GYAPuvC1znoo64X/UaaVXzZOS4gxmDEyDUzHvkl6JAAAAA=') center/cover no-repeat"
    width="1280"
    height="720"
    aria-label="Video ngắn có lời dẫn kể lại bài viết này">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film.webm" type="video/webm">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_en.vtt" srclang="en" label="English">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ja.vtt" srclang="ja" label="日本語">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ko.vtt" srclang="ko" label="한국어">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_vi.vtt" srclang="vi" label="Tiếng Việt" default>
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_zh-cn.vtt" srclang="zh-cn" label="简体中文">
  </video><figcaption>Bài viết này dưới dạng video dài khoảng tám phút. Lời dẫn bằng tiếng Anh, có phụ đề tiếng Việt.</figcaption>
</figure>


<h2 class="relative group">Vì sao tôi thêm nó
    <div id="vì-sao-tôi-thêm-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="#v%c3%ac-sao-t%c3%b4i-th%c3%aam-n%c3%b3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một yêu cầu tính năng (feature request) thường rất nhỏ: một màn hình, một endpoint, hoặc một quy tắc cần hoạt động khác đi. Thế nhưng việc phải chạy toàn bộ bộ kiểm thử khiến yêu cầu nào cũng phình to hơn thực tế. Bộ kiểm thử có 2,248 bài kiểm thử Django, chạy với bốn tiến trình mất khoảng ba phút rưỡi, còn trên một tiến trình là 11 phút, và lần sửa nào cũng chạy hết toàn bộ, dù gần như tất cả các bài đều kiểm tra những thứ mà yêu cầu đó chưa hề động tới. Khi một bài chẳng liên quan gì bị thất bại, hoặc đơn giản là lần chạy cứ thong thả kéo dài, thì tính năng phải ngồi chờ. Nó bị giữ lại bởi những chuyện nằm ngoài yêu cầu tính năng, và thứ giữ nó lại lại chính là bộ kiểm thử của tôi.</p>
<p>Cách còn lại cũng chẳng tốt hơn: bỏ qua lần chạy toàn bộ, tự đoán xem tệp kiểm thử nào bao phủ thay đổi, và thỉnh thoảng đoán sai. Làm vậy thì nhanh, nhưng không chính xác, mà một thay đổi chưa được kiểm thử đã bị tôi đem phát hành thì cũng là một yêu cầu tính năng tôi sẽ phải làm lại lần thứ hai.</p>
<p>Những bài kiểm thử tôi phải chờ phải đúng là những bài mà thay đổi đó có thể chạm tới, không thừa một bài, không thiếu một bài. testmon cho tôi đúng tập hợp đó dựa trên dữ liệu đã ghi lại, chứ không phải dựa trên phỏng đoán. Bộ kiểm thử đầy đủ thì vẫn chạy, nhưng chạy trong CI, ở một chỗ không cản trở việc tiếp theo tôi đang làm.</p>

<h2 class="relative group">Cách nó quyết định chạy bài nào
    <div id="cách-nó-quyết-định-chạy-bài-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%a1ch-n%c3%b3-quy%e1%ba%bft-%c4%91%e1%bb%8bnh-ch%e1%ba%a1y-b%c3%a0i-n%c3%a0o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>testmon chia mỗi tệp Python thành các khối: phần cấp cao nhất của module, từng class, từng hàm, trong đó thân của các hàm lồng bên trong được tách ra khỏi khối bao quanh nó. Nó lưu checksum của từng khối chứ không bao giờ lưu bản thân code. Trong lúc một bài kiểm thử chạy, coverage.py ghi lại những dòng được thực thi, còn testmon ghi nhận các dòng đó thuộc về khối nào. Ở đây không có phân tích tĩnh (static analysis), cũng không có chuyện đoán mò dựa vào các câu import. Một bài kiểm thử phụ thuộc vào đúng những khối mà nó đã chạy qua.</p>
<p>Lấy ví dụ một module 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"># pricing.py</span>
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">subtotal</span><span class="p">(</span><span class="n">items</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nb">sum</span><span class="p">(</span><span class="n">item</span><span class="o">.</span><span class="n">price</span> <span class="o">*</span> <span class="n">item</span><span class="o">.</span><span class="n">quantity</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span><span class="p">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">def</span> <span class="nf">discount</span><span class="p">(</span><span class="n">total</span><span class="p">,</span> <span class="n">code</span><span class="p">):</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="n">code</span> <span class="o">==</span> <span class="s2">&#34;WELCOME10&#34;</span><span class="p">:</span>
</span></span><span class="line"><span class="cl">        <span class="k">return</span> <span class="n">total</span> <span class="o">*</span> <span class="mf">0.9</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="n">total</span></span></span></code></pre></div></div>
<p>Một bài kiểm thử chỉ gọi <code>subtotal()</code> sẽ phụ thuộc vào phần cấp cao nhất của module và thân hàm <code>subtotal</code>. Bạn có viết lại <code>discount()</code> thế nào đi nữa thì testmon cũng không chạy lại bài đó, vì nó chưa từng thực thi dòng nào của <code>discount</code>. Ngược lại, chỉ cần đổi một ký tự bên trong <code>subtotal</code> là bài đó chạy lại, kể cả khi thay đổi ấy không thể làm kết quả khác đi. testmon không cố tỏ ra thông minh với các tác dụng phụ: khối nào bạn đã thực thi mà bị thay đổi thì bạn chạy lại.</p>
<p>Nó còn theo dõi cả những thứ bao quanh code. Bản ghi bao gồm phiên bản Python và các gói đã cài cùng phiên bản của chúng. Nó không theo dõi biến môi trường: nếu cùng một bộ code chạy dưới hai module settings khác nhau, bạn phải đặt tên cho chúng bằng <code>--testmon-env</code> (bước 8), không thì chúng dùng chung một bản ghi. Nâng cấp Django thì mọi bài từng chạm vào Django lại bị tính là bị ảnh hưởng, mà trong một ứng dụng Django thì đó là tất cả.</p>

<h2 class="relative group">Những gì nó không nhìn thấy
    <div id="những-gì-nó-không-nhìn-thấ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-g%c3%ac-n%c3%b3-kh%c3%b4ng-nh%c3%acn-th%e1%ba%a5y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>testmon theo dõi phần code Python mà bài kiểm thử thực thi. Nó không theo dõi những tệp khác mà bài kiểm thử đọc: template, JSON, văn bản, hay bất cứ thứ gì khác trên đĩa. Nó không theo dõi các dịch vụ được gọi qua mạng. Và những gì chạy một lần trước khi các bài kiểm thử bắt đầu thì nó không thể gắn vào một bài cụ thể nào.</p>
<p>Trong một dự án Django, trước hết đó là migration: chúng chỉ chạy một lần, lúc tạo cơ sở dữ liệu kiểm thử, nên không bài kiểm thử nào có chúng trong bản ghi của mình (script bọc ở bên dưới sẽ dựng lại cơ sở dữ liệu kiểm thử khi có migration mới). Tiếp đến là settings và code khởi động, chạy trong lúc pytest khởi động, nằm ngoài mọi bài kiểm thử riêng lẻ. Và cuối cùng là template cùng các tệp dữ liệu: template không phải là Python, nên sửa một template sẽ không làm chạy lại bài nào render nó.</p>
<p>Bộ kiểm thử của tôi còn có thêm vài trường hợp riêng. Một số bài đọc một tệp JSON chứa dữ liệu mẫu (fixture) dùng chung với bộ kiểm thử của ứng dụng di động. Một số bài kiểm tra gói web đã dựng (bundle), và một số khác kiểm tra đầu ra của một trang tĩnh được sinh ra ngay trong cùng kho mã. Đổi bất cứ thứ nào trong số đó, testmon sẽ vui vẻ báo cho bạn rằng chẳng cần chạy gì cả. Đó là lý do phần hướng dẫn bên dưới bọc testmon trong một script, để script nhận ra những tệp này và chạy toàn bộ khi có tệp nào đã thay đổi.</p>

<h2 class="relative group">Cách thêm nó vào một dự án Django
    <div id="cách-thêm-nó-vào-một-dự-án-django" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1ch-th%c3%aam-n%c3%b3-v%c3%a0o-m%e1%bb%99t-d%e1%bb%b1-%c3%a1n-django" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Các bước dưới đây giả định bạn dùng pytest và <a href="https://pytest-django.readthedocs.io/"  target="_blank" rel="noreferrer">pytest-django</a>, cùng với uv để quản lý thư viện phụ thuộc. Nếu dùng pip thì thay <code>uv add --dev</code> bằng <code>pip install</code> và bỏ <code>uv run</code> đi.</p>
<p><strong>1. Cài đặt.</strong></p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv add --dev pytest-testmon</span></span></code></pre></div></div>
<p><strong>2. Bảo đảm pytest chạy được bộ kiểm thử.</strong> testmon là một plugin của pytest, nên khi chạy bằng <code>python manage.py test</code> thì nó không làm gì cả. pytest-django cần biết module settings và các mẫu tên tệp mà bài kiểm thử của bạn dùng:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-ini" data-lang="ini"><span class="line"><span class="cl"><span class="c1"># pytest.ini</span>
</span></span><span class="line"><span class="cl"><span class="k">[pytest]</span>
</span></span><span class="line"><span class="cl"><span class="na">DJANGO_SETTINGS_MODULE</span> <span class="o">=</span> <span class="s">myproject.settings</span>
</span></span><span class="line"><span class="cl"><span class="na">python_files</span> <span class="o">=</span> <span class="s">tests.py test_*.py *_tests.py</span>
</span></span><span class="line"><span class="cl"><span class="na">python_classes</span> <span class="o">=</span> <span class="s">Test* *Tests *TestCase</span></span></span></code></pre></div></div>
<p>Nếu từ trước đến giờ bộ kiểm thử chỉ chạy bằng trình chạy kiểm thử của Django, hãy chạy nó bằng pytest một lần và sửa hết những chỗ khác biệt trước khi thêm testmon. Nếu không, bạn sẽ phải gỡ lỗi hai thứ cùng một lúc.</p>
<p><strong>3. Đừng để bản ghi lọt vào git.</strong> testmon ghi tệp <code>.testmondata</code> vào thư mục gốc của pytest. Đây là một cơ sở dữ liệu SQLite, nên bạn sẽ thấy cả các tệp <code>-shm</code> và <code>-wal</code> nằm cạnh nó, và script bên dưới còn thêm một tệp đánh dấu thời điểm (stamp). Chỉ một mẫu là đủ cho tấ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"># .gitignore
</span></span><span class="line"><span class="cl">.testmondata*</span></span></code></pre></div></div>
<p><strong>4. Ghi lại toàn bộ bộ kiểm thử một lần.</strong> testmon chưa chọn được gì cho đến khi nó đã thấy mọi bài kiểm thử chạy qua:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">uv run pytest --testmon</span></span></code></pre></div></div>
<p>Trong một bản checkout mới tinh, lần chạy đầu tiên đó là toàn bộ bộ kiểm thử trên một tiến trình. Ở dự án của tôi, lần đó mất khoảng 12 phút cho 2,248 bài. Từ đó trở đi, cũng lệnh này sẽ chỉ chạy những bài kiểm thử bị ảnh hưởng. Không có gì thay đổi thì nó không chạy gì cả.</p>
<p>Có hai chỗ dễ vấp. Với testmon 2.2.0 trở về trước, nó cần plugin cache của pytest: lần thử đầu tiên của tôi chạy với <code>-p no:cacheprovider</code> và bị sập với lỗi <code>KeyError: 'lf'</code> (bản 2.3.0 coi việc thiếu <code>--lf</code> là chưa đặt). Còn nếu bạn dùng Python 3.14 trở lên, hãy đặt <code>COVERAGE_CORE=ctrace</code> trước khi ghi. Lý do được giải thích ở phần &ldquo;Lỗi trông như một lần chạy toàn xanh&rdquo; phía dưới. Đây là dòng quan trọng nhất trong script bên dưới.</p>
<p>Cập nhật ngày 7 tháng 11: testmon 2.3.0, phát hành ngày 2 tháng 11, tự ép coverage dùng tracer C, nên với phiên bản này dòng export chỉ còn là cho chắc. Nếu bạn đang ở 2.2.0 trở về trước thì cứ giữ nó. Còn bước cố ý làm hỏng code để kiểm tra thì phiên bản nào cũng nên làm.</p>
<p><strong>5. Bọc nó lại để lo những tệp nó không nhìn thấy.</strong> Đây là phần giúp bạn yên tâm dùng nó hằng ngày. Script giữ một tệp stamp từ lúc bắt đầu lần chạy trước. Nếu có tệp nào mà testmon không lần theo được lại mới hơn tệp stamp, script sẽ chạy toàn bộ và ghi lại từ đầu. Một migration mới thì chỉ khiến script dựng lại cơ sở dữ liệu kiểm thử. Còn không thì để testmon tự chọn. Một ví dụ đã cắt gọ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"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/test-changed.sh: the tests a change can affect, or all when in doubt.</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh            # affected tests</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh --all      # everything, re-recorded</span>
</span></span><span class="line"><span class="cl"><span class="c1">#   scripts/test-changed.sh -k orders  # anything else goes to pytest</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nb">cd</span> <span class="s2">&#34;</span><span class="k">$(</span>dirname <span class="s2">&#34;</span><span class="nv">$0</span><span class="s2">&#34;</span><span class="k">)</span><span class="s2">/..&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># coverage&#39;s C tracer: from Python 3.14 coverage defaults to sys.monitoring,</span>
</span></span><span class="line"><span class="cl"><span class="c1"># which reports a line only the first time a process runs it, so testmon</span>
</span></span><span class="line"><span class="cl"><span class="c1"># would credit each line to one test. Changing this means --all.</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">COVERAGE_CORE</span><span class="o">=</span>ctrace
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">STAMP</span><span class="o">=</span>.testmondata.started
</span></span><span class="line"><span class="cl"><span class="nv">full</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="nv">rebuild</span><span class="o">=</span><span class="nb">false</span>
</span></span><span class="line"><span class="cl"><span class="k">if</span> <span class="o">[</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="s2">&#34;--all&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span>true<span class="p">;</span> <span class="nb">shift</span>
</span></span><span class="line"><span class="cl"><span class="k">elif</span> <span class="o">[</span> ! -f .testmondata <span class="o">]</span> <span class="o">||</span> <span class="o">[</span> ! -f <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;No record yet: running everything once.&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">else</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># Changed since the last run started, and not traceable per test:</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># anything that isn&#39;t Python, plus settings and conftest.</span>
</span></span><span class="line"><span class="cl">    <span class="nv">newer</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>find myapp myproject fixtures pytest.ini <span class="se">\
</span></span></span><span class="line"><span class="cl">        -type f -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> ! -path <span class="s1">&#39;*/migrations/*&#39;</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        <span class="se">\(</span> ! -name <span class="s1">&#39;*.py&#39;</span> -o -path <span class="s1">&#39;myproject/settings*&#39;</span> -o -name <span class="s1">&#39;conftest.py&#39;</span> <span class="se">\)</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">        ! -path <span class="s1">&#39;*/__pycache__/*&#39;</span> <span class="p">|</span> head -5<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="o">[</span> -n <span class="s2">&#34;</span><span class="nv">$newer</span><span class="s2">&#34;</span> <span class="o">]</span><span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">        <span class="nb">echo</span> <span class="s2">&#34;Changed, and not traceable per test:&#34;</span><span class="p">;</span> <span class="nb">printf</span> <span class="s1">&#39;  %s\n&#39;</span> <span class="nv">$newer</span>
</span></span><span class="line"><span class="cl">        <span class="nv">full</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl">    <span class="k">fi</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># A new migration needs a fresh test database, not a full run.</span>
</span></span><span class="line"><span class="cl">    <span class="o">[</span> -n <span class="s2">&#34;</span><span class="k">$(</span>find myapp -path <span class="s1">&#39;*/migrations/*.py&#39;</span> -newer <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">|</span> head -1<span class="k">)</span><span class="s2">&#34;</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">rebuild</span><span class="o">=</span><span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">fi</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Stamp the start, so an edit made while the tests run counts next time.</span>
</span></span><span class="line"><span class="cl"><span class="nv">started</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>mktemp .testmondata.run.XXXXXX<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --create-db alone deletes the database when the run ends; with</span>
</span></span><span class="line"><span class="cl"><span class="c1"># --reuse-db as well, the rebuilt one is kept for next time.</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$rebuild</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$full</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="nb">true</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nv">args</span><span class="o">=(</span>--testmon-noselect --reuse-db --create-db<span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">status</span><span class="o">=</span><span class="m">0</span>
</span></span><span class="line"><span class="cl">uv run pytest <span class="s2">&#34;</span><span class="si">${</span><span class="nv">args</span><span class="p">[@]</span><span class="si">}</span><span class="s2">&#34;</span> -q <span class="s2">&#34;</span><span class="nv">$@</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nv">status</span><span class="o">=</span><span class="nv">$?</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 0: passed, or nothing selected. 1: some failed (still recorded).</span>
</span></span><span class="line"><span class="cl"><span class="c1"># 5: nothing collected at all.</span>
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> in 0<span class="p">|</span>1<span class="p">|</span>5<span class="o">)</span> mv <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="s2">&#34;</span><span class="nv">$STAMP</span><span class="s2">&#34;</span> <span class="p">;;</span> *<span class="o">)</span> rm -f <span class="s2">&#34;</span><span class="nv">$started</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span>
</span></span><span class="line"><span class="cl"><span class="o">[</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span> <span class="o">=</span> <span class="m">5</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">0</span>
</span></span><span class="line"><span class="cl"><span class="nb">exit</span> <span class="s2">&#34;</span><span class="nv">$status</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>Có hai chi tiết trong đó đáng giải thích. <code>--reuse-db</code> là cờ của pytest-django để giữ lại cơ sở dữ liệu kiểm thử giữa các lần chạy, và điều này trở nên quan trọng khi một lần chạy chỉ còn mất vài giây: tạo lại cơ sở dữ liệu mỗi lần có khi còn lâu hơn chính các bài kiểm thử. Lần chạy toàn bộ, hoặc lần chạy ngay sau khi có migration mới, thì thêm <code>--create-db</code> để cơ sở dữ liệu có đúng cấu trúc bảng (schema) hiện tại. Hãy giữ <code>--reuse-db</code> đi kèm với nó: nếu chỉ có <code>--create-db</code> thì pytest-django sẽ xóa cơ sở dữ liệu khi lần chạy kết thúc, và lần chạy sau lại phải dựng lại tất cả từ đầu.</p>
<p>Còn khi testmon bỏ chọn mọi bài, lần chạy sẽ thoát với mã 0, nên &ldquo;không có gì thay đổi&rdquo; được tính là qua. Mã thoát (exit code) 5 là cách pytest báo &ldquo;không thu thập được bài kiểm thử nào&rdquo;, và script cũng coi trường hợp đó là ổn.</p>
<p>Hãy để ý những gì không có trong danh sách: <code>pyproject.toml</code> và tệp khóa phiên bản (lock file). Phiên bản đầu tiên của tôi chạy toàn bộ mỗi khi tệp lock thay đổi, khiến phương án dự phòng chậm chạp kia bị kích hoạt gần như ngày nào cũng có, và mọi thứ còn tệ hơn cả lúc chưa có script. testmon vốn đã tự theo dõi phiên bản của các gói đã cài, nên chuyện này nó không cần ai giúp.</p>
<p><strong>6. Cho nó cơ sở dữ liệu và bộ nhớ đệm riêng.</strong> Nếu trên cùng máy còn có thứ khác chạy kiểm thử, chẳng hạn một terminal thứ hai, một agent viết code (coding agent) hay <code>manage.py test</code>, hãy trỏ script tới một cơ sở dữ liệu kiểm thử riêng và một số hiệu bộ nhớ đệm riêng (trong ví dụ là database số 8 của Redis), để cơ sở dữ liệu được giữ lại không bao giờ đụng phải lần chạy của người khác:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">DATABASE_URL</span><span class="o">=</span><span class="s2">&#34;postgresql://myapp:myapp@localhost:5432/myapp_changed&#34;</span>
</span></span><span class="line"><span class="cl"><span class="nb">export</span> <span class="nv">REDIS_URL</span><span class="o">=</span><span class="s2">&#34;redis://localhost:6379/8&#34;</span></span></span></code></pre></div></div>
<p><strong>7. Xóa sạch bộ nhớ đệm đó trước mỗi lần chạy.</strong> Chuyện này tôi phải vấp mới biết. Các lần chạy một phần bắt đầu thất bại với những lỗi <code>KeyError</code> mà lần chạy toàn bộ không hề gặp. Nguyên do là một số nhãn danh mục được cache mà không có thời hạn hết hạn, và một cơ sở dữ liệu kiểm thử giữ lại từ lần chạy này lại gặp phải bộ nhớ đệm vẫn còn &ldquo;ấm&rdquo; từ một lần chạy trước đó nữa. Xóa cache ngay đầu script là hết lỗ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">uv run python -c <span class="s2">&#34;import os, redis; redis.Redis.from_url(os.environ[&#39;REDIS_URL&#39;]).flushdb()&#34;</span></span></span></code></pre></div></div>
<p>Đó chính là testmon làm đúng như tài liệu của nó đã nói: mỗi lần chạy một tập con khác nhau sẽ làm lộ ra những bài kiểm thử chỉ qua được nhờ những gì đã chạy trước chúng. Chỗ cần sửa là phần thiết lập kiểm thử, không phải testmon.</p>
<p><strong>8. Nắm các cờ để dùng khi testmon chọn sai.</strong></p>
<ul>
<li><code>--testmon-noselect</code> chạy mọi bài kiểm thử nhưng đưa những bài có khả năng thất bại lên trước. testmon tự chuyển sang chế độ này khi bạn truyền thêm <code>-k</code>, <code>-m</code>, <code>--lf</code> hoặc node ID của một bài kiểm thử.</li>
<li><code>--testmon-forceselect</code> chỉ chạy những bài bị ảnh hưởng mà đồng thời khớp với <code>-k</code> hoặc <code>-m</code> của bạn.</li>
<li><code>--testmon-nocollect</code> dùng bản ghi hiện có nhưng không ghi thêm gì vào. Chế độ này bị bật bắt buộc khi chạy dưới trình gỡ lỗi (debugger) hoặc khi coverage đang chạy sẵn.</li>
<li><code>--no-testmon</code> tắt nó cho một lần chạy, dùng khi bạn đã đặt nó trong <code>addopts</code>.</li>
<li><code>--testmon-env=&lt;name&gt;</code> giữ các bản ghi riêng biệt trong cùng một tệp, dành cho bộ kiểm thử mà bạn chạy dưới hai module settings khác nhau.</li>
</ul>
<p>Nếu dự án có cả phần JavaScript thì Jest có sẵn một phiên bản riêng của tính năng này. <code>jest --changedSince=main</code> chạy những bài kiểm thử import bất cứ thứ gì đã thay đổi kể từ <code>main</code>, dựa theo các câu import chứ không dựa theo coverage. Tôi đã thêm nó vào <code>package.json</code> của ứng dụng dưới tên <code>npm run test:changed</code>, để cả hai nửa của dự án có cùng một thói quen. Một lần sửa ở một module tiện ích đã chọn ra 7 trong số 121 tệp kiểm thử của ứng dụng.</p>

<h2 class="relative group">Nó tiết kiệm được gì
    <div id="nó-tiết-kiệm-được-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="#n%c3%b3-ti%e1%ba%bft-ki%e1%bb%87m-%c4%91%c6%b0%e1%bb%a3c-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi script bọc đã vào chỗ, và sau khi sửa lỗi được kể ở phần tiếp theo, tôi đo thời gian của nó, tính cả phần khởi động:</p>
<table>
	<thead>
			<tr>
					<th>Thứ đã thay đổi</th>
					<th style="text-align: right">Số bài đã chạy</th>
					<th style="text-align: right">Thời gian</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Không có gì</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0 s</td>
			</tr>
			<tr>
					<td>Một lần sửa trong phần code đứng sau một banner ở màn hình chính</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5 s</td>
			</tr>
			<tr>
					<td>Hoàn tác lần sửa đó</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7 s</td>
			</tr>
			<tr>
					<td>Toàn bộ bộ kiểm thử, để so sánh (4 tiến trình)</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">khoảng 3½ phút</td>
			</tr>
	</tbody>
</table>
<p>Với một lần sửa thường ngày, như vậy là nhanh hơn khoảng 25 lần so với bộ kiểm thử chạy song song, còn khi không có gì thay đổi thì nhanh hơn khoảng hai trăm lần.</p>
<p>Bản ghi cũng cho thấy điều tương tự, ở phạm vi rộng hơn. Trong 307 tệp Python không phải tệp kiểm thử mà bộ kiểm thử chạy qua, thay đổi ở tệp trung vị chạm tới 22 bài kiểm thử, tức khoảng 8 giây thời gian kiểm thử. Ở đầu bên kia là vài module lõi: gần như bài kiểm thử nào cũng chạy qua chúng, nên một thay đổi ở đó chạm tới khoảng 1,500 bài, và testmon sẽ chạy lại phần lớn bộ kiểm thử. Đó là câu trả lời đúng, chứ không phải một thất bại. Các con số này là giới hạn trên tính theo từng tệp: testmon làm việc theo từng khối, nên đổi một hàm sẽ chỉ chọn những bài đã chạy qua hàm đó.</p>
<p>Cái giá lộ ra khi script bọc phải quay về phương án dự phòng. Một lần chạy toàn bộ cũng là một lần ghi trên một tiến trình giống như lần đầu, mất khoảng 12 phút, tức là còn chậm hơn bộ kiểm thử chạy song song mà nó thay thế. Nó chạy trên một tiến trình chỉ vì tôi chưa thử testmon cùng pytest-xdist; testmon đã hỗ trợ xdist từ bản 1.4, nên đó là thứ tiếp theo cần đo. Vì vậy, danh sách các tệp kích hoạt nó nên ngắn hết mức có thể, miễn là vẫn trung thực.</p>

<h2 class="relative group">Lỗi trông như một lần chạy toàn xanh
    <div id="lỗi-trông-như-một-lần-chạy-toàn-xanh" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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-tr%c3%b4ng-nh%c6%b0-m%e1%bb%99t-l%e1%ba%a7n-ch%e1%ba%a1y-to%c3%a0n-xanh" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Những con số đầu tiên của tôi còn đẹp hơn thế này: một lần sửa, 6 bài kiểm thử, 3.3 giây. Chúng sai, và không có gì trên màn hình cho biết điều đó.</p>
<p>Từ Python 3.14, coverage.py mặc định dùng một lõi (core) mới, xây dựng trên <code>sys.monitoring</code> của trình thông dịch. Lõi này chỉ báo một dòng ở lần đầu tiên một tiến trình chạy qua dòng đó. testmon dựng bản ghi bài nào đã chạy dòng nào từ dữ liệu của coverage, nên mỗi dòng chỉ được tính cho bài kiểm thử đầu tiên tình cờ chạy qua nó, chứ không tính cho bài nào khác. Sửa một dòng mà tám bài kiểm thử cùng dùng, testmon sẽ chỉ chọn một trong số đó.</p>
<p>Lỗi này lộ ra trên một dự án khác của tôi, nhỏ hơn, nơi tôi chép lại cách thiết lập này. Một phần của việc áp dụng nó ở đó là cố ý làm hỏng một dòng rồi so sánh kết quả, và các con số không khớp nhau. Quay lại bộ kiểm thử này, tôi làm đúng phép thử đó. Tôi làm hỏng một dòng trong phần code của banner ở màn hình chính, cụ thể là tiêu đề của một slide. Chạy pytest bình thường thì có 8 bài thất bại. testmon chỉ chọn 4. 4 bài hỏng còn lại không hề được chạy, và đầu ra không có dòng nào cho biết chúng đã bị bỏ qua.</p>
<p>Cách sửa chỉ là một dòng, <code>export COVERAGE_CORE=ctrace</code>, để đưa coverage về lại trình theo dõi (tracer) cổ điển viết bằng C của nó. Đổi tracer thì bản ghi cũng khác, nên tôi ghi lại từ đầu: 12 phút 3 giây cho 2,248 bài. Sau đó, cùng một kiểu sửa làm hỏng code như vậy đã chạy đủ cả 11 bài mà nó làm hỏng.</p>
<p>Cập nhật ngày 7 tháng 11: testmon 2.3.0, phát hành ngày 2 tháng 11, giờ tự đặt tracer C, cả trong tiến trình lẫn cho các tiến trình con, nên cài mới thì không cần dòng export này nữa. Phần kiểm tra ở đoạn tiếp theo thì vẫn cần.</p>
<p>Nếu phải truyền lại một bài học trước mọi thứ khác trong bài này, tôi sẽ chọn bài học này. Một công cụ chọn bài kiểm thử mà chọn quá ít thì trông y hệt như thành công: ít bài hơn, chạy nhanh hơn, toàn màu xanh. Vì vậy, khi thiết lập một công cụ như thế, hãy cố ý làm hỏng một thứ dùng chung, chạy cả bộ kiểm thử thông thường lẫn công cụ chọn bài, rồi kiểm tra xem hai bên có báo thất bại đúng những bài giống nhau không. Sau đó, làm lại việc này mỗi khi Python hoặc coverage.py lên phiên bản mới.</p>

<h2 class="relative group">Khi agent viết code vào cuộc
    <div id="khi-agent-viết-code-vào-cuộ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="#khi-agent-vi%e1%ba%bft-code-v%c3%a0o-cu%e1%bb%99c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Tôi không còn tự tay làm mọi thay đổi trong kho mã đó nữa. Một agent viết code, trong trường hợp của tôi là Claude Code, làm khá nhiều thay đổi nhỏ, và một agent tốt sẽ chạy kiểm thử sau mỗi lần sửa. Đứng trước một bộ kiểm thử 11 phút, nó cũng chỉ có đúng hai lựa chọn tồi như tôi từng có: ngồi chờ, hoặc đoán xem tệp kiểm thử nào mới quan trọng.</p>
<p>Vì vậy, phần hướng dẫn dành cho agent trong kho mã giờ ghi rằng script bọc kia là cách mặc định để chạy kiểm thử trong lúc làm việc, còn <code>--all</code> dành cho lần chạy toàn bộ. Sau mỗi lần sửa, agent nhận được đúng tập bài kiểm thử chính xác như tôi, chỉ trong vài giây. Đây cũng là lý do bước 6 cho script một cơ sở dữ liệu và bộ nhớ đệm riêng: vào một ngày bận rộn, sẽ có nhiều hơn một phiên làm việc cùng chạy kiểm thử trên một máy, và cơ sở dữ liệu được giữ lại tuyệt đối không được đụng phải lần chạy của người khác.</p>
<p>Không phần nào trong số này đòi hỏi testmon phải có gì dành riêng cho AI. Vẫn là bản ghi đó, vẫn là cách chọn đó. Chỉ là nó trở nên quan trọng hơn khi thứ đang tạo ra thay đổi lại tạo ra rất, rất nhiều thay đổi.</p>

<h2 class="relative group">Vì sao CI chỉ nhận một phiên bản thô hơn
    <div id="vì-sao-ci-chỉ-nhận-một-phiên-bản-thô-hơ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="#v%c3%ac-sao-ci-ch%e1%bb%89-nh%e1%ba%adn-m%e1%bb%99t-phi%c3%aan-b%e1%ba%a3n-th%c3%b4-h%c6%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>testmon không có chế độ dành riêng cho CI. Trên runner nó hoạt động y như trên laptop, và chính tác giả của nó viết rằng việc chia sẻ hoặc cache <code>.testmondata</code> giữa các runner CI kém hiệu quả hơn nhiều so với dịch vụ lưu trữ sẵn (hosted) của họ là testmon.net. Một bản ghi được cache lại cũng sẽ mù theo đúng những cách đã kể ở trên, mà CI của tôi lại chạy bộ kiểm thử bằng trình chạy của Django trên bốn tiến trình, nên tôi không đưa testmon vào CI.</p>
<p>Thay vào đó, CI nhận cùng ý tưởng này nhưng ở cấp độ tệp. Workflow kiểm tra giờ bắt đầu bằng một job nhỏ, lấy diff của lần push rồi xác định nó đã chạm vào nửa nào của kho mã: API Python, ứng dụng di động, cả hai, hay không bên nào. Trước đây, CI chạy cả job Python lẫn job ứng dụng ở mọi lần push, kể cả những lần chỉ sửa tài liệu. Giờ thì một lần push chỉ sửa tài liệu không chạy gì cả, còn một lần push chỉ đụng tới ứng dụng thì bỏ qua bộ kiểm thử Django. Tôi đã chạy thử (dry run) job này trên năm commit thật: commit chỉ sửa tài liệu không chạy gì, commit chỉ sửa ứng dụng bỏ qua job Python, còn commit chỉ sửa backend thì bỏ qua job ứng dụng. Một ví dụ đã cắt gọn:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">changes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">outputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">python</span><span class="p">:</span><span class="w"> </span><span class="l">${{ steps.diff.outputs.python }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">diff</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">BEFORE</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.event.before }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">HEAD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ github.sha }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          FILES=&#34;$(git diff --name-only &#34;$BEFORE&#34; &#34;$HEAD&#34;)&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          # Python&#39;s inputs include the non-Python files its tests read.
</span></span></span><span class="line"><span class="cl"><span class="sd">          PY=&#39;^(myapp/|myproject/|fixtures/shared\.json$|web/dist/|pyproject\.toml$|uv\.lock$)&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">          if printf &#39;%s\n&#39; &#34;$FILES&#34; | grep -qE &#34;$PY&#34;; then
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=true&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          else
</span></span></span><span class="line"><span class="cl"><span class="sd">            echo &#34;python=false&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          fi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">changes</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">needs.changes.outputs.python == &#39;true&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span></span></span></code></pre></div></div>
<p>Workflow thật còn chạy toàn bộ khi không có khoảng commit nào để so sánh: một nhánh mới, một lần force push hoặc một lần chạy tay.</p>
<p>Điểm mù vẫn là điểm mù cũ, chỉ mang một hình dạng khác. testmon không nhìn thấy tệp JSON, và bộ lọc theo đường dẫn cũng vậy, trừ khi bạn liệt kê tệp đó ra. Vì thế, mẫu ở trên ghi rõ tên tệp fixture dùng chung, gói web đã dựng và trang được sinh ra, kèm một dòng chú thích nhắc người sửa nó giữ cho danh sách luôn đúng. Phiên bản cấp tệp thô hơn, nhưng khi sai thì sai về phía an toàn: chỉ cần bên phía Python có gì thay đổi là cả bộ kiểm thử sẽ chạy.</p>

<h2 class="relative group">Có đáng không?
    <div id="có-đáng-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="#c%c3%b3-%c4%91%c3%a1ng-kh%c3%b4ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với một bộ kiểm thử chạy mất vài phút thì có. Cái giá thật sự nằm ở những gì nó không nhìn thấy, và script bọc sinh ra là để lo việc đó. Dù vậy, chỗ của testmon là vòng lặp làm việc trên laptop. CI vẫn chạy toàn bộ bộ kiểm thử mỗi khi phía Python có thay đổi, và đó mới là lần chạy tôi tin tưởng trước khi triển khai.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Docker Compose trong CI GitHub Actions: tôi đã thay nó bằng gì, và các pipeline tôi đang chạy</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/</guid>
      <pubDate>Thu, 22 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>Docker</category>
      <category>DevOps</category>
      <category>Deployment</category>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>Django</category>
      <category>PostgreSQL</category>
      <category>Hugo</category>
      <category>Python</category>
      <description>Các pipeline GitHub Actions tôi chạy năm 2026: service container thay cho Docker Compose trong CI, cache chọn theo thời gian từng bước, và triển khai bằng script.</description>
      <content:encoded><![CDATA[<p>Quy trình CI/CD (CI/CD pipeline) là con đường tự động mà một thay đổi đi từ lúc <code>git push</code> cho đến khi lên production: CI dựng (build) và kiểm thử mọi lần push trên một máy sạch, còn CD phát hành những gì đã qua bằng một bước duy nhất, lặp lại được. Trên GitHub Actions, đó là một tệp YAML trong <code>.github/workflows/</code>, và các quy trình của tôi được cố ý làm mỏng: phần kiểm tra chạy đúng những lệnh tôi vẫn gõ trên laptop, còn mọi khâu triển khai đều là script hoặc CLI, không có GitHub thì vẫn chạy y như vậy.</p>
<p>Nếu bạn vào đây để tìm cách chạy Docker Compose trong GitHub Actions: tôi đã làm thế suốt hai năm rồi bỏ. Giờ CI khai báo cùng các image Postgres và Valkey đó dưới dạng <code>services:</code> và chạy thẳng trên runner đúng các lệnh <code>uv run …</code> và <code>npm run …</code>; Compose quay về làm thứ lập trình viên chạy ở máy mình. Phần còn lại là lý do, kèm các tệp.</p>
<p>Năm 2024 tôi có viết bài <a href="https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/" >Dùng Docker Compose cho CI/CD thay vì YAML riêng của từng nền tảng</a>, với ý chính là để logic dựng trong Compose, còn công cụ CI chỉ việc gọi nó. Hai năm sau, tôi xem lại toàn bộ các kho mã mà tôi đang dùng để phát hành. Có bốn kho có workflow, kho nào cũng chỉ có một người commit là tôi, và trong số các workflow đó không còn cái nào gọi <code>docker compose</code> nữa. Nguyên tắc thì vẫn đứng vững, còn cách làm thì không. Dưới đây là những thứ đã thay thế nó: phiên bản ví dụ đã cắt gọn của các tệp, thời gian từng bước mà GitHub ghi lại, và cả những phần ít tự động hơn so với mong đợi của mấy sơ đồ CI/CD.</p>

<h2 class="relative group">Mọi quy trình tôi đang chạy, gói trong một bảng
    <div id="mọi-quy-trình-tôi-đang-chạy-gói-trong-một-bả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="#m%e1%bb%8di-quy-tr%c3%acnh-t%c3%b4i-%c4%91ang-ch%e1%ba%a1y-g%c3%b3i-trong-m%e1%bb%99t-b%e1%ba%a3ng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>Quy trình</th>
					<th>Bắt đầu bởi</th>
					<th>CI kiểm tra gì</th>
					<th>Đi đến đâu</th>
					<th>Ai phát hành</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Blog này và trang Hugo của một khách hàng</td>
					<td>Mọi lần push, trên bất kỳ nhánh nào</td>
					<td><code>hugo --gc</code> dựng được hay không</td>
					<td>Cloudflare Pages: <code>main</code> là production, các nhánh khác có URL xem trước</td>
					<td>Không ai cả; push chính là phát hành</td>
			</tr>
			<tr>
					<td>API của ứng dụng chợ mà tôi đang làm</td>
					<td>Một script triển khai, chạy tay</td>
					<td>Lint, kiểm tra kiểu và bộ kiểm thử Django, mỗi lần push lên <code>main</code></td>
					<td>Fly.io, triển khai cuốn chiếu (rolling deploy)</td>
					<td>Tôi</td>
			</tr>
			<tr>
					<td>Giao diện của chính ứng dụng đó: web, cập nhật OTA, bản dựng cho store</td>
					<td>Các script phát hành và bản dựng store trên máy, chạy tay</td>
					<td>TypeScript và Jest, mỗi lần push lên <code>main</code></td>
					<td>Kho lưu trữ đối tượng (object storage), một dịch vụ cập nhật OTA và hai kho ứng dụng</td>
					<td>Tôi</td>
			</tr>
			<tr>
					<td>pyhtml2text, gói Python mã nguồn mở của tôi</td>
					<td>Mọi lần push, pull request và tag</td>
					<td>Wheel cho 15 nền tảng đích, kiểm thử trên phần lớn trong số đó</td>
					<td>PyPI, khi gắn tag</td>
					<td>Cái tag</td>
			</tr>
	</tbody>
</table>
<p>Nói theo sách vở thì hai trang Hugo là triển khai liên tục, còn ứng dụng kia (một ứng dụng chợ kiêm cộng đồng mà tôi đang làm) là tích hợp liên tục cộng với phát hành bằng đúng một lệnh, tức là chuyển giao liên tục: phần kiểm tra báo cho tôi biết khi có gì sai, nhưng không chặn tôi triển khai. Tôi cố ý chia như vậy. Một bản cập nhật OTA sẽ đến mọi điện thoại đang dùng phiên bản ứng dụng mà nó nhắm tới, nên việc phát hành phải là một quyết định, chứ không phải tác dụng phụ của một lần merge. Còn API là nơi chạy migration cơ sở dữ liệu. Hai ứng dụng Django còn lại của tôi thì hoàn toàn không có workflow nào.</p>

<h2 class="relative group">Phần CI: một workflow, hai job
    <div id="phần-ci-một-workflow-hai-job" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#ph%e1%ba%a7n-ci-m%e1%bb%99t-workflow-hai-job" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Toàn bộ CI của ứng dụng nằm trong một tệp, <code>.github/workflows/checks.yml</code>. Trước ngày 11 tháng 10, ngay cả tệp này cũng chưa có. Đây là một ví dụ đã cắt gọn cho thấy hình dạng của nó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Lint, types and tests on every push and pull request to main.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Nothing here deploys.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Checks</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">checks-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">python</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">30</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_USER</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;5432:5432&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-cmd &#34;pg_isready -U myapp&#34;
</span></span></span><span class="line"><span class="cl"><span class="sd">          --health-interval 5s --health-timeout 5s --health-retries 20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;6379:6379&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DEBUG</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;True&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgresql://myapp:myapp@localhost:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">REDIS_URL</span><span class="p">:</span><span class="w"> </span><span class="l">redis://localhost:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">PYTHON_JIT</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0&#34;</span><span class="w">   </span><span class="c"># match production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">enable-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv sync --locked</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run ruff check .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run mypy myapp/</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv run python manage.py test --parallel 4 --noinput</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">timeout-minutes</span><span class="p">:</span><span class="w"> </span><span class="m">20</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">defaults</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">run</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">working-directory</span><span class="p">:</span><span class="w"> </span><span class="l">frontend</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-node@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">node-version</span><span class="p">:</span><span class="w"> </span><span class="m">24</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache</span><span class="p">:</span><span class="w"> </span><span class="l">npm</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">cache-dependency-path</span><span class="p">:</span><span class="w"> </span><span class="l">frontend/package-lock.json</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Expo writes this on `expo start` and it is gitignored,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># so a fresh checkout doesn&#39;t have it.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">printf &#39;/// &lt;reference types=&#34;expo/types&#34; /&gt;\n&#39; &gt; expo-env.d.ts</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npm run typecheck</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">npx jest --ci</span></span></span></code></pre></div></div>
<p>Hai job chạy song song. Trong 35 lần chạy thành công tính đến giờ, trung vị của job ứng dụng là khoảng bảy phút, còn job Python là khoảng mười hai phút rưỡi.</p>
<p>Khối <code>services:</code> chính là chỗ tệp Compose đã chuyển tới, phần sau sẽ nói rõ. Môi trường chỉ có vài biến và một cờ. Mọi thứ còn lại đều rơi về giá trị mặc định cho môi trường phát triển mà lập trình viên có được khi chạy <code>docker compose up</code>, nên bộ kiểm thử không cần bí mật (secret) nào. Để chắc chắn, tôi đã chạy thử một phần bộ kiểm thử với đúng môi trường đó và không có gì khác.</p>
<p>CI chạy cùng trình thông dịch với laptop của tôi và với image production, tức là bản Python 3.15.0 do chính uv dựng, vì <code>uv sync</code> đọc <code>.python-version</code> và tải về đúng bản đó. (Tệp thật của tôi còn truyền cho setup-uv một tham số <code>python-version-file</code>, thật ra chẳng làm gì cả: setup-uv v6 không có tham số nào như vậy, và lần chạy nào cũng kèm một cảnh báo nói đúng điều đó. Viết bài này tôi mới để ý.)</p>
<p>Việc dùng chung trình thông dịch đã chứng minh giá trị chỉ tám ngày sau khi workflow ra đời. Ngày 19 tháng 10, tôi bật trình biên dịch JIT của 3.15, và ngay lần chạy đầu tiên có JIT, một bài kiểm thử đã thất bại: một câu <code>yield from</code> rất bình thường, nằm trên đoạn code mà lần lưu dữ liệu nào của ứng dụng cũng đi qua, lại ném ra <code>AttributeError: 'list' object has no attribute 'send'</code>. Cùng commit đó mà tắt JIT thì qua. Máy Mac của tôi là arm64 nên chưa bao giờ gặp lỗi này; còn máy chạy CI (runner) là x86_64, giống các máy production. JIT giờ đã được tắt lại, cả trong CI lẫn trong Dockerfile, và lỗi này chưa bao giờ lọt lên production. Đó là lỗi python/cpython#155823, đã được sửa ở upstream vào ngày 13 tháng 10, sau khi 3.15.0 phát hành.</p>
<p>Thật ra ngay lần chạy đầu tiên cũng đã thất bại, nhưng vì một lý do tẻ nhạt hơn. Expo tạo tệp <code>expo-env.d.ts</code> khi bạn chạy <code>expo start</code>, mà tệp này lại nằm trong gitignore. Vì vậy bản làm việc nào tôi từng dùng cũng có nó, còn một bản checkout mới tinh thì không. TypeScript báo năm lỗi chưa từng xuất hiện trên máy tôi. Bước tạo tệp đó chính là cách sửa.</p>
<p><code>concurrency</code> sẽ hủy lần chạy cũ khi có một lần push mới hơn lên cùng nhánh. Cả mười hai lần chạy bị hủy tính đến giờ đều kết thúc theo cách này. Một hook trước commit (pre-commit) chạy công cụ lint, và chạy thêm bộ kiểm tra kiểu khi có tệp Python hoặc TypeScript được stage, nên phần lớn lỗi không đi được tới lúc push.</p>

<h2 class="relative group">Tệp Docker Compose đã đi đâu
    <div id="tệp-docker-compose-đã-đi-đâ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="#t%e1%bb%87p-docker-compose-%c4%91%c3%a3-%c4%91i-%c4%91%c3%a2u" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là phần trong tệp <code>docker-compose.yml</code> của ứng dụng mà CI quan tâm, viết lại dưới dạng ví dụ. Tệp thật còn nhiều dịch vụ hơn và nhiều biến môi trường hơn hẳn.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">valkey/valkey:8-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;6379:6379&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># Host port 55432, not 5432, so it can&#39;t collide with a local Postgres.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:17-alpine</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_USER=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_PASSWORD=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">POSTGRES_DB=myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;55432:5432&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">postgres_data:/var/lib/postgresql/data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">healthcheck</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">test</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s2">&#34;CMD-SHELL&#34;</span><span class="p">,</span><span class="w"> </span><span class="s2">&#34;pg_isready -U myapp -d myapp&#34;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">interval</span><span class="p">:</span><span class="w"> </span><span class="l">5s</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">retries</span><span class="p">:</span><span class="w"> </span><span class="m">10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">api</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py runserver 0.0.0.0:8000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">ports</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="s2">&#34;8000:8000&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">DATABASE_URL=postgresql://myapp:myapp@postgres:5432/myapp</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">REDIS_URL=redis://valkey:6379</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">depends_on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_healthy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">valkey</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">condition</span><span class="p">:</span><span class="w"> </span><span class="l">service_started</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="l">postgres_data:</span></span></span></code></pre></div></div>
<p>Đặt nó cạnh khối <code>services:</code> trong <code>checks.yml</code> thì thấy đó là cùng hai image: Postgres 17 (ở đây là bản Alpine, trong CI là bản Debian) và <code>valkey/valkey:8-alpine</code>. Quan hệ giữa hai bên bây giờ là vậy. Tệp Compose là thứ lập trình viên chạy; còn CI khai báo cùng các image đó dưới dạng service container, rồi chạy thẳng trên runner đúng những lệnh mà lập trình viên vẫn chạy, <code>uv run …</code> và <code>npm run …</code>.</p>
<p>So với bài năm 2024, phần tôi quan tâm nhất vẫn đứng vững: công cụ CI vẫn mỏng, và các bước kiểm tra lẫn triển khai đều là những lệnh tôi tự chạy được. Thứ không trụ lại được là để CI gọi Compose.</p>
<p>CI của ứng dụng hoàn toàn không dựng Docker image. Image được dựng đúng một lần, lúc triển khai, trên máy dựng (builder) của Fly, còn CI chỉ cần trả lời code có ổn không, và khi bộ công cụ đã có sẵn trong bộ nhớ đệm (cache) thì việc đó chỉ tốn sáu giây cài đặt. Ngoài ra, tệp Compose còn bind-mount bản làm việc và nạp dữ liệu mẫu, những việc một lần chạy kiểm thử không nên làm. Vậy nên CI sẽ cần một tệp Compose riêng, tức là một bản mô tả thứ hai cho cùng các dịch vụ, phải giữ cho khớp nhau bằng tay.</p>
<p>Thêm nữa, ví dụ năm 2024 có một lỗi mà lúc đó tôi không để ý. <code>docker compose build</code> chỉ dựng image, chứ không bao giờ chạy <code>command</code> của dịch vụ. Thế nên lệnh <code>npm test</code> trong tệp đó chưa từng chạy trong CI, và workflow vẫn xanh dù kiểm thử có qua hay không.</p>
<p>Rủi ro mà bài đó lo nhất, tức là đổi nhà cung cấp CI, rốt cuộc chưa bao giờ xảy ra. Cuộc chuyển dịch thật sự lại diễn ra ở đầu bên kia: blog này và trang của khách hàng kia đã đổi nơi lưu trữ sang Cloudflare Pages, và với blog thì chỉ cần viết mới một workflow dài 45 dòng, vì khâu dựng vốn đã chỉ là một lệnh. Còn cái workflow ba trăm dòng mà tôi từng cảnh báo? Tôi có đúng một cái, của pyhtml2text. Nó dài vì wheel phải được dựng trên runner Linux, macOS và Windows, mỗi loại cho nhiều kiến trúc, và cũng vì vậy mà đây là việc duy nhất mà YAML riêng của nền tảng là công cụ đúng.</p>

<h2 class="relative group">Bộ nhớ đệm: đọc thời gian từng bước trước đã
    <div id="bộ-nhớ-đệm-đọc-thời-gian-từng-bước-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="#b%e1%bb%99-nh%e1%bb%9b-%c4%91%e1%bb%87m-%c4%91%e1%bb%8dc-th%e1%bb%9di-gian-t%e1%bb%abng-b%c6%b0%e1%bb%9bc-tr%c6%b0%e1%bb%9bc-%c4%91%c3%a3" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>GitHub ghi lại thời gian của từng bước, và <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code> sẽ trả về cho bạn. Đây là số liệu từ lần chạy thành công gần nhất của ứng dụng, ngày 20 tháng 10:</p>
<table>
	<thead>
			<tr>
					<th>Job</th>
					<th>Bước</th>
					<th>Giây</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>Khởi động Postgres và Valkey</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>, đã có cache</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Kiểm thử Django</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>, đã có cache</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>Cả hai bộ nhớ đệm cho thư viện phụ thuộc đều đang bật, và cả hai đều là phần rẻ. Bộ nhớ đệm uv mà GitHub giữ cho kho này chỉ khoảng 200 KB, vì setup-uv dọn đi những gì uv chỉ cần tải lại là xong; còn của npm thì gần 400 MB. Thời gian nằm ở bộ kiểm thử, mà bộ kiểm thử thì đã chạy song song bốn luồng rồi. Muốn chạy nhanh hơn, tôi sẽ sửa bộ kiểm thử chứ không phải bộ nhớ đệm.</p>
<p>Blog thì ngược lại. Ảnh đã được Hugo xử lý nằm trong thư mục <code>resources/_gen</code> vốn bị gitignore, nên runner mới nào cũng phải mã hóa lại toàn bộ ảnh bìa, ảnh trong bài và ảnh giữ chỗ. Workflow ở phía dưới khôi phục chúng bằng <code>actions/cache</code>, với khóa là mã băm của chính các bức ảnh; nếu khóa không khớp thì dùng bản cache cũ mới nhất, vì Hugo chỉ xử lý phần còn thiếu. Trong tám lần chạy cuối cùng trước khi có cache, bước dựng mất từ 33 đến 76 giây; trong 26 lần kể từ đó là 6 đến 20 giây, chỉ một lần ngoại lệ là 94 giây, dù chính commit đó còn thêm ảnh giữ chỗ và một cỡ thẻ thứ hai.</p>
<p>Image của ứng dụng được dựng trên builder của Fly: các tệp khai báo thư viện phụ thuộc được chép vào trước code, còn bộ nhớ đệm tải về của uv nằm trong một cache mount của BuildKit (<code>RUN --mount=type=cache,target=/root/.cache/uv</code>). Nhưng cải thiện lớn nhất ở đây lại không phải là bộ nhớ đệm. Tệp <code>.dockerignore</code> cũ là danh sách chặn (blocklist), và nó bỏ sót một thư mục chứa các git worktree phụ, mỗi worktree lại có thư viện phụ thuộc và sản phẩm dựng của riêng nó. Kết quả là 7,7 GB với 277.000 tệp bị đẩy lên builder mỗi lần triển khai. Viết lại thành danh sách cho phép (allowlist), ngữ cảnh dựng giờ chỉ còn khoảng 40 MB. Một ví dụ cho cách làm này:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl"># Exclude everything, then let back in only what the Dockerfile COPYs.
</span></span><span class="line"><span class="cl">*
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">!pyproject.toml
</span></span><span class="line"><span class="cl">!uv.lock
</span></span><span class="line"><span class="cl">!manage.py
</span></span><span class="line"><span class="cl">!myapp
</span></span><span class="line"><span class="cl">!static
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"># Rules match the whole relative path and the last match wins,
</span></span><span class="line"><span class="cl"># so these re-excludes apply inside the allowed directories too.
</span></span><span class="line"><span class="cl">**/__pycache__/
</span></span><span class="line"><span class="cl">**/*.pyc
</span></span><span class="line"><span class="cl">**/.DS_Store
</span></span><span class="line"><span class="cl">**/*.log</span></span></code></pre></div></div>

<h2 class="relative group">Bí mật, đặt ở càng ít chỗ càng tốt
    <div id="bí-mật-đặt-ở-càng-ít-chỗ-càng-tố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%c3%ad-m%e1%ba%adt-%c4%91%e1%ba%b7t-%e1%bb%9f-c%c3%a0ng-%c3%adt-ch%e1%bb%97-c%c3%a0ng-t%e1%bb%91t" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Bí mật nằm ở ba nơi. Bí mật của GitHub Actions dành cho những việc workflow làm, và hai kho Hugo mỗi kho chỉ có đúng một cái, <code>CLOUDFLARE_API_TOKEN</code>. Bí mật của Fly là cấu hình lúc chạy của ứng dụng, được đặt bằng <code>flyctl secrets set</code>, và không bao giờ đi qua Actions. Cuối cùng là tệp <code>.env</code> bị gitignore trên laptop của tôi, chứa token mà script triển khai dùng để xóa cache của Cloudflare.</p>
<p>PyPI thì hoàn toàn không cần bí mật. pyhtml2text dùng cơ chế trusted publishing: PyPI chấp nhận token OIDC ngắn hạn mà GitHub cấp cho workflow, nên job phát hành chỉ xin quyền <code>id-token: write</code>, và trang bí mật của kho thì trống trơn. Không có gì để rò rỉ, cũng không có gì phải xoay vòng.</p>
<p>Token chỉ được cấp đúng quyền cần thiết. Token Fly của ứng dụng trên GitHub phục vụ một bước kiểm tra định kỳ chỉ liệt kê máy, nên nó được tạo bằng <code>fly tokens create readonly</code>. Các lần triển khai lên Pages chỉ xin <code>contents: read</code> và <code>deployments: write</code>, không hơn.</p>
<p>Dữ liệu đầu vào luôn đi qua <code>env:</code>, không bao giờ nhét thẳng vào script. Workflow OTA nhận ghi chú phát hành dạng văn bản tự do. Nếu viết <code>${{ inputs.notes }}</code> ngay trong <code>run:</code>, đoạn văn bản sẽ được dán thẳng vào script trước khi bash đọc, nên một ghi chú có dấu nháy và dấu chấm phẩy sẽ biến thành code. Truyền qua biến môi trường thì nó vẫn chỉ là dữ liệu. Một ví dụ cho cách làm này:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">OTA_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.OTA_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_PLATFORM</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.platform }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_ROLLOUT</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.rollout }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">INPUT_NOTES</span><span class="p">:</span><span class="w"> </span><span class="l">${{ inputs.notes }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          args=(&#34;$INPUT_PLATFORM&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_ROLLOUT&#34; ] &amp;&amp; args+=(--rollout &#34;$INPUT_ROLLOUT&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          [ -n &#34;$INPUT_NOTES&#34; ] &amp;&amp; args+=(--notes &#34;$INPUT_NOTES&#34;)
</span></span></span><span class="line"><span class="cl"><span class="sd">          ./scripts/publish-ota.sh &#34;${args[@]}&#34;</span></span></span></code></pre></div></div>
<p>Có những khóa tôi để hẳn bên ngoài GitHub. Một bản báo cáo định kỳ cần một khóa có quyền quản trị phần đăng nhập của ứng dụng, nên nó đã chuyển từ một workflow định kỳ sang một tác vụ định kỳ trên máy Mac của tôi. Workflow vẫn còn đó làm phương án dự phòng chạy tay, và bỏ qua những mục cần đến khóa đó.</p>

<h2 class="relative group">Triển khai: mỗi lần push, bằng script, và theo tag
    <div id="triển-khai-mỗi-lần-push-bằng-script-và-theo-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#tri%e1%bb%83n-khai-m%e1%bb%97i-l%e1%ba%a7n-push-b%e1%ba%b1ng-script-v%c3%a0-theo-tag" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>

<h3 class="relative group">Các trang Hugo được triển khai mỗi lần push
    <div id="các-trang-hugo-được-triển-khai-mỗi-lần-push" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#c%c3%a1c-trang-hugo-%c4%91%c6%b0%e1%bb%a3c-tri%e1%bb%83n-khai-m%e1%bb%97i-l%e1%ba%a7n-push" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Đây là phiên bản ví dụ của workflow cho blog, đã lược bỏ bước tạo dự án Pages ở lần chạy đầu tiên:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy to Cloudflare Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">HUGO_VERSION</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;0.165.0&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PAGES_PROJECT</span><span class="p">:</span><span class="w"> </span><span class="l">my-site</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">deploy-${{ github.ref }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">read</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">deployments</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v7</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">submodules</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">peaceiris/actions-hugo@v3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">hugo-version</span><span class="p">:</span><span class="w"> </span><span class="l">${{ env.HUGO_VERSION }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">extended</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># Processed images are gitignored; restore them so Hugo only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># re-encodes what is new. An older cache still helps.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/cache@v6</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">path</span><span class="p">:</span><span class="w"> </span><span class="l">resources/_gen</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">key</span><span class="p">:</span><span class="w"> </span><span class="l">hugo-resources-${{ hashFiles(&#39;content/**/*.jpg&#39;, &#39;content/**/*.png&#39;, &#39;assets/img/**&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">restore-keys</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">            hugo-resources-</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># A multilingual site has no root 404.html; without one, Pages</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># answers unknown paths with index.html and a 200.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          hugo --gc
</span></span></span><span class="line"><span class="cl"><span class="sd">          cp public/en/404.html public/404.html</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="c"># ...</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Deploy</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare/wrangler-action@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">apiToken</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.CLOUDFLARE_API_TOKEN }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">accountId</span><span class="p">:</span><span class="w"> </span><span class="l">${{ vars.CLOUDFLARE_ACCOUNT_ID }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">pages deploy public --project-name=${{ env.PAGES_PROJECT }} --branch=${{ github.ref_name }}</span></span></span></code></pre></div></div>
<p><code>--branch=${{ github.ref_name }}</code> làm cho <code>main</code> thành production và cho mọi nhánh khác một URL xem trước. Workflow cho trang của khách hàng có cùng cấu trúc, chỉ thêm một dòng <code>rm -rf public</code> trước khi dựng, vì bên đó thư mục <code>public/</code> được commit, nếu không thì các trang đã xóa sẽ cứ nằm lại trong bản tải lên.</p>
<p>Phiên bản Hugo chỉ được ghim ở đây, không ở đâu khác. Tháng 10, một lần cập nhật của trình quản lý gói đã nâng Hugo trên laptop của tôi lên 0.168, cao hơn một bậc so với bản mới nhất mà theme hỗ trợ. Các lần triển khai vẫn ở 0.165 và chẳng hề hấn gì.</p>
<p>Và bước dựng chính là bài kiểm thử. Ngày 14 tháng 9, bản triển khai xem trước của một nhánh thất bại ba lần liên tiếp: một bài viết trên nhánh đó dùng <code>ref</code> của Hugo để liên kết tới một bài vẫn còn là bản nháp. CI dựng đúng như production dựng, tức là bỏ bản nháp ra, nên liên kết trỏ vào hư không và Hugo dừng lại với lỗi <code>REF_NOT_FOUND</code>. Trong lúc đó, <code>main</code> vẫn triển khai bình thường.</p>

<h3 class="relative group">API của ứng dụng được triển khai bằng script
    <div id="api-của-ứng-dụng-được-triển-khai-bằng-script" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#api-c%e1%bb%a7a-%e1%bb%a9ng-d%e1%bb%a5ng-%c4%91%c6%b0%e1%bb%a3c-tri%e1%bb%83n-khai-b%e1%ba%b1ng-script" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Phần cốt lõi của script triển khai, viết dưới dạng ví dụ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">IMAGE_LABEL</span><span class="o">=</span><span class="s2">&#34;deploy-</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">-</span><span class="k">$(</span>date +%Y%m%d%H%M%S<span class="k">)</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="c1"># Build first, then wake a spare machine, then roll out exactly that image.</span>
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --build-only --push --image-label <span class="s2">&#34;</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">curl -fsS --retry <span class="m">5</span> https://myapp.fly.dev/health &gt; /dev/null
</span></span><span class="line"><span class="cl">flyctl deploy --config fly.toml --image <span class="s2">&#34;registry.fly.io/myapp:</span><span class="nv">$IMAGE_LABEL</span><span class="s2">&#34;</span></span></span></code></pre></div></div>
<p>Và các dòng tương ứng trong <code>fly.toml</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="p">[</span><span class="nx">deploy</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="c"># max_unavailable only counts machines that are running,</span>
</span></span><span class="line"><span class="cl">  <span class="c"># which is why the script wakes one first.</span>
</span></span><span class="line"><span class="cl">  <span class="nx">strategy</span> <span class="p">=</span> <span class="s2">&#34;rolling&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">max_unavailable</span> <span class="p">=</span> <span class="mi">1</span></span></span></code></pre></div></div>
<p>Image được dựng đúng một lần, đẩy lên registry của Fly với một nhãn ghép từ commit và thời điểm, rồi được triển khai theo đúng nhãn đó, nên thứ được tung ra chính xác là thứ đã được dựng.</p>
<p>Thứ giúp triển khai không gián đoạn (zero-downtime) là việc đánh thức một máy dự phòng trước. <code>max_unavailable = 1</code> chỉ đếm những máy đang chạy, mà ứng dụng lại không giữ máy nào chạy khi rảnh (một máy đang tạm dừng chỉ mất khoảng một giây để tỉnh lại). Vì vậy, trước đây cái máy duy nhất đang thức bị thay cùng đợt với các máy đang ngủ, và đằng sau nó không còn máy nào sẵn sàng. Ngày 28 tháng 9, tôi cho một đầu dò gửi yêu cầu liên tục tới origin: khi chạy <code>flyctl deploy</code> trơn thì có 7 lần hết thời gian chờ và một khoảng trống 7,3 giây, còn với script triển khai, vốn dựng trước và có sẵn một máy đang thức trước khi bắt đầu tung ra, thì không có lỗi nào trong 2.514 yêu cầu.</p>
<p>Phần còn lại của script đánh dấu lần triển khai trên kênh chat của đội, nơi các cảnh báo lỗi gửi về, để khi lỗi dồn dập thì có thể đối chiếu với thứ vừa thay đổi, rồi xóa cache Cloudflare và làm nóng các máy. Giao diện web thì hoàn toàn không đi qua image; chuyện đó tôi đã viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-14-web-releases-without-redeploying/" >Triển khai frontend và backend riêng biệt, không cần triển khai lại API</a>.</p>
<p>Migration không phải là một bước trong quy trình. <code>release_command</code> của Fly bị treo trong trường hợp của tôi, nên máy đầu tiên khởi động với image mới sẽ giữ khóa và chạy migration, trong khi các máy khác chờ. Vì thế, migration nào cũng phải tương thích với code vẫn đang chạy: thêm cột ở lần triển khai này, ngừng dùng nó ở lần sau, và xóa nó ở lần thứ ba.</p>
<p>Script này không có chỗ nào bắt buộc phải là laptop của tôi, nhưng có vài tệp chỉ nằm trên máy đang giữ nó ở đó: thông tin đăng nhập Cloudflare, và một bản ghi về thứ đã được triển khai lần gần nhất, thứ mà một runner mới tinh sẽ không có. Nó còn triển khai cả những thay đổi chưa commit, và có báo điều đó trên kênh chat, trong khi script phát hành web thì từ chối một cây làm việc chưa sạch.</p>
<p>Trong bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >Môi trường xem trước cho từng nhánh</a>, tôi viết rằng làm những việc này bằng tay thì ổn khi chỉ có một mình bạn làm, nhưng từ lập trình viên thứ hai trở đi thì nó phải xảy ra vì có gì đó xảy ra trong git. Ứng dụng vẫn chỉ có một lập trình viên. Các workflow môi trường theo nhánh trong bài đó vẫn nằm trên chính nhánh mà tôi đã dựng chúng.</p>
<p>Workflow OTA chỉ chạy tay, vì việc phát hành phải là một quyết định, và vì dịch vụ cập nhật từ chối bản phát hành mới khi đang có một đợt tung ra từng phần (partial rollout) còn mở, nên nếu kích hoạt theo push thì đợt canary nào cũng sẽ vướng. Workflow này chưa từng chạy lần nào. Mọi bản cập nhật OTA cho đến nay đều được đẩy đi từ laptop của tôi, qua chính script mà workflow gọi. Lý do nên giữ workflow mỏng gói gọn trong một câu: phát hành bắt đầu bằng cách nào thì cũng là cùng một script.</p>

<h3 class="relative group">pyhtml2text phát hành theo tag
    <div id="pyhtml2text-phát-hành-theo-tag" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#pyhtml2text-ph%c3%a1t-h%c3%a0nh-theo-tag" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Job phát hành, với tên gói đã được đổi thành tên chung chung:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="w">  </span><span class="nt">release</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) || github.event_name == &#39;workflow_dispatch&#39; }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">linux, macos, windows, sdist]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">pypi</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">url</span><span class="p">:</span><span class="w"> </span><span class="l">https://pypi.org/p/mypackage</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">permissions</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">id-token</span><span class="p">:</span><span class="w"> </span><span class="l">write      </span><span class="w"> </span><span class="c"># trusted publishing and signing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">contents</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">attestations</span><span class="p">:</span><span class="w"> </span><span class="l">write</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/download-artifact@v8</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/attest@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">subject-path</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;wheels-*/*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">astral-sh/setup-uv@v10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">prune-cache</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Publish to PyPI</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">${{ startsWith(github.ref, &#39;refs/tags/&#39;) }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">uv publish --trusted-publishing always &#39;wheels-*/*&#39;</span></span></span></code></pre></div></div>
<p>Job phát hành chờ mọi job dựng xong, và chỉ khi có tag mới thật sự xuất bản; chạy tay thì chỉ dựng và tạo chứng thực (attestation) chứ không tải gì lên. <code>actions/attest</code> ghi lại workflow nào đã dựng từng wheel, còn <code>uv publish --trusted-publishing always</code> tải chúng lên bằng token OIDC thay vì mật khẩu. Bản thân gói thư viện này tôi đã viết trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-18-pyhtml2text/" >Một pull request bị đóng, một bản fork và một gói PyPI</a>.</p>

<h2 class="relative group">Workflow định kỳ chỉ là &ldquo;cố gắng hết sức&rdquo;
    <div id="workflow-định-kỳ-chỉ-là-cố-gắng-hết-sứ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="#workflow-%c4%91%e1%bb%8bnh-k%e1%bb%b3-ch%e1%bb%89-l%c3%a0-c%e1%bb%91-g%e1%ba%afng-h%e1%ba%bft-s%e1%bb%a9c" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Ứng dụng có một workflow chạy định kỳ, kiểm tra xem Fly có bật nhiều máy hơn mức cơ bản hay không, chuyện mà nếu không kiểm tra thì sẽ chỉ lộ ra trên hóa đơn. Nó cảnh báo bằng cách cố ý thất bại, vì GitHub sẽ gửi email cho chủ kho khi một lần chạy định kỳ thất bại.</p>
<p>Lịch được đặt là <code>cron: '*/15 * * * *'</code>, tức 96 lần một ngày. Vậy mà trong hai tuần qua, mỗi ngày nó chỉ chạy từ ba đến tám lần, với khoảng cách trung vị giữa các lần chạy khoảng ba tiếng bốn mươi phút. Lời giải thích duy nhất trong tài liệu của GitHub là tải: lần chạy định kỳ có thể bị trễ khi Actions bận, và bị bỏ qua khi bận đến một mức nào đó. Với một đợt mở rộng quy mô kéo dài, vài tiếng là đủ. Còn với thứ gì cần báo trong vòng vài phút, cron của GitHub không phải là công cụ giám sát.</p>

<h2 class="relative group">Những câu tôi sẽ hỏi về một quy trình CI/CD
    <div id="những-câu-tôi-sẽ-hỏi-về-một-quy-trình-cicd" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a2u-t%c3%b4i-s%e1%ba%bd-h%e1%bb%8fi-v%e1%bb%81-m%e1%bb%99t-quy-tr%c3%acnh-cicd" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Dù bạn đang dựng một quy trình mới hay xem lại quy trình đang có:</p>
<ol>
<li>Nếu GitHub Actions ngừng hoạt động cả một ngày, bạn có còn phát hành được không? Nếu không, logic phát hành của bạn đang nằm trong YAML, và bài năm 2024 vẫn còn đúng.</li>
<li>CI có chạy cùng trình thông dịch, cùng kiến trúc CPU và cùng phiên bản chính của cơ sở dữ liệu với production không? CI của tôi bắt được một lỗi trình biên dịch chỉ vì runner và production đều là x86_64, còn laptop của tôi thì không.</li>
<li>Một bản checkout mới tinh có qua được không? Nếu không, thủ phạm thường là các tệp được sinh tự động và bị gitignore.</li>
<li>Thời gian của một lần chạy thật ra tiêu vào đâu? Hãy đọc thời gian từng bước trước khi thêm bộ nhớ đệm.</li>
<li>Mỗi bí mật đang nằm ở bao nhiêu chỗ, và nó có thật sự cần nằm trên GitHub không?</li>
</ol>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Môi trường xem trước cho từng nhánh: hết tranh giành staging trên Fly.io và Cloudflare</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/</guid>
      <pubDate>Sat, 19 Sep 2026 00:00:00 +1200</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Fly.io</category>
      <category>Cloudflare</category>
      <category>DevOps</category>
      <category>CI/CD</category>
      <category>GitHub Actions</category>
      <category>PostgreSQL</category>
      <category>Redis</category>
      <category>Django</category>
      <category>Database Migrations</category>
      <category>Branching</category>
      <category>Preview Environments</category>
      <description>Cách tôi cho mỗi PR một môi trường xem trước (preview environment) trên Fly.io và Cloudflare: một nhãn, mã băm từ tên nhánh chia Postgres, Redis, bucket dùng chung, đóng PR là dỡ bỏ.</description>
      <content:encoded><![CDATA[<p>Để mỗi nhánh có một môi trường xem trước (preview environment) riêng trên Fly.io, tôi không nhân bản cả hạ tầng mà chia nhỏ phần hạ tầng dùng chung. Gắn nhãn <code>env</code> lên PR là một workflow GitHub Actions chạy một script; script rút ra một cái tên từ tên nhánh (một slug cộng một mã băm ngắn) rồi đóng dấu nó lên một ứng dụng Fly, một cơ sở dữ liệu Postgres nhân bản từ mẫu, một tiền tố khóa Redis, một tiền tố trong bucket, một bộ bí mật được sinh mới và một tên miền dưới wildcard của Cloudflare. Mỗi lần push là môi trường được cập nhật, đóng hoặc merge PR là nó bị dỡ bỏ, còn thứ gì sót lại thì một tác vụ dọn dẹp chạy hằng đêm sẽ lo. Thế là không ai phải hỏi &ldquo;có ai đang dùng staging không?&rdquo; nữa.</p>
<p>Hãy hình dung một đội nhỏ làm chung một ứng dụng. Hai lập trình viên, hay năm, cũng được. Không phải các chuyên gia mỗi người một mảng, mà là những người mỗi người trên nhánh riêng của mình, ôm trọn một tính năng từ migration cơ sở dữ liệu, qua API, đến màn hình trên ứng dụng di động. Đó là cách vận hành hay cho một đội, và kể từ khi có lập trình viên thứ hai, nó có một lỗi lặp đi lặp lại: tin nhắn &ldquo;Có ai đang dùng staging không?&rdquo;</p>
<p>Đằng sau tin nhắn ấy là một lập trình viên đã làm xong tính năng, muốn tester hoặc designer xem, mà không được, vì staging đang chạy nhánh của người thứ hai, còn dev đang chạy nhánh của người thứ ba, cái nhánh đã làm hỏng đăng nhập hai ngày trước và từ đó chưa được triển khai lại. Vài người, hai môi trường dùng chung, và một luồng chat làm việc xếp lịch.</p>
<p>Đó không phải vấn đề con người. Đó là vấn đề topology. Mỗi lập trình viên đều coi môi trường là nơi nhánh của mình trở thành thật, nên môi trường đang làm thay việc mà merge đáng lẽ phải làm, mỗi lần một nhánh, và những người còn lại xếp hàng chờ.</p>
<p>Tôi đã mô tả hình dạng production của stack mình trong <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-07-22-my-current-stack/" >My Current Stack</a>: Django chạy trên những máy rẻ nhất của Fly.io, Postgres đứng sau một bộ gộp kết nối (connection pooler), Redis cho cache và tầng Channels, một ứng dụng WebSocket riêng, một worker chạy việc nền, một bucket tương thích S3 cho tệp, một dịch vụ xác thực bên ngoài, Cloudflare đứng trước, và một ứng dụng di động nói chuyện với tất cả. Bài này kể tôi đã biến stack ấy thành thứ mà với bất kỳ nhánh nào, lập trình viên chỉ cần gắn một nhãn lên PR là vài phút sau có ngay bản sao công khai, có HTTPS, của toàn bộ hệ thống, không phải nhờ ai khác và không ai phải cấp phát (provision) cho, mà cũng không cần cụm Postgres thứ hai, Redis thứ hai hay bucket thứ hai. Hạ tầng vẫn dùng chung. Điều thay đổi là mọi tài nguyên dùng chung học cách bị cắt lát theo một cái tên, và cái tên ấy đến từ nhánh. Không có gì trong đó giả định quy mô đội: thiết kế này giống hệt nhau cho hai lập trình viên hay hai mươi, thứ duy nhất tăng lên là danh sách ứng dụng, và việc đó đã có bộ dọn dẹp (sweeper) lo. Tôi dựng nó, chạy ba nhánh cùng lúc, làm hỏng ở hai chỗ không lường trước, rồi dỡ đi, tất cả trong một buổi chiều; các kết quả bên dưới lấy từ lần chạy đó.</p>

<h2 class="relative group">Vì sao một môi trường dùng chung không chịu nổi quá một lập trình viên
    <div id="vì-sao-một-môi-trường-dùng-chung-không-chịu-nổi-quá-một-lập-trình-viê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="#v%c3%ac-sao-m%e1%bb%99t-m%c3%b4i-tr%c6%b0%e1%bb%9dng-d%c3%b9ng-chung-kh%c3%b4ng-ch%e1%bb%8bu-n%e1%bb%95i-qu%c3%a1-m%e1%bb%99t-l%e1%ba%adp-tr%c3%acnh-vi%c3%aan" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Một môi trường dev dùng chung có sẵn một giả định: chỉ có một &ldquo;phiên bản code hiện tại&rdquo; mà ai cũng muốn xem. Điều đó đúng với một lập trình viên, hoặc với một nhóm cùng bước trên một dòng thay đổi duy nhất. Nó hết đúng ngay khi người thứ hai bắt đầu một tính năng theo lịch khác, và với kiểu sở hữu trọn gói thì tính năng nào cũng thế. Từ đó, mọi thứ môi trường nắm giữ đều thành thứ bị tranh:</p>
<ul>
<li><strong>Code đã triển khai.</strong> Ai triển khai sau cùng thì thắng. Công việc của những người còn lại vô hình cho đến khi họ triển khai lại, và việc đó xóa của người trước.</li>
<li><strong>Schema cơ sở dữ liệu.</strong> Đây là chỗ cắn đau nhất khi mỗi người sở hữu mô hình dữ liệu của tính năng mình. Nhánh thứ nhất thêm một cột. Nhánh thứ hai, triển khai một giờ sau, không biết về cột đó và ORM bắt đầu lỗi khi insert. Hoặc cả hai nhánh cùng có migration <code>0042_*</code>, cơ sở dữ liệu dùng chung rơi vào trạng thái không khớp lịch sử migration của ai, rồi ai đó reset nó, xóa luôn dữ liệu test mà người thứ ba mất cả buổi chiều chuẩn bị.</li>
<li><strong>Cache.</strong> Thay đổi serializer của một nhánh bị cache dưới đúng những khóa Redis mà code nhánh khác đọc. Các báo cáo lỗi sinh ra từ đó khó hiểu và ngốn mỗi cái một buổi sáng.</li>
<li><strong>Tích hợp bên thứ ba.</strong> Một OAuth callback, một URL webhook, một chứng chỉ push. Nhánh của ai đang được triển khai thì nhánh ấy nhận sự kiện.</li>
<li><strong>Tester.</strong> Họ chỉ test được thứ đã triển khai, nên họ test từng tính năng theo thứ tự môi trường tình cờ bị chiếm, chứ không theo thứ tự tính năng sẵn sàng.</li>
</ul>
<p>Nhóm ứng phó bằng hệ thống đặt chỗ: một luồng chat, một tin nhắn ghim, một bot <code>/claim staging</code>. Đó là một cái khóa, và khóa đặt lên nơi duy nhất công việc của bạn có thể được nhìn thấy là khóa đặt lên tốc độ giao hàng. Với hai người thì chỉ hơi khó chịu. Với ba người thì tuần thuận lợi còn chịu được. Thêm người thứ tư, hoặc có một tính năng giữ staging ba ngày để qua lại với designer, là hết chịu nổi.</p>
<p>Cách sửa đầu tiên người ta với tới là môi trường dùng chung thứ ba, &ldquo;dev2&rdquo; hay &ldquo;uat&rdquo;. Nó mua được vài tuần. Hàng chờ chỉ có thêm một làn.</p>
<p>Cách sửa thật sự là nhận ra dev và staging chưa bao giờ là môi trường. Chúng là <em>trạng thái hiện tại của nhánh ai đó, kèm một URL</em>. Nếu chúng là thế, thì số lượng đúng là số nhánh mà cả đội đang quan tâm lúc này, và vòng đời đúng là vòng đời của nhánh.</p>

<h2 class="relative group">Mô hình: nhánh chính là môi trường
    <div id="mô-hình-nhánh-chính-là-môi-trườ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="#m%c3%b4-h%c3%acnh-nh%c3%a1nh-ch%c3%adnh-l%c3%a0-m%c3%b4i-tr%c6%b0%e1%bb%9dng" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là trạng thái đích.</p>
<table>
	<thead>
			<tr>
					<th>Môi trường</th>
					<th>Bao nhiêu</th>
					<th>Ai tạo</th>
					<th>Ai hủy</th>
					<th>Sống bao lâu</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Môi trường nhánh (trước là &ldquo;dev&rdquo;)</td>
					<td>Mỗi nhánh đang hoạt động một cái</td>
					<td>Nhãn <code>env</code> trên PR của nhánh, rồi mỗi lần push sau đó; với một spike chưa có PR thì tự chạy cùng script <code>./scripts/env up</code> ấy bằng tay</td>
					<td>Xóa nhánh, cộng với trình quét chạy hằng đêm</td>
					<td>Vài ngày</td>
			</tr>
			<tr>
					<td>Staging</td>
					<td>Mỗi ứng viên phát hành một cái</td>
					<td>CI khi cắt tag từ <code>main</code></td>
					<td>CI khi cùng image đó được đưa lên prod</td>
					<td>Vài giờ</td>
			</tr>
			<tr>
					<td>Prod</td>
					<td>Một</td>
					<td>Bạn, một lần</td>
					<td>Không ai</td>
					<td>Mãi mãi</td>
			</tr>
	</tbody>
</table>
<p>Hãy nhìn lại trong bảng xem ai tạo và ai hủy: mục nào cũng là một sự kiện git hoặc một cái đồng hồ. Git chính là mặt phẳng điều khiển (control plane). Tập hợp môi trường cần tồn tại được suy thẳng từ tập hợp nhánh đang tồn tại, và việc duy nhất của tự động hóa là giữ cho thực tế khớp với nó: nhãn trên PR thì tạo, mỗi lần push sau đó thì cập nhật, đóng hay merge PR thì hủy, và một job chạy hằng đêm dọn nốt những gì ba bước trước bỏ sót. Cũng không phải nhánh nào cũng có môi trường: nhãn chính là cái cổng, một cú bấm của lập trình viên, nên một lần push để sao lưu hay bản cập nhật thư viện của bot không tốn gì. Không ai cấp phát môi trường cả. Không phải kỹ sư DevOps nhận ticket, không phải bot bạn gọi trong kênh chat, không phải AI agent bạn phải nhắc (prompt) mỗi lần. Nhánh đầu tiên của lập trình viên thứ mười có môi trường theo đúng cách nhánh của người thứ nhất đã có: vì PR của nó được gắn nhãn. Nếu vẫn phải nhờ đến một người, thì hệ thống chưa mở rộng được; hàng đợi chỉ đổi chỗ thôi.</p>
<p>Và quy tắc khiến nó vận hành: <strong>mọi tài nguyên mà một môi trường chạm vào đều được đặt tên theo nhánh, một cách tất định.</strong> Không theo lập trình viên, không theo ticket, không theo số PR vốn không tồn tại cho đến khi ai đó mở. Theo nhánh, vì nhánh là thứ lập trình viên thật sự làm việc cùng, và vì tên tất định nghĩa là lần chạy <code>./scripts/env up</code> thứ hai trên cùng nhánh sẽ tìm thấy môi trường đã tạo thay vì tạo thêm cái mới.</p>
<p>Tên gồm một slug cho người đọc và một mã băm ngắn để đảm bảo duy nhất:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="k">$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span>              <span class="c1"># feature/payments-retry</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">hash</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> sha256sum <span class="p">|</span> cut -c1-6<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span><span class="s2">-</span><span class="si">${</span><span class="nv">hash</span><span class="si">}</span><span class="s2">&#34;</span>                                 <span class="c1"># feature-payments-retr-3f9a1c</span></span></span></code></pre></div></div>
<p><code>feature-payments-retr-3f9a1c</code> là môi trường. Chuỗi đó trở thành tên ứng dụng Fly, tên cơ sở dữ liệu Postgres (gạch ngang đổi thành gạch dưới), tiền tố khóa Redis, tiền tố đối tượng trong bucket, environment trên công cụ theo dõi lỗi, và nhãn ngoài cùng bên trái của tên miền. Ai biết tên nhánh đều tính ra được. Không phải tra cứu ở đâu.</p>
<p>Câu cuối ấy chính là thiết kế. Không có sổ đăng ký môi trường và không có người điều phối. <code>env up</code>, <code>env down</code>, Cloudflare Worker định tuyến tên miền, và trình quét hằng đêm, mỗi thứ tự tính lại tên từ nhánh một cách độc lập và khớp nhau nhờ cấu trúc. Riêng Cloudflare không điều phối gì cả: Worker là một hàm thuần từ tên miền sang ứng dụng Fly, và nó không bao giờ biết một môi trường vừa được tạo hay hủy. Ứng dụng tồn tại thì yêu cầu đến nơi; không thì Fly trả lời bằng lỗi của chính nó.</p>
<p>Ba điều rút ra từ đây.</p>
<p>Thứ nhất, &ldquo;ai đang dùng staging&rdquo; không còn là câu hỏi. Có <code>feature-payments-retr-3f9a1c-dev.example.com</code> và có <code>fix-login-redirect-8b21e0-dev.example.com</code>, mỗi cái thuộc về người đang ở nhánh đó.</p>
<p>Thứ hai, staging trở thành buổi tổng duyệt trung thực của prod thay vì bãi rác. Nó được dựng từ chính image sẽ lên prod, trên cơ sở dữ liệu nhân bản vài phút trước từ snapshot có hình dạng prod. Chạy được ở đó thì biến số duy nhất còn lại trên prod là dữ liệu của prod.</p>
<p>Thứ ba, mỗi lập trình viên có quyền lực mà không cần root trên bất cứ thứ gì dùng chung. Bán kính nổ của một môi trường nhánh là chính môi trường nhánh đó. Người thứ tư vào ngày đầu có thể triển khai gì tùy thích lên nhánh mình, và kết cục tệ nhất là nhánh của họ hỏ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">fly.io/dashboard/my-org</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_5259e860ae14c489.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_e5b19353521f0ce4.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Bảng điều khiển Fly.io của tổ chức: ba ứng dụng của các nhánh tính năng (hai cái đã suspended) bên cạnh các ứng dụng production và Postgres, Redis preview dùng chung"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-apps_hu_a766ad7bf75a9514.webp"></span></div>
  <figcaption>Danh sách ứng dụng của tổ chức khi ba nhánh đang chạy. Hai trong ba đã suspended chỉ một phút sau khi triển khai; Postgres và Redis preview dùng chung nằm bên cạnh. Các ứng dụng production là những dòng bên dưới.</figcaption>
</figure>


<h2 class="relative group">Dùng chung về vật lý, tách biệt về logic
    <div id="dùng-chung-về-vật-lý-tách-biệt-về-logic" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%c3%b9ng-chung-v%e1%bb%81-v%e1%ba%adt-l%c3%bd-t%c3%a1ch-bi%e1%bb%87t-v%e1%bb%81-logic" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Phiên bản cám dỗ của &ldquo;mỗi nhánh một môi trường&rdquo; là sao chép toàn bộ: mỗi cái một cụm Postgres, một Redis, một bucket. Sạch sẽ, và là một cuộc đổi chác sai. Một cụm Fly Postgres là một máy cộng một volume cộng một lần khôi phục; một Redis là thêm một máy nữa; bạn sẽ chờ vài phút cho mỗi môi trường và trả tiền cho hàng chục cơ sở dữ liệu nằm không. Trình quét sẽ phải dọn bốn loại thứ thay vì một.</p>
<p>Thay vào đó, mỗi dịch vụ dùng chung được cấp phát một lần cho cả tổ chức preview, cỡ như một prod nhỏ, và mỗi môi trường nhận một lát cắt khóa theo <code>$ENV</code>. Đây là bảng tôi muốn dán lên tường:</p>
<table>
	<thead>
			<tr>
					<th>Tài nguyên</th>
					<th>Thứ dùng chung</th>
					<th>Thứ riêng từng môi trường</th>
					<th>Được đảm bảo bởi</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Tính toán</td>
					<td>Tổ chức Fly <code>myapp-preview</code></td>
					<td>Một ứng dụng Fly <code>myapp-$ENV</code>, một máy</td>
					<td>Fly: ứng dụng là đơn vị cô lập</td>
			</tr>
			<tr>
					<td>Bí mật</td>
					<td>Kho bí mật theo ứng dụng của Fly</td>
					<td>Bí mật của riêng ứng dụng; <code>SECRET_KEY</code>, <code>ENCRYPTION_KEY</code> và <code>ENV_TOKEN</code> được <em>sinh mới</em> cho từng môi trường, không sao chép</td>
					<td>Fly: bí mật thuộc về ứng dụng, máy chỉ đọc được của mình</td>
			</tr>
			<tr>
					<td>Postgres</td>
					<td>Một cụm Fly Postgres <code>myapp-preview-pg</code></td>
					<td>Một cơ sở dữ liệu <code>env_feature_payments_retr_3f9a1c</code>, nhân bản từ <code>seed_template</code></td>
					<td>Postgres: cơ sở dữ liệu là ranh giới cứng, không truy vấn chéo được</td>
			</tr>
			<tr>
					<td>Redis</td>
					<td>Một Redis dùng chung</td>
					<td>Tiền tố khóa <code>feature-payments-retr-3f9a1c:</code> trên cache và tầng Channels</td>
					<td><code>KEY_PREFIX</code> của cache Django, <code>prefix</code> của tầng channel</td>
			</tr>
			<tr>
					<td>Tệp</td>
					<td>Một bucket tương thích S3 <code>myapp-preview</code></td>
					<td>Tiền tố đối tượng <code>feature-payments-retr-3f9a1c/</code></td>
					<td><code>location</code> của backend lưu trữ; URL ký sẵn vốn đã giới hạn theo khóa</td>
			</tr>
			<tr>
					<td>Tên miền</td>
					<td>Bản ghi <code>*.example.com</code> đã proxy sẵn có của zone</td>
					<td>Nhãn ngoài cùng bên trái <code>&lt;env&gt;-dev</code></td>
					<td>Một route Cloudflare Worker trên <code>*-dev.example.com/*</code></td>
			</tr>
			<tr>
					<td>Xác thực</td>
					<td>Một dự án dev trên dịch vụ xác thực</td>
					<td>Không có gì; danh tính người dùng cố ý dùng chung (xem dưới)</td>
					<td>Tên miền được ủy quyền <code>example.com</code></td>
			</tr>
			<tr>
					<td>Lỗi và log</td>
					<td>Công cụ theo dõi lỗi, log của Fly</td>
					<td>Thẻ <code>environment=$ENV</code> trên mọi thứ</td>
					<td>Cấu hình</td>
			</tr>
	</tbody>
</table>
<p>Hai dòng trong đó đáng nói thêm một câu.</p>
<p><strong>Postgres: cơ sở dữ liệu, không phải schema.</strong> Cách khác để cắt lát một cụm là mỗi môi trường một schema, đặt <code>search_path</code> theo từng kết nối. Django làm được nếu bạn nhét <code>-c search_path=...</code> qua <code>options</code> của kết nối, nhưng bộ gộp kết nối ở chế độ transaction bỏ qua tham số khởi động trừ khi bạn bảo nó đừng, và dù có thế thì mọi kết nối trong pool rốt cuộc cũng phải thuộc về một môi trường. Đó là nguồn của những lỗi kiểu &ldquo;sao truy vấn của tôi lại chạm bảng khác&rdquo;, thứ mà mỗi môi trường một cơ sở dữ liệu đơn giản là không có. Cơ sở dữ liệu là ranh giới cứng, <code>CREATE DATABASE ... TEMPLATE</code> tạo một cái trong vài giây, và <code>DROP DATABASE</code> xóa nó không để lại gì. Các môi trường nhánh kết nối thẳng vào Postgres qua cổng 5433 thay vì qua bộ gộp kết nối, giống như worker chạy nền làm trên prod; pooler chỉ đáng giá ở mức đồng thời của production, thứ mà một môi trường nhánh không bao giờ thấy.</p>
<p><strong>Bí mật: sinh mới, không dùng chung.</strong> Sẽ dễ nếu có một bộ bí mật dev rồi chép vào mọi môi trường. Đừng. Script <code>env up</code> sinh <code>SECRET_KEY</code> (ký phiên), <code>ENCRYPTION_KEY</code> (mọi thứ mã hóa khi lưu) và <code>ENV_TOKEN</code> (bearer token mà ứng dụng di động xuất trình, nói thêm bên dưới) mới cho mỗi môi trường và chỉ lưu trong kho bí mật của ứng dụng Fly đó. Nghĩa là cookie phiên của nhánh này vô giá trị trên nhánh khác, một token preview bị lộ chỉ mở được một preview, và lập trình viên không bao giờ thấy giá trị trừ khi cố tình tìm. Nếu bạn giữ bí mật trong một trình quản lý bí mật (secrets manager) riêng thay vì chỉ trong Fly, quy tắc vẫn vậy: đường dẫn là <code>myapp/preview/$ENV/</code>, và trình quét xóa đường dẫn cùng với môi trường. Những bí mật <em>thật sự</em> dùng chung giữa các môi trường chỉ là thông tin đăng nhập của chính hạ tầng dùng chung (URL quản trị Postgres, URL Redis, khóa truy cập bucket), và chúng nằm trong CI, không nằm trong môi trường nào.</p>
<p>Dòng Redis là dòng dễ nghi ngờ nhất, nên đây là bài kiểm tra. Đặt một khóa cache từ bên trong một môi trường, đọc từ môi trường kia, rồi liệt kê mọi khóa trong Redis dùng chung:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-tagl-fa8832 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; cache.set(&#39;who-am-i&#39;, &#39;tagline-branch&#39;, 600)\&#34;&#34;
</span></span><span class="line"><span class="cl">$ fly ssh console -a myapp-feature-profile-webs-dcb2e7 -C &#34;python manage.py shell -c \&#34;from django.core.cache import cache; print(cache.get(&#39;who-am-i&#39;))\&#34;&#34;
</span></span><span class="line"><span class="cl">None
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ # every key in the shared Redis
</span></span><span class="line"><span class="cl">  feature-profile-tagl-fa8832:1:who-am-i
</span></span><span class="line"><span class="cl">  ...</span></span></code></pre></div></div>
<p>Cùng một Redis, cùng tên khóa, môi trường kia không thấy gì.</p>
<p>Phần còn lại của bài là script đóng dấu <code>$ENV</code> lên tám dòng ấy, và các quy tắc giữ cho nó trung thực.</p>

<h2 class="relative group"><code>./scripts/env up</code>
    <div id="scriptsenv-up" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#scriptsenv-up" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Đây là toàn bộ ý tưởng, dưới dạng một script lập trình viên chạy từ nhánh của mình. CI chạy cùng script; không có đường riêng cho CI để lệch đi. Trên laptop chỉ cần <code>fly</code> và <code>git</code>, không gì khác: SQL đi tới ứng dụng Postgres qua <code>fly ssh</code>, còn khóa Redis và đối tượng trong bucket của môi trường được chính máy của môi trường xóa trước khi nó bị hủy, nên laptop không giữ thông tin đăng nhập Redis hay bucket dùng chung ngoài một tệp env đã gitignore. Dưới đây là bản rút gọn, chỉ giữ những dòng quan trọng:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/env up|down|name|url: one environment per branch on shared preview infrastructure</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">PG_APP</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_PG_APP</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview-pg</span><span class="si">}</span><span class="p">;</span> <span class="nv">DOMAIN</span><span class="o">=</span><span class="si">${</span><span class="nv">BASE_DOMAIN</span><span class="k">:-</span><span class="nv">example</span><span class="p">.com</span><span class="si">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">branch</span><span class="o">=</span><span class="si">${</span><span class="nv">BRANCH</span><span class="k">:-$(</span>git rev-parse --abbrev-ref HEAD<span class="k">)</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;main has staging, not a branch environment&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">slug</span><span class="o">=</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> tr <span class="s1">&#39;[:upper:]/_ &#39;</span> <span class="s1">&#39;[:lower:]---&#39;</span> <span class="p">|</span> tr -cd <span class="s1">&#39;a-z0-9-&#39;</span> <span class="p">|</span> cut -c1-20<span class="k">)</span><span class="p">;</span> <span class="nv">slug</span><span class="o">=</span><span class="si">${</span><span class="nv">slug</span><span class="p">%-</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">ENV</span><span class="o">=</span><span class="si">${</span><span class="nv">ENV_OVERRIDE</span><span class="k">:-</span><span class="si">${</span><span class="nv">slug</span><span class="si">}</span><span class="p">-</span><span class="k">$(</span><span class="nb">printf</span> <span class="s1">&#39;%s&#39;</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="p">|</span> shasum -a <span class="m">256</span> <span class="p">|</span> cut -c1-6<span class="k">)</span><span class="si">}</span>   <span class="c1"># the sweeper passes the name</span>
</span></span><span class="line"><span class="cl"><span class="nv">APP</span><span class="o">=</span><span class="s2">&#34;myapp-</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">DB</span><span class="o">=</span><span class="s2">&#34;env_</span><span class="si">${</span><span class="nv">ENV</span><span class="p">//-/_</span><span class="si">}</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nv">HOST</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">ENV</span><span class="si">}</span><span class="s2">-dev.</span><span class="si">${</span><span class="nv">DOMAIN</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">pg<span class="o">()</span> <span class="o">{</span> fly ssh console -a <span class="s2">&#34;</span><span class="nv">$PG_APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;psql postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@localhost:5433/postgres -At -c \&#34;</span><span class="nv">$1</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">exists<span class="o">()</span> <span class="o">{</span> fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -q <span class="s2">&#34;\&#34;Name\&#34;: *\&#34;</span><span class="nv">$APP</span><span class="s2">\&#34;&#34;</span><span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">up<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;== </span><span class="nv">$branch</span><span class="s2"> -&gt; </span><span class="nv">$ENV</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  exists <span class="o">||</span> fly apps create <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span>                         <span class="c1"># idempotent</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="k">$(</span>pg <span class="s2">&#34;SELECT 1 FROM pg_database WHERE datname=&#39;</span><span class="nv">$DB</span><span class="s2">&#39;&#34;</span><span class="k">)</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;1&#34;</span> <span class="o">]]</span> <span class="o">||</span> pg <span class="s2">&#34;CREATE DATABASE </span><span class="nv">$DB</span><span class="s2"> TEMPLATE seed_template&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  <span class="nv">have</span><span class="o">=</span><span class="k">$(</span>fly secrets list -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4 <span class="o">||</span> <span class="nb">true</span><span class="k">)</span>
</span></span><span class="line"><span class="cl">  gen<span class="o">()</span> <span class="o">{</span> grep -qx <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$have</span><span class="s2">&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$1</span><span class="s2">=</span><span class="k">$(</span>openssl rand -hex 32<span class="k">)</span><span class="s2">&#34;</span><span class="p">;</span> <span class="o">}</span>   <span class="c1"># generated once, never rotated by a later `up`</span>
</span></span><span class="line"><span class="cl">  <span class="o">{</span> gen SECRET_KEY<span class="p">;</span> gen ENCRYPTION_KEY<span class="p">;</span> gen ENV_TOKEN
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;ENV_NAME=</span><span class="nv">$ENV</span><span class="s2">&#34;</span><span class="p">;</span> <span class="nb">echo</span> <span class="s2">&#34;ALLOWED_HOSTS=</span><span class="nv">$HOST</span><span class="s2">,</span><span class="nv">$APP</span><span class="s2">.fly.dev&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="nb">echo</span> <span class="s2">&#34;DATABASE_URL=postgres://postgres:</span><span class="si">${</span><span class="nv">PG_PASSWORD</span><span class="si">}</span><span class="s2">@</span><span class="si">${</span><span class="nv">PG_APP</span><span class="si">}</span><span class="s2">.flycast:5432/</span><span class="si">${</span><span class="nv">DB</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">    <span class="c1"># ... plus the shared Redis URL and bucket credentials</span>
</span></span><span class="line"><span class="cl">  <span class="o">}</span> <span class="p">|</span> fly secrets import -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --stage
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">  fly deploy -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --config fly.preview.toml --remote-only --ha<span class="o">=</span><span class="nb">false</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --build-arg <span class="nv">GIT_SHA</span><span class="o">=</span><span class="s2">&#34;</span><span class="k">$(</span>git rev-parse --short HEAD<span class="k">)</span><span class="s2">&#34;</span> --build-arg <span class="nv">GIT_BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">down<span class="o">()</span> <span class="o">{</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^myapp-<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;refusing to destroy </span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;<span class="p">&amp;</span>2<span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">  <span class="k">if</span> exists<span class="p">;</span> <span class="k">then</span>
</span></span><span class="line"><span class="cl">    fly machine start -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> &gt;/dev/null 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="o">||</span> <span class="nb">true</span>                 <span class="c1"># it may be suspended</span>
</span></span><span class="line"><span class="cl">    fly ssh console -a <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> -q -C <span class="s2">&#34;python manage.py teardown_env&#34;</span> <span class="o">||</span> <span class="nb">echo</span> <span class="s2">&#34;warning: cleanup failed; the sweeper will catch it&#34;</span> &gt;<span class="p">&amp;</span><span class="m">2</span>
</span></span><span class="line"><span class="cl">    fly apps destroy <span class="s2">&#34;</span><span class="nv">$APP</span><span class="s2">&#34;</span> --yes
</span></span><span class="line"><span class="cl">  <span class="k">fi</span>
</span></span><span class="line"><span class="cl">  pg <span class="s2">&#34;DROP DATABASE IF EXISTS </span><span class="nv">$DB</span><span class="s2"> WITH (FORCE)&#34;</span> <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="o">}</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="k">case</span> <span class="s2">&#34;</span><span class="si">${</span><span class="nv">1</span><span class="k">:-</span><span class="si">}</span><span class="s2">&#34;</span> in up<span class="o">)</span> up <span class="p">;;</span> down<span class="o">)</span> down <span class="p">;;</span> name<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;</span><span class="nv">$ENV</span><span class="s2">&#34;</span> <span class="p">;;</span> url<span class="o">)</span> <span class="nb">echo</span> <span class="s2">&#34;https://</span><span class="nv">$HOST</span><span class="s2">&#34;</span> <span class="p">;;</span> <span class="k">esac</span></span></span></code></pre></div></div>
<p>Trong bản demo tôi chạy nó từ laptop thay vì push, vì ba nhánh này là đồ dùng một lần tôi không muốn để lại trong lịch sử kho, và tôi muốn nhìn output chạy ra từng dòng. Khi cả đội dùng thì không ai gõ lệnh này cả: workflow gắn nhãn ở phần dưới chạy đúng script này, và những gì bạn thấy tiếp theo là thứ sẽ nằm trong log của Actions. Đây là những gì nó in ra lần đầu, trên chính nhánh chứa bộ công 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">$ ./scripts/env up
</span></span><span class="line"><span class="cl">== feature/branch-environments -&gt; feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">New app created: myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">CREATE DATABASE
</span></span><span class="line"><span class="cl">Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
</span></span><span class="line"><span class="cl">==&gt; Building image
</span></span><span class="line"><span class="cl">image size: 139 MB
</span></span><span class="line"><span class="cl">✔ Machine 1a2b3c4d5e6f78 [app] update finished: success
</span></span><span class="line"><span class="cl">https://feature-branch-envir-c65cb4-dev.example.com
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s https://feature-branch-envir-c65cb4-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">{&#34;environment&#34;: &#34;feature-branch-envir-c65cb4&#34;, &#34;branch&#34;: &#34;feature/branch-environments&#34;, &#34;commit&#34;: &#34;a1b2c3d4&#34;, &#34;region&#34;: &#34;syd&#34;}</span></span></code></pre></div></div>
<p>Khoảng hai phút rưỡi từ lệnh đến URL, phần lớn là build image. Lần <code>up</code> thứ hai trên cùng nhánh, sau một commit nữa, không in &ldquo;New app created&rdquo; hay &ldquo;CREATE DATABASE&rdquo;, stage đúng các tên bí mật cũ mà không sinh lại những cái được sinh, rồi build lại: cùng URL, commit mới.</p>
<p>Vài lựa chọn trong đó xứng đáng mỗi cái một câu.</p>
<p><strong><code>fly.preview.toml</code> không phải <code>fly.toml</code>.</strong> Prod chạy API, máy chủ WebSocket và worker chạy nền như các ứng dụng Fly và nhóm tiến trình riêng để mỗi thứ mở rộng theo chỉ số của mình, đó là toàn bộ lập luận của bài về stack. Một môi trường nhánh không cần mở rộng; nó cần là một máy. Nên cấu hình preview chỉ có một tiến trình: script khởi chạy (entrypoint) của container có thêm một chế độ preview, chạy migration rồi khởi động <em>một</em> máy chủ ASGI có bật WebSocket. Channels vốn đã định tuyến cả lưu lượng HTTP lẫn WebSocket trong cùng một ứng dụng ASGI, nên một tiến trình phục vụ cả ứng dụng. Ở chế độ đó các view đồng bộ chạy kiểu thread-sensitive, gần như mỗi lần một yêu cầu, chính là cuộc đổi chác tôi đã rời bỏ trên prod và lại đúng ở đây, vì không ai test một nhánh mà nhận ra.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-toml" data-lang="toml"><span class="line"><span class="cl"><span class="c"># fly.preview.toml (the app name comes from `fly deploy -a`)</span>
</span></span><span class="line"><span class="cl"><span class="nx">app</span> <span class="p">=</span> <span class="s1">&#39;placeholder&#39;</span>
</span></span><span class="line"><span class="cl"><span class="nx">primary_region</span> <span class="p">=</span> <span class="s1">&#39;syd&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">processes</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">app</span> <span class="p">=</span> <span class="s2">&#34;/app/entrypoint.sh preview&#34;</span>   <span class="c"># migrate, then one ASGI server for HTTP and WebSockets</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">env</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DJANGO_SETTINGS_MODULE</span> <span class="p">=</span> <span class="s1">&#39;myapp.settings&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">DEBUG</span> <span class="p">=</span> <span class="s1">&#39;False&#39;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[</span><span class="nx">http_service</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">internal_port</span> <span class="p">=</span> <span class="mi">8000</span>
</span></span><span class="line"><span class="cl">  <span class="nx">force_https</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;suspend&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">auto_start_machines</span> <span class="p">=</span> <span class="kc">true</span>
</span></span><span class="line"><span class="cl">  <span class="nx">min_machines_running</span> <span class="p">=</span> <span class="mi">0</span>
</span></span><span class="line"><span class="cl">  <span class="p">[[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">    <span class="nx">path</span> <span class="p">=</span> <span class="s1">&#39;/healthz&#39;</span>
</span></span><span class="line"><span class="cl">    <span class="p">[</span><span class="nx">http_service</span><span class="p">.</span><span class="nx">checks</span><span class="p">.</span><span class="nx">headers</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">      <span class="nx">Host</span> <span class="p">=</span> <span class="s1">&#39;127.0.0.1&#39;</span>               <span class="c"># the app&#39;s own hostname isn&#39;t known to a static file</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="p">[[</span><span class="nx">vm</span><span class="p">]]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">size</span> <span class="p">=</span> <span class="s1">&#39;shared-cpu-1x&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">memory</span> <span class="p">=</span> <span class="s1">&#39;1gb&#39;</span></span></span></code></pre></div></div>
<p><strong><code>auto_stop_machines = &quot;suspend&quot;</code>, <code>min_machines_running = 0</code>.</strong> Một môi trường nhánh không ai mở từ sau bữa trưa nên tốn số không. Suspend thay vì stop cho ra khoảng 3 giây khôi phục mà tôi đạt được <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/" >sau khi đuổi theo lỗi 504</a> thay vì 30 giây khởi động lạnh, và ở đây ngay cả 30 giây cũng chấp nhận được. Hai trong ba môi trường demo đã <code>suspended</code> trong <code>fly apps list</code> chỉ một phút sau khi triển khai xong.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">fly.io/apps/myapp-feature-profile-tagl-fa8832</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d26550a0a30599da.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_d2f15e465075b4eb.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Tổng quan Fly.io của một ứng dụng nhánh: một máy shared-cpu-1x ở SYD, một nhóm tiến trình, tên miền *.fly.dev riêng"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-fly-branch-app_hu_381dc63e1f9d24c0.webp"></span></div>
  <figcaption>Một môi trường nhánh dưới mắt Fly: một máy, một nhóm tiến trình, một vùng. Không có gì ở đây đáng để mở rộng.</figcaption>
</figure>

<p><strong><code>DEBUG = False</code>, cùng module settings với prod.</strong> Môi trường nhánh là một URL công khai. Nó nên hành xử như prod ở mọi mặt quan trọng cho việc test (HTTPS thật, cookie thật, cùng health check) và khác prod ở mọi mặt có thể làm ai đó bị hại. Thay vì một tệp settings riêng cho preview, sự khác biệt được mang bởi các bí mật mà <code>env up</code> đặt: khóa thanh toán sandbox, không có thông tin quản trị của dịch vụ xác thực, và <code>ENV_NAME</code> bật mọi tiền tố bên dưới.</p>
<p><strong>Một cấu hình cắt lát mọi thứ.</strong> <code>ENV_NAME</code> là núm vặn mới duy nhất trong settings, và được áp ở bốn chỗ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-python" data-lang="python"><span class="line"><span class="cl"><span class="n">ENV_NAME</span> <span class="o">=</span> <span class="n">os</span><span class="o">.</span><span class="n">getenv</span><span class="p">(</span><span class="s2">&#34;ENV_NAME&#34;</span><span class="p">,</span> <span class="s2">&#34;&#34;</span><span class="p">)</span>
</span></span><span class="line"><span class="cl"><span class="n">_prefix</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">/&#34;</span> <span class="k">if</span> <span class="n">ENV_NAME</span> <span class="k">else</span> <span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">CACHES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;KEY_PREFIX&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="n">ENV_NAME</span>                           <span class="c1"># every cache key</span>
</span></span><span class="line"><span class="cl"><span class="n">CHANNEL_LAYERS</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;CONFIG&#34;</span><span class="p">][</span><span class="s2">&#34;prefix&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">ENV_NAME</span><span class="si">}</span><span class="s2">:asgi&#34;</span>  <span class="c1"># Channels group and channel names</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;default&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">media&#34;</span>      <span class="c1"># uploads</span>
</span></span><span class="line"><span class="cl"><span class="n">STORAGES</span><span class="p">[</span><span class="s2">&#34;staticfiles&#34;</span><span class="p">][</span><span class="s2">&#34;OPTIONS&#34;</span><span class="p">][</span><span class="s2">&#34;location&#34;</span><span class="p">]</span> <span class="o">=</span> <span class="sa">f</span><span class="s2">&#34;</span><span class="si">{</span><span class="n">_prefix</span><span class="si">}</span><span class="s2">static&#34;</span></span></span></code></pre></div></div>
<p>Bỏ sót chỗ channels thì máy chủ WebSocket của hai nhánh dùng chung tên nhóm, và tin nhắn gửi ở môi trường này hiện ra ở môi trường kia. Đó là loại lỗi tốn cả ngày.</p>
<p>Còn chỗ thứ năm tôi chỉ tìm thấy khi đọc Redis dùng chung sau đó. Cái khóa lúc khởi động từ bài về 504, thứ cho phép mỗi image chỉ một máy chạy migration trong khi các máy khác chờ, vốn đặt tên khóa theo tham chiếu image. Hai môi trường nhánh dựng từ cùng một commit có cùng image. Cái thứ hai sẽ thấy dấu &ldquo;done&rdquo; của cái thứ nhất và bỏ qua migration của chính nó trên cơ sở dữ liệu chưa migration của chính nó. Giờ tên môi trường là một phần của tên khóa, và khóa nằm dưới tiền tố. Đó là loại chuyện mà thiết kế hạ tầng dùng chung cứ sinh ra mãi: mọi thứ ngầm là &ldquo;theo lần triển khai&rdquo; phải được làm rõ thành theo môi trường, và bạn không biết cái gì là ngầm cho đến khi nhìn vào.</p>
<p><strong>Cơ sở dữ liệu được nhân bản, không migration từ rỗng.</strong> Đó là mục tiếp theo.</p>

<h2 class="relative group">Cơ sở dữ liệu: một mẫu, nhân bản trong vài giây
    <div id="cơ-sở-dữ-liệu-một-mẫu-nhân-bản-trong-vài-giâ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="#c%c6%a1-s%e1%bb%9f-d%e1%bb%af-li%e1%bb%87u-m%e1%bb%99t-m%e1%ba%abu-nh%c3%a2n-b%e1%ba%a3n-trong-v%c3%a0i-gi%c3%a2y" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Cơ sở dữ liệu của một môi trường nhánh có thể là một trong ba thứ. Dùng chung với mọi người, thứ đưa chúng ta đến đây. Rỗng, migration từ số không rồi đổ fixture: tất định, nhưng fixture chưa bao giờ tốt bằng dữ liệu có hình dạng thật và luôn cũ mười tám tháng. Hoặc một bản nhân bản từ snapshot có hình dạng prod: số dòng thực tế, những giá trị null thực tế trong các cột mà fixture cứ tưởng luôn có dữ liệu, cái migration tức thì trên bảng rỗng nhưng mất bốn mươi phút trên bảng thật sẽ lộ mặt.</p>
<p>Chọn cách thứ ba, giữ cách thứ hai làm một kiểm tra chỉ chạy trên CI. Và thứ khiến cách thứ ba đủ nhanh để làm cho từng nhánh là một tính năng Postgres mà hầu hết mọi người quên là có tồn tại:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="c1">-- Nightly, on the preview cluster. Restore last night&#39;s prod backup into seed_raw,
</span></span></span><span class="line"><span class="cl"><span class="c1">-- run the anonymiser, then freeze it as a template nobody can connect to.
</span></span></span><span class="line"><span class="cl"><span class="k">ALTER</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">seed_raw</span><span class="w"> </span><span class="k">RENAME</span><span class="w"> </span><span class="k">TO</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="k">UPDATE</span><span class="w"> </span><span class="n">pg_database</span><span class="w"> </span><span class="k">SET</span><span class="w"> </span><span class="n">datistemplate</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">true</span><span class="p">,</span><span class="w"> </span><span class="n">datallowconn</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">false</span><span class="w"> </span><span class="k">WHERE</span><span class="w"> </span><span class="n">datname</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s1">&#39;seed_template&#39;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c1">-- Per branch, in the time it takes to copy the files:
</span></span></span><span class="line"><span class="cl"><span class="k">CREATE</span><span class="w"> </span><span class="k">DATABASE</span><span class="w"> </span><span class="n">env_feature_payments_retr_3f9a1c</span><span class="w"> </span><span class="k">TEMPLATE</span><span class="w"> </span><span class="n">seed_template</span><span class="p">;</span></span></span></code></pre></div></div>
<p><code>CREATE DATABASE ... TEMPLATE</code> là sao chép ở mức tệp trong cụm. Vài gigabyte là vài giây, không phải vài phút như <code>pg_restore</code>. Mẫu không được có kết nối mở trong khi đang nhân bản, đó là điều <code>datallowconn = false</code> đảm bảo, và cũng là lý do tác vụ đêm dựng vào <code>seed_raw</code> rồi đổi tên ở cuối thay vì khôi phục đè lên mẫu đang sống.</p>
<p>Trong demo, mẫu được dựng bằng tay thay vì từ dump prod: một đường hầm tới cụm preview bằng <code>fly proxy</code>, <code>manage.py migrate</code>, rồi các lệnh seed sẵn có của repo để tạo một bộ dữ liệu nhỏ nhưng sát với thực tế. Mười chín megabyte. Ba bản nhân bản của nó xuất hiện trong cụm khi ba nhánh khởi lên, mỗi cái 19 MB, mỗi cái chưa tới một giây:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ fly ssh console -a myapp-preview-pg -C &#34;psql ... -c &#39;\l&#39;&#34;
</span></span><span class="line"><span class="cl">             datname             | template | size
</span></span><span class="line"><span class="cl">---------------------------------+----------+-------
</span></span><span class="line"><span class="cl"> env_feature_branch_envir_c65cb4 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_tagl_fa8832 | f        | 19 MB
</span></span><span class="line"><span class="cl"> env_feature_profile_webs_dcb2e7 | f        | 19 MB
</span></span><span class="line"><span class="cl"> seed_template                   | t        | 19 MB</span></span></code></pre></div></div>
<p>Cụm preview là một node Fly Postgres không quản lý, <code>shared-cpu-1x</code> với volume 1 GB, khoảng hai đô la một tháng. Các môi trường nhánh kết nối tới nó qua <code>.flycast</code> trên mạng riêng; không có gì công khai.</p>
<p>Bộ ẩn danh hóa không phải tùy chọn. Khoảnh khắc dữ liệu prod có thể truy cập trên một tên miền <code>-dev</code>, nó phải không còn là dữ liệu prod: tên, email, số điện thoại, địa chỉ, văn bản tự do, tham chiếu thanh toán, token, tất cả bị ghi đè trước khi đổi tên. Nếu bộ ẩn danh hóa thất bại, mẫu của đêm trước được giữ lại và ai đó nhận tin nhắn. Đó là một việc nhỏ và là thứ khiến toàn bộ cách tiếp cận này chấp nhận được với người trả lời bảng câu hỏi về quyền riêng tư.</p>
<p>Script khởi chạy chạy <code>migrate</code> ở lần khởi động đầu của mỗi image, nên migration của chính nhánh được áp lên trên mẫu. Đó là bài kiểm tra trung thực đầu tiên cho câu &ldquo;migration của tôi có chạy trên dữ liệu có hình dạng prod không&rdquo;, và nó xảy ra trước khi ai đó mở URL.</p>

<h2 class="relative group">Tên miền: một wildcard, một Worker, và hai điều tài liệu không nói
    <div id="tên-miền-một-wildcard-một-worker-và-hai-điều-tài-liệu-không-nó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="#t%c3%aan-mi%e1%bb%81n-m%e1%bb%99t-wildcard-m%e1%bb%99t-worker-v%c3%a0-hai-%c4%91i%e1%bb%81u-t%c3%a0i-li%e1%bb%87u-kh%c3%b4ng-n%c3%b3i" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mỗi môi trường cần một tên miền HTTPS công khai, và &ldquo;công khai&rdquo; chính là điểm mấu chốt: tester cầm điện thoại, designer ở mạng khác, product manager không chịu cài VPN. Fly cho mỗi ứng dụng <code>myapp-$ENV.fly.dev</code> miễn phí, và chỉ thế cũng chạy được. Nhưng tên miền <code>*.fly.dev</code> có ba vấn đề: gửi đi trông xấu, Cloudflare không đứng trước nên không có lớp bảo vệ nào của prod được áp dụng, và nếu muốn đặt chúng dưới tên miền của mình thì phải tạo mỗi môi trường một bản ghi DNS và một chứng chỉ, thêm một thứ nữa cho trình quét.</p>
<p>Phiên bản loại bỏ tất cả những thứ ấy là một wildcard và một Cloudflare Worker. Tôi đã định dùng <code>*.dev.example.com</code>. Hai điều đã chặn lại, và cả hai đều đáng biết trước khi bạn lên kế hoạch tương tự.</p>
<p><strong>Universal SSL chỉ phủ một tầng wildcard.</strong> Chứng chỉ miễn phí của Cloudflare được cấp cho <code>example.com</code> và <code>*.example.com</code>. Nó không phủ <code>*.dev.example.com</code>; wildcard tầng hai cần Advanced Certificate Manager giá 10 đô la một tháng. Yêu cầu đầu tiên tới <code>feature-branch-envir-c65cb4.dev.example.com</code> chết vì lỗi bắt tay SSL trước khi bất cứ thứ gì của tôi kịp chạy. Cách sửa là giữ môi trường ở nhãn đầu tiên: <code>feature-branch-envir-c65cb4-dev.example.com</code>. Nó được chứng chỉ sẵn có phủ, và được bản ghi <code>*.example.com</code> đã proxy sẵn có phủ, chính bản ghi tôi dựng làm hố đen cho <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-09-09-scale-to-zero-504s/#the-spike-was-a-red-herring" >các trình quét lần mò những tên miền con bịa đặt</a>. Vậy nên một môi trường hoàn toàn không cần DNS.</p>
<p><strong>Redirect Rules chạy trước Workers.</strong> Với route đã đặt, mọi tên miền <code>-dev</code> đều trả 301 về trang chủ www mà Worker không hề chạy. Hố đen là một quy tắc chuyển hướng cấp zone, &ldquo;mọi tên miền con khác → www&rdquo;, và pha chuyển hướng của Cloudflare thực thi trước pha Workers. Quy tắc cần thêm một mệnh đề: <code>and not ends_with(http.host, &quot;-dev.example.com&quot;)</code>. Script triển khai thêm nó khi <code>up</code> và gỡ khi <code>down</code>, vì một quy tắc ai đó sửa tay chính là thứ bị quên.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/rules/redirect-rules/…/edit</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_4f6c3fdde94f28d6.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_38e5e373828b8373.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Quy tắc chuyển hướng trong bảng điều khiển Cloudflare, biểu thức kết thúc bằng ngoại lệ -dev"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-redirect-rules_hu_71d546da5b5668b6.webp"></span></div>
  <figcaption>Chuyển hướng hố đen sau khi script đã can thiệp. Mọi thứ dưới zone vẫn bật về www, trừ các tên miền kết thúc bằng <code>-dev.example.com</code>, chúng lọt xuống Worker.</figcaption>
</figure>

<p>Phía DNS là bản ghi vốn đã có sẵn:</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/dns/records</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_6d425bb8f7651010.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_2c615b9e52444abd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Bản ghi DNS của zone lọc theo wildcard: một bản ghi A đã proxy cho wildcard của zone, trỏ tới 192.0.2.1"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-dns_hu_952422604a6c8304.webp"></span></div>
  <figcaption>Bản ghi DNS duy nhất liên quan, và nó có trước bài viết này. Một wildcard đã proxy trỏ tới địa chỉ giữ chỗ: Cloudflare trả lời, còn chuyện tiếp theo tùy quy tắc và Workers.</figcaption>
</figure>

<p>Gỡ xong hai thứ đó, việc định tuyến là một Worker duy nhất trên <code>*-dev.example.com/*</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-javascript" data-lang="javascript"><span class="line"><span class="cl"><span class="c1">// dev-router.js: &lt;env&gt;-dev.example.com -&gt; myapp-&lt;env&gt;.fly.dev
</span></span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">DEV_SUFFIX</span> <span class="o">=</span> <span class="s2">&#34;-dev.example.com&#34;</span><span class="p">;</span>
</span></span><span class="line"><span class="cl"><span class="kr">const</span> <span class="nx">ENV_NAME</span> <span class="o">=</span> <span class="sr">/^[a-z0-9-]+-[0-9a-f]{6}$/</span><span class="p">;</span>   <span class="c1">// slug-hash, as scripts/env names them
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="kr">export</span> <span class="k">default</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="kr">async</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">request</span><span class="p">)</span> <span class="p">{</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">url</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">URL</span><span class="p">(</span><span class="nx">request</span><span class="p">.</span><span class="nx">url</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">endsWith</span><span class="p">(</span><span class="nx">DEV_SUFFIX</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="s2">&#34;not a dev hostname&#34;</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">env</span> <span class="o">=</span> <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span><span class="p">.</span><span class="nx">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="nx">DEV_SUFFIX</span><span class="p">.</span><span class="nx">length</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">if</span> <span class="p">(</span><span class="o">!</span><span class="nx">ENV_NAME</span><span class="p">.</span><span class="nx">test</span><span class="p">(</span><span class="nx">env</span><span class="p">))</span> <span class="k">return</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="sb">`no such environment: </span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">`</span><span class="p">,</span> <span class="p">{</span> <span class="nx">status</span><span class="o">:</span> <span class="mi">404</span> <span class="p">});</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="nx">url</span><span class="p">.</span><span class="nx">hostname</span> <span class="o">=</span> <span class="sb">`myapp-</span><span class="si">${</span><span class="nx">env</span><span class="si">}</span><span class="sb">.fly.dev`</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">upstream</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Request</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="nx">request</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">upstream</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Forwarded-Host&#34;</span><span class="p">,</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">get</span><span class="p">(</span><span class="s2">&#34;host&#34;</span><span class="p">)</span> <span class="o">??</span> <span class="s2">&#34;&#34;</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">res</span> <span class="o">=</span> <span class="kr">await</span> <span class="nx">fetch</span><span class="p">(</span><span class="nx">upstream</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="kr">const</span> <span class="nx">out</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Response</span><span class="p">(</span><span class="nx">res</span><span class="p">.</span><span class="nx">body</span><span class="p">,</span> <span class="nx">res</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Robots-Tag&#34;</span><span class="p">,</span> <span class="s2">&#34;noindex, nofollow&#34;</span><span class="p">);</span>   <span class="c1">// a half-finished branch is not for search engines
</span></span></span><span class="line"><span class="cl">    <span class="nx">out</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nx">set</span><span class="p">(</span><span class="s2">&#34;X-Dev-Environment&#34;</span><span class="p">,</span> <span class="nx">env</span><span class="p">);</span>
</span></span><span class="line"><span class="cl">    <span class="k">return</span> <span class="nx">out</span><span class="p">;</span>
</span></span><span class="line"><span class="cl">  <span class="p">},</span>
</span></span><span class="line"><span class="cl"><span class="p">};</span></span></span></code></pre></div></div>
<p>Cloudflare kết thúc TLS trên wildcard, Worker tính tên miền Fly từ nhãn, và chứng chỉ <code>*.fly.dev</code> của chính Fly phủ chặng tới máy. Nâng cấp WebSocket đi qua <code>fetch</code> không đổi. Và vì route nằm trên zone, mọi thứ Cloudflare làm cho prod đều dùng được: quy tắc WAF, giới hạn tốc độ, chế độ chống bot, và Access.</p>
<figure>
  <div class="browser-frame">
    <div class="browser-frame__bar" aria-hidden="true">
      <span class="browser-frame__dots"><i></i><i></i><i></i></span>
      <span class="browser-frame__url">dash.cloudflare.com/…/example.com/workers</span>
    </div><span class="browser-frame__shot" ><img
      class="browser-frame__page"
      src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp"
      srcset="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_bd62e960f954de34.webp 800w, https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_9996f094fe4379cd.webp 1280w"
      sizes="(min-width: 768px) 50vw, 65vw"
      width="1400" height="900"
      alt="Trang Workers Routes của zone trên Cloudflare: một route *-dev.example.com/* gắn với Worker dev-router"
      loading="lazy" decoding="async"
      data-zoom-src="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/dash-cf-zone-worker-routes_hu_78420fc686a1e6e3.webp"></span></div>
  <figcaption>Toàn bộ phía Cloudflare của một môi trường: một mẫu route, một Worker. Không có tên môi trường nào xuất hiện ở bất kỳ đâu trong bảng điều khiển này.</figcaption>
</figure>

<p>Ba nhánh cùng chạy, một Worker, và câu trả lời của từng tên miền:</p>
<table>
	<thead>
			<tr>
					<th>Tên miền</th>
					<th>HTTP</th>
					<th>Định tuyến tới</th>
					<th><code>branch</code></th>
					<th><code>commit</code></th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>feature-branch-envir-c65cb4-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-branch-envir-c65cb4.fly.dev</code></td>
					<td><code>feature/branch-environments</code></td>
					<td><code>a1b2c3d4</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-tagl-fa8832-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-tagl-fa8832.fly.dev</code></td>
					<td><code>feature/profile-tagline</code></td>
					<td><code>e5f6a7b8</code></td>
			</tr>
			<tr>
					<td><code>feature-profile-webs-dcb2e7-dev.example.com</code></td>
					<td>200</td>
					<td><code>myapp-feature-profile-webs-dcb2e7.fly.dev</code></td>
					<td><code>feature/profile-website</code></td>
					<td><code>c9d0e1f2</code></td>
			</tr>
			<tr>
					<td><code>not-an-env-dev.example.com</code></td>
					<td>404</td>
					<td>không đâu cả; Worker từ chối nhãn</td>
					<td></td>
					<td></td>
			</tr>
	</tbody>
</table>
<p>Mọi phản hồi 200 đều kèm <code>X-Robots-Tag: noindex, nofollow</code> và <code>X-Dev-Environment: &lt;env&gt;</code> do Worker thêm vào. Cái 404 là của chính Worker, trước cả khi hỏi Fly.</p>
<p>Cloudflare Access là thứ biến &ldquo;công khai&rdquo; thành &ldquo;đến được với những người nên đến&rdquo;. Một ứng dụng trên <code>*-dev.example.com</code> với chính sách &ldquo;bất kỳ ai có email @example.com&rdquo; và người dùng trình duyệt thấy trang đăng nhập một lần rồi không bao giờ phải nghĩ tới nữa. Designer, PM và tester dùng laptop đều được phủ. Tôi không thêm nó cho demo; các môi trường chạy một giờ với <code>noindex</code> và không có gì ngoài dữ liệu seed.</p>
<p>Nó không phủ được ứng dụng di động, vì ứng dụng native không hoàn tất được chuyển hướng đăng nhập của Access. Hai lối đi. Access hỗ trợ service token (cặp header <code>CF-Access-Client-Id</code> / <code>CF-Access-Client-Secret</code>) bỏ qua đăng nhập, và menu dev của ứng dụng di động có thể mang một cặp cho các tên miền dev. Hoặc đơn giản hơn, thêm một bypass Access cho <code>/api/</code> và để Django từ chối mọi yêu cầu không có người dùng đã đăng nhập hoặc <code>ENV_TOKEN</code> của môi trường, chính cái token <code>env up</code> đã sinh cho đúng môi trường này. Dù cách nào, phía ứng dụng di động là một màn hình menu dev với hai trường, URL gốc và token, và ứng dụng tester đã cài sẵn giờ nói chuyện với nhánh đó. Không cần build mới. <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-02-22-self-hosted-ota-updates/" >Kênh cập nhật qua mạng (OTA)</a> vẫn trỏ về thứ ứng dụng được build cùng; URL gốc của API là cấu hình lúc chạy, không phải lúc build, và vốn nên như thế từ đầu.</p>
<p>Xác thực là dịch vụ dùng chung duy nhất được cố ý dùng chung. Danh tính không thuộc về môi trường nào; tester muốn đăng nhập bằng cùng tài khoản trên mọi nhánh. Một dự án dev trên dịch vụ xác thực, với <code>example.com</code> trong danh sách tên miền được ủy quyền phủ mọi tên miền <code>-dev</code>, và mọi môi trường xác minh ID token với nó. Bảng người dùng trong cơ sở dữ liệu mỗi môi trường là bản sao người dùng của mẫu, nên tài khoản của tester tồn tại ở mọi nơi mẫu có.</p>

<h2 class="relative group">Tự động hóa môi trường xem trước bằng GitHub Actions: sự kiện vào, môi trường ra
    <div id="tự-động-hóa-môi-trường-xem-trước-bằng-github-actions-sự-kiện-vào-môi-trường-ra" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%b1-%c4%91%e1%bb%99ng-h%c3%b3a-m%c3%b4i-tr%c6%b0%e1%bb%9dng-xem-tr%c6%b0%e1%bb%9bc-b%e1%ba%b1ng-github-actions-s%e1%bb%b1-ki%e1%bb%87n-v%c3%a0o-m%c3%b4i-tr%c6%b0%e1%bb%9dng-ra" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Mọi thứ ở trên làm bằng tay thì ổn khi chỉ có bạn làm. Từ khi có lập trình viên thứ hai, nó phải xảy ra vì có gì đó xảy ra trong git, chứ không phải vì ai đó nhớ. Toàn bộ tự động hóa là bốn workflow, một cài đặt kho, một ruleset và một GitHub environment, và mỗi thứ ánh xạ một sự kiện git thành một lời gọi tới chính <code>scripts/env</code> mà lập trình viên chạy bằng tay. Không có đường riêng cho CI để lệch đi.</p>
<table>
	<thead>
			<tr>
					<th>Sự kiện git</th>
					<th>Chạy gì</th>
					<th>Làm gì</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>PR được gắn nhãn <code>env</code></td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>tạo môi trường của nhánh đó, đăng URL lên PR</td>
			</tr>
			<tr>
					<td>push lên nhánh của một PR đã gắn nhãn</td>
					<td><code>branch-env.yml</code> → <code>scripts/env up</code></td>
					<td>cập nhật tại chỗ</td>
			</tr>
			<tr>
					<td>gỡ nhãn, PR bị đóng hoặc merge</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>môi trường tự dọn lát cắt của mình rồi bị hủy</td>
			</tr>
			<tr>
					<td>xóa nhánh</td>
					<td><code>branch-env.yml</code> → <code>scripts/env down</code></td>
					<td>lưới an toàn cho nhánh dựng bằng tay mà chưa từng có PR; cài đặt kho <em>delete branch on merge</em> làm merge cũng kích hoạt nó</td>
			</tr>
			<tr>
					<td>push lên bất kỳ nhánh nào khác</td>
					<td>không gì cả</td>
					<td>một lần push để sao lưu hay một spike không tốn gì cho đến khi có người muốn xem</td>
			</tr>
			<tr>
					<td>pull request vào <code>main</code></td>
					<td><code>migrations-check.yml</code></td>
					<td>một lá duy nhất trong đồ thị migration sau khi merge <code>main</code>; chuỗi đầy đủ áp được từ rỗng</td>
			</tr>
			<tr>
					<td>push tag <code>v*</code></td>
					<td><code>release.yml</code></td>
					<td>môi trường staging từ tag; người duyệt phê chuẩn thì cùng image được đưa lên prod và staging bị hủy</td>
			</tr>
			<tr>
					<td>02:00 hằng đêm</td>
					<td><code>sweep-envs.yml</code> → <code>scripts/sweep-envs</code></td>
					<td>hủy mọi thứ có nhánh đã mất hoặc nằm không một tuần</td>
			</tr>
	</tbody>
</table>

<h3 class="relative group">Workflow nhãn, push và delete
    <div id="workflow-nhãn-push-và-delete" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#workflow-nh%c3%a3n-push-v%c3%a0-delete" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/branch-env.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">branch environment</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">types</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">labeled, unlabeled, synchronize, closed]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">delete</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">inputs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">description</span><span class="p">:</span><span class="w"> </span><span class="nt">branch to bring up without a PR, required</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">concurrency</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">group</span><span class="p">:</span><span class="w"> </span><span class="l">env-${{ github.head_ref || github.event.ref || inputs.branch }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">cancel-in-progress</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">FLY_API_TOKEN</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.FLY_PREVIEW_TOKEN }}  </span><span class="w"> </span><span class="c"># org-scoped deploy token; a separate preview org keeps it away from prod</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">PG_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="l">${{ secrets.PREVIEW_PG_PASSWORD }}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="c"># ... the shared Redis URL and bucket credentials, the same way</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">up</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># the gate: a PR carrying the `env` label, or someone asking for a branch by hand</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      github.event_name == &#39;workflow_dispatch&#39; ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp; github.event.action != &#39;closed&#39;
</span></span></span><span class="line"><span class="cl"><span class="sd">        &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">ref</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">id</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">BRANCH</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;${{ github.head_ref || inputs.branch }}&#34;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/env up &amp;&amp; echo &#34;url=$(scripts/env url)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="l">github.event_name == &#39;pull_request&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">marocchino/sticky-pull-request-comment@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">header</span><span class="p">:</span><span class="w"> </span><span class="l">env</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">          </span><span class="nt">message</span><span class="p">:</span><span class="w"> </span><span class="s2">&#34;Branch environment: ${{ steps.env.outputs.url }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">down</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="c"># label removed, labelled PR closed or merged, or the branch itself deleted</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">if</span><span class="p">:</span><span class="w"> </span><span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;pull_request&#39; &amp;&amp;
</span></span></span><span class="line"><span class="cl"><span class="sd">        ((github.event.action == &#39;closed&#39; &amp;&amp; contains(github.event.pull_request.labels.*.name, &#39;env&#39;)) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">         (github.event.action == &#39;unlabeled&#39; &amp;&amp; github.event.label.name == &#39;env&#39;))) ||
</span></span></span><span class="line"><span class="cl"><span class="sd">      (github.event_name == &#39;delete&#39; &amp;&amp; github.event.ref_type == &#39;branch&#39;)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;${{ github.head_ref || github.event.ref }}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>Cái cổng là nhãn <code>env</code>, và đây là phần tôi sẽ bảo vệ mạnh nhất. Không có cổng, mỗi nhánh đều tốn một ứng dụng và một bản nhân bản cơ sở dữ liệu: nhánh ai đó push lên chỉ để sao lưu laptop, spike ngày mai sẽ bị squash, bản cập nhật thư viện mà bot mở lúc 3 giờ sáng. Nên chỉ push thì không có gì xảy ra, và chỉ mở PR cũng không có gì xảy ra. Gắn nhãn <code>env</code> lên PR thì môi trường được dựng lên, mỗi lần push khi nhãn còn đó thì nó được cập nhật, còn gỡ nhãn, đóng PR hay merge thì nó bị hạ xuống. Việc chọn tham gia vẫn là một sự kiện trên GitHub chứ không phải lời nhờ vả một ai, và đó là một cú bấm của đúng người biết có ai sẽ xem hay không. Bình luận ghim là giao diện người dùng: gắn nhãn cho PR, một bình luận hiện ra với URL có thể gửi cho bất kỳ ai, và nó cập nhật tại chỗ sau mỗi lần push thay vì chồng lên nhau. <code>workflow_dispatch</code> lo cho nhánh bạn muốn dựng trước khi có PR, còn <code>./scripts/env up</code> từ laptop lo phần còn lại.</p>
<p>Có hai kiểu cổng khác đáng biết. Bộ lọc theo tên nhánh (<code>branches: ['feature/**', 'fix/**']</code>) không cần PR nhưng khiến mọi nhánh tính năng đều phải trả tiền. &ldquo;Mọi PR không phải bản nháp&rdquo; là kiểu tự động nhất, và là lựa chọn đúng nếu đội bạn mở PR muộn, chỉ khi đã sẵn sàng. Tôi chọn nhãn vì nó đặt quyết định chi phí vào tay người có ngữ cảnh, và vì có thể rút lại mà không phải đóng gì cả. Một tác dụng phụ tôi thích: các lần chạy <code>pull_request</code> từ fork không nhận được secret, nên fork không thể dựng môi trường.</p>
<p><code>concurrency</code> quan trọng hơn vẻ ngoài. Một loạt push nhanh vào một nhánh sẽ hủy các lần triển khai trước thay vì để chúng chạy đua; hai <code>fly deploy</code> cùng lúc vào cùng ứng dụng kết thúc bằng việc một cái thua.</p>
<p><code>closed</code> lo việc dỡ bỏ cho mọi thứ từng có PR, merge hay không. Sự kiện <code>delete</code> là lưới an toàn cho nhánh dựng bằng <code>workflow_dispatch</code> mà rốt cuộc không có PR, và nó chỉ kích hoạt nếu nhánh thật sự bị xóa. Đó là một cài đặt kho, <em>Automatically delete head branches</em>, mặc định tắt. Bật lên, bấm Merge sẽ đóng PR và xóa nhánh, và chỉ một trong hai sự kiện ấy cũng đủ để chạy <code>env down</code>. Nút merge chính là việc dỡ bỏ.</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">gh api -X PATCH repos/org/myapp -f <span class="nv">delete_branch_on_merge</span><span class="o">=</span>true</span></span></code></pre></div></div>
<p><code>FLY_PREVIEW_TOKEN</code> là dòng nguy hiểm trong tệp. Nó tạo và hủy được ứng dụng, và nếu <code>$APP</code> có lúc nào là thứ gì ngoài môi trường nhánh, nó có thể hủy prod. Hai lớp bảo vệ, và tôi giữ cả hai: token giới hạn trong một tổ chức Fly riêng để nó theo đúng nghĩa đen không nhìn thấy tổ chức production (trong demo tôi dùng tổ chức production để khỏi phải gắn thanh toán cho tổ chức mới, và chỉ dựa vào lớp thứ hai); và <code>down()</code> từ chối mọi tên ứng dụng không khớp mẫu <code>slug-hash</code>.</p>

<h3 class="relative group">Kiểm tra mà môi trường nhánh không tự làm được
    <div id="kiểm-tra-mà-môi-trường-nhánh-không-tự-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="#ki%e1%bb%83m-tra-m%c3%a0-m%c3%b4i-tr%c6%b0%e1%bb%9dng-nh%c3%a1nh-kh%c3%b4ng-t%e1%bb%b1-l%c3%a0m-%c4%91%c6%b0%e1%bb%a3c" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Một nhánh chỉ thấy migration của chính nó, nên kiểm tra quan trọng chạy trên pull request đối với trạng thái đã merge <code>main</code>, và đó là kiểm tra trạng thái duy nhất <code>main</code> yêu cầu:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/migrations-check.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">migrations</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">pull_request</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branches</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">main]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">check</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">postgres</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">postgres:18</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">env</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">POSTGRES_PASSWORD</span><span class="p">:</span><span class="w"> </span><span class="nt">ci, POSTGRES_DB</span><span class="p">:</span><span class="w"> </span><span class="l">ci }</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">ports</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;5432:5432&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">options</span><span class="p">:</span><span class="w"> </span>--<span class="l">health-cmd pg_isready --health-interval 5s --health-retries 10</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">env</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">DATABASE_URL</span><span class="p">:</span><span class="w"> </span><span class="l">postgres://postgres:ci@localhost:5432/ci</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">SECRET_KEY</span><span class="p">:</span><span class="w"> </span><span class="l">ci-only</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/setup-python@v5</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">python-version</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;3.14&#39;</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">pip install -r requirements.txt</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Merge main into the branch (a conflict here fails the check too)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="p">|</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">          git config user.email ci@example.com &amp;&amp; git config user.name ci
</span></span></span><span class="line"><span class="cl"><span class="sd">          git merge --no-edit origin/main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">One leaf in the migration graph</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py makemigrations --check --dry-run</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">The full chain applies from nothing</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">python manage.py migrate --noinput</span></span></span></code></pre></div></div>
<p>Ba mươi giây. Chính nó biến va chạm <code>0042</code> / <code>0042</code> từ bất ngờ ngày phát hành thành một dấu đỏ trên PR của lập trình viên thứ hai; mục migration bên dưới cho thấy nó nổ thật.</p>

<h3 class="relative group">Quy tắc trên <code>main</code>
    <div id="quy-tắc-trên-main" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#quy-t%e1%ba%afc-tr%c3%aan-main" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Không gì trong đây trụ được nếu nhánh sống một tháng hay ai đó push thẳng lên <code>main</code>. Các quy tắc thiên về giữ môi trường nhỏ và migration mới hơn là bảo vệ:</p>
<ul>
<li><strong><code>main</code> được bảo vệ và luôn triển khai được.</strong> Chỉ merge qua PR với kiểm tra <code>migrations</code> xanh <em>đối với <code>main</code> hiện tại</em> (kiểm tra trạng thái nghiêm ngặt, nên PR xanh tuần trước phải chạy lại khi <code>main</code> tiến lên). Lịch sử tuyến tính, không force-push, không xóa.</li>
<li><strong>Nhánh tính năng phải ngắn.</strong> Nhánh mở hai tuần có môi trường tụt sau <code>main</code> hai tuần và migration có thêm hai tuần khả năng va với của người khác. Tính năng kéo dài một tháng thì merge vào <code>main</code> từng phần sau feature flag. Thứ sống lâu là flag, không phải nhánh.</li>
<li><strong>Một nhánh, một môi trường, một cơ sở dữ liệu.</strong> Cách đặt tên đã đảm bảo.</li>
<li><strong>Chỉ tag phát hành mới lên prod.</strong> Image được build một lần từ tag; staging và prod chạy image đó, không build lại.</li>
<li><strong>PR chạm vào <code>migrations/</code> được một trong hai người còn lại review.</strong> Bên dưới.</li>
</ul>
<p>Dưới dạng ruleset, áp một lần bằng <code>gh api -X POST repos/org/myapp/rulesets --input ruleset-main.json</code>:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-json" data-lang="json"><span class="line"><span class="cl"><span class="p">{</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;name&#34;</span><span class="p">:</span> <span class="s2">&#34;main&#34;</span><span class="p">,</span> <span class="nt">&#34;target&#34;</span><span class="p">:</span> <span class="s2">&#34;branch&#34;</span><span class="p">,</span> <span class="nt">&#34;enforcement&#34;</span><span class="p">:</span> <span class="s2">&#34;active&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;conditions&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;ref_name&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;include&#34;</span><span class="p">:</span> <span class="p">[</span><span class="s2">&#34;~DEFAULT_BRANCH&#34;</span><span class="p">],</span> <span class="nt">&#34;exclude&#34;</span><span class="p">:</span> <span class="p">[]</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">  <span class="nt">&#34;rules&#34;</span><span class="p">:</span> <span class="p">[</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;deletion&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;non_fast_forward&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_linear_history&#34;</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;pull_request&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;required_approving_review_count&#34;</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nt">&#34;dismiss_stale_reviews_on_push&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_review_thread_resolution&#34;</span><span class="p">:</span> <span class="kc">true</span> <span class="p">}</span> <span class="p">},</span>
</span></span><span class="line"><span class="cl">    <span class="p">{</span> <span class="nt">&#34;type&#34;</span><span class="p">:</span> <span class="s2">&#34;required_status_checks&#34;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">      <span class="nt">&#34;parameters&#34;</span><span class="p">:</span> <span class="p">{</span> <span class="nt">&#34;strict_required_status_checks_policy&#34;</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                      <span class="nt">&#34;required_status_checks&#34;</span><span class="p">:</span> <span class="p">[</span> <span class="p">{</span> <span class="nt">&#34;context&#34;</span><span class="p">:</span> <span class="s2">&#34;check&#34;</span> <span class="p">}</span> <span class="p">]</span> <span class="p">}</span> <span class="p">}</span>
</span></span><span class="line"><span class="cl">  <span class="p">]</span>
</span></span><span class="line"><span class="cl"><span class="p">}</span></span></span></code></pre></div></div>
<p>Một nếp gấp thành thật. Kho này là kho riêng tư trên gói GitHub Free, và trên Free, bảo vệ nhánh và ruleset chỉ có cho kho công khai; API trả lời <code>403 Upgrade to GitHub Pro or make this repository public</code>. Cài đặt <code>delete_branch_on_merge</code>, các environment và workflow đều chạy trên Free. Vậy nên với một đội nhỏ trên kho riêng tư Free, ruleset ở trên là một quy ước: workflow <code>migrations</code> vẫn chạy và vẫn đỏ, nhưng không gì làm xám nút merge. Đó là quyết định 4 đô la một tháng, và tôi sẽ quyết ngay khoảnh khắc có người thứ hai được phép merge.</p>
<p>Không có nhánh <code>develop</code> và không có nhánh <code>release/*</code>. Git-flow được thiết kế quanh giả định rằng tích hợp đắt đỏ và phải gom theo đợt. Môi trường nhánh làm tích hợp rẻ đi, nên việc gom đợt biến mất.</p>

<h3 class="relative group">Staging là môi trường của một tag, và prod là một lần thăng cấp
    <div id="staging-là-môi-trường-của-một-tag-và-prod-là-một-lần-thăng-cấ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="#staging-l%c3%a0-m%c3%b4i-tr%c6%b0%e1%bb%9dng-c%e1%bb%a7a-m%e1%bb%99t-tag-v%c3%a0-prod-l%c3%a0-m%e1%bb%99t-l%e1%ba%a7n-th%c4%83ng-c%e1%ba%a5p" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Staging tái sử dụng toàn bộ bộ máy. Một tag <code>v*</code> trên <code>main</code> chạy <code>scripts/env up</code> với tên nhánh <code>staging/v1.42.0</code>, cách đặt tên biến nó thành <code>staging-v1-42-0-&lt;hash&gt;</code>: cùng cơ sở dữ liệu nhân bản từ mẫu, cùng máy suspend khi rảnh, cùng sơ đồ tên miền. Khác biệt là chuyện xảy ra sau đó:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/release.yml (abridged)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">push</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="s1">&#39;v*&#39;</span><span class="p">]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">staging</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env up</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">echo &#34;image=$(fly image show -a &#34;myapp-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)&#34; --json | jq -r &#39;…&#39;)&#34; &gt;&gt; &#34;$GITHUB_OUTPUT&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">production</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">needs</span><span class="p">:</span><span class="w"> </span><span class="l">staging</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w"> </span><span class="l">production       </span><span class="w"> </span><span class="c"># required reviewer on this environment = the promotion gate</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.api.toml --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">fly deploy --config fly.ws.toml  --image &#34;${{ needs.staging.outputs.image }}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">BRANCH=&#34;staging/${GITHUB_REF_NAME}&#34; scripts/env down</span></span></span></code></pre></div></div>
<p>Job <code>production</code> chờ một GitHub environment có người duyệt bắt buộc. Ai đó xem staging, bấm Approve, và prod nhận <em>cùng image</em> staging đã chạy, theo tham chiếu, không build lại. Bước cuối hủy staging vì nó đã xong việc. Nếu không ai duyệt, ứng viên phát hành bị từ chối, và trình quét gỡ staging khi &ldquo;nhánh&rdquo; của nó (<code>staging/v1.42.0</code> chưa bao giờ tồn tại trên origin) rớt kiểm tra còn sống. Không thứ gì được tồn tại mà không có lý do trình quét kiểm chứng được.</p>

<h3 class="relative group">Trình quét hằng đêm
    <div id="trình-quét-hằng-đê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="#tr%c3%acnh-qu%c3%a9t-h%e1%ba%b1ng-%c4%91%c3%aam" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># .github/workflows/sweep-envs.yml</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">schedule</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span>- <span class="nt">cron</span><span class="p">:</span><span class="w"> </span><span class="s1">&#39;0 14 * * *&#39;</span><span class="w">     </span><span class="c"># 02:00 NZST</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">workflow_dispatch</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">sweep</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v4</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">with</span><span class="p">:</span><span class="w"> </span>{<span class="w"> </span><span class="nt">fetch-depth</span><span class="p">:</span><span class="w"> </span><span class="m">0</span><span class="w"> </span>}<span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">superfly/flyctl-actions/setup-flyctl@master</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">scripts/sweep-envs</span></span></span></code></pre></div></div>
<p><code>fetch-depth: 0</code> vì trình quét cần mọi nhánh từ xa và ngày commit cuối của chúng, chứ không phải một commit Actions checkout mặc định. <code>workflow_dispatch</code> để có thể chạy tay sau sự cố.</p>

<h3 class="relative group">Lập trình viên thật sự làm gì
    <div id="lập-trình-viên-thật-sự-làm-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="#l%e1%ba%adp-tr%c3%acnh-vi%c3%aan-th%e1%ba%adt-s%e1%bb%b1-l%c3%a0m-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h3>
<p>Push một nhánh, mở PR, và gắn nhãn <code>env</code> khi muốn ai đó xem. Danh sách chỉ có thế. Một URL xuất hiện trên PR trong vòng ba phút, cập nhật sau mỗi lần push, và biến mất khi PR được merge hoặc đóng. Kiểm tra migration báo cho họ trước bất kỳ ai nếu họ va với đồng đội. Không ai giành staging vì không có staging để giành, và không ai dỡ gì cả vì dỡ bỏ chính là merge.</p>
<p>Còn người đã dựng hệ thống này, sau đó phải làm gì cho mỗi nhánh? Không gì cả. Postgres, Redis và bucket dùng chung được cấp phát một lần, các bí mật được đưa vào Actions một lần, và từ đó việc lặp lại duy nhất là đọc log của trình quét vào sáng thứ Hai. Không hàng đợi ticket, không bot để gọi, không ai để nhắc.</p>

<h2 class="relative group">Dỡ bỏ không tin vào việc dỡ bỏ
    <div id="dỡ-bỏ-không-tin-vào-việc-dỡ-bỏ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%a1-b%e1%bb%8f-kh%c3%b4ng-tin-v%c3%a0o-vi%e1%bb%87c-d%e1%bb%a1-b%e1%bb%8f" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p><code>env down</code> là trường hợp thông thường, và phần thú vị là <em>ai</em> dọn. Khóa Redis và đối tượng trong bucket thuộc về các dịch vụ dùng chung, và laptop chạy <code>down</code> không cần thông tin đăng nhập của cả hai, vì chính máy của môi trường đã có chúng và đã biết tiền tố của mình. Nên <code>down</code> khởi động máy nếu nó đang suspend, chạy một lệnh dọn dẹp nhỏ trên đó, rồi mới hủy ứng dụng và xóa cơ sở dữ liệu. Môi trường tự dọn dẹp sau mình:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ BRANCH=feature/profile-website ./scripts/env down
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-webs-dcb2e7/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-webs-dcb2e7
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ curl -s -o /dev/null -w &#39;%{http_code}\n&#39; https://feature-profile-webs-dcb2e7-dev.example.com/api/version/
</span></span><span class="line"><span class="cl">530</span></span></code></pre></div></div>
<p>Cái 530 lại là điểm về điều phối: Worker vẫn định tuyến tên miền, Fly không có gì ở đó, và không ai phải báo cho Cloudflare.</p>
<p>Lệnh dọn dẹp từ chối chạy khi không có <code>ENV_NAME</code>, vì với tiền tố rỗng nó sẽ xóa sạch cả bucket dùng chung. Lớp bảo vệ ấy là toàn bộ lý do nó tồn tại như một lệnh bên trong ứng dụng thay vì ba dòng trong shell script.</p>
<p>Môi trường vẫn rò rỉ. Một nhánh bị xóa khi GitHub Actions đang gặp sự cố. Một lập trình viên chạy <code>env up</code> từ laptop cho một thử nghiệm và không bao giờ push. Một <code>fly apps destroy</code> thất bại thoáng qua và <code>|| true</code> nuốt mất. Ba tháng sau có bốn mươi ứng dụng và một hóa đơn khiến ai đó hỏi chuyện gì đã xảy ra.</p>
<p>Nên có cơ chế thứ hai không tin cơ chế thứ nhất: trình quét hằng đêm trong bảng ở trên. Nó liệt kê mọi ứng dụng <code>myapp-*</code>, tìm ra mỗi cái thuộc nhánh nào, và hủy mọi cái có nhánh đã mất hoặc không được push trong bảy ngày. Một nhánh nằm không một tuần không cần URL sống; lần push tiếp theo dựng lại nó trong hai phút. Mã băm không đảo ngược được, nên trình quét tính tên môi trường cho mọi nhánh còn sống và hủy mọi ứng dụng không nằm trong tập đó, rồi xóa mọi cơ sở dữ liệu <code>env_*</code> không có ứng dụng đứng sau:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl"><span class="cp">#!/usr/bin/env bash
</span></span></span><span class="line"><span class="cl"><span class="c1"># scripts/sweep-envs: nightly</span>
</span></span><span class="line"><span class="cl"><span class="nb">set</span> -euo pipefail
</span></span><span class="line"><span class="cl"><span class="nv">ORG</span><span class="o">=</span><span class="si">${</span><span class="nv">PREVIEW_ORG</span><span class="k">:-</span><span class="nv">myapp</span><span class="p">-preview</span><span class="si">}</span><span class="p">;</span> <span class="nv">TTL_DAYS</span><span class="o">=</span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="k">:-</span><span class="nv">7</span><span class="si">}</span>
</span></span><span class="line"><span class="cl"><span class="nv">cutoff</span><span class="o">=</span><span class="k">$((</span> <span class="k">$(</span>date +%s<span class="k">)</span> <span class="o">-</span> TTL_DAYS*86400 <span class="k">))</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">git fetch --prune --quiet origin
</span></span><span class="line"><span class="cl"><span class="nv">live</span><span class="o">=</span><span class="s2">&#34;&#34;</span>
</span></span><span class="line"><span class="cl"><span class="k">while</span> <span class="nv">IFS</span><span class="o">=</span><span class="s1">$&#39;\t&#39;</span> <span class="nb">read</span> -r ts branch<span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> <span class="o">==</span> <span class="s2">&#34;main&#34;</span> <span class="o">]]</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="o">((</span> ts &gt;<span class="o">=</span> cutoff <span class="o">))</span> <span class="o">||</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nv">live</span><span class="o">+=</span><span class="s2">&#34;</span><span class="k">$(</span><span class="nv">BRANCH</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$branch</span><span class="s2">&#34;</span> scripts/env name<span class="k">)</span><span class="s2">&#34;</span><span class="s1">$&#39;\n&#39;</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span> &lt; &lt;<span class="o">(</span>git <span class="k">for</span>-each-ref --format<span class="o">=</span><span class="s1">&#39;%(committerdate:unix)%09%(refname:short)&#39;</span> refs/remotes/origin <span class="p">|</span> sed <span class="s1">&#39;s#^\([0-9]*\)\torigin/#\1\t#&#39;</span><span class="o">)</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nv">apps</span><span class="o">=</span><span class="k">$(</span>fly apps list --org <span class="s2">&#34;</span><span class="nv">$ORG</span><span class="s2">&#34;</span> --json <span class="p">|</span> grep -o <span class="s1">&#39;&#34;Name&#34;: *&#34;myapp-[^&#34;]*&#34;&#39;</span> <span class="p">|</span> cut -d<span class="s1">&#39;&#34;&#39;</span> -f4<span class="k">)</span>
</span></span><span class="line"><span class="cl"><span class="k">for</span> app in <span class="nv">$apps</span><span class="p">;</span> <span class="k">do</span>
</span></span><span class="line"><span class="cl">  <span class="nv">env</span><span class="o">=</span><span class="s2">&#34;</span><span class="si">${</span><span class="nv">app</span><span class="p">#myapp-</span><span class="si">}</span><span class="s2">&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="o">[[</span> <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">=</span>~ ^<span class="o">[</span>a-z0-9-<span class="o">]</span>+-<span class="o">[</span>0-9a-f<span class="o">]{</span>6<span class="o">}</span>$ <span class="o">]]</span> <span class="o">||</span> <span class="k">continue</span>       <span class="c1"># not a branch environment (e.g. myapp-preview-pg)</span>
</span></span><span class="line"><span class="cl">  grep -qx <span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> <span class="o">&lt;&lt;&lt;</span><span class="s2">&#34;</span><span class="nv">$live</span><span class="s2">&#34;</span> <span class="o">&amp;&amp;</span> <span class="k">continue</span>
</span></span><span class="line"><span class="cl">  <span class="nb">echo</span> <span class="s2">&#34;sweeping </span><span class="nv">$app</span><span class="s2">: branch gone or idle &gt; </span><span class="si">${</span><span class="nv">TTL_DAYS</span><span class="si">}</span><span class="s2">d&#34;</span>
</span></span><span class="line"><span class="cl">  <span class="nv">ENV_OVERRIDE</span><span class="o">=</span><span class="s2">&#34;</span><span class="nv">$env</span><span class="s2">&#34;</span> scripts/env down <span class="o">||</span> <span class="nb">true</span>
</span></span><span class="line"><span class="cl"><span class="k">done</span>
</span></span><span class="line"><span class="cl"><span class="c1"># ...then any env_* database with no app: DROP DATABASE.</span></span></span></code></pre></div></div>
<p>Không nhánh demo nào từng được push (lần chạy từ laptop ở trên), nên dưới mắt trình quét tất cả đều đã mất; đó cũng chính là trạng thái mà một spike ai đó dựng từ laptop rồi không bao giờ push sẽ rơi vào sau một tuần. Nó tháo dỡ hai cái còn lại, gồm cả các khóa Redis mà môi trường tagline đã ghi:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ ./scripts/sweep-envs
</span></span><span class="line"><span class="cl">sweeping myapp-feature-branch-envir-c65cb4: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 0 keys under feature-branch-envir-c65cb4:*
</span></span><span class="line"><span class="cl">bucket: deleted 322 objects under feature-branch-envir-c65cb4/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-branch-envir-c65cb4
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">sweeping myapp-feature-profile-tagl-fa8832: branch gone or idle &gt; 7d
</span></span><span class="line"><span class="cl">redis: deleted 3 keys under feature-profile-tagl-fa8832:*
</span></span><span class="line"><span class="cl">bucket: deleted 278 objects under feature-profile-tagl-fa8832/
</span></span><span class="line"><span class="cl">Destroyed app myapp-feature-profile-tagl-fa8832
</span></span><span class="line"><span class="cl">DROP DATABASE
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ fly apps list --org myapp-preview | grep myapp-
</span></span><span class="line"><span class="cl"> myapp-preview-pg │ myapp-preview │ deployed</span></span></code></pre></div></div>
<p>(Một thứ nhỏ đã cắn: macOS đi kèm bash 3, không có mảng kết hợp. Phiên bản đầu của trình quét dùng nó và chết ở <code>declare -A</code> trên laptop của tôi. Phiên bản trên dùng danh sách phân cách bằng xuống dòng và chạy được ở mọi nơi.)</p>
<p>Chi phí là phần dễ chịu. Một máy suspend chỉ tốn rootfs, vài xu một tháng. Một cơ sở dữ liệu là 19 MB trên volume đã trả tiền. Redis chạy trên một gói trả theo mức dùng, hai mươi xu cho một trăm nghìn lệnh, ở mức test nhánh thì làm tròn thành không. Một tiền tố trong bucket tốn đúng phần nó lưu. Node Postgres dùng chung là khoản cố định duy nhất, khoảng hai đô la một tháng. Hai mươi nhánh đang hoạt động tốn cỡ một <code>shared-cpu-1x</code> chạy liên tục, và trình quét giữ ở hai mươi thay vì hai trăm. Cả buổi demo, ba môi trường trong một giờ cộng các phần dùng chung, rẻ hơn ly cà phê tôi uống trong lúc đó.</p>

<h2 class="relative group">Migration qua nhiều nhánh, không có cơ sở dữ liệu dùng chung để cảnh báo
    <div id="migration-qua-nhiều-nhánh-không-có-cơ-sở-dữ-liệu-dùng-chung-để-cảnh-bá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="#migration-qua-nhi%e1%bb%81u-nh%c3%a1nh-kh%c3%b4ng-c%c3%b3-c%c6%a1-s%e1%bb%9f-d%e1%bb%af-li%e1%bb%87u-d%c3%b9ng-chung-%c4%91%e1%bb%83-c%e1%ba%a3nh-b%c3%a1o" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Khi mỗi lập trình viên sở hữu trọn một tính năng, mỗi nhánh đều có một migration. Điều đó khiến mục này là mục quan trọng.</p>
<p>Đây là điều người ta bỏ lỡ khi rời thế giới môi trường dùng chung. Khi cả đội cùng triển khai vào một cơ sở dữ liệu dev, xung đột migration nổi lên ngay lập tức và đau đớn, nhưng chúng <em>nổi lên</em>. Một migration chạy, lần triển khai tiếp theo thất bại, ai đó sửa ngay chiều đó. Với mỗi nhánh một cơ sở dữ liệu, mỗi migration chạy hoàn hảo trong môi trường của mình, và hai cái gặp nhau lần đầu khi cái thứ hai merge vào <code>main</code>. Làm cẩu thả, việc này chuyển xung đột từ dev, nơi nó rẻ, sang phát hành, nơi nó không rẻ.</p>
<p>Bốn quy tắc, tất cả CI ép được nên không ai phải nhớ.</p>
<p><strong>1. CI thất bại khi đồ thị migration có hai lá, đối với trạng thái đã merge <code>main</code>.</strong> <code>makemigrations --check</code> bắt được model lệch với migration, nhưng xung đột đáng kể là hai nhánh cùng thêm <code>0042_*</code>. Kiểm tra trên nhánh đã merge vào <code>main</code>, không phải nhánh đơn lẻ:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">git fetch origin main
</span></span><span class="line"><span class="cl">git merge --no-commit --no-ff origin/main <span class="o">||</span> <span class="o">{</span> <span class="nb">echo</span> <span class="s2">&#34;merge conflict&#34;</span><span class="p">;</span> <span class="nb">exit</span> 1<span class="p">;</span> <span class="o">}</span>
</span></span><span class="line"><span class="cl">python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">python manage.py migrate --plan 2&gt;<span class="p">&amp;</span><span class="m">1</span> <span class="p">|</span> grep -q <span class="s2">&#34;Conflicting migrations&#34;</span> <span class="o">&amp;&amp;</span> <span class="nb">exit</span> <span class="m">1</span></span></span></code></pre></div></div>
<p>Cái này tôi cố ý gây ra. Hai trong ba nhánh demo, <code>feature/profile-tagline</code> và <code>feature/profile-website</code>, mỗi cái thêm một trường vào model hồ sơ người dùng bằng một migration, và mỗi môi trường áp migration của mình khi khởi lên. Các cột mới chỉ ở đúng chỗ nên 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">env_feature_branch_envir_c65cb4      (no new column)
</span></span><span class="line"><span class="cl">env_feature_profile_tagl_fa8832      tagline
</span></span><span class="line"><span class="cl">env_feature_profile_webs_dcb2e7      website</span></span></code></pre></div></div>
<p>Cả hai migration đều là <code>0042_*</code>. Nhánh nào cũng không thấy của nhánh kia, nên môi trường nào cũng không báo được có vấn đề. Merge cái này vào cái kia, kiểm tra sẽ báo:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">$ git merge --no-commit --no-ff feature/profile-tagline
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --check --dry-run
</span></span><span class="line"><span class="cl">CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
</span></span><span class="line"><span class="cl">  (0042_profile_tagline, 0042_profile_website in accounts).
</span></span><span class="line"><span class="cl">To fix them run &#39;python manage.py makemigrations --merge&#39;
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">$ python manage.py makemigrations --merge --noinput
</span></span><span class="line"><span class="cl">Created new merge migration accounts/migrations/0043_merge_20260919_0324.py
</span></span><span class="line"><span class="cl">    dependencies = [
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_tagline&#39;),
</span></span><span class="line"><span class="cl">        (&#39;accounts&#39;, &#39;0042_profile_website&#39;),
</span></span><span class="line"><span class="cl">    ]</span></span></code></pre></div></div>
<p>Tác giả thứ hai chạy <code>--merge</code> đó trên nhánh mình, trong môi trường mình, không làm phiền ai, và lần <code>env up</code> tiếp theo áp <code>0043</code> lên bản nhân bản của họ. (Trong trường hợp này git còn báo xung đột văn bản trước, vì cả hai trường được thêm vào cùng một dòng của tệp model. Thực tế, và đáng biết rằng xung đột văn bản che mất xung đột migration cho đến khi được giải quyết.)</p>
<p><strong>2. Migration tương thích ngược với code của bản phát hành trước.</strong> Trong lúc triển khai có một khoảng thời gian schema mới và code cũ cùng tồn tại, hoặc ngược lại nếu một máy rollback. Thêm cột nullable hoặc có giá trị mặc định, không bao giờ đổi tên trong một bước, không bao giờ xóa thứ bản phát hành hiện tại vẫn đọc: thêm, triển khai, backfill, đổi code, xóa ở bản phát hành sau. Expand and contract. Lý do nó quan trọng <em>hơn</em> ở đây là môi trường dùng một lần khiến triển khai thường xuyên hơn, nên bạn ở trong khoảng đó nhiều hơn. Staging theo tag là nơi tổng duyệt điều này.</p>
<p><strong>3. CI cũng migration từ số không vào cơ sở dữ liệu rỗng.</strong> Nhân bản mẫu kiểm tra &ldquo;migration của tôi có áp được lên dữ liệu thực tế không&rdquo;. Chạy rỗng kiểm tra &ldquo;cả chuỗi còn chạy từ số không không&rdquo;, bắt được migration giả định một data migration trước đó đã điền gì đó. Ba mươi giây, và nó bắt được một lớp lỗi mà nếu không sẽ chờ đến chiếc laptop mới tiếp theo.</p>
<p><strong>4. Thay đổi schema có cặp mắt thứ hai.</strong> Trong một đội nhỏ không cần tệp <code>CODEOWNERS</code>, nhưng cần một quy tắc: PR chạm vào <code>migrations/</code> được một đồng đội review trước khi merge, và việc của người review không phải kiểm tra SQL. Là biết rằng họ, hoặc ai đó khác trong đội, đang chạm vào cùng bảng trong hai tuần này và nói ra. Quá mười mấy lập trình viên thì kiến thức ấy không còn nằm gọn trong đầu một người nữa, và lúc đó một dòng <code>CODEOWNERS</code> cho <code>migrations/</code> mới đáng có. Không công cụ nào thay được. Với công cụ bắt hết phần cơ học, đó là việc hai phút, và là phần duy nhất của thế giới môi trường dùng chung cũ đáng giữ lại: khoảnh khắc hai người phát hiện họ sắp va nhau.</p>

<h2 class="relative group">Phiên bản, để một URL nói nó đang chạy gì
    <div id="phiên-bản-để-một-url-nói-nó-đang-chạy-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="#phi%c3%aan-b%e1%ba%a3n-%c4%91%e1%bb%83-m%e1%bb%99t-url-n%c3%b3i-n%c3%b3-%c4%91ang-ch%e1%ba%a1y-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Với một môi trường dùng chung, &ldquo;staging đang có gì&rdquo; là câu hỏi trong kênh chat. Với nhiều môi trường, nó phải là thuộc tính của môi trường, nếu không mọi báo cáo lỗi đều bắt đầu bằng khảo cổ.</p>
<ul>
<li><strong>Mỗi image mang SHA commit và ứng dụng lộ nó ra.</strong> <code>fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...</code> đóng chúng vào image dưới dạng biến môi trường, và <code>/api/version/</code> trả chúng về kèm tên môi trường và vùng (xem output ở trên). Việc đầu tiên ai cũng làm với &ldquo;nó hỏng trên feature-profile-tagl-fa8832&rdquo; là kiểm tra nó đang chạy commit mà họ nghĩ. Môi trường demo đầu tiên được triển khai từ working tree một commit trước khi bộ công cụ được commit, và endpoint nói đúng thế: nó báo commit trước đó, không phải SHA tôi mong đợi.</li>
<li><strong><code>ENVIRONMENT=$ENV</code> có trong mọi dòng log và thẻ environment trên công cụ theo dõi lỗi.</strong> Lỗi của nhánh không làm bẩn theo dõi lỗi của prod và lọc được bằng một cú bấm.</li>
<li><strong>Nhánh có SHA, bản phát hành có tag.</strong> <code>feature-payments-retr-3f9a1c</code> không bao giờ có số phiên bản; nó có commit. Staging và prod có <code>v1.42.0</code>.</li>
<li><strong>Ứng dụng di động mang phiên bản API tối thiểu, không phải URL.</strong> Trường URL trong menu dev là cách tester trỏ điện thoại thật vào một nhánh, và endpoint phiên bản mang phiên bản schema API là cách ứng dụng biết nó đang nói chuyện với thứ nó hiểu được.</li>
</ul>

<h2 class="relative group">Điều này không giải quyết được gì
    <div id="điều-này-không-giải-quyết-được-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="#%c4%91i%e1%bb%81u-n%c3%a0y-kh%c3%b4ng-gi%e1%ba%a3i-quy%e1%ba%bft-%c4%91%c6%b0%e1%bb%a3c-g%c3%ac" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nó không giải quyết hai tính năng chỉ có nghĩa khi đi cùng nhau. Một môi trường nhánh hiện một nhánh. Câu trả lời là merge cả hai sau feature flag rồi nhìn vào <code>main</code>, làm được bằng một môi trường thường trực bám theo <code>main</code> (<code>main-dev.example.com</code>, triển khai lại sau mỗi lần merge). Đó là môi trường phi production sống lâu duy nhất tôi giữ, và không ai triển khai <em>lên</em> nó; nó để xem bản phát hành tiếp theo sẽ là gì.</p>
<p>Nó không giải quyết các nhà cung cấp đòi callback đã đăng ký và không chấp nhận wildcard. Dịch vụ xác thực tôi dùng chấp nhận tên miền cha, nên <code>example.com</code> phủ mọi tên miền nhánh <code>-dev</code>. Một số nhà cung cấp OAuth và thanh toán thì không, và với họ bạn giữ một tên miền dùng chung mà Worker định tuyến tới môi trường nào vừa giành nó gần nhất. Đó là cái khóa nhỏ lên một thứ nhỏ, và tốt hơn nhiều so với cái khóa lên mọi thứ.</p>
<p>Nó không giải quyết phía con người của migration. Công cụ bắt xung đột cơ học. Nó không bắt được hai người trong các bạn mô hình hóa cùng một khái niệm theo cách không tương thích trong cùng hai tuần, và cách sửa duy nhất là buổi review migration mà một người trong hai nhận ra.</p>

<h2 class="relative group">Tôi sẽ kiểm tra gì trong hệ thống của bạn
    <div id="tôi-sẽ-kiểm-tra-gì-trong-hệ-thống-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="#t%c3%b4i-s%e1%ba%bd-ki%e1%bb%83m-tra-g%c3%ac-trong-h%e1%bb%87-th%e1%bb%91ng-c%e1%bb%a7a-b%e1%ba%a1n" aria-label="Cọc dấu">#</a>
    </span>
    
</h2>
<p>Nếu bạn có dev hoặc staging dùng chung và một luồng chat để đặt chỗ:</p>
<ol>
<li>Một nhánh đã xong phải chờ bao lâu để ai đó ngoài tác giả thấy nó chạy? Quá một giờ là bạn đang trả giá cho cái khóa bằng thời gian giao hàng, và với một đội nhỏ thì đó là một phần lớn của đội đang chờ.</li>
<li>Cơ sở dữ liệu dùng chung bị reset bao lâu một lần, và khi đó ai mất việc đã làm?</li>
<li>Một lập trình viên mới, ngày đầu, có thể có URL công khai chạy nhánh của mình mà không phải xin ai bất cứ thứ gì không? Thứ họ phải xin chính là thứ cần tự động hóa trước tiên.</li>
<li>Môi trường mới có bắt đầu từ dữ liệu thực tế không? Nếu bắt đầu rỗng, lỗi đầu tiên nó không bắt được là migration ổn trên bảng rỗng nhưng mất bốn mươi phút trên bảng thật.</li>
<li>Trong hạ tầng phi production của bạn có thứ gì vẫn tồn tại một tháng sau khi mọi người quên nó không? Nếu có, hãy viết trình quét trước mọi thứ khác. Rò rỉ bắt đầu từ ngày đầu tiên.</li>
</ol>
<p>Câu hỏi trong kênh chat biến mất không phải vì người ta thôi hỏi, mà vì câu trả lời luôn là &ldquo;có, đang dùng cái của tôi.&rdquo;</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>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>
    <item>
      <title>Dùng Docker Compose cho CI/CD thay vì YAML riêng của từng nền tảng</title>
      <link>https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/vi/posts/2024/2024-06-02-simplifyingcicd/</guid>
      <pubDate>Sun, 02 Jun 2024 00:30:03 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>CI/CD</category>
      <category>Docker</category>
      <category>GitHub Actions</category>
      <category>DevOps</category>
      <description>Đưa khâu dựng (build) và kiểm thử vào tệp Docker Compose thay vì viết trong YAML của GitHub Actions: dễ chuyển sang nền tảng khác, chạy thử được ngay trên máy mình, bảo trì cũng nhẹ đầu hơn.</description>
      <content:encoded><![CDATA[<p><em>Cập nhật tháng 10/2026: hai năm sau, tôi đã rà lại mọi kho mã mà tôi đang dùng để triển khai sản phẩm, và viết lại những gì còn đứng vững trong lời khuyên này ở bài <a href="https://jared.lynskey.co.nz/vi/posts/2026/2026-10-22-docker-compose-ci-github-actions/" >Dựng quy trình CI/CD trên GitHub Actions</a>. Nguyên tắc thì vẫn đúng; còn cách chạy Docker Compose bên trong CI thì phần lớn đã không trụ được.</em></p>
<p>Đa số các đội đều viết thẳng các bước dựng (build) và kiểm thử vào tệp YAML của GitHub Actions. Cách này vẫn chạy tốt, cho đến khi bạn muốn chạy lại đúng bản dựng đó trên laptop của mình, hoặc có người đề xuất chuyển sang GitLab, và lúc đó bạn mới nhận ra cả quy trình CI/CD (pipeline) đã dính chặt vào định dạng cấu hình của một nhà cung cấp duy nhất. Cách tôi chọn thì khá nhàm: toàn bộ logic thật sự nằm trong Docker Compose, còn công cụ CI chỉ có mỗi việc gọi nó, ngoài ra không làm gì thêm.</p>

<h4 class="relative group">Vì sao tôi không để các bước dựng trong YAML của Actions
    <div id="vì-sao-tôi-không-để-các-bước-dựng-trong-yaml-của-actions" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#v%c3%ac-sao-t%c3%b4i-kh%c3%b4ng-%c4%91%e1%bb%83-c%c3%a1c-b%c6%b0%e1%bb%9bc-d%e1%bb%b1ng-trong-yaml-c%e1%bb%a7a-actions" aria-label="Cọc dấu">#</a>
    </span>
    
</h4>
<p>Vấn đề dễ thấy nhất là bị phụ thuộc vào nhà cung cấp (vendor lock-in). GitLab, Bitbucket hay Jenkins đều không hiểu workflow của Actions, nên nếu các bước dựng nằm trong tệp workflow thì đổi nhà cung cấp cũng có nghĩa là viết lại từ đầu. Mà có ai lên kế hoạch chừa thời gian cho việc đó bao giờ đâu.</p>
<p>Vấn đề thứ hai ít ai để ý hơn: bạn không chạy được quy trình CI/CD trên máy mình. Nếu muốn thử một thay đổi trong bước dựng mà cách duy nhất là đẩy (push) commit lên rồi ngồi canh tab Actions, thì lịch sử git của bạn sẽ toàn những commit kiểu &ldquo;fix ci&rdquo;, &ldquo;fix ci again&rdquo;, &ldquo;please&rdquo;. Còn tệp Compose thì chạy trên laptop của tôi giống hệt như trên máy chạy CI (runner), nhờ vậy phần lớn những màn cãi nhau &ldquo;máy tôi chạy được mà&rdquo; bị dập tắt ngay từ đầu.</p>
<p>Chưa hết, tệp YAML còn cứ thế phình ra. Hai mươi dòng gọn gàng ban đầu sẽ thành ba trăm dòng khi các mẹo lưu đệm (caching) và điều kiện rẽ nhánh lần lượt được thêm vào, và giờ lập trình viên nào cũng phải học thêm một &ldquo;phương ngữ&rdquo; CI nữa, bên cạnh Git và đủ thứ khác. Một script chỉ nên làm một việc; còn một tệp workflow cồng kềnh thì rốt cuộc sẽ ôm đồm mọi việc cùng lúc.</p>
<p>Với Compose, trách nhiệm được chia rất rạch ròi: tệp Compose lo phần dựng và kiểm thử, còn nền tảng CI chỉ đơn giản là nơi chạy nó mỗi lần có commit mới được đẩy lên.</p>

<h4 class="relative group">Cách thiết lập
    <div id="cách-thiết-lậ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="#c%c3%a1ch-thi%e1%ba%bft-l%e1%ba%adp" aria-label="Cọc dấu">#</a>
    </span>
    
</h4>
<ol>
<li>Bắt đầu với một <code>Dockerfile</code>, để khâu dựng image chỉ nằm ở đúng một chỗ:</li>
</ol>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="c"># Use an official Node.js runtime as the base image</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">FROM node:14</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Set the working directory in the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">WORKDIR /app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copy the package.json and package-lock.json files to the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY package*.json ./</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Install the dependencies</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">RUN npm install</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Copy the rest of the application code to the container</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">COPY . .</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Expose the port the app runs on</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">EXPOSE 3000</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="c"># Define the command to run the application</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="l">CMD [&#34;npm&#34;, &#34;start&#34;]</span></span></span></code></pre></div></div>
<ol start="2">
<li>
<p>Sau đó tạo tệp <code>docker-compose.build.yaml</code>, khai báo các dịch vụ (service) cần cho việc dựng và kiểm thử:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">services</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">app</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">myprivaterepo.com/image:latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">build</span><span class="p">:</span><span class="w"> </span><span class="l">.</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">volumes</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">.:/app</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">command</span><span class="p">:</span><span class="w"> </span><span class="l">sh -c &#34;npm install &amp;&amp; npm test&#34;</span></span></span></code></pre></div></div>
</li>
<li>
<p>Chạy thử trên máy mình trước. Cả bài này thật ra chỉ xoay quanh bước này:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sh" data-lang="sh"><span class="line"><span class="cl">docker compose -f docker-compose.build.yaml build</span></span></code></pre></div></div>
</li>
<li>
<p>Khi bước trên đã chạy ổn, workflow của GitHub Actions (hay thứ tương đương trên bất kỳ nền tảng nào khác) chỉ còn đóng vai trò điều phố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">name</span><span class="p">:</span><span class="w"> </span><span class="l">CI/CD Pipeline</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">on</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, pull_request]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">jobs</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">runs-on</span><span class="p">:</span><span class="w"> </span><span class="l">ubuntu-latest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">actions/checkout@v2</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Set up Docker Buildx</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">uses</span><span class="p">:</span><span class="w"> </span><span class="l">docker/setup-buildx-action@v1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Build and Test with Docker Compose</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">docker compose -f docker-compose.build.yaml build</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">name</span><span class="p">:</span><span class="w"> </span><span class="l">Push the image referenced in the docker compose file</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">run</span><span class="p">:</span><span class="w"> </span><span class="l">docker compose -f docker-compose.build.yaml push</span></span></span></code></pre></div></div>
</li>
</ol>
<p>Như vậy công cụ CI giờ chỉ làm hai việc là dựng và đẩy image lên. Mọi thứ đáng để hiểu đều nằm trong các tệp mà nền tảng nào cũng chạy trực tiếp được, và thành viên mới vào đội cũng vậy.</p>

<h4 class="relative group">Đẩy image lên kho lưu trữ
    <div id="đẩy-image-lên-kho-lưu-trữ" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity 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%ba%a9y-image-l%c3%aan-kho-l%c6%b0u-tr%e1%bb%af" aria-label="Cọc dấu">#</a>
    </span>
    
</h4>
<p>Một cái lợi đi kèm của tệp Compose là việc đẩy image trở nên cực kỳ đơn giản. Docker tự biết image sẽ được đẩy lên đâu dựa vào tên của nó: <code>image: myprivaterepo.com/image:latest</code> nghĩa là image sẽ được đẩy lên <code>myprivaterepo.com</code>. Nếu dùng kho lưu trữ image riêng (private registry) thì bạn sẽ cần token truy cập, phần này bạn xem thêm tài liệu của kho lưu trữ mình đang dùng nhé.</p>
<p>Nếu bạn đang dùng GitHub Actions và thấy ổn thì cũng chẳng có gì phải vội. Nhưng đến ngày cần đổi nhà cung cấp, việc chuyển đổi chỉ là chép lại hai câu lệnh, chứ không phải chuyển ba trăm dòng YAML sang định dạng mới. Còn từ giờ đến lúc đó, ít ra bạn cũng được gỡ lỗi bản dựng ngay trên máy mình, đàng hoàng như một người văn minh.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
