<?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>Python · Jared Lynskey</title>
    <link>https://jared.lynskey.co.nz/en/tags/python/</link>
    <description>Short stories of value on software engineering and engineering leadership — performance reviews, hiring and scaling teams, CI/CD, DevOps monitoring and agentic AI. Written from Seoul.</description>
    <generator>Hugo</generator>
    <language>en</language>
    <copyright>© 2026 Jared Lynskey</copyright>
    <lastBuildDate>Sat, 07 Nov 2026 00:00:00 +1300</lastBuildDate>
    <atom:link href="https://jared.lynskey.co.nz/en/tags/python/index.xml" rel="self" type="application/rss+xml" />
    <item>
      <title>pytest-testmon: Run Only the Tests Affected by Your Code Change</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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 runs only the tests a change can reach, from a coverage record, not a guess. Adding it to a 2,248-test Django suite, the wrapper it needs, and a Python 3.14 trap.</description>
      <content:encoded><![CDATA[<p><a href="https://www.testmon.org/"  target="_blank" rel="noreferrer">pytest-testmon</a> is a pytest plugin that runs only the tests your change can affect. It watches which lines of code each test executes, using coverage.py, and stores a checksum of every block of code a test touched. On the next run it compares those checksums with the code on disk and runs only the tests whose blocks changed, plus anything that failed last time, so it also does the job of <code>pytest --lf</code>.</p>
<p>The general technique is called test impact analysis, and testmon is a free, open-source way to get it in Python. Nothing about it depends on git: it compares against what it saw on the last run, committed or not.</p>
<p>I added it to my marketplace app&rsquo;s Python tests this week, and the reason wasn&rsquo;t speed for its own sake.</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="A short narrated film of this post">
    <source src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film.webm" type="video/webm">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_en.vtt" srclang="en" label="English">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ja.vtt" srclang="ja" label="日本語">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_ko.vtt" srclang="ko" label="한국어">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_vi.vtt" srclang="vi" label="Tiếng Việt">
    <track kind="subtitles" src="https://jared.lynskey.co.nz/en/posts/2026/2026-10-26-pytest-testmon/testmon-film_zh-cn.vtt" srclang="zh-cn" label="简体中文">
  </video><figcaption>This post as an eight-minute film, if you&rsquo;d rather watch than read.</figcaption>
</figure>


<h2 class="relative group">Why I added it
    <div id="why-i-added-it" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-i-added-it" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>A feature request is usually small: one screen, one endpoint, one rule that should work differently. Running the whole test suite made every one of them bigger than it was. The suite is 2,248 Django tests, about three and a half minutes with four processes and 11 in one, and every edit ran all of it, though almost all of it checks things the request never touched. When something unrelated failed, or the run simply took its time, the feature waited. It was being held up by circumstances outside the feature request, and the thing holding it up was my own test suite.</p>
<p>The alternative wasn&rsquo;t better. Skip the full run, guess which test file covers the change, and now and then guess wrong. That&rsquo;s quick, but it isn&rsquo;t precise, and an untested change I ship is a feature request I&rsquo;ll be doing twice.</p>
<p>The tests I wait for should be exactly the tests the change can reach, no more and no fewer. testmon gives me that set from a record instead of a guess. The full suite still runs, in CI, where it doesn&rsquo;t hold up whatever I&rsquo;m working on next.</p>

<h2 class="relative group">How it decides what to run
    <div id="how-it-decides-what-to-run" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-it-decides-what-to-run" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>testmon splits each Python file into blocks: the module&rsquo;s top level, each class, each function, with the bodies of nested functions cut out of the block around them. It stores a checksum of each block, never the code itself. While a test runs, coverage.py records the lines it executes, and testmon notes which blocks those lines belong to. There&rsquo;s no static analysis and no guessing from imports. A test depends on exactly the blocks it ran.</p>
<p>Take a module like this:</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>A test that only calls <code>subtotal()</code> depends on the module&rsquo;s top level and on <code>subtotal</code>&rsquo;s body. Rewrite <code>discount()</code> however you like and testmon won&rsquo;t rerun that test, because it never executed a line of <code>discount</code>. Change one character inside <code>subtotal</code> and it runs again, even if the change can&rsquo;t alter the result. testmon doesn&rsquo;t try to be clever about side effects: if a block you executed changed, you run.</p>
<p>It also tracks what&rsquo;s around the code. The record includes the Python version and the installed packages with their versions. It doesn&rsquo;t watch environment variables: if the same code runs under two settings modules, name them with <code>--testmon-env</code> (step 8) or they share one record. Upgrade Django and everything that touched Django is affected again, which in a Django app is everything.</p>

<h2 class="relative group">What it can&rsquo;t see
    <div id="what-it-cant-see" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-it-cant-see" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>testmon tracks Python that a test executes. It doesn&rsquo;t track the other files your tests read: templates, JSON, text, anything else on disk. It doesn&rsquo;t track services reached over the network. And it can&rsquo;t pin anything that runs once before the tests start on a particular test.</p>
<p>In a Django project that means migrations, which run once when the test database is created, so no test&rsquo;s record includes them (the wrapper below rebuilds the test database when there&rsquo;s a new one); settings and start-up code, which run while pytest starts up, outside any one test; and templates and data files, since a template isn&rsquo;t Python, so editing one reruns nothing that renders it.</p>
<p>My suite has a few more of its own. Some tests read a JSON file of fixtures that the mobile app&rsquo;s tests share. Some check the built web bundle, and some check the output of a static site generated in the same repository. Change any of those and testmon will happily tell you nothing needs to run. That&rsquo;s why the how-to below wraps testmon in a script that notices these files and runs everything when one has changed.</p>

<h2 class="relative group">How to add it to a Django project
    <div id="how-to-add-it-to-a-django-project" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-to-add-it-to-a-django-project" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>These steps assume pytest and <a href="https://pytest-django.readthedocs.io/"  target="_blank" rel="noreferrer">pytest-django</a>, and uv for the dependencies. With pip, swap <code>uv add --dev</code> for <code>pip install</code> and drop <code>uv run</code>.</p>
<p><strong>1. Install it.</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. Make sure pytest can run the suite.</strong> testmon is a pytest plugin, so it does nothing under <code>python manage.py test</code>. pytest-django needs the settings module and the file patterns your tests use:</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>If the suite has only ever run under Django&rsquo;s runner, run it under pytest once and fix whatever differs before adding testmon. Otherwise you&rsquo;ll be debugging two things at once.</p>
<p><strong>3. Keep the record out of git.</strong> testmon writes <code>.testmondata</code> in the pytest root directory. It&rsquo;s a SQLite database, so you&rsquo;ll also see <code>-shm</code> and <code>-wal</code> files next to it, and the script below adds a stamp file. One pattern covers all of them:</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. Record the whole suite once.</strong> testmon can&rsquo;t select anything until it has seen every test run:</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>In a fresh checkout that first run is the whole suite in one process. Here it takes about 12 minutes for 2,248 tests. From then on, the same command runs only the affected tests. With nothing changed, it runs nothing.</p>
<p>Two catches. On testmon 2.2.0 and earlier, it needs pytest&rsquo;s cache plugin: my first attempt ran with <code>-p no:cacheprovider</code> and crashed with <code>KeyError: 'lf'</code> (2.3.0 treats a missing <code>--lf</code> as unset). And on Python 3.14 or later, set <code>COVERAGE_CORE=ctrace</code> before you record. &ldquo;The bug that looked like a green run&rdquo;, further down, explains why. It&rsquo;s the most important line in the script below.</p>
<p>Update, 7 November: testmon 2.3.0, released on 2 November, forces coverage&rsquo;s C tracer itself, so on that version the export is belt and braces. Keep it if you&rsquo;re on 2.2.0 or earlier, and keep the break-something check whatever the version.</p>
<p><strong>5. Wrap it for the files it can&rsquo;t see.</strong> This is the part that makes it safe to use every day. The script keeps a stamp file from the start of the last run. If any file testmon can&rsquo;t trace is newer than the stamp, it runs everything and records afresh. A new migration only rebuilds the test database. Otherwise it lets testmon choose. A cut-down example:</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>Two details in there are worth explaining. <code>--reuse-db</code> is pytest-django&rsquo;s flag for keeping the test database between runs, which matters once a run takes seconds: creating it again each time can take longer than the tests themselves. A full run, or one after a new migration, adds <code>--create-db</code> so the database has the current schema. Keep <code>--reuse-db</code> next to it: <code>--create-db</code> on its own makes pytest-django delete the database when the run ends, so the next run would build it all over again.</p>
<p>And when testmon deselects everything, the run exits 0, so &ldquo;nothing changed&rdquo; counts as a pass. Exit code 5 is pytest&rsquo;s &ldquo;no tests collected&rdquo;, which the script treats as fine too.</p>
<p>Notice what&rsquo;s not in the list: <code>pyproject.toml</code> and the lock file. My first version ran everything whenever the lock file changed, which made the slow fallback fire on most days and the whole thing worse than before. testmon already tracks installed package versions itself, so it doesn&rsquo;t need the help.</p>
<p><strong>6. Give it its own database and cache.</strong> If other things run tests on the same machine, like a second terminal, a coding agent or <code>manage.py test</code>, point the script at its own test database and its own cache number so a kept database never meets someone else&rsquo;s run:</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. Empty that cache before each run.</strong> I learnt this one the hard way. Subset runs started failing with <code>KeyError</code>s that full runs didn&rsquo;t. Some category labels are cached with no expiry, and a test database kept from one run met a cache still warm from an earlier one. Clearing the cache at the top of the script fixed it:</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>That&rsquo;s testmon doing what its docs say it will: running a different subset each time exposes tests that only passed because of what ran before them. The fix belongs in the test setup, not in testmon.</p>
<p><strong>8. Know the flags for when selection is wrong.</strong></p>
<ul>
<li><code>--testmon-noselect</code> runs every test but puts the likely failures first. testmon switches to it by itself when you also pass <code>-k</code>, <code>-m</code>, <code>--lf</code> or a test&rsquo;s node ID.</li>
<li><code>--testmon-forceselect</code> runs only affected tests that also match your <code>-k</code> or <code>-m</code>.</li>
<li><code>--testmon-nocollect</code> uses the existing record but doesn&rsquo;t add to it. It&rsquo;s forced under a debugger or when coverage is already running.</li>
<li><code>--no-testmon</code> turns it off for one run, for when it&rsquo;s in <code>addopts</code>.</li>
<li><code>--testmon-env=&lt;name&gt;</code> keeps separate records in one file, for a suite you run under two settings modules.</li>
</ul>
<p>If the project has a JavaScript side, Jest has its own version built in. <code>jest --changedSince=main</code> runs the tests that import anything changed since <code>main</code>, following imports rather than coverage. I added it to the app&rsquo;s <code>package.json</code> as <code>npm run test:changed</code> so both halves have the same habit. An edit to one utility module selected 7 of the app&rsquo;s 121 test files.</p>

<h2 class="relative group">What it saves
    <div id="what-it-saves" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-it-saves" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>I timed the wrapper once it was in place, start-up included, after fixing the bug in the next section:</p>
<table>
	<thead>
			<tr>
					<th>What changed</th>
					<th style="text-align: right">Tests run</th>
					<th style="text-align: right">Time</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Nothing</td>
					<td style="text-align: right">0</td>
					<td style="text-align: right">1.0 s</td>
			</tr>
			<tr>
					<td>One edit to the code behind a home-screen banner</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.5 s</td>
			</tr>
			<tr>
					<td>Reverting that edit</td>
					<td style="text-align: right">17</td>
					<td style="text-align: right">8.7 s</td>
			</tr>
			<tr>
					<td>The whole suite, for comparison (4 processes)</td>
					<td style="text-align: right">2,248</td>
					<td style="text-align: right">about 3½ min</td>
			</tr>
	</tbody>
</table>
<p>For an everyday edit that&rsquo;s about 25 times faster than the parallel suite, and when nothing has changed it&rsquo;s about two hundred times faster.</p>
<p>The record says the same thing more broadly. Of the 307 non-test Python files the suite runs, a change to the median one reaches 22 tests, about 8 seconds of test time. A few core modules sit at the other end: almost every test runs them, so a change there reaches around 1,500 tests, and testmon reruns most of the suite. That&rsquo;s the right answer, not a failure. Those figures are upper bounds per file: testmon works per block, so changing one function selects only the tests that ran that function.</p>
<p>The cost shows up when the wrapper falls back. A full run is the same single-process recording as the first one, about 12 minutes, which is slower than the parallel suite it replaces. It&rsquo;s single-process only because I haven&rsquo;t tried testmon under pytest-xdist yet, which it has supported since version 1.4; that&rsquo;s the obvious next thing to measure. That&rsquo;s why the list of files that trigger it should be as short as it can honestly be.</p>

<h2 class="relative group">The bug that looked like a green run
    <div id="the-bug-that-looked-like-a-green-run" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-bug-that-looked-like-a-green-run" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>My first numbers were better than these: one edit, 6 tests, 3.3 seconds. They were wrong, and nothing on screen said so.</p>
<p>From Python 3.14, coverage.py uses a new core by default, built on the interpreter&rsquo;s <code>sys.monitoring</code>. That core reports a line only the first time a process runs it. testmon builds its record of which test ran which line from coverage, so each line was credited to the first test that happened to run it, and to no other. Edit a line that eight tests share, and testmon selects one of them.</p>
<p>It was caught on a second, smaller project of mine that copied this setup. Part of adopting it there was breaking a line on purpose and comparing the results, and the numbers didn&rsquo;t match. Back in this suite I ran the same check. I broke one line in the home-screen banner code, the title of a slide. Plain pytest reported 8 failing tests. testmon selected 4. The other 4 broken tests never ran, and nothing in the output said they&rsquo;d been left out.</p>
<p>The fix is one line, <code>export COVERAGE_CORE=ctrace</code>, which puts coverage back on its classic C tracer. A different tracer means a different record, so I re-recorded: 12 minutes 3 seconds for 2,248 tests. Afterwards, the same kind of breaking edit ran all 11 tests it broke.</p>
<p>Update, 7 November: testmon 2.3.0, released on 2 November, now sets the C tracer itself, in process and for subprocesses, so a fresh install no longer needs the export. The check in the next paragraph still does.</p>
<p>That&rsquo;s the lesson I&rsquo;d pass on before anything else here. A test selector that selects too little looks exactly like success: fewer tests, a faster run, all green. So when you set one up, break something shared on purpose, run the plain suite and the selector, and check they fail the same tests. Then do it again whenever Python or coverage.py changes version.</p>

<h2 class="relative group">Where a coding agent comes in
    <div id="where-a-coding-agent-comes-in" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#where-a-coding-agent-comes-in" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>I don&rsquo;t make every change in that repository myself any more. A coding agent, Claude Code in my case, makes a lot of the small ones, and a good agent runs the tests after each edit. Faced with an 11-minute suite, it has the same two bad options I had: wait, or guess which test file matters.</p>
<p>So the repository&rsquo;s agent instructions now say the wrapper script is the default way to run tests while working, with <code>--all</code> for a full run. The agent gets the same precise set of tests I do, after every edit, in seconds. It&rsquo;s also why step 6 gives the script its own database and cache: on a busy day there&rsquo;s more than one session running tests on the same machine, and a kept database must never meet somebody else&rsquo;s run.</p>
<p>None of this needs anything AI-specific from testmon. It&rsquo;s the same record and the same selection. It just matters more when whatever is making the changes makes a great many of them.</p>

<h2 class="relative group">Why CI got something coarser
    <div id="why-ci-got-something-coarser" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-ci-got-something-coarser" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>testmon has no CI mode. It behaves the same on a runner as on a laptop, and its author writes that sharing or caching <code>.testmondata</code> across CI runners is far less effective than their hosted service, testmon.net. A cached record would also be blind in all the ways above, and my CI runs the suite with Django&rsquo;s runner in four processes, so I left testmon out.</p>
<p>What CI got instead is the same idea at file level. The checks workflow now starts with a small job that diffs the push and decides which half of the repository it touched: the Python API, the mobile app, both or neither. Before this, CI ran both the Python and the app jobs on every push, docs-only ones included. Now a docs-only push runs nothing, and a push to the app alone skips the Django suite. I dry-ran the job against five real commits: the docs-only one ran nothing, the app-only one skipped the Python job, and the backend-only one skipped the app job. A cut-down example:</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>The real one also runs everything when there&rsquo;s no range to compare: a new branch, a force push or a manual run.</p>
<p>The blind spot is the same one in a different form. testmon can&rsquo;t see a JSON file, and neither can a path filter unless you list it. So the pattern names the shared fixtures, the built bundle and the generated site explicitly, with a comment asking whoever edits it to keep the list honest. The file-level version is cruder, but it fails in the safe direction: if anything on the Python side changed, the whole suite runs.</p>

<h2 class="relative group">Is it worth it?
    <div id="is-it-worth-it" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#is-it-worth-it" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>For a suite that takes minutes, yes. The real cost is what it can&rsquo;t see, and that&rsquo;s what the wrapper script is for. Even so, the laptop loop is where testmon belongs. CI still runs the whole suite whenever the Python side changes, and that&rsquo;s the run I trust before a deploy.</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 in GitHub Actions CI: What I Replaced It With, and the Pipelines I Run Now</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-22-docker-compose-ci-github-actions/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/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>The GitHub Actions pipelines I run in 2026: service containers instead of Docker Compose in CI, caching chosen from step timings, and deploys that are scripts.</description>
      <content:encoded><![CDATA[<p>A CI/CD pipeline is the automated route a change takes from <code>git push</code> to production: CI builds and tests every push on a clean machine, and CD releases whatever passed through one repeatable step. On GitHub Actions that&rsquo;s a YAML file under <code>.github/workflows/</code>, and mine are deliberately thin: the checks run the commands I run on my laptop, and every deploy is a script or a CLI that would work the same without GitHub.</p>
<p>If you came here to run Docker Compose in GitHub Actions: I did that for two years and stopped. CI now declares the same Postgres and Valkey images as <code>services:</code> and runs the same <code>uv run …</code> and <code>npm run …</code> commands straight on the runner; Compose stays what a developer runs. The rest is why, with the files.</p>
<p>In 2024 I wrote <a href="https://jared.lynskey.co.nz/en/posts/2024/2024-06-02-simplifyingcicd/" >Use Docker Compose for CI/CD Instead of Platform-Specific YAML</a>, which said to keep the build logic in Compose and let the CI tool do nothing but call it. Two years on, I went back through every repository I ship from. Four have workflows, each with one committer, me, and none of those workflows calls <code>docker compose</code> any more. The principle held; the mechanism didn&rsquo;t. Here&rsquo;s what replaced it, with cut-down example versions of the files, the step timings GitHub recorded, and the parts that are less automated than a CI/CD diagram would like.</p>

<h2 class="relative group">Every pipeline I run, in one table
    <div id="every-pipeline-i-run-in-one-table" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#every-pipeline-i-run-in-one-table" aria-label="Anchor">#</a>
    </span>
    
</h2>
<table>
	<thead>
			<tr>
					<th>Pipeline</th>
					<th>Started by</th>
					<th>What CI checks</th>
					<th>Where it goes</th>
					<th>Who releases</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>This blog, and a client&rsquo;s Hugo site</td>
					<td>Every push, on any branch</td>
					<td>That <code>hugo --gc</code> builds</td>
					<td>Cloudflare Pages: <code>main</code> is production, other branches get a preview URL</td>
					<td>Nobody; the push is the release</td>
			</tr>
			<tr>
					<td>My marketplace app&rsquo;s API</td>
					<td>A deploy script, run by hand</td>
					<td>Lint, type checks and the Django tests, on every push to <code>main</code></td>
					<td>Fly.io, as a rolling deploy</td>
					<td>Me</td>
			</tr>
			<tr>
					<td>The same app&rsquo;s front end: web, OTA updates, store builds</td>
					<td>Release scripts and local store builds, by hand</td>
					<td>TypeScript and Jest, on every push to <code>main</code></td>
					<td>Object storage, an OTA update service and the two app stores</td>
					<td>Me</td>
			</tr>
			<tr>
					<td>pyhtml2text, my open-source Python package</td>
					<td>Every push, pull request and tag</td>
					<td>Wheels for 15 build targets, tested on most of them</td>
					<td>PyPI, on a tag</td>
					<td>The tag</td>
			</tr>
	</tbody>
</table>
<p>In textbook terms the Hugo sites have continuous deployment, and the app (a marketplace and community app I build) has continuous integration plus one-command releases, which is continuous delivery: the checks tell me something is wrong, but they don&rsquo;t stop me deploying. That&rsquo;s deliberate. An OTA update reaches every phone on the binary version it targets, so publishing one should be a decision rather than a side effect of a merge, and the API is where the database migrations run. My other two Django apps have no workflows at all.</p>

<h2 class="relative group">The CI half: one workflow, two jobs
    <div id="the-ci-half-one-workflow-two-jobs" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-ci-half-one-workflow-two-jobs" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The app&rsquo;s CI is one file, <code>.github/workflows/checks.yml</code>. Until 11 October there wasn&rsquo;t any. This is a trimmed example of its shape:</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>The two jobs run side by side. Across the 35 green runs so far, the app job&rsquo;s median is about seven minutes and the Python job&rsquo;s about twelve and a half.</p>
<p>The <code>services:</code> block is where the Compose file went, which is the next section. The environment is a few variables and a flag; everything else falls back to the development default <code>docker compose up</code> gives a developer, so the tests need no secrets. I checked by running part of the suite with exactly that environment and nothing else.</p>
<p>CI runs the same interpreter as my laptop and the production image, uv&rsquo;s own build of Python 3.15.0, because <code>uv sync</code> reads <code>.python-version</code> and downloads exactly that build. (My real file also passes setup-uv a <code>python-version-file</code> input, which does nothing: setup-uv v6 has no such input, and every run carries a warning saying so. I only noticed while writing this.)</p>
<p>Matching interpreters earned their keep eight days after the workflow went in. On 19 October I switched on 3.15&rsquo;s JIT compiler, and the first run with it failed a test: a plain <code>yield from</code>, on a code path the app takes on every save, raised <code>AttributeError: 'list' object has no attribute 'send'</code>. The same commit with the JIT off passed. My Mac is arm64 and never hit it; the runner is x86_64, like the production machines. The JIT is off again, in CI and in the Dockerfile, and the bug never reached production. It&rsquo;s python/cpython#155823, fixed upstream on 13 October, after 3.15.0 shipped.</p>
<p>The very first run failed too, for a duller reason. Expo writes <code>expo-env.d.ts</code> when you run <code>expo start</code>, and the file is gitignored, so every working copy I&rsquo;d used had one and a fresh checkout didn&rsquo;t. TypeScript reported five errors that had never appeared locally. The step that writes the file is the fix.</p>
<p><code>concurrency</code> cancels a run when a newer push to the same branch arrives, which is how all twelve cancelled runs so far ended. A pre-commit hook runs the linter, plus the type checker when Python or TypeScript is staged, so most mistakes never reach a push.</p>

<h2 class="relative group">Where the Docker Compose file went
    <div id="where-the-docker-compose-file-went" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#where-the-docker-compose-file-went" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Here is the part of the app&rsquo;s <code>docker-compose.yml</code> that CI cares about, as an example. The real one has more services and a lot more environment.</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>Put it next to the <code>services:</code> block in <code>checks.yml</code> and it&rsquo;s the same two images: Postgres 17 (the Alpine build here, the Debian one in CI) and <code>valkey/valkey:8-alpine</code>. That&rsquo;s the relationship now. The Compose file is what a developer runs; CI declares the same images as service containers and runs the same commands a developer runs, <code>uv run …</code> and <code>npm run …</code>, straight on the runner.</p>
<p>Against the 2024 post, the part I cared about most held: the CI tool is thin, and the checks and deploys are commands I can run myself. What didn&rsquo;t survive is CI calling Compose.</p>
<p>The app&rsquo;s CI doesn&rsquo;t build the Docker image at all; Fly&rsquo;s builder does that once, at deploy time, and CI only has to say whether the code is good, which with a cached toolchain takes a six-second install. The Compose file also bind-mounts the working copy and seeds data, which a test run shouldn&rsquo;t, so CI would have needed a Compose file of its own: a second description of the same services, kept in step by hand.</p>
<p>And the 2024 example had a bug I didn&rsquo;t notice at the time. <code>docker compose build</code> builds the image and never runs the service&rsquo;s <code>command</code>, so the <code>npm test</code> in that file never ran in CI. The workflow went green whether the tests passed or not.</p>
<p>The risk that post worried about most, switching CI vendors, never came up. The move that did happen was at the other end: this blog and the client&rsquo;s site moved hosts to Cloudflare Pages, and for the blog that took one new 45-line workflow, because the build was already one command. And the three-hundred-line workflow I warned about? I have one, pyhtml2text&rsquo;s. It&rsquo;s long because the wheels have to be built on Linux, macOS and Windows runners, for several architectures each, which makes it the one job where platform-specific YAML is the right tool.</p>

<h2 class="relative group">Caching: read the step timings first
    <div id="caching-read-the-step-timings-first" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#caching-read-the-step-timings-first" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>GitHub keeps the duration of every step, and <code>gh api repos/&lt;owner&gt;/&lt;repo&gt;/actions/runs/&lt;run-id&gt;/jobs</code> hands it over. These are from the app&rsquo;s latest green run, on 20 October:</p>
<table>
	<thead>
			<tr>
					<th>Job</th>
					<th>Step</th>
					<th>Seconds</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Python</td>
					<td>Start Postgres and Valkey</td>
					<td>18</td>
			</tr>
			<tr>
					<td>Python</td>
					<td><code>uv sync --locked</code>, cache warm</td>
					<td>6</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>mypy</td>
					<td>27</td>
			</tr>
			<tr>
					<td>Python</td>
					<td>Django tests</td>
					<td>761</td>
			</tr>
			<tr>
					<td>App</td>
					<td><code>npm ci</code>, cache warm</td>
					<td>60</td>
			</tr>
			<tr>
					<td>App</td>
					<td>Jest</td>
					<td>333</td>
			</tr>
	</tbody>
</table>
<p>Both dependency caches are on, and both are the cheap part. The uv cache GitHub keeps for the repository is about 200 KB, because setup-uv prunes whatever uv can simply download again; the npm one is nearly 400 MB. The time is in the tests, which already run four ways in parallel. If I wanted the run faster, I&rsquo;d change the suite, not the cache.</p>
<p>The blog is the opposite case. Hugo&rsquo;s processed images live in the gitignored <code>resources/_gen</code>, so every fresh runner re-encoded every cover, body image and placeholder. The workflow further down restores them with <code>actions/cache</code>, keyed on a hash of the pictures and falling back to the newest older cache, since Hugo only processes what&rsquo;s missing. In the last eight runs before the cache, the build step took 33 to 76 seconds; in the 26 since, 6 to 20, with one exception at 94, even though the same commit added placeholders and a second card size.</p>
<p>The app&rsquo;s image builds on Fly&rsquo;s builder, with the dependency files copied before the code and uv&rsquo;s download cache in a BuildKit cache mount (<code>RUN --mount=type=cache,target=/root/.cache/uv</code>). The bigger fix wasn&rsquo;t a cache. The old <code>.dockerignore</code> was a blocklist, and it missed a folder of extra git worktrees, each with its own dependencies and build output, so 7.7 GB across 277,000 files went to the builder on every deploy. Rewritten as an allowlist, the context is about 40 MB. An example of the pattern:</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">Secrets, in as few places as possible
    <div id="secrets-in-as-few-places-as-possible" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#secrets-in-as-few-places-as-possible" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Secrets live in three places. GitHub Actions secrets are for what workflows do, and the two Hugo repositories have exactly one each, <code>CLOUDFLARE_API_TOKEN</code>. Fly secrets are the app&rsquo;s runtime configuration, set with <code>flyctl secrets set</code>, and they never pass through Actions. And a gitignored <code>.env</code> on my laptop holds the token the deploy script uses to purge Cloudflare&rsquo;s cache.</p>
<p>PyPI needs no secret at all. pyhtml2text uses trusted publishing: PyPI accepts the short-lived OIDC token GitHub issues to the workflow, so the release job asks for <code>id-token: write</code> and the repository&rsquo;s secrets page is empty. There&rsquo;s nothing to leak and nothing to rotate.</p>
<p>Tokens get the least they need. The app&rsquo;s Fly token in GitHub serves a scheduled check that only lists machines, so it&rsquo;s created with <code>fly tokens create readonly</code>. The Pages deploys ask for <code>contents: read</code> and <code>deployments: write</code>, and nothing more.</p>
<p>Inputs go through <code>env:</code>, never straight into a script. The OTA workflow takes free-text release notes. Written as <code>${{ inputs.notes }}</code> inside <code>run:</code>, the text would be pasted into the script before bash reads it, so a note with a quote and a semicolon in it would become code. Passed as an environment variable, it stays data. An example of the pattern:</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>Some keys stay off GitHub altogether. One scheduled report needs a key with admin rights over the app&rsquo;s sign-in backend, so it moved from a scheduled workflow to a scheduled job on my Mac. The workflow is still there as a manual fallback, and leaves out the sections that need that key.</p>

<h2 class="relative group">Deploys: on every push, by script, and on a tag
    <div id="deploys-on-every-push-by-script-and-on-a-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="#deploys-on-every-push-by-script-and-on-a-tag" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">The Hugo sites deploy on every push
    <div id="the-hugo-sites-deploy-on-every-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="#the-hugo-sites-deploy-on-every-push" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>An example version of the blog&rsquo;s workflow, with the first-run step that creates the Pages project left out:</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> makes <code>main</code> production and gives every other branch a preview URL. The client site&rsquo;s workflow mirrors this one, plus an <code>rm -rf public</code> before the build, because its <code>public/</code> is committed and removed pages would otherwise linger in the upload.</p>
<p>Hugo is pinned here and nowhere else. In October a package-manager upgrade moved my laptop to 0.168, one past the newest version the theme supports. The deploys stayed on 0.165 and never noticed.</p>
<p>And the build is the test. On 14 September the preview deploy for one branch failed three times running: a post on it linked, through Hugo&rsquo;s <code>ref</code>, to one that was still a draft. CI builds what production builds, drafts excluded, so the link pointed at nothing and Hugo stopped with <code>REF_NOT_FOUND</code>. <code>main</code> carried on deploying in the meantime.</p>

<h3 class="relative group">The app&rsquo;s API deploys from a script
    <div id="the-apps-api-deploys-from-a-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="#the-apps-api-deploys-from-a-script" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The core of the deploy script, as an example:</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>And the matching lines in <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>The image is built once, pushed to Fly&rsquo;s registry under a label made of the commit and the time, and deployed by that label, so what rolls out is exactly what was built.</p>
<p>Waking a spare first is what makes it zero-downtime. <code>max_unavailable = 1</code> only counts running machines, and the app keeps none running when it&rsquo;s idle (a suspended machine resumes in about a second), so a lone awake machine used to be replaced in the same batch as the sleeping ones, with nothing warm behind it. On 28 September a probe against the origin saw 7 timeouts and a 7.3-second gap during a plain <code>flyctl deploy</code>, and no failures in 2,514 requests during the scripted deploy, which builds first and has a machine awake before the rollout starts.</p>
<p>The rest of the script marks the deploy in the team chat channel the error alerts go to, so a burst of errors can be read against what just changed, purges Cloudflare&rsquo;s cache and warms the machines. The web front end skips the image entirely; that&rsquo;s in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-14-web-releases-without-redeploying/" >Deploy Frontend and Backend Separately, Without Redeploying the API</a>.</p>
<p>Migrations aren&rsquo;t a pipeline step. Fly&rsquo;s <code>release_command</code> hung for me, so the first machine to boot on a new image takes a lock and migrates while the others wait. Every migration therefore has to work with the code that&rsquo;s still running: add a column in one deploy, stop using it in the next, drop it in a third.</p>
<p>Nothing in the script needs my laptop as such, but a couple of local-only files keep it there: the Cloudflare credentials, and a record of what was last deployed, which a fresh runner wouldn&rsquo;t have. It will also deploy uncommitted changes, and says so in the chat channel, where the web release script refuses a dirty tree.</p>
<p>In <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-19-ephemeral-dev-environments-on-fly/" >Preview Environments per Branch on Fly.io</a> I wrote that doing this by hand is fine while you&rsquo;re the only one doing it, and that from the second developer on it has to happen because something happened in git. The app is still at one developer; the branch-environment workflows from that post live on the branch I built them on.</p>
<p>The OTA workflow is manual-only because publishing should be a decision, and because the update service refuses a release while a partial rollout is open, which an on-push trigger would hit during every canary. It has never run. Every OTA update so far has gone out from my laptop through the same script the workflow calls, which is the case for thin workflows in one line: however a release starts, it&rsquo;s the same script.</p>

<h3 class="relative group">pyhtml2text releases on a tag
    <div id="pyhtml2text-releases-on-a-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-releases-on-a-tag" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The release job, with the package name made generic:</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>The release job waits for every build job, and only a tag publishes; a manual run builds and attests but uploads nothing. <code>actions/attest</code> records which workflow built each wheel, and <code>uv publish --trusted-publishing always</code> uploads them with the OIDC token rather than a password. The package itself is in <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/" >A Closed Pull Request, a Fork and a PyPI Package</a>.</p>

<h2 class="relative group">Scheduled workflows are best effort
    <div id="scheduled-workflows-are-best-effort" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#scheduled-workflows-are-best-effort" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The app has one scheduled workflow, a check that Fly hasn&rsquo;t started more machines than the baseline, which would otherwise first show up on the bill. It alerts by failing, since GitHub emails the owner about a failed scheduled run.</p>
<p>It&rsquo;s set to <code>cron: '*/15 * * * *'</code>, 96 runs a day. Over the past two weeks it ran between three and eight times a day, with a median gap of about three hours forty. The only explanation GitHub&rsquo;s documentation offers is load: scheduled runs can be delayed when Actions is busy, and dropped when it&rsquo;s busy enough. For a sustained scale-out, a few hours is fine. For anything that has to fire within minutes, a GitHub cron isn&rsquo;t a monitor.</p>

<h2 class="relative group">What I&rsquo;d ask of a pipeline
    <div id="what-id-ask-of-a-pipeline" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-id-ask-of-a-pipeline" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>If you&rsquo;re building one, or looking at one you already have:</p>
<ol>
<li>If GitHub Actions were down for a day, could you still release? If not, your release logic lives in YAML, and the 2024 post still applies.</li>
<li>Does CI run the interpreter, the CPU architecture and the database major version that production runs? Mine caught a compiler bug only because the runner and production are both x86_64 and my laptop isn&rsquo;t.</li>
<li>Does a fresh checkout pass? Generated, gitignored files are the usual reason it doesn&rsquo;t.</li>
<li>Where does a run&rsquo;s time actually go? Read the step timings before you add a cache.</li>
<li>For each secret, how many places does it live, and does it need to be in GitHub at all?</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>A Closed Pull Request, a Fork and a PyPI Package: Fast HTML to Markdown for Python</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/</guid>
      <pubDate>Sun, 18 Oct 2026 00:00:00 +1300</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Python</category>
      <category>Rust</category>
      <category>PyPI</category>
      <category>Open Source</category>
      <category>Markdown</category>
      <category>LLM</category>
      <category>PyO3</category>
      <category>Performance</category>
      <description>I needed relative links resolved when converting HTML to Markdown for AI agents. The upstream Rust crate couldn&#39;t take my pull request because it was written with an LLM, so I forked it and published pyhtml2text: Python bindings with base_url, a new html2markdown, profile-guided wheels, and a patched parser that handles deeply nested HTML in linear time. Here&#39;s why it exists, what&#39;s in it, and how I checked the output.</description>
      <content:encoded><![CDATA[<p>On 15 October I published <a href="https://pypi.org/project/pyhtml2text/"  target="_blank" rel="noreferrer">pyhtml2text</a> on PyPI. It converts HTML to plain text or Markdown and is built on <a href="https://github.com/jugglerchris/rust-html2text"  target="_blank" rel="noreferrer">rust-html2text</a>, Chris Emerson&rsquo;s Rust crate. Converting to Markdown, it was between 3 and 21 times faster than the usual Python converters on the pages I measured. It can also resolve relative links against the page&rsquo;s URL, so the links in the output still work once they&rsquo;re away from the page.</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">pip install pyhtml2text</span></span></code></pre></div></div>
<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">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html</span> <span class="o">=</span> <span class="s2">&#34;&#34;&#34;&lt;h2&gt;Before you start&lt;/h2&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;p&gt;Read the &lt;a href=&#34;setup.html&#34;&gt;setup guide&lt;/a&gt; and &lt;b&gt;back up&lt;/b&gt; your data.&lt;/p&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;img src=&#34;https://jared.lynskey.co.nz/img/diagram.png&#34; alt=&#34;How it fits together&#34;&gt;
</span></span></span><span class="line"><span class="cl"><span class="s2">&lt;table&gt;&lt;tr&gt;&lt;th&gt;Plan&lt;/th&gt;&lt;th&gt;Price&lt;/th&gt;&lt;/tr&gt;&lt;tr&gt;&lt;td&gt;Basic&lt;/td&gt;&lt;td&gt;Free&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#34;&#34;&#34;</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nb">print</span><span class="p">(</span><span class="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl"><span class="gu">## Before you start
</span></span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">Read the [<span class="nt">setup guide</span>](<span class="na">https://example.com/docs/setup.html</span>) and <span class="gs">**back up**</span> your data.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">![<span class="nt">How it fits together</span>](<span class="na">https://example.com/img/diagram.png</span>)
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">| Plan | Price |
</span></span><span class="line"><span class="cl">| --- | --- |
</span></span><span class="line"><span class="cl">| Basic | Free |</span></span></code></pre></div></div>
<p>It started as a pull request to rust-html2text. The maintainer closed it because it was written with an LLM, so pyhtml2text runs on a fork instead. This post covers how that happened, what&rsquo;s in the fork and the package, and how I checked that the Markdown is right.</p>

<h2 class="relative group">Why I needed it
    <div id="why-i-needed-it" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-i-needed-it" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>A marketplace and community app I build answers <code>Accept: text/markdown</code> with Markdown instead of HTML, for AI agents and LLM clients that would rather not wade through nav bars and footers. I covered the edge side of that in the <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-cloudflare-crawlers-cache/" >post on crawlers and the cache</a>. Pages that come from app data build their Markdown directly, because their HTML is an empty JavaScript shell. Everything else is static HTML (the marketing, legal and blog pages), and that gets converted.</p>
<p>The problem with converting it is the links. A static site is full of links like <code>/en/posts/...</code> and <code>../setup/</code>. They&rsquo;re fine in a browser, which knows the page they came from. An agent reading the Markdown has no such context, so it can&rsquo;t follow <code>/login</code>. It needs <code>https://example.com/login</code>.</p>
<p>The pure-Python <a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> has a <code>baseurl</code> option for this, and that&rsquo;s what I used at first. But it&rsquo;s slow, and its Markdown has problems I&rsquo;ll get to below. rust-html2text was fast and well tested, but it had no way to resolve links.</p>

<h2 class="relative group">Asking upstream first
    <div id="asking-upstream-first" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#asking-upstream-first" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>On 29 August I opened <a href="https://github.com/jugglerchris/rust-html2text/issues/287"  target="_blank" rel="noreferrer">issue #287</a> with the use case and the behaviour I wanted: RFC 3986 resolution, with absolute URLs and <code>mailto:</code>, <code>data:</code> and <code>#fragment</code> links left alone. A few minutes later I opened <a href="https://github.com/jugglerchris/rust-html2text/pull/288"  target="_blank" rel="noreferrer">pull request #288</a>. It added a <code>Config::base_url(...)</code> option, and I said in the issue that it was a starting point and that I&rsquo;d understand if the maintainer wanted to take a different approach.</p>
<p>On 11 October the maintainer closed the PR:</p>
<blockquote><p>Thanks for the contribution, but I&rsquo;m unable to accept this as it appears to be LLM generated. (I have now added a brief policy to the README.md - it wasn&rsquo;t previously stated)</p>
</blockquote><p>and replied on the issue:</p>
<blockquote><p>Hi - this is a reasonable feature request, thanks! I have closed the PR as it appears to be LLM-written which I can&rsquo;t accept, but thanks for the attempt.</p>
<p>A short HTML sample and expected output would definitely help get the feature implemented!</p>
</blockquote><p>The README now has this policy:</p>
<blockquote><p>At this time, this project is not able to accept code contributions produced with generative AI/LLMs due to the uncertain copyright status.</p>
</blockquote><p>That was a correct read: I wrote that code with an AI coding assistant. I don&rsquo;t think the policy is unreasonable, either. The copyright status of LLM output isn&rsquo;t settled, and it&rsquo;s the maintainer who would carry the risk of merging it into an MIT crate that other projects depend on. It&rsquo;s their project, and the reply was polite. The feature request is still open.</p>
<p>I still needed the feature, though, and arguing with a maintainer over their own contribution policy isn&rsquo;t a good way to get it. So I kept the change on a fork and built the Python package on top of that, and both say clearly that they aren&rsquo;t upstream.</p>

<h2 class="relative group">What&rsquo;s in the fork
    <div id="whats-in-the-fork" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#whats-in-the-fork" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The fork is the <a href="https://github.com/jaredlynskey/rust-html2text/tree/pyhtml2text-perf"  target="_blank" rel="noreferrer"><code>pyhtml2text-perf</code> branch</a> of my rust-html2text fork. I&rsquo;ve kept it to three small commits so that it rebases cleanly onto upstream. The main one is the <code>Config::base_url</code> option from the PR: relative links are resolved per <a href="https://www.rfc-editor.org/rfc/rfc3986#section-5.3"  target="_blank" rel="noreferrer">RFC 3986 §5.3</a>, and without a base URL the output is unchanged. Another stops the crate rebuilding its style rules on every call, which took a short snippet from 7.3 µs to 5.6 µs. That&rsquo;s nothing on a big page, but it adds up when you call it a few thousand times. The third just exposes what the Markdown converter below needs from the crate.</p>

<h2 class="relative group">The Python package
    <div id="the-python-package" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-python-package" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p><a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">pyhtml2text</a> is built with PyO3 and maturin, and it has two functions:</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">pyhtml2text</span> <span class="kn">import</span> <span class="n">html2text</span><span class="p">,</span> <span class="n">html2markdown</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="n">html2text</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="n">width</span><span class="o">=</span><span class="mi">80</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">decorate</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">link_footnotes</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">table_borders</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">wrap_links</span><span class="o">=</span><span class="kc">True</span><span class="p">,</span> <span class="n">max_wrap_width</span><span class="o">=</span><span class="kc">None</span><span class="p">,</span> <span class="n">raw</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="n">html2markdown</span><span class="p">(</span><span class="n">html</span><span class="p">,</span> <span class="o">*</span><span class="p">,</span> <span class="n">base_url</span><span class="o">=</span><span class="kc">None</span><span class="p">)</span></span></span></code></pre></div></div>
<p><code>html2text</code> is rust-html2text&rsquo;s plain-text renderer, which is built for terminals and the <code>text/plain</code> part of an email:</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="nb">print</span><span class="p">(</span><span class="n">html2text</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Please &lt;a href=&#34;https://jared.lynskey.co.nz/login&#34;&gt;sign in&lt;/a&gt; first.&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">                <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/page.html&#34;</span><span class="p">))</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-text" data-lang="text"><span class="line"><span class="cl">Please [sign in][1] first.
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl">[1]: https://example.com/login</span></span></code></pre></div></div>
<p>Some things about the bindings that may not be obvious:</p>
<ul>
<li><strong>Both functions take <code>str</code> or UTF-8 <code>bytes</code></strong>, so you can pass <code>response.content</code> without decoding it first. The input is borrowed, not copied.</li>
<li><strong>The GIL is released while converting.</strong> It&rsquo;s pure Rust from start to finish, so threads scale across cores. The module also says it doesn&rsquo;t need the GIL, so free-threaded Python 3.14t doesn&rsquo;t turn it back on when the module is imported.</li>
<li><strong>A panic comes back as <code>ValueError</code>.</strong> By default PyO3 raises a <code>PanicException</code>, which <code>except Exception</code> doesn&rsquo;t catch. A web worker that thinks it has handled conversion errors would crash on that, so the bindings catch it and re-raise it as a <code>ValueError</code>.</li>
<li><strong>There are 30 wheels:</strong> abi3 wheels for CPython 3.10 and later, plus free-threaded 3.14t wheels, across Linux (glibc and musl), macOS and Windows on every architecture maturin&rsquo;s CI can build. So nobody needs a Rust toolchain to install it.</li>
</ul>

<h2 class="relative group">Making it fast
    <div id="making-it-fast" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#making-it-fast" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Version 0.2.0 was 1.5 to 1.7 times faster than 0.1.0 on real pages, and twice as fast on short snippets, with byte-for-byte identical output. Most of that came from profile-guided optimisation: CI builds an instrumented wheel, runs it over a training set of documentation pages and RFCs, and uses the profile to build the real wheel. That&rsquo;s about 25% faster on pages outside the training set, on the platforms where CI can run the instrumented build natively. The rest came from switching to the mimalloc allocator, which copes far better with lots of small allocations, and from the style-rule change in the fork.</p>
<p>To check the speed-ups didn&rsquo;t change anything, I hashed the output for a corpus of real pages under every set of options, plus edge cases like empty input and very deep nesting. Each change had to leave every hash the same.</p>
<p>Here are the median times on an Apple Silicon Mac, converting to Markdown:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Encyclopaedia article (1 MB)</th>
					<th>News site front page (370 KB)</th>
					<th>Python docs page (245 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/markdownify/"  target="_blank" rel="noreferrer">markdownify</a></td>
					<td>184 ms</td>
					<td>28 ms</td>
					<td>97 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (pure Python)</td>
					<td>73 ms</td>
					<td>13 ms</td>
					<td>37 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html-to-markdown/"  target="_blank" rel="noreferrer">html-to-markdown</a></td>
					<td>44 ms</td>
					<td>5.6 ms</td>
					<td>15 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>14 ms</strong></td>
					<td><strong>1.7 ms</strong></td>
					<td><strong>4.5 ms</strong></td>
			</tr>
	</tbody>
</table>
<p>And converting to plain text:</p>
<table>
	<thead>
			<tr>
					<th></th>
					<th>Encyclopaedia article (780 KB)</th>
					<th>News site front page (370 KB)</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><a href="https://pypi.org/project/html2text/"  target="_blank" rel="noreferrer">html2text</a> (pure Python)</td>
					<td>71.5 ms</td>
					<td>14.9 ms</td>
			</tr>
			<tr>
					<td><a href="https://pypi.org/project/html2text-rs/"  target="_blank" rel="noreferrer">html2text-rs</a></td>
					<td>15.2 ms</td>
					<td>2.5 ms</td>
			</tr>
			<tr>
					<td><strong>pyhtml2text</strong></td>
					<td><strong>10.9 ms</strong></td>
					<td><strong>1.6 ms</strong></td>
			</tr>
	</tbody>
</table>

<h2 class="relative group">Deeply nested HTML, and a patch to html5ever
    <div id="deeply-nested-html-and-a-patch-to-html5ever" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#deeply-nested-html-and-a-patch-to-html5ever" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Nobody writes 40,000 nested lists by hand. But if you convert pages you didn&rsquo;t write, sooner or later something hostile or broken will send you one. With the stock parser, 40,000 nested <code>&lt;ul&gt;&lt;li&gt;</code> took <strong>6 seconds</strong> to parse, and that&rsquo;s a worker stuck on a single request.</p>
<p>The cause was in <a href="https://github.com/servo/html5ever"  target="_blank" rel="noreferrer">html5ever</a>, the HTML5 parser rust-html2text uses. Most block-level start tags have to check whether there&rsquo;s an open <code>&lt;p&gt;</code> element &ldquo;in button scope&rdquo;, and html5ever answered that by walking the whole stack of open elements. The deeper the nesting, the longer each tag took, so the total time grew with the square of the depth.</p>
<p>The package ships html5ever 0.39.0 with one patch that keeps the answer up to date as elements are opened and closed, so the check no longer depends on depth. The same document now parses in <strong>0.14 seconds</strong>. To be sure the patch changed nothing else, I compared the DOMs from the stock and patched parsers on every html5lib tree-construction test, about 20 real pages and 300,000 random tag-soup documents, and they were all identical.</p>
<p>The Markdown converter has its own limits for the same reason. No input can overflow the thread&rsquo;s stack, blockquotes and lists stop indenting past 32 levels so the output can&rsquo;t grow quadratically, and a table whose expanded grid would pass 100,000 cells is written as its cells alone.</p>

<h2 class="relative group">html2markdown
    <div id="html2markdown" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#html2markdown" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Version 0.3.0 added <code>html2markdown</code>, and most of the work in the package went into it. rust-html2text&rsquo;s renderer makes text that looks like Markdown, but it&rsquo;s wrapped for a terminal and isn&rsquo;t meant to be parsed. So <code>html2markdown</code> only borrows the parser and writes its own <a href="https://commonmark.org/"  target="_blank" rel="noreferrer">CommonMark</a>, plus GitHub-style pipe tables and <code>~~strikethrough~~</code>:</p>
<ul>
<li><code>#</code> headings, <code>-</code> bullets, <code>_emphasis_</code> and <code>**strong**</code>.</li>
<li>Inline links and images, made absolute when <code>base_url</code> is given. A link whose only content is an icon uses its <code>aria-label</code> or <code>title</code> as its text.</li>
<li>Fenced code blocks, tagged with the language when the page names one (<code>class=&quot;language-python&quot;</code>).</li>
<li>Pipe tables. Cells that span rows or columns become empty cells, a table without a header row gets an empty one, and tables used for page layout are written as paragraphs.</li>
<li>No line wrapping. Hidden content (<code>hidden</code>, <code>aria-hidden</code>, <code>display: none</code>), scripts, styles and form controls are left out.</li>
</ul>

<h3 class="relative group">Escaping only what needs it
    <div id="escaping-only-what-needs-it" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#escaping-only-what-needs-it" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Some HTML-to-Markdown converters escape every character that could be Markdown syntax. markdownify, for example, escapes every <code>_</code> and <code>*</code> by default. The output is correct, but it&rsquo;s full of <code>snake\_case</code> and <code>5 \* 3</code>, which is harder for people to read and a waste of tokens for a model. pyhtml2text escapes a character only where a CommonMark parser would otherwise read it as syntax:</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">html2markdown</span><span class="p">(</span><span class="s1">&#39;&lt;p&gt;Use &lt;code&gt;snake_case&lt;/code&gt;, 5 * 3, $5 and &#39;</span>
</span></span><span class="line"><span class="cl">              <span class="s1">&#39;&lt;a href=&#34;../setup.html&#34;&gt;setup&lt;/a&gt;&lt;/p&gt;&#39;</span><span class="p">,</span>
</span></span><span class="line"><span class="cl">              <span class="n">base_url</span><span class="o">=</span><span class="s2">&#34;https://example.com/docs/intro.html&#34;</span><span class="p">)</span></span></span></code></pre></div></div>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">Use <span class="sb">`snake_case`</span>, 5 * 3, $5 and [<span class="nt">setup</span>](<span class="na">https://example.com/setup.html</span>)</span></span></code></pre></div></div>

<h3 class="relative group">Bold before a Korean particle
    <div id="bold-before-a-korean-particle" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#bold-before-a-korean-particle" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>This one came from my app&rsquo;s own pages. In Korean the grammatical particle goes straight after the noun with no space, so a bold term with brackets in it looks like this:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-html" data-lang="html"><span class="line"><span class="cl"><span class="p">&lt;</span><span class="nt">p</span><span class="p">&gt;&lt;</span><span class="nt">b</span><span class="p">&gt;</span>타이타닉(Titanic)<span class="p">&lt;/</span><span class="nt">b</span><span class="p">&gt;</span>의 감독<span class="p">&lt;/</span><span class="nt">p</span><span class="p">&gt;</span></span></span></code></pre></div></div>
<p>The obvious Markdown is <code>**타이타닉(Titanic)**의</code>, which is what markdownify writes, and it doesn&rsquo;t work. CommonMark&rsquo;s flanking rules don&rsquo;t let <code>**</code> close after punctuation unless a space or more punctuation follows it, and <code>의</code> is a letter, so the asterisks stay as literal text. The pure-Python html2text avoids this by adding a space:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic)** 의 감독</span></span></code></pre></div></div>
<p>That renders as bold, but it changes the Korean. The particle is now a separate word, which is a spelling mistake. pyhtml2text moves the punctuation outside the markers instead:</p>
<div class="highlight-wrapper"><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-markdown" data-lang="markdown"><span class="line"><span class="cl">**타이타닉(Titanic**)의 감독</span></span></code></pre></div></div>
<p>The bracket is no longer bold, but the emphasis still parses and every character of the original is kept. The same applies to Japanese and Chinese, and to any language that doesn&rsquo;t put a space before what follows.</p>

<h3 class="relative group">Checking the output by reading it back
    <div id="checking-the-output-by-reading-it-back" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#checking-the-output-by-reading-it-back" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>I don&rsquo;t trust a converter because its unit tests pass, because the bugs in Markdown output are in combinations nobody thought to write a test for. So the output gets checked by reading it back.</p>
<p>While converting, each paragraph is parsed again as CommonMark to confirm the emphasis reads as intended. If it doesn&rsquo;t, the paragraph is written again with the other emphasis style (<code>*</code> instead of <code>_</code>, or the other way round), and if neither works the text, links and code are kept without the formatting, so no words are lost. The test suite does a round trip through a different Markdown parser and compares every visible word, link target and image source with the original HTML. Text eaten by syntax that wasn&rsquo;t escaped, a link that didn&rsquo;t parse or a stray <code>*</code> all show up as differences. It runs over real pages and tens of thousands of random documents, and shrinks any failure down to a minimal case.</p>

<h2 class="relative group">In production
    <div id="in-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="#in-production" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>My app&rsquo;s Markdown negotiation switched from the pure-Python html2text to pyhtml2text the day 0.3.0 came out. On one of its guide pages the conversion went from 4.2 ms to 0.36 ms, about 12 times faster. The links are absolute, the tables are real pipe tables, and the Korean bold keeps its particles attached. The abi3 wheels already cover Python 3.14 on x86_64 and arm64, so the container image needed nothing new to install it.</p>

<h2 class="relative group">Credits, and where to report bugs
    <div id="credits-and-where-to-report-bugs" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#credits-and-where-to-report-bugs" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The plain-text rendering is rust-html2text, by Chris Emerson, under the MIT licence. pyhtml2text adds a fork of it, a patched copy of html5ever (MIT/Apache-2.0) and the Markdown converter. The package isn&rsquo;t affiliated with or endorsed by either project, so <strong>please report problems to <a href="https://github.com/jaredlynskey/pyhtml2text/issues"  target="_blank" rel="noreferrer">pyhtml2text&rsquo;s issue tracker</a></strong>, not upstream. Any bug in this code is mine to fix, and the upstream maintainers shouldn&rsquo;t get reports about code they declined to take.</p>
<p>If you&rsquo;re converting HTML in Python, especially for a model to read, give it a try:</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">pip install pyhtml2text</span></span></code></pre></div></div>
<p>The source is on <a href="https://github.com/jaredlynskey/pyhtml2text"  target="_blank" rel="noreferrer">GitHub</a>, and the changelog lists everything in 0.1.0 to 0.3.0.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-10-18-pyhtml2text/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>Django on Fly.io: Load Testing and Autoscaling on Minimal Compute</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/</guid>
      <pubDate>Wed, 22 Jul 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Django</category>
      <category>Python</category>
      <category>React Native</category>
      <category>Expo</category>
      <category>Fly.io</category>
      <category>PostgreSQL</category>
      <category>WebSockets</category>
      <category>Scaling</category>
      <category>Load Testing</category>
      <category>Performance</category>
      <category>Tech Stack</category>
      <description>How I run two Django apps on Fly.io&#39;s shared-cpu-1x machines: make one machine fast, load-test it to find where latency collapses, and set Fly.io autoscaling just below it.</description>
      <content:encoded><![CDATA[<p>To run Django on Fly.io cheaply, I make one small shared-cpu-1x machine as fast as I can, load-test it until latency collapses, and set Fly&rsquo;s autoscaler to start a second machine just before that point. For the Django load testing I use Locust with a step-load ramp (and, on the other app, a small Python script that sweeps concurrency), watching p95 latency and error rates. When traffic drops, Fly stops the idle machines again, so the baseline cost is one small VM per app.</p>
<p>I&rsquo;m currently shipping two products as a solo developer: a <strong>field estimation app</strong> for tradespeople who quote jobs on site, and a <strong>marketplace and community app</strong> with listings and real-time messaging.</p>
<p>Different products, different users, nearly identical stack — and both run entirely on <strong>shared-cpu-1x machines</strong>, the cheapest tier Fly.io offers. That constraint is deliberate. Instead of sizing servers for peak load, I squeeze as much as possible out of one small machine, measure exactly where it collapses, and let Fly&rsquo;s autoscaler add and remove machines around that measured limit. This post covers the optimisations that make that possible, the load-test numbers behind the thresholds, and how scaling up and down actually works on Fly.io.</p>

<h2 class="relative group">The Philosophy: Measure One Machine, Then Multiply
    <div id="the-philosophy-measure-one-machine-then-multiply" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-philosophy-measure-one-machine-then-multiply" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The scaling strategy for both apps is the same three steps:</p>
<ol>
<li>Make a single small machine as fast as it can reasonably be.</li>
<li>Load-test it until it breaks, and note the concurrency level where latency collapses.</li>
<li>Configure Fly&rsquo;s autoscaler to start a second machine just before that point, and stop idle machines when traffic drops.</li>
</ol>
<p>Capacity then scales roughly linearly with machine count, and the baseline cost is one small VM per app. Everything below serves one of those three steps.</p>

<h2 class="relative group">Making One Small Machine Fast
    <div id="making-one-small-machine-fast" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#making-one-small-machine-fast" aria-label="Anchor">#</a>
    </span>
    
</h2>

<h3 class="relative group">Granian: WSGI for the API, uvloop ASGI for WebSockets
    <div id="granian-wsgi-for-the-api-uvloop-asgi-for-websockets" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#granian-wsgi-for-the-api-uvloop-asgi-for-websockets" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Both backends are Django 6 + Django REST Framework. The API is served by <strong>Granian</strong>, a Rust-based server — but under <strong>WSGI with a blocking thread pool</strong>, not ASGI:</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">granian --interface wsgi myapp.wsgi:application <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers <span class="m">1</span> --blocking-threads <span class="m">16</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-max-rss <span class="m">1400</span> <span class="se">\
</span></span></span><span class="line"><span class="cl">    --workers-kill-timeout <span class="m">15</span> --respawn-failed-workers</span></span></code></pre></div></div>
<p>I started on <code>--interface asgi --loop uvloop</code>, and it was the wrong call. Every HTTP view in these apps is sync, and Django&rsquo;s ASGI handler runs sync views <code>thread_sensitive</code> — one at a time per worker. So ASGI gave me roughly one concurrent request per worker <em>and</em> loaded the whole asyncio/uvloop/Channels stack into RAM for nothing. WSGI with <code>--blocking-threads</code> runs those same sync views in a real thread pool: one worker × 16 threads = 16 concurrent I/O-bound requests (DB reads, S3 presigns) at lower resident memory than the async build.</p>
<p>Why a single worker? The VM is <code>shared-cpu-1x</code> — one core. A second worker can&rsquo;t run Python in parallel on one core (GIL + one CPU), but it does cost a whole extra resident Django process; Granian warns about exactly this. Sixteen threads on one worker is the honest ceiling of a 1-vCPU box for I/O-bound work. <code>--workers-max-rss 1400</code> respawns the worker if it drifts past its memory cap, so a runaway PDF or image job gets recycled instead of walking the machine toward OOM.</p>
<p>WebSockets moved out to their own Granian process — an <strong>ASGI</strong> app with uvloop and <code>--ws</code>, one worker for long-lived connections. The sync API needs no ASGI path at all: when a view wants to push to a socket it calls <code>async_to_sync(channel_layer.group_send)</code> over Redis, so the message still crosses to the WebSocket process. Two servers, each running the interface it&rsquo;s actually good at. Local Docker runs the same WSGI thread pool, so my laptop approximates one production machine when testing.</p>

<h3 class="relative group">Rust in the Hot Paths
    <div id="rust-in-the-hot-paths" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#rust-in-the-hot-paths" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Wherever a Rust-backed drop-in exists for a Python hot path, I take it:</p>
<ul>
<li><strong>orjson</strong> via <code>drf-orjson-renderer</code> — every API response serialises through Rust instead of the stdlib JSON encoder.</li>
<li><strong>blurhash-rs</strong> — image placeholders computed at upload time.</li>
<li><strong>uuid-utils</strong> — Rust UUID generation.</li>
<li><strong>psycopg 3 (binary)</strong> — C-accelerated Postgres driver.</li>
</ul>
<p>None of these change the architecture; they just raise the ceiling of what one vCPU can do before you need a second machine.</p>

<h3 class="relative group">Kill the Hidden Latency Taxes
    <div id="kill-the-hidden-latency-taxes" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#kill-the-hidden-latency-taxes" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The single biggest win in either codebase wasn&rsquo;t an algorithm — it was connection reuse. Under Granian&rsquo;s thread pool, every new thread was building a fresh boto3 S3 client, and every endpoint that returns a photo or PDF paid that cost. A small custom storage class now caches the client once per process instead:</p>
<blockquote><p>Signed URLs went from <strong>~150ms to ~1ms</strong> on new threads.</p>
</blockquote><p>The same lesson applies to the database: Fly&rsquo;s Postgres sits behind PgBouncer, so the app runs <code>conn_max_age=0</code> and lets the pooler own connection reuse, with a 5-second <code>connect_timeout</code> to fail fast on stale SSL instead of hanging a worker.</p>

<h3 class="relative group">Cache Where the Bottleneck Actually Is
    <div id="cache-where-the-bottleneck-actually-is" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#cache-where-the-bottleneck-actually-is" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Load testing the marketplace revealed something worth internalising: <strong>the ceiling was Postgres connections, not CPU</strong>. Browse traffic is overwhelmingly anonymous and identical across users, and under load those identical list queries saturate the connection pool while the CPU sits idle.</p>
<p>The fix is a Redis cache for anonymous list pages, with per-namespace version counters for invalidation — a write bumps one counter (O(1), no key bookkeeping) and every cached page for that resource is instantly stale. A short TTL backstops anything missed. Serving browse pages straight from Redis removes the real bottleneck and lets the small machine spend its connections on logged-in traffic.</p>

<h3 class="relative group">Background Work Without a Broker
    <div id="background-work-without-a-broker" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#background-work-without-a-broker" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Both apps use <strong>django-tasks-db</strong>, Django&rsquo;s database-backed task framework, instead of Celery. A separate <code>db_worker</code> process polls the database for jobs — image processing, PDF generation, machine translation, reminders. No broker, no extra service to size and monitor, and the API process never blocks on heavy work. The worker even connects straight to Postgres, bypassing PgBouncer, since long-running jobs don&rsquo;t play well with transaction pooling.</p>

<h3 class="relative group">Memory: jemalloc and a Swap Cushion
    <div id="memory-jemalloc-and-a-swap-cushion" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#memory-jemalloc-and-a-swap-cushion" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Image and PDF processing spike memory in ways steady-state sizing doesn&rsquo;t predict. Two cheap mitigations: <strong>jemalloc</strong> preloaded in the Docker image to reduce fragmentation, and <strong>swap on every machine</strong> (2GB on the API machines, 1GB on the WebSocket machine) so a spike swaps to disk instead of getting the process OOM-killed. Swap is not a performance feature — it&rsquo;s an insurance policy that lets me keep memory allocations small.</p>

<h2 class="relative group">Django Load Testing: Finding the Knee
    <div id="django-load-testing-finding-the-knee" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#django-load-testing-finding-the-knee" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Thresholds are only defensible if they&rsquo;re measured. Each project has its own harness:</p>
<ul>
<li>The estimation app has a small Python stress script that sweeps concurrency levels and reports RPS, p50/p95/p99 and error rates. It runs against a few hundred generated sample records, so the list endpoints are queried at realistic data volumes.</li>
<li>The marketplace uses <strong>Locust</strong> with a step-load shape that ramps 50 → 500 users in 30-second steps to find the saturation point, with endpoint weights matching real browse-heavy traffic, plus a second, smaller suite that ramps 5 → 50 virtual users against production with a p95 &lt; 2s threshold.</li>
</ul>
<p>Representative numbers from the estimation app&rsquo;s sweep (one Granian worker × 16 blocking threads, Docker on my dev machine):</p>
<table>
	<thead>
			<tr>
					<th>Endpoint</th>
					<th>Concurrency</th>
					<th>RPS</th>
					<th>p95</th>
					<th>Errors</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>health</td>
					<td>25</td>
					<td>602</td>
					<td>52ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>heavy list</td>
					<td>25</td>
					<td>309</td>
					<td>260ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>light list</td>
					<td>25</td>
					<td>357</td>
					<td>61ms</td>
					<td>0%</td>
			</tr>
			<tr>
					<td>heavy list</td>
					<td>50</td>
					<td>309</td>
					<td>347ms</td>
					<td>0%</td>
			</tr>
	</tbody>
</table>
<p>And the number that drives everything else comes from load-testing the marketplace on a <strong>single shared-cpu-1x machine</strong>. On the old ASGI build this was a hard wall: sync views serialised to roughly <strong>three usable slots</strong>, so p95 held under 300ms up to ~5 concurrent requests and then collapsed past <strong>~8–10</strong> as everything queued behind the thread-sensitive lock. Moving the API to a WSGI thread pool is what pushed that knee out — <strong>16 real concurrent slots</strong> per machine instead of ~3 — and that new ceiling is the whole basis of the autoscaling config.</p>

<h2 class="relative group">How Scaling Works on Fly.io
    <div id="how-scaling-works-on-flyio" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#how-scaling-works-on-flyio" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Fly&rsquo;s model is simple: your app is a fleet of identical machines behind a proxy, and the proxy counts load per machine. You declare two numbers:</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">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;requests&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">12</span>   <span class="c"># past this, start another machine</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">24</span>   <span class="c"># past this, shed load instead of queueing</span>
</span></span><span class="line"><span class="cl">
</span></span><span class="line"><span class="cl"><span class="nx">auto_stop_machines</span> <span class="p">=</span> <span class="s1">&#39;stop&#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">1</span></span></span></code></pre></div></div>
<p><strong>Scaling up:</strong> when in-flight requests on a machine exceed the soft limit, the proxy starts a stopped machine and routes new traffic there. The WSGI thread pool gives each machine 16 real concurrent slots, so the soft limit sits at 12 — about 75% of the pool, so machine #2 starts while threads still have headroom rather than after users are seeing multi-second latency. The hard limit of 24 (1.5× the pool) is the circuit breaker: short bursts queue on threads, and beyond it the proxy sheds load rather than letting one machine melt down. (The old ASGI build ran 8/20, sized for its ~3 real slots — widening the pool is what let both numbers move up.)</p>
<p><strong>Scaling down:</strong> when traffic drops, Fly stops idle machines automatically, down to <code>min_machines_running = 1</code>. A stopped machine costs nothing for compute, only its rootfs storage, so the baseline cost of each app is literally one small VM with burst capacity always ready.</p>
<p>I originally wrote here that a stopped machine &ldquo;cold-starts in well under a second.&rdquo; That was wrong, and expensively so. When I finally measured it instead of assuming it, a cold start took <strong>25 to 33 seconds</strong>, and a request that lands on a machine still booting has to wait out all of it. I first blamed those cold starts for a 22% failure rate on my CDN dashboard, while every health check stayed green. That number turned out to be the CDN&rsquo;s own Early Hints probes failing against the origin; no real client got a 504. The cold starts were real all the same.</p>
<p>The fix is not in the scaling numbers above; those are fine. It is in what the machine does on the way up, and in asking Fly to suspend machines rather than stop them, which turns a wake into a ~3 second memory restore. I wrote that up separately: <a href="https://jared.lynskey.co.nz/en/posts/2026/2026-09-09-scale-to-zero-504s/" >Scale to Zero, and Every Restart Is a 30-Second Outage</a> covers the diagnosis and what to change. If you are running anything on Fly with <code>auto_stop_machines</code>, read that one before you trust this paragraph.</p>
<p>One subtlety: the concurrency type is <code>requests</code>, not <code>connections</code>. Modern clients hold keep-alive connections open, so counting connections barely moves under real load and the autoscaler never triggers. Counting in-flight requests tracks actual work.</p>

<h3 class="relative group">WebSockets Scale on a Different Axis
    <div id="websockets-scale-on-a-different-axis" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#websockets-scale-on-a-different-axis" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The marketplace splits its WebSocket server into a <strong>second Fly app</strong> sharing the same Docker image. The API app runs one worker × 16 blocking threads under WSGI and counts <em>requests</em> (soft limit 12). The WebSocket app runs 1 worker under ASGI with uvloop on a <strong>512MB</strong> machine and counts <em>connections</em>:</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">http_service</span><span class="p">.</span><span class="nx">concurrency</span><span class="p">]</span>
</span></span><span class="line"><span class="cl">  <span class="nx">type</span> <span class="p">=</span> <span class="s1">&#39;connections&#39;</span>
</span></span><span class="line"><span class="cl">  <span class="nx">soft_limit</span> <span class="p">=</span> <span class="mi">1500</span>
</span></span><span class="line"><span class="cl">  <span class="nx">hard_limit</span> <span class="p">=</span> <span class="mi">2000</span></span></span></code></pre></div></div>
<p>A long-lived socket is nearly free to hold open but occupies a slot for hours, while an HTTP request occupies a slot for milliseconds — the same autoscaling signal can&rsquo;t serve both. Splitting them means each fleet scales on its own honest metric, and idle WebSocket capacity costs 512MB, not 2GB. On the Channels side, the Redis layer runs with <code>capacity: 1500</code> and 60-second message expiry, plus socket keepalives every 30 seconds because Fly&rsquo;s Redis proxy idle-closes the long-lived BRPOP sockets that Channels depends on.</p>

<h2 class="relative group">Offloading Compute to the Edge and the Client
    <div id="offloading-compute-to-the-edge-and-the-client" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#offloading-compute-to-the-edge-and-the-client" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The cheapest server work is the work the server never does:</p>
<ul>
<li><strong>Static and media files</strong> live in Fly&rsquo;s S3-compatible object storage, served through a CDN with signed URLs for private media — zero image bytes transit the Django machines.</li>
<li><strong>AI inference runs on the user&rsquo;s phone.</strong> Both apps embed a small local LLM (a quantised 4B model, around 2.7GB) that runs on the device. Zero inference cost server-side, and it works offline.</li>
<li><strong>Offline-first mobile</strong> (SQLite on the device and a sync queue with retries) means the apps batch writes and tolerate dead zones instead of hammering the API with chatty per-keystroke requests.</li>
</ul>

<h2 class="relative group">The Rest of the Stack, Briefly
    <div id="the-rest-of-the-stack-briefly" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-rest-of-the-stack-briefly" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Auth is a hosted identity provider plus WebAuthn passkeys, and WebSocket connections are authenticated before the upgrade. Postgres in production, SQLite in development, and a couple of off-the-shelf Django packages for the admin and audit history. Mobile is React Native and TypeScript on Expo, with Expo Router and Tailwind, over-the-air updates from a self-hosted Expo Updates server, and a subscriptions service for in-app purchases. Quality gates: pytest and mypy on the backend, unit and end-to-end tests on the client.</p>

<h2 class="relative group">What I&rsquo;d Tell You to Steal
    <div id="what-id-tell-you-to-steal" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-id-tell-you-to-steal" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The transferable idea isn&rsquo;t any single library — it&rsquo;s the loop. Make one cheap machine genuinely fast (Rust hot paths, connection reuse, cache in front of the real bottleneck, offload what you can to CDN and client). Load-test until you find the exact concurrency where it breaks. Set the autoscaler&rsquo;s soft limit at that knee, let idle machines stop, and keep the minimum fleet at one. Two production apps, real-time features, AI assistants — and the steady-state bill is a couple of the cheapest VMs on the platform.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-07-22-my-current-stack/featured.jpg" medium="image" type="image/jpeg" />
    </item>
    <item>
      <title>What Determines a Painting&#39;s Value? Building an Algorithm to Predict Art Auction Prices</title>
      <link>https://jared.lynskey.co.nz/en/posts/2026/2026-04-01-art-evaluation-algorithm/</link>
      <guid isPermaLink="true">https://jared.lynskey.co.nz/en/posts/2026/2026-04-01-art-evaluation-algorithm/</guid>
      <pubDate>Wed, 01 Apr 2026 00:00:00 +0000</pubDate>
      <dc:creator>Jared Lynskey</dc:creator>
      <category>Machine Learning</category>
      <category>XGBoost</category>
      <category>Art Market</category>
      <category>Python</category>
      <category>Data Science</category>
      <description>What drives art auction prices? Reproducing a 2024 XGBoost study of 34,200 sales shows the market prices the artist, not the artwork. The features, the results, and the art valuation platform I built around them.</description>
      <content:encoded><![CDATA[<p>What determines a painting&rsquo;s value at auction? Mostly the artist, not the painting. In a 2024 study of 34,200 contemporary art auction sales, a model that only looked at the artworks explained about 5% of the variation in price. One built on the artist&rsquo;s career, exhibition history and past sales explained about 73%, and adding the auction house&rsquo;s pre-sale estimate took it to 92%. Here&rsquo;s how I reproduced that, and what it means for valuing art with an algorithm.</p>
<p>I spent the last few weeks on two related projects. The first was reproducing a 2024 <em>Scientific Reports</em> paper by Lee et al., &ldquo;Social signals predict contemporary art prices better than visual features, particularly in emerging markets&rdquo;. It trains an XGBoost model on 34,200 auction records from 590 living artists. The second was building an art platform with a knowledge graph of artists, owners, and auction records. It&rsquo;s basically the data plumbing you&rsquo;d need around a model like that one if you wanted to do anything useful with it.</p>
<p>Both projects kept circling back to the same question: can you actually predict what an artwork sells for? The answer is yes, but not from looking at the artwork. The features that matter are almost entirely social.</p>

<h2 class="relative group">Why Art Is Hard to Value
    <div id="why-art-is-hard-to-value" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-art-is-hard-to-value" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Before writing any code, I spent a couple of days reading about how appraisers and auction houses price work. The list of factors is long and most of them don&rsquo;t quantify cleanly. Provenance — who&rsquo;s owned it, where it&rsquo;s been shown, which catalogues reference it. Attribution: a piece confirmed by the artist&rsquo;s foundation is worth orders of magnitude more than one that&rsquo;s only &ldquo;attributed to&rdquo; them. The artist&rsquo;s reputation, built from solo shows, biennial inclusions, museum acquisitions, and critical reception. Then condition and restoration history, rarity within the artist&rsquo;s body of work, and medium — oil on canvas tends to outperform works on paper, which outperform prints. A piece held by a major museum carries a stamp the secondary market trusts. And timing matters: five-year-old comparables are stale.</p>
<p>Auction-house specialists also talk about &ldquo;emotional value&rdquo; — collector desire, bidding-war dynamics, personal resonance. It can push hammer prices well past any rational estimate. You can&rsquo;t model that directly, but you can pick up its shape indirectly through auction-house estimates.</p>
<p>The dominant valuation method in practice is comparable sales analysis: find recent auction results for similar works by the same artist and adjust for differences. An XGBoost model is doing the same thing, just at scale and with more features than any human appraiser would track by hand.</p>

<h2 class="relative group">The Paper&rsquo;s Central Claim
    <div id="the-papers-central-claim" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-papers-central-claim" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Lee et al. make a counterintuitive argument: the visual content of an artwork explains very little of its price. Their visual-only model, using color, composition, edge density, and ResNet18 embeddings, gets an R² of about 0.055. That&rsquo;s barely better than guessing the mean.</p>
<p>A model using only artist-level &ldquo;social&rdquo; features (career stage, exhibition history, past auction prices, ranking on ArtFacts) hits R² ≈ 0.73. Add the auction house&rsquo;s pre-sale estimate as a feature and it climbs to 0.92.</p>
<p>In other words, the artwork barely matters. What matters is who made it and what the market has already said about them.</p>
<p>The paper&rsquo;s secondary claim is that this gap widens further in emerging markets, by which they mean anything outside the established USA, UK, France, and Germany core. In those markets, social signals matter more and expert estimates are less accurate, leaving more room for algorithmic prediction to add value.</p>

<h2 class="relative group">Reproducing the Paper
    <div id="reproducing-the-paper" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#reproducing-the-paper" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The brief was straightforward: reproduce the paper as a single-developer MVP. No production deployment, no live data pipeline. Faithful reimplementation, prototype-quality code, and a written report on findings.</p>

<h3 class="relative group">The Data
    <div id="the-data" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-data" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The paper provides cleaned CSVs as supplementary material:</p>
<table>
	<thead>
			<tr>
					<th>File</th>
					<th>Rows</th>
					<th>What it is</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td><code>Df_mloutfull.csv</code></td>
					<td>86,221</td>
					<td>Full raw dataset, 500+ columns</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_up_to_2012.csv</code></td>
					<td>34,200</td>
					<td>Main cleaned dataset, 1996–2012</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_old_market.csv</code></td>
					<td>29,853</td>
					<td>Established markets only</td>
			</tr>
			<tr>
					<td><code>df_for_ml_improved_new_market.csv</code></td>
					<td>4,346</td>
					<td>Emerging markets only</td>
			</tr>
			<tr>
					<td><code>transactions.csv</code></td>
					<td>114,283</td>
					<td>Raw auction records with image links</td>
			</tr>
	</tbody>
</table>
<p>The whole thing is about 5 GB. It fits comfortably in RAM, and XGBoost training on a dataset this size takes under five minutes on a laptop CPU. No GPU needed for the core model. Gradient-boosted trees on tabular data at this scale are nearly free to train, which is easy to forget if you&rsquo;ve spent the last few years in neural-net land.</p>

<h3 class="relative group">Features
    <div id="features" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#features" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The model uses 38 features split across three categories.</p>
<p><strong>About the artist (30 features):</strong></p>
<ul>
<li>Demographics: age, gender, education level, top-school flag.</li>
<li>Career: ArtFacts ranking, solo shows, group shows, biennial inclusions, awards.</li>
<li>Collections: counts of private and public collections.</li>
<li>Price history: mean, median, max, and min from the artist&rsquo;s last 5 and last 10 sales.</li>
<li>Size-adjusted price history (price per square inch).</li>
<li>Geography: where the artist works and lives, encoded as country flags.</li>
<li>Match flags: does the artwork&rsquo;s genre match the artist&rsquo;s typical genre? Does the sale country match?</li>
</ul>
<p><strong>About the market (8 features):</strong></p>
<ul>
<li>Auction house tier (1–4).</li>
<li>Country and region price levels for that year (min, mean, median, max).</li>
</ul>
<p><strong>About the artwork itself (non-visual):</strong></p>
<ul>
<li>Width, height, area in square inches.</li>
<li>Medium category (painting, print, photo, sculpture, other).</li>
</ul>
<p>Notice what&rsquo;s absent: anything about the artwork&rsquo;s actual visual content. That comes in only as a separate ablation study.</p>

<h3 class="relative group">The Train/Test Split
    <div id="the-traintest-split" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-traintest-split" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>Time-based, not random. Everything before May 2011 goes into training (~80%, ~27,360 records); everything after goes into the test set (~20%, ~6,840 records). Random splits would leak future information through the artist&rsquo;s price-history features. If you scatter one artist&rsquo;s records randomly across train and test, the test rows have already &ldquo;seen&rdquo; each other&rsquo;s prices through the rolling windows, and your R² gets artificially inflated.</p>
<p>It&rsquo;s the kind of thing that&rsquo;s obvious once you see it but easy to miss on the first build.</p>

<h3 class="relative group">The Model
    <div id="the-model" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#the-model" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>XGBoost regressor, predicting <code>log10(price_usd)</code>. Hyperparameter search over <code>max_depth</code> and <code>learning_rate</code> against a held-out validation slice. Two model variants:</p>
<ol>
<li><strong>Without expert estimates.</strong> Target R² ≈ 0.73.</li>
<li><strong>With expert estimates.</strong> Target R² ≈ 0.92.</li>
</ol>
<p>The &ldquo;with estimates&rdquo; variant uses the auction house&rsquo;s pre-sale low and high estimates as additional features. Those two estimates alone, used by themselves, get R² ≈ 0.90. Adding the social features on top picks up another 0.02. That&rsquo;s small in absolute terms, but it&rsquo;s the part of the model doing real predictive work beyond what the experts already know.</p>

<h3 class="relative group">What I Aimed For
    <div id="what-i-aimed-for" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-i-aimed-for" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>I wasn&rsquo;t trying to beat the paper. The goal was to land within ±0.03 of their R² values across all conditions. For the headline numbers (~0.73 metadata-only, ~0.92 with estimates), reproducibility is good. XGBoost is deterministic with a fixed seed, and the features are well-specified. I expected drift in two places: the visual-feature ablation, where small differences in PCA dimensions or image preprocessing can move things, and the emerging-market split, where there are only 4,346 records and the noise floor is higher.</p>

<h2 class="relative group">Why Visual Features Barely Help
    <div id="why-visual-features-barely-help" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#why-visual-features-barely-help" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This was the most interesting result for me. The paper&rsquo;s visual pipeline extracts 8,971 numbers per image:</p>
<ul>
<li><strong>GIST descriptor</strong> (960 dims): overall spatial layout.</li>
<li><strong>Histogram of Oriented Gradients</strong> (2,915 dims): edge structure.</li>
<li><strong>Color histogram</strong> (4,096 dims): palette breakdown.</li>
<li><strong>ResNet18 features</strong> (1,000 dims): high-level pretrained embedding.</li>
<li><strong>Colorfulness</strong> (1 dim): vividness scalar.</li>
<li><strong>Complexity</strong> (1 dim): edge-density scalar.</li>
</ul>
<p>Compress all that with PCA and feed it to XGBoost, and you get R² ≈ 0.055. Color alone gets 0.056. Edge structure gets 0.029. The neural-net embedding gets 0.009, basically zero.</p>
<p>The clean explanation is that auction prices are set at the level of the artist, not the individual work. Two paintings by the same artist sell for similar amounts regardless of what&rsquo;s on the canvas, because what&rsquo;s being priced is the signature.</p>
<p>You can see why this would be true if you&rsquo;ve ever read auction catalogue notes. They spend almost all their words on provenance, exhibition history, and comparable sales. They barely describe the work itself.</p>
<p>If you take that 5% number seriously, the aesthetic content of a work is almost decoupled from its market value. Whatever the market rewards, it isn&rsquo;t what&rsquo;s on the canvas. A model that understood art at the pixel level would predict prices worse than one that only knew the artist&rsquo;s CV.</p>

<h3 class="relative group">Phase 3 Was Conditional
    <div id="phase-3-was-conditional" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#phase-3-was-conditional" aria-label="Anchor">#</a>
    </span>
    
</h3>
<p>The paper&rsquo;s image-link column points to URLs that were valid in 2012. By 2026, most are dead. The plan I wrote up flagged this risk on day 8: pull a hundred random URLs and see how many resolve. If more than half were gone, skip the visual phase entirely and document why.</p>
<p>Visual features only explain about 5% of price variance anyway. Spending two weeks fighting bit rot to confirm a number that small isn&rsquo;t a good trade. Better to write up the dead-link finding as part of the report and move on.</p>

<h2 class="relative group">Established vs. Emerging Markets
    <div id="established-vs-emerging-markets" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#established-vs-emerging-markets" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The paper trains separate models for established markets (USA, UK, France, Germany) versus emerging markets (19 other countries). The headline finding:</p>
<table>
	<thead>
			<tr>
					<th>Feature set</th>
					<th>Established R²</th>
					<th>Emerging R²</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>Visual only</td>
					<td>0.053</td>
					<td>0.056</td>
			</tr>
			<tr>
					<td>Metadata (social signals)</td>
					<td>0.667</td>
					<td>0.750</td>
			</tr>
			<tr>
					<td>Metadata + estimates</td>
					<td>0.916</td>
					<td>0.859</td>
			</tr>
	</tbody>
</table>
<p>Two things stand out. First, social signals matter more in emerging markets, not less. I&rsquo;d assumed established markets, with their richer data, would show better social-feature performance, but it&rsquo;s the opposite.</p>
<p>Second, expert estimates are less accurate in emerging markets — that&rsquo;s the 0.916 vs 0.859 gap once estimates are added in. In an established market, an algorithmic prediction has to compete with a specialist at a major auction house who has decades of comparable-sales data behind them. In an emerging market, that specialist has thinner data and the algorithm has more room to add value.</p>
<p>If you ever wanted to commercialise a model like this, emerging markets are where it would earn its keep.</p>

<h2 class="relative group">Limitations Worth Stating
    <div id="limitations-worth-stating" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#limitations-worth-stating" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>This is a paper reproduction, not a production system. It has no live data pipeline — the model trains on 1996–2012 auction data and never updates. There&rsquo;s no deployment; the outputs are notebooks and a report, not a prediction API. There&rsquo;s no drift detection, so once trained, the model has no idea when the market has changed. And the dataset is frozen: crypto art, NFTs, and post-COVID market shifts simply don&rsquo;t exist in it. If you wanted to predict prices at a Korean auction house, you&rsquo;d want auction-specific features and possibly a separate model for the Korean market — nothing here specialises that far.</p>
<p>The model also reflects historical patterns. If the contemporary market changed structurally around 2015 (and there are arguments it did, with art fairs becoming a major primary-market venue and a new generation of collectors entering), then the 1996–2012 patterns may not generalise.</p>

<h2 class="relative group">From Paper to Platform
    <div id="from-paper-to-platform" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#from-paper-to-platform" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Reading the paper changed how I thought about the second project. The platform was originally framed as a crowdfunding marketplace for art exhibitions. Artists secure funding for upcoming shows, and investors get exposure to emerging artists earlier than the auction market typically allows.</p>
<p>The product surface is deliberately simple. Artists post upcoming shows that need funding (venue, dates, expected works). Investors back specific shows in tiered amounts ($100, $500, $1k, $5k) and share in the upside when the work sells. AI agents handle most of the artist-investor chat so that neither side has to be online for the conversation to keep moving. That&rsquo;s particularly useful when a Korean gallery is talking to a U.S. collector across a 14-hour gap.</p>
<p>Once it sinks in that the artist is the asset and not the artwork, the data model has to change to match. What you need to capture is who has owned each work, where it has been shown, what comparable works have sold for, and who else owns work by the same artist. Ownership chains, institutional recognition, auction comps, collector networks — that&rsquo;s what a knowledge graph is for.</p>
<p>The data model is small on purpose: artists, artworks, auction sales, ownership periods, and a provenance log for the data itself, so every record can be traced back to where it was scraped from. That last part sounds like bookkeeping, but with scraped data it&rsquo;s what lets you trust the graph at all.</p>
<p>The mobile app renders all of this as a force-directed graph. A time-range slider lets you scrub through years and watch the network evolve: which collectors entered when, which artists got picked up by which museums, which works changed hands during the 2008 dip.</p>
<p>Each kind of entity gets its own shape and colour. Artists are diamonds, artworks are rounded rectangles, auction houses are hexagons, and the different kinds of owner (collectors, dealers, museums, estates) are circles in different colours.</p>
<p>The shapes aren&rsquo;t decorative. When you zoom out to a few hundred nodes the silhouette tells you what type of entity you&rsquo;re looking at without waiting for labels to render. A diamond surrounded by circles is an artist with a collector network. A hexagon connected to many diamonds is an auction house that handles a roster of artists. You can read the structure of a market at a glance.</p>
<p>A second tab shows the same data on a timeline: ownership periods as horizontal bars, sales as point events. Same underlying graph, different lens. Useful when you want to see the sequence of who held what, rather than the network shape at a single moment.</p>
<p>The platform isn&rsquo;t itself a price-prediction tool. It&rsquo;s the data layer underneath one. If you wanted to feed something like the Lee et al. model with live, current data instead of frozen 2012 CSVs, this is the structure you&rsquo;d want.</p>

<h2 class="relative group">Crowdsourced Valuation as a Game
    <div id="crowdsourced-valuation-as-a-game" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#crowdsourced-valuation-as-a-game" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>The piece I haven&rsquo;t built yet, and the part I&rsquo;m most interested in, is turning art valuation into a game.</p>
<p>The Lee et al. paper takes auction-house pre-sale estimates as a feature and gets a big R² lift from them. Those estimates are valuable because they&rsquo;re the aggregated judgment of trained specialists who have seen thousands of comparable works and have skin in the game. But auction-house specialists are a narrow, expensive resource. There are a few hundred of them globally, they work on specific consignments, and they don&rsquo;t touch the long tail of artists nobody is consigning yet.</p>
<p>The question I keep coming back to is whether you can synthesise a comparable signal from a much wider, cheaper pool of judgments.</p>
<p>The rough design:</p>
<ul>
<li>Show a user an artwork: image, dimensions, year, artist name, brief CV.</li>
<li>Ask them what it would sell for at auction.</li>
<li>Let them pick a price bracket, or guess a hammer figure directly.</li>
<li>Reveal the actual sale price after they commit.</li>
<li>Score them with a Brier-score-style accuracy rating over time.</li>
<li>Surface a leaderboard, daily streaks, and badges for hot streaks.</li>
<li>Weight each user&rsquo;s future predictions by their historical accuracy.</li>
</ul>
<p>It&rsquo;s a prediction market for art prices in the form of a guessing game. The loop is the same as a geography guessing game or online chess puzzle ratings: repeatable, scorable, with feedback you can build skill against. People who guess well rise on the leaderboard, and their future guesses count for more in the aggregated signal.</p>
<p>The model side is where this gets useful. A weighted aggregation of those guesses becomes a feature you can feed into XGBoost alongside the social signals. If the crowd&rsquo;s accuracy-weighted median guess correlates well with hammer prices, you have something close to a synthetic auction-house estimate for works that don&rsquo;t have a real one. That&rsquo;s exactly the gap on the long tail of artists where the Lee et al. model needs the most help.</p>
<p>There&rsquo;s also a cold-start angle. The model trains well on artists with rich price histories and badly on artists with three sales to their name. Three sales plus a thousand crowd guesses is at least something to work with. Crowdsourced valuation is a way of generating training signal for the cases the underlying data doesn&rsquo;t cover.</p>
<p>Gamification matters here because the only way to make the crowd reliable is to make participating fun enough that people do it a lot. Single-question forms get you noisy data from people who clicked through once. A daily streak with a leaderboard and a calibration badge gets you the same person making 500 carefully considered predictions over a year. Volume plus self-selection (good predictors stay, bad predictors get bored) is what gives you a signal rather than noise.</p>
<p>There are design questions I&rsquo;d need to answer before building any of it. How much do you show the user — just image and CV, or comparable sales too? More context means better-informed guesses, but past a point the user is mostly regurgitating what you&rsquo;ve already shown them. How much price feedback do you give? Reveal the exact hammer, a range, or just whether they landed within ±20%? Too granular and you train people to anchor on specific numbers; too vague and they can&rsquo;t calibrate. Then there&rsquo;s leaderboard gaming: players will gravitate toward artists they already know, and a Banksy guess is mostly trivia rather than skill. Probably the fix is forced random sampling, the way language-learning apps pick lessons, plus a separate ranking for &ldquo;blind&rdquo; rounds where the artist&rsquo;s name is hidden. And hindsight bias is unavoidable — once a user knows a Basquiat sold for $110M, they can&rsquo;t un-know it. The supply of fresh, unseen artworks is what keeps the loop honest, and that supply is finite per week.</p>
<p>The gamification framing also pulls the platform&rsquo;s two halves together. The crowdfunding side rewards investors when shows perform well financially. The guessing-game side rewards anyone, paying investor or not, for being good at calling prices. Both are versions of the same idea: let the crowd&rsquo;s collective judgment turn into a tradeable signal. One trades capital, the other trades attention. Players who develop a real edge could eventually graduate to the investor side, bringing a verifiable track record of price-calling accuracy with them. That&rsquo;s a better filter than &ldquo;knew the right gallerist.&rdquo;</p>

<h2 class="relative group">What I Took Away
    <div id="what-i-took-away" class="anchor"></div>
    
    <span
        class="absolute top-0 w-6 transition-opacity opacity-0 -start-6 not-prose group-hover:opacity-100 select-none">
        <a class="text-primary-300 dark:text-neutral-700 !no-underline" href="#what-i-took-away" aria-label="Anchor">#</a>
    </span>
    
</h2>
<p>Pre-sale estimates are very good. Auction houses get a lot of grief for opaque pricing, but their estimates capture something close to 90% of price variance on their own. Whatever specialists are doing, they&rsquo;re doing it well. And yet the artwork itself barely matters statistically — which is uncomfortable if you care about art for aesthetic reasons. The market isn&rsquo;t pricing what you see. It&rsquo;s pricing what other people have already paid for adjacent work.</p>
<p>On the modelling side, two habits earned their keep. Time-based splits are the only honest way to evaluate price models; random splits give you flattering R² values that don&rsquo;t survive contact with production. And gradient-boosted trees are still the right tool here — XGBoost beats every neural baseline in the paper, and for tabular data at this scale, with this much engineered feature structure, there&rsquo;s no reason to reach for a transformer.</p>
<p>The commercial observations: the interesting opportunity is in emerging markets, where expert pricing is thinnest and algorithmic prediction has the biggest gap to fill. And the crowd is an underused data source. The single biggest feature in the Lee et al. model was an auction house&rsquo;s pre-sale estimate; if a calibrated crowd can produce a comparable signal for the long tail of artists, that&rsquo;s a real opening, both as a model input and as a product in its own right.</p>
<p>The two projects ended up more complementary than I planned. The paper reproduction taught me what predicts prices. The platform work taught me what data structure you&rsquo;d need to put that knowledge to use. The gamification idea ties the two together: the knowledge graph supplies the artworks and historical sales, the guessing game generates a crowd-derived estimate feature, and the model consumes that feature alongside the social signals to produce predictions for artists who would otherwise be invisible to it.</p>
<p>None of this is finished. The paper reproduction is a working prototype with a written report, the platform is a demo with seeded data, and the crowdsourced game only exists as the design notes you just read. But the connecting idea is consistent enough that I think the right shape is reasonably clear: price the artist rather than the artwork, aggregate the crowd rather than relying only on experts, and treat valuation as something the market produces collectively rather than something specialists hand down.</p>
<p>That&rsquo;s the version I&rsquo;d ship next, given the time to build it.</p>
]]></content:encoded>
      <media:content url="https://jared.lynskey.co.nz/en/posts/2026/2026-04-01-art-evaluation-algorithm/featured.jpg" medium="image" type="image/jpeg" />
    </item>
  </channel>
</rss>
