<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <author>
    <name>Vladimir Babin</name>
  </author>
  <generator uri="https://hexo.io/">Hexo</generator>
  <id>https://chiliec.github.io/</id>
  <link href="https://chiliec.github.io/" rel="alternate"/>
  <link href="https://chiliec.github.io/atom.xml" rel="self"/>
  <rights>All rights reserved 2026, Vladimir Babin</rights>
  <subtitle>developer</subtitle>
  <title>Vladimir Babin</title>
  <updated>2026-10-01T14:00:00.000Z</updated>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="sqlite" scheme="https://chiliec.github.io/tags/sqlite/"/>
    <category term="backup" scheme="https://chiliec.github.io/tags/backup/"/>
    <category term="bash" scheme="https://chiliec.github.io/tags/bash/"/>
    <category term="ops" scheme="https://chiliec.github.io/tags/ops/"/>
    <content>
      <![CDATA[<p>One of my servers runs a Claude agent whose entire state is a directory of SQLite databases. Its nightly backup used to stop every container on that volume, tar the directory, encrypt it, upload it, and start everything again. Safe, and about 20 minutes of downtime per night. On 2026-09-29 the backup failed outright, and fixing that turned into removing the stop entirely. <span id="more"></span></p><h2 id="Why-it-failed"><a href="#Why-it-failed" class="headerlink" title="Why it failed"></a>Why it failed</h2><p>The archive had grown from 2.1 GB at the end of August to 11 GB. The script staged the encrypted archive in <code>/tmp</code>, which on that box is a 7.7 GB tmpfs. Age encryption ran out of space, the EXIT trap restarted the containers, and the night’s backup was gone.</p><p>The growth was the agent’s own doing. It had installed a Swift toolchain (3.3 GB) and a pnpm store (3.0 GB) for its coding work inside the data directory. The exclude list already had an entry for the toolchain, but as an exact name, and the installed directory carried a version suffix. Two globs fixed the size:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="string">&#x27;data/hermes/swift-toolchain*&#x27;</span></span><br><span class="line"><span class="string">&#x27;data/hermes/.pnpm-store&#x27;</span></span><br></pre></td></tr></table></figure><p>The next run produced a 4.6 GB archive and succeeded. That was the quick fix. The downtime was still there.</p><h2 id="Why-a-plain-tar-of-SQLite-is-not-a-backup"><a href="#Why-a-plain-tar-of-SQLite-is-not-a-backup" class="headerlink" title="Why a plain tar of SQLite is not a backup"></a>Why a plain tar of SQLite is not a backup</h2><p>A SQLite database in WAL mode is three files on disk: the main <code>.db</code>, the <code>-wal</code> log, and the <code>-shm</code> index. Tar reads them one after another, at slightly different instants. Under a concurrent writer that can capture them mid-checkpoint, which gives you a snapshot that opens fine and is missing or corrupting the last transactions. Stopping the writer is the obvious fix, and it is the one I had.</p><p>The alternative is to ask SQLite for a consistent copy:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">sqlite3 state.db &quot;VACUUM INTO &#x27;/path/to/snapshot.db&#x27;&quot;</span><br></pre></td></tr></table></figure><p><code>VACUUM INTO</code> writes a single self-contained file with every committed WAL frame folded in and no sidecars. It takes a read transaction, so it is safe against a concurrent writer, and the output opens as a normal rollback-journal database. I already used it in the fleet-wide backup script for Kamal apps. The agent’s backup had just never adopted it.</p><h2 id="The-rewrite"><a href="#The-rewrite" class="headerlink" title="The rewrite"></a>The rewrite</h2><p>The new script finds every non-empty <code>.db</code> under the data directory, snapshots each into a staging directory on the root disk, and tells tar to skip the live files:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">STAGE=$(<span class="built_in">mktemp</span> -d <span class="string">&quot;<span class="variable">$PLAN_DIR</span>/.snap.XXXXXX&quot;</span>)</span><br><span class="line">SNAPS=(); SNAP_FAIL=()</span><br><span class="line"><span class="keyword">while</span> IFS= <span class="built_in">read</span> -r -d <span class="string">&#x27;&#x27;</span> db; <span class="keyword">do</span></span><br><span class="line">    snap=<span class="string">&quot;snap/<span class="variable">$&#123;db#data/&#125;</span>&quot;</span></span><br><span class="line">    <span class="built_in">mkdir</span> -p <span class="string">&quot;<span class="variable">$STAGE</span>/<span class="subst">$(dirname <span class="string">&quot;<span class="variable">$snap</span>&quot;</span>)</span>&quot;</span></span><br><span class="line">    <span class="keyword">if</span> sqlite3 -cmd <span class="string">&#x27;.timeout 10000&#x27;</span> <span class="string">&quot;<span class="variable">$db</span>&quot;</span> <span class="string">&quot;VACUUM INTO &#x27;<span class="variable">$STAGE</span>/<span class="variable">$snap</span>&#x27;&quot;</span>; <span class="keyword">then</span></span><br><span class="line">        <span class="built_in">chown</span> --reference=<span class="string">&quot;<span class="variable">$db</span>&quot;</span> <span class="string">&quot;<span class="variable">$STAGE</span>/<span class="variable">$snap</span>&quot;</span></span><br><span class="line">        <span class="built_in">chmod</span> --reference=<span class="string">&quot;<span class="variable">$db</span>&quot;</span> <span class="string">&quot;<span class="variable">$STAGE</span>/<span class="variable">$snap</span>&quot;</span></span><br><span class="line">        TAR_ARGS+=(--exclude=<span class="string">&quot;<span class="variable">$db</span>&quot;</span> --exclude=<span class="string">&quot;<span class="variable">$db</span>-wal&quot;</span> --exclude=<span class="string">&quot;<span class="variable">$db</span>-shm&quot;</span> --exclude=<span class="string">&quot;<span class="variable">$db</span>-journal&quot;</span>)</span><br><span class="line">        SNAPS+=(<span class="string">&quot;<span class="variable">$snap</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">else</span></span><br><span class="line">        <span class="built_in">echo</span> <span class="string">&quot;[backup] WARNING: sqlite snapshot failed for <span class="variable">$db</span> — tarring it raw&quot;</span> &gt;&amp;2</span><br><span class="line">        SNAP_FAIL+=(<span class="string">&quot;<span class="variable">$db</span>&quot;</span>)</span><br><span class="line">    <span class="keyword">fi</span></span><br><span class="line"><span class="keyword">done</span> &lt; &lt;(find data \( <span class="string">&quot;<span class="variable">$&#123;PRUNE[@]&#125;</span>&quot;</span> \) -prune -o -<span class="built_in">type</span> f -name <span class="string">&#x27;*.db&#x27;</span> -size +0 -print0)</span><br></pre></td></tr></table></figure><p>Three details that matter:</p><ul><li><strong><code>.timeout 10000</code></strong> gives a non-WAL database up to ten seconds to wait out a writer’s lock instead of failing immediately.</li><li><strong>Snapshots stage on the root disk</strong>, not in <code>/tmp</code>. The tmpfs is what overflowed. Only the final encrypted archive goes there.</li><li><strong>A failed snapshot is a warning, not a failure.</strong> The raw files get tarred, the names go into the heartbeat message, and I find out in the morning rather than losing the whole night.</li></ul><p>Then one tar reads two trees and archives the snapshots under their original paths, so the restore script did not have to change:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">tar -c <span class="string">&quot;<span class="variable">$&#123;TAR_ARGS[@]&#125;</span>&quot;</span> -C <span class="string">&quot;<span class="variable">$PLAN_DIR</span>&quot;</span> data \</span><br><span class="line">        -C <span class="string">&quot;<span class="variable">$STAGE</span>&quot;</span> --transform=<span class="string">&#x27;s|^snap/|data/|S&#x27;</span> <span class="string">&quot;<span class="variable">$&#123;SNAPS[@]&#125;</span>&quot;</span> \</span><br><span class="line">    | age -r <span class="string">&quot;<span class="variable">$AGE_RECIPIENT</span>&quot;</span> -o <span class="string">&quot;<span class="variable">$ARCHIVE</span>&quot;</span></span><br></pre></td></tr></table></figure><p>The rest of the directory is tarred live. Something will change under tar during a one-minute read, and GNU tar reports that as exit code 1, “some files differ”. The archive is still complete. Only exit code 2 is fatal:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">PIPE_RC=(<span class="string">&quot;<span class="variable">$&#123;PIPESTATUS[@]&#125;</span>&quot;</span>)</span><br><span class="line">TAR_RC=<span class="variable">$&#123;PIPE_RC[0]&#125;</span>; AGE_RC=<span class="variable">$&#123;PIPE_RC[1]&#125;</span></span><br><span class="line"><span class="keyword">if</span> [ <span class="string">&quot;<span class="variable">$TAR_RC</span>&quot;</span> -gt 1 ] || [ <span class="string">&quot;<span class="variable">$AGE_RC</span>&quot;</span> -ne 0 ]; <span class="keyword">then</span></span><br><span class="line">    alert <span class="string">&quot;tar/age failed (tar=<span class="variable">$TAR_RC</span> age=<span class="variable">$AGE_RC</span>)&quot;</span>; <span class="built_in">exit</span> 1</span><br><span class="line"><span class="keyword">fi</span></span><br></pre></td></tr></table></figure><p>I learned that distinction the hard way in August, when treating exit code 1 as fatal turned a harmless warning into zero backups for a night. <code>age</code> has no such nuance. Any non-zero exit means no ciphertext.</p><h2 id="What-it-looks-like-now"><a href="#What-it-looks-like-now" class="headerlink" title="What it looks like now"></a>What it looks like now</h2><p>The dry run on the real box: seven databases snapshotted, about one minute end to end, the agent healthy the whole time, a 4.3 GB archive. I extracted the snapshots from the archive and ran <code>PRAGMA integrity_check</code> on each. All clean. The container stop, start, and the EXIT trap that restarted things on failure are all deleted, which made the script shorter than the version with downtime.</p><p>Two smaller things came out of the same incident. The bootstrap script now installs <code>sqlite3</code> on a fresh box, since the backup depends on the CLI and not on the app’s own bindings. And the exclude list has a comment with the number that matters: keep the archive under about 7 GB, because that is the tmpfs, and the day it grows back past that is the day both bugs return at once.</p><p>Related: <a href="/post/46-apps-on-5-servers-with-Kamal-SOPS-and-age-solo/">46 apps on 5 servers with Kamal, SOPS, and age, solo</a>.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Backing-up-live-SQLite-without-stopping-the-container/</id>
    <link href="https://chiliec.github.io/post/Backing-up-live-SQLite-without-stopping-the-container/"/>
    <published>2026-10-01T14:00:00.000Z</published>
    <summary>
      <![CDATA[<p>One of my servers runs a Claude agent whose entire state is a directory of SQLite databases. Its nightly backup used to stop every container on that volume, tar the directory, encrypt it, upload it, and start everything again. Safe, and about 20 minutes of downtime per night. On 2026-09-29 the backup failed outright, and fixing that turned into removing the stop entirely.]]>
    </summary>
    <title>Backing up live SQLite without stopping the container, with VACUUM INTO</title>
    <updated>2026-10-01T14:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="app-store" scheme="https://chiliec.github.io/tags/app-store/"/>
    <category term="ios" scheme="https://chiliec.github.io/tags/ios/"/>
    <category term="api" scheme="https://chiliec.github.io/tags/api/"/>
    <category term="release" scheme="https://chiliec.github.io/tags/release/"/>
    <content>
      <![CDATA[<p><a href="https://apps.apple.com/us/app/callback-interview-prep/id6796242315">Callback</a> ships without me opening App Store Connect in a browser. A few Ruby scripts sign an ES256 JWT with the OpenSSL stdlib and talk to the API directly: push the listing, attach the build, submit to TestFlight, submit for review. For 0.2.0 every metadata call returned 200, and the review submission still failed. The two things that blocked it were not the thing I spent a session diagnosing. <span id="more"></span></p><h2 id="The-failure"><a href="#The-failure" class="headerlink" title="The failure"></a>The failure</h2><p>Creating the review submission worked. Adding the version to it did not:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">POST /v1/reviewSubmissions            → 201</span><br><span class="line">POST /v1/reviewSubmissionItems        → 409</span><br><span class="line">  STATE_ERROR: appStoreVersion is not in valid state — cannot be reviewed</span><br></pre></td></tr></table></figure><p>Every gate I could read back looked green: export compliance set, age rating set, description, keywords, six screenshots at 1320×2868, review contact, build attached. So I went looking for a gate I could not read and found one: the App Privacy nutrition label. There is no public endpoint for it. Every spelling I tried (<code>/v1/appDataUsages</code>, <code>/v1/appDataUsagePublishState</code>, <code>/v1/apps/{id}/appDataUsagesPublishState</code>) answers <code>PATH_ERROR: resource does not exist</code>, not a 403. I concluded the unpublished label was the blocker.</p><p>It was not. The label was already published.</p><h2 id="Read-the-409-body"><a href="#Read-the-409-body" class="headerlink" title="Read the 409 body"></a>Read the 409 body</h2><p>The next session started by reading the whole error body instead of the first line. The 409 from <code>POST /v1/reviewSubmissionItems</code> carries a <code>meta.associatedErrors</code> map that names every failing gate by resource path. Two entries. Neither was privacy.</p><p><strong>Gate 1: <code>copyright</code>.</strong> It is an attribute of the <code>appStoreVersions</code> record, not of the version localization where description and keywords live. My script patched the localization for every release, every patch succeeded, and the version record never had a copyright string. Nothing complains until submit time, when it surfaces as <code>ENTITY_ERROR.ATTRIBUTE.REQUIRED</code>. The fix is one more PATCH:</p><figure class="highlight ruby"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># `copyright` hangs off the version record, not the localization, so it is easy</span></span><br><span class="line"><span class="comment"># to miss: every metadata PATCH succeeds without it and ASC only surfaces it at</span></span><br><span class="line"><span class="comment"># submit time, as an ENTITY_ERROR.ATTRIBUTE.REQUIRED inside the 409 that</span></span><br><span class="line"><span class="comment"># POST /v1/reviewSubmissionItems returns. It blocked the 0.2.0 submission once.</span></span><br><span class="line">patch_attrs(<span class="string">&quot;/v1/appStoreVersions/<span class="subst">#&#123;version_id&#125;</span>&quot;</span>, <span class="string">&quot;appStoreVersions&quot;</span>, version_id,</span><br><span class="line">            &#123; <span class="symbol">copyright:</span> field(<span class="string">&quot;Copyright&quot;</span>) &#125;, <span class="string">&quot;copyright → <span class="subst">#&#123;field(<span class="string">&#x27;Copyright&#x27;</span>).inspect&#125;</span>&quot;</span>)</span><br></pre></td></tr></table></figure><p>The value comes from the same Markdown listing file as the rest of the metadata, with a 100-character limit added to the validator next to the other field limits.</p><p><strong>Gate 2: pricing.</strong> This one lied harder. <code>GET /v1/apps/{id}/appPriceSchedule</code> returned 200 with <code>baseTerritory: USA</code>, which I read as “pricing is set”. The <code>manualPrices</code> relationship in the same response was empty. A 200 on a container resource means the container exists, not that it has anything in it.</p><p>The runbook I was working from said pricing can only be set in the web UI. That is wrong. It is a single POST with an included price entry whose dates are nil, which means “from now, forever”:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;appPriceSchedules&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;relationships&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;app&quot;</span><span class="punctuation">:</span>           <span class="punctuation">&#123;</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;apps&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;APP_ID&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;baseTerritory&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;territories&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;USA&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">      <span class="attr">&quot;manualPrices&quot;</span><span class="punctuation">:</span>  <span class="punctuation">&#123;</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;appPrices&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;$&#123;new-price-1&#125;&quot;</span> <span class="punctuation">&#125;</span><span class="punctuation">]</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;included&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;appPrices&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;$&#123;new-price-1&#125;&quot;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;attributes&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;startDate&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span><span class="punctuation">,</span> <span class="attr">&quot;endDate&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">null</span></span> <span class="punctuation">&#125;</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;relationships&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">      <span class="attr">&quot;appPricePoint&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;data&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span> <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;appPricePoints&quot;</span><span class="punctuation">,</span> <span class="attr">&quot;id&quot;</span><span class="punctuation">:</span> <span class="string">&quot;PRICE_POINT_ID&quot;</span> <span class="punctuation">&#125;</span> <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">&#125;</span></span><br><span class="line">  <span class="punctuation">&#125;</span><span class="punctuation">]</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>The <code>${new-price-1}</code> placeholder is how JSON:API lets you create the parent and the child in one request. The free price point ID comes from <code>/v1/apps/{id}/appPricePoints?filter[territory]=USA</code>. The <code>/v2/</code> forms of these paths, which some docs still reference, 404.</p><h2 id="Things-the-API-does-that-are-worth-knowing"><a href="#Things-the-API-does-that-are-worth-knowing" class="headerlink" title="Things the API does that are worth knowing"></a>Things the API does that are worth knowing</h2><ul><li><strong>A review submission with no items cannot be deleted.</strong> Both delete and cancel fail with a 403 or 409. It is harmless. Reuse it on the retry instead of creating another.</li><li><strong>Renaming a version record is allowed.</strong> The 0.1.0 record was never released and every uploaded build was 0.2.0, so the script patches <code>versionString</code> on the open record instead of creating a new one. ASC only offers builds whose <code>CFBundleShortVersionString</code> matches the record.</li><li><strong>Attach the build through the relationship endpoint</strong>, <code>PATCH /v1/appStoreVersions/{id}/relationships/build</code>, then read the version back. Do not trust the request succeeding.</li><li><strong>Dry-run the metadata script before touching a version in review.</strong> A real push could disturb a <code>WAITING_FOR_REVIEW</code> record, so the copyright fix was verified with <code>--dry-run --skip-screenshots</code> only and pushed for real on the next release.</li></ul><h2 id="Outcome"><a href="#Outcome" class="headerlink" title="Outcome"></a>Outcome</h2><p>With both gates fixed, the reused submission accepted the version and went to <code>WAITING_FOR_REVIEW</code> on 2026-08-04 with <code>releaseType: AFTER_APPROVAL</code>. Review later bounced it once under guideline 5.2.5 for the word “iOS” in the subtitle. One line changed in the listing file, resubmitted, and it has been live since 2026-08-21.</p><p>The lesson is less about Apple than about my own diagnosis. I spent a session inferring the blocker by elimination across gates I could read, when the response I already had named both gates I could not see. Read the whole error body first.</p><p>Related: <a href="/post/Four-App-Store-apps-in-ten-weeks/">Four App Store apps in ten weeks, solo</a>.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/The-App-Store-Connect-API-accepts-your-metadata-and-rejects-your-submission/</id>
    <link href="https://chiliec.github.io/post/The-App-Store-Connect-API-accepts-your-metadata-and-rejects-your-submission/"/>
    <published>2026-10-01T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p><a href="https://apps.apple.com/us/app/callback-interview-prep/id6796242315">Callback</a> ships without me opening App Store Connect in a browser. A few Ruby scripts sign an ES256 JWT with the OpenSSL stdlib and talk to the API directly: push the listing, attach the build, submit to TestFlight, submit for review. For 0.2.0 every metadata call returned 200, and the review submission still failed. The two things that blocked it were not the thing I spent a session diagnosing.]]>
    </summary>
    <title>The App Store Connect API accepts your metadata and rejects your submission</title>
    <updated>2026-10-01T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="swift" scheme="https://chiliec.github.io/tags/swift/"/>
    <content>
      <![CDATA[<p>In 2015 I wrote a short Russian post about getting an element’s index while looping over an array in Swift. The answer back then was the global <code>enumerate(array)</code>, which Swift 2 turned into the method <code>array.enumerate()</code>. Swift is now at 6.3, the method is called <code>enumerated()</code>, and the old post missed the important part: <code>enumerated()</code> doesn’t give you indices. <span id="more"></span></p><h2 id="The-easy-case"><a href="#The-easy-case" class="headerlink" title="The easy case"></a>The easy case</h2><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> array <span class="operator">=</span> [<span class="string">&quot;zero&quot;</span>, <span class="string">&quot;one&quot;</span>, <span class="string">&quot;two&quot;</span>, <span class="string">&quot;three&quot;</span>]</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> (i, value) <span class="keyword">in</span> array.enumerated() &#123;</span><br><span class="line">    <span class="built_in">print</span>(i, value)</span><br><span class="line">&#125;</span><br><span class="line"><span class="comment">// 0 zero</span></span><br><span class="line"><span class="comment">// 1 one</span></span><br><span class="line"><span class="comment">// 2 two</span></span><br><span class="line"><span class="comment">// 3 three</span></span><br></pre></td></tr></table></figure><p>For an <code>Array</code> starting at 0, the numbers are also valid indices, so this works and it’s what most code does.</p><h2 id="Where-it-breaks"><a href="#Where-it-breaks" class="headerlink" title="Where it breaks"></a>Where it breaks</h2><p><code>enumerated()</code> counts from zero, whatever the collection is. The numbers are <em>offsets</em>. Take a slice:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> tail <span class="operator">=</span> array[<span class="number">2</span><span class="operator">...</span>]   <span class="comment">// ArraySlice, indices 2 and 3</span></span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> (i, value) <span class="keyword">in</span> tail.enumerated() &#123;</span><br><span class="line">    <span class="built_in">print</span>(i, value)</span><br><span class="line">&#125;</span><br><span class="line"><span class="comment">// 0 two</span></span><br><span class="line"><span class="comment">// 1 three</span></span><br></pre></td></tr></table></figure><p>A slice keeps its parent’s indices, so <code>tail[0]</code> crashes. If you use the <code>i</code> from <code>enumerated()</code> to index back into a slice, the code works in tests on full arrays and crashes on the first slice.</p><h2 id="Indices-done-properly"><a href="#Indices-done-properly" class="headerlink" title="Indices, done properly"></a>Indices, done properly</h2><p>Pair each element with its real index:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> (i, value) <span class="keyword">in</span> <span class="built_in">zip</span>(tail.indices, tail) &#123;</span><br><span class="line">    <span class="built_in">print</span>(i, value)</span><br><span class="line">&#125;</span><br><span class="line"><span class="comment">// 2 two</span></span><br><span class="line"><span class="comment">// 3 three</span></span><br></pre></td></tr></table></figure><p>Or loop over the indices directly when you need to subscript:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> i <span class="keyword">in</span> tail.indices &#123;</span><br><span class="line">    <span class="built_in">print</span>(i, tail[i])</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>zip(c.indices, c)</code> works for any collection, including those whose indices aren’t integers at all, like <code>String</code>:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">let</span> s <span class="operator">=</span> <span class="string">&quot;héllo&quot;</span></span><br><span class="line"><span class="keyword">for</span> (i, c) <span class="keyword">in</span> <span class="built_in">zip</span>(s.indices, s) &#123;</span><br><span class="line">    <span class="comment">// i is a String.Index, s[i] == c</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h2 id="The-rule"><a href="#The-rule" class="headerlink" title="The rule"></a>The rule</h2><ul><li>Want a counter (“row 1, row 2…”)? Use <code>enumerated()</code>.</li><li>Want to subscript the collection? Use <code>indices</code> or <code>zip(c.indices, c)</code>.</li></ul><p>All snippets checked with Swift 6.3.1.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Array-indices-in-Swift-ten-years-later/</id>
    <link href="https://chiliec.github.io/post/Array-indices-in-Swift-ten-years-later/"/>
    <published>2026-09-30T19:00:00.000Z</published>
    <summary>
      <![CDATA[<p>In 2015 I wrote a short Russian post about getting an element’s index while looping over an array in Swift. The answer back then was the global <code>enumerate(array)</code>, which Swift 2 turned into the method <code>array.enumerate()</code>. Swift is now at 6.3, the method is called <code>enumerated()</code>, and the old post missed the important part: <code>enumerated()</code> doesn’t give you indices.]]>
    </summary>
    <title>Array indices in Swift, ten years later: enumerated() gives you offsets</title>
    <updated>2026-09-30T19:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="app-store" scheme="https://chiliec.github.io/tags/app-store/"/>
    <category term="github-actions" scheme="https://chiliec.github.io/tags/github-actions/"/>
    <category term="ios" scheme="https://chiliec.github.io/tags/ios/"/>
    <category term="kotlin-multiplatform" scheme="https://chiliec.github.io/tags/kotlin-multiplatform/"/>
    <category term="fastlane" scheme="https://chiliec.github.io/tags/fastlane/"/>
    <content>
      <![CDATA[<p>Kotlin Multiplatform tutorials end at “it runs in the simulator”. Getting a Compose Multiplatform app onto TestFlight and into the App Store from CI is where the real work begins. This is the pipeline I built for <a href="https://apps.apple.com/us/app/slovo-russian-phrases/id6796900036">SLOVO</a> and then copied almost verbatim into <a href="https://apps.apple.com/us/app/balisurf/id6801866165">BaliSurf</a>: App Store Connect API key, fastlane, GitHub Actions, and almost no Apple ID logins or 2FA prompts. “Almost” is explained at the end. <span id="more"></span></p><h2 id="The-Xcode-project-is-committed"><a href="#The-Xcode-project-is-committed" class="headerlink" title="The Xcode project is committed"></a>The Xcode project is committed</h2><p>The standard KMP&#x2F;Compose Multiplatform layout has a real Xcode project in <code>iosApp/iosApp.xcodeproj</code>, and I keep it in git. A stable <code>.pbxproj</code> is what fastlane edits: build numbers, signing settings, provisioning profile.</p><p>The bridge between Xcode and Kotlin is one Run Script build phase that runs before compilation:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">cd</span> <span class="string">&quot;<span class="variable">$SRCROOT</span>/..&quot;</span></span><br><span class="line">./gradlew :composeApp:embedAndSignAppleFrameworkForXcode</span><br></pre></td></tr></table></figure><p>Gradle builds the shared <code>ComposeApp.framework</code> (static) for the architecture Xcode is building, signs it and puts it where Xcode expects it. The Kotlin targets are just <code>iosArm64()</code> and <code>iosSimulatorArm64()</code> — no <code>iosX64</code>, since recent Compose Multiplatform versions no longer ship Intel-simulator artifacts.</p><p><strong>Pitfall #1:</strong> if you’ve only ever run the simulator, <code>iosArm64</code> has never been compiled. The first device archive is the first real test of that target. Build it early, not on release day.</p><h2 id="Authentication-one-API-key"><a href="#Authentication-one-API-key" class="headerlink" title="Authentication: one API key"></a>Authentication: one API key</h2><p>Everything talks to App Store Connect through an API key (a <code>.p8</code> file from <em>Users and Access → Integrations</em>). On CI it comes from secrets; locally it’s a gitignored file:</p><figure class="highlight ruby"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">asc_api_key</span></span><br><span class="line">  <span class="keyword">if</span> <span class="variable constant_">ENV</span>[<span class="string">&quot;ASC_KEY_P8&quot;</span>] &amp;&amp; !<span class="variable constant_">ENV</span>[<span class="string">&quot;ASC_KEY_P8&quot;</span>].empty?</span><br><span class="line">    app_store_connect_api_key(</span><br><span class="line">      <span class="symbol">key_id:</span> <span class="variable constant_">ENV</span>.fetch(<span class="string">&quot;ASC_KEY_ID&quot;</span>),</span><br><span class="line">      <span class="symbol">issuer_id:</span> <span class="variable constant_">ENV</span>.fetch(<span class="string">&quot;ASC_ISSUER_ID&quot;</span>),</span><br><span class="line">      <span class="symbol">key_content:</span> <span class="variable constant_">ENV</span>.fetch(<span class="string">&quot;ASC_KEY_P8&quot;</span>),</span><br><span class="line">      <span class="symbol">is_key_content_base64:</span> <span class="literal">true</span>,</span><br><span class="line">      <span class="symbol">in_house:</span> <span class="literal">false</span></span><br><span class="line">    )</span><br><span class="line">  <span class="keyword">else</span></span><br><span class="line">    key_path = <span class="title class_">Dir</span>[<span class="title class_">File</span>.expand_path(<span class="string">&quot;../AuthKey_*.p8&quot;</span>, __dir__)].first</span><br><span class="line">    app_store_connect_api_key(</span><br><span class="line">      <span class="symbol">key_id:</span> <span class="variable constant_">ENV</span>.fetch(<span class="string">&quot;ASC_KEY_ID&quot;</span>, <span class="title class_">File</span>.basename(key_path)[<span class="regexp">/AuthKey_(.+)\.p8/</span>, <span class="number">1</span>]),</span><br><span class="line">      <span class="symbol">issuer_id:</span> <span class="variable constant_">ENV</span>.fetch(<span class="string">&quot;ASC_ISSUER_ID&quot;</span>),</span><br><span class="line">      <span class="symbol">key_filepath:</span> key_path,</span><br><span class="line">      <span class="symbol">in_house:</span> <span class="literal">false</span></span><br><span class="line">    )</span><br><span class="line">  <span class="keyword">end</span></span><br><span class="line"><span class="keyword">end</span></span><br></pre></td></tr></table></figure><p>No <code>match</code>, no certificates repo, no Apple ID session cookies that expire every month.</p><h2 id="The-lanes"><a href="#The-lanes" class="headerlink" title="The lanes"></a>The lanes</h2><table><thead><tr><th>Lane</th><th>What it does</th></tr></thead><tbody><tr><td><code>signing_assets</code></td><td>One-time, local: creates the distribution certificate and App Store profile via the API</td></tr><tr><td><code>beta</code></td><td>Builds, signs and uploads to TestFlight</td></tr><tr><td><code>release</code></td><td>Pushes metadata and screenshots, doesn’t submit</td></tr><tr><td><code>submit</code></td><td>Attaches the latest build and submits for review</td></tr></tbody></table><h3 id="Build-numbers-come-from-the-store"><a href="#Build-numbers-come-from-the-store" class="headerlink" title="Build numbers come from the store"></a>Build numbers come from the store</h3><p>Nothing to commit, nothing to forget. The build number is “highest build on TestFlight + 1”:</p><figure class="highlight ruby"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">next_build = latest_testflight_build_number(</span><br><span class="line">  <span class="symbol">api_key:</span> api_key,</span><br><span class="line">  <span class="symbol">app_identifier:</span> <span class="variable constant_">APP_IDENTIFIER</span>,</span><br><span class="line">  <span class="symbol">initial_build_number:</span> <span class="number">0</span>   <span class="comment"># a brand-new app record has no builds yet</span></span><br><span class="line">) + <span class="number">1</span></span><br><span class="line">increment_build_number_in_xcodeproj(</span><br><span class="line">  <span class="symbol">build_number:</span> next_build.to_s,</span><br><span class="line">  <span class="symbol">xcodeproj:</span> <span class="variable constant_">XCODEPROJ</span>,</span><br><span class="line">  <span class="symbol">target:</span> <span class="variable constant_">SCHEME</span></span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>The Android side uses the same idea: the next <code>versionCode</code> is computed from what Google Play already has on all tracks. The store is the source of truth; git doesn’t need to know.</p><h3 id="Manual-signing-on-purpose"><a href="#Manual-signing-on-purpose" class="headerlink" title="Manual signing, on purpose"></a>Manual signing, on purpose</h3><p><code>beta</code> fetches the App Store provisioning profile through the API key, switches the project to manual signing with that profile, and exports with <code>export_method: &quot;app-store&quot;</code>.</p><p><strong>Pitfall #2:</strong> an <code>ExportOptions.plist</code> with <code>signingStyle: automatic</code> makes Xcode try cloud signing, which fails locally with a confusing “Cloud signing permission error”. Manual signing with an explicit profile name makes the build reproducible on any machine.</p><h3 id="Export-compliance-answered-once"><a href="#Export-compliance-answered-once" class="headerlink" title="Export compliance, answered once"></a>Export compliance, answered once</h3><p>Every build otherwise asks about encryption. Answer it in <code>Info.plist</code>:</p><figure class="highlight xml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">key</span>&gt;</span>ITSAppUsesNonExemptEncryption<span class="tag">&lt;/<span class="name">key</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;<span class="name">false</span>/&gt;</span></span><br></pre></td></tr></table></figure><p>and in the <code>submit</code> lane’s <code>submission_information</code> (<code>export_compliance_uses_encryption: false</code>, <code>add_id_info_uses_idfa: false</code>).</p><h2 id="CI-GitHub-Actions"><a href="#CI-GitHub-Actions" class="headerlink" title="CI: GitHub Actions"></a>CI: GitHub Actions</h2><p><code>ios-testflight.yml</code> runs on a tag <code>v*</code> or manually:</p><ul><li><code>macos-15</code> runner, the newest installed Xcode (<code>ls -d /Applications/Xcode*.app | sort -V | tail -n1</code>), JDK 21 with Gradle cache, Ruby with bundler cache.</li><li>A dedicated step creates a temporary keychain with <code>security create-keychain</code>, imports Apple’s WWDR intermediate certificates and the distribution certificate from a secret, and sets the key partition list so <code>codesign</code> can use it without a prompt.</li><li>Then <code>fastlane ios beta</code> with <code>SKIP_KEYCHAIN_SETUP=1</code>, so fastlane doesn’t redo the keychain setup CI has already done.</li></ul><p>Secrets, by name: <code>ASC_KEY_ID</code>, <code>ASC_ISSUER_ID</code>, <code>ASC_KEY_P8</code>, <code>DIST_CERT_P12</code>, <code>DIST_CERT_PASSWORD</code>.</p><p>BaliSurf added a second workflow for <code>release</code> and <code>submit</code>. Those lanes only call the App Store Connect API — no Xcode needed — so they run on a cheap Linux runner.</p><h2 id="Metadata-lives-in-git"><a href="#Metadata-lives-in-git" class="headerlink" title="Metadata lives in git"></a>Metadata lives in git</h2><p>Descriptions, keywords, release notes, copyright and screenshots for both stores live in <code>store-assets/metadata/{ios,android}</code> and <code>store-assets/screenshots/{ios,android}</code>. Each run, fastlane copies them into the layout <code>deliver</code> and <code>supply</code> expect. One source of truth, reviewed like code.</p><p><strong>Pitfall #3:</strong> <code>copyright</code> is required. A missing copyright can leave a submission stuck, and the error doesn’t make that obvious.</p><p><strong>Pitfall #4:</strong> <code>deliver</code> with <code>overwrite_screenshots</code> could upload every screenshot twice when it retried after a timeout — identical checksums, duplicates in every locale. BaliSurf switched to <code>sync_screenshots</code> (still behind fastlane’s <code>FASTLANE_ENABLE_BETA_DELIVER_SYNC_SCREENSHOTS=1</code> flag), and a small script now checks for duplicates.</p><h2 id="Two-bugs-a-review-caught"><a href="#Two-bugs-a-review-caught" class="headerlink" title="Two bugs a review caught"></a>Two bugs a review caught</h2><p>The Android half of the pipeline got a review before merge, and it found two bugs worth sharing:</p><ol><li><strong>A dry run that could never pass.</strong> The build lane hard-failed when <code>keystore.properties</code> was missing, so the “build without credentials” check the PR itself described could never go green. Now a missing keystore is a warning for plain builds and fatal only for the Play upload lane.</li><li><strong>Two builds per release.</strong> The workflow ran the build lane <em>and</em> the upload lane, which rebuilds. Every release took two full Gradle builds, and the archived artifact had a different <code>versionCode</code> from the one sent to Play. Now the workflow picks one lane and archives what it actually uploaded.</li></ol><p>A related lesson: keystore passwords are written with <code>printf</code>, not an unquoted heredoc. A heredoc runs command substitution, and a <code>$</code> in a password is all it takes.</p><h2 id="The-“almost”-in-“no-Apple-ID”"><a href="#The-“almost”-in-“no-Apple-ID”" class="headerlink" title="The “almost” in “no Apple ID”"></a>The “almost” in “no Apple ID”</h2><p>The API key covers almost everything. The exceptions I hit:</p><ul><li><strong>App Privacy.</strong> Publishing the privacy answers (“Data Not Collected”) isn’t available with an API key — the endpoint returns 404. BaliSurf has a separate <code>privacy</code> lane that logs in with an Apple ID and 2FA. It runs once per app, not per release.</li><li><strong>Review contact.</strong> A brand-new app record rejects review contact details without a phone number. Fill it in once.</li><li><strong>Google Play’s draft wall.</strong> On Android, a new app stays a “draft” until someone publishes the first release in the Play Console web UI. Until then, the API only accepts draft releases, so you can’t start closed testing from fastlane alone.</li></ul><p>Everything after that first setup — every build, every TestFlight upload, every metadata change — is one command or one tag push.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Shipping-a-Kotlin-Multiplatform-app-to-the-App-Store-with-fastlane/</id>
    <link href="https://chiliec.github.io/post/Shipping-a-Kotlin-Multiplatform-app-to-the-App-Store-with-fastlane/"/>
    <published>2026-09-30T18:00:00.000Z</published>
    <summary>
      <![CDATA[<p>Kotlin Multiplatform tutorials end at “it runs in the simulator”. Getting a Compose Multiplatform app onto TestFlight and into the App Store from CI is where the real work begins. This is the pipeline I built for <a href="https://apps.apple.com/us/app/slovo-russian-phrases/id6796900036">SLOVO</a> and then copied almost verbatim into <a href="https://apps.apple.com/us/app/balisurf/id6801866165">BaliSurf</a>: App Store Connect API key, fastlane, GitHub Actions, and almost no Apple ID logins or 2FA prompts. “Almost” is explained at the end.]]>
    </summary>
    <title>Shipping a Kotlin Multiplatform app to the App Store with an API key and fastlane</title>
    <updated>2026-09-30T18:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="app-store" scheme="https://chiliec.github.io/tags/app-store/"/>
    <category term="api" scheme="https://chiliec.github.io/tags/api/"/>
    <category term="hexo" scheme="https://chiliec.github.io/tags/hexo/"/>
    <content>
      <![CDATA[<p>The <a href="/apps/">apps page</a> on this site is built from Apple’s public iTunes Lookup API when the site deploys. That’s one request, no key, and JSON back. The catch: the response sits behind Akamai with a one-day cache, so a new release can take up to a day to show up. <span id="more"></span></p><h2 id="The-request"><a href="#The-request" class="headerlink" title="The request"></a>The request</h2><p>Every app from one developer account:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://itunes.apple.com/lookup?id=1557125814&amp;entity=software&amp;limit=200</span><br></pre></td></tr></table></figure><p><code>id</code> is the developer (artist) ID from your App Store URL. <code>entity=software</code> returns the apps as well as the developer record, and <code>limit=200</code> raises the default page size. Each result has the name, icon, screenshots, version, genre, minimum OS and release dates. That covers a portfolio page.</p><h2 id="The-cache"><a href="#The-cache" class="headerlink" title="The cache"></a>The cache</h2><p>Check the headers:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">$ curl -sD - -o /dev/null &quot;https://itunes.apple.com/lookup?id=1557125814&amp;entity=software&amp;limit=200&quot; | grep -iE &quot;cache&quot;</span><br><span class="line">cache-control: max-age=86400</span><br><span class="line">x-cache: TCP_MISS from a23-219-204-163.deploy.akamaitechnologies.com ...</span><br></pre></td></tr></table></figure><p>Run the same request again a few seconds later:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">cache-control: max-age=86394</span><br><span class="line">x-cache: TCP_HIT from a23-219-204-163.deploy.akamaitechnologies.com ...</span><br></pre></td></tr></table></figure><p><code>max-age=86400</code> is 24 hours, and the second request is a <code>TCP_HIT</code>: the edge node answered and Apple’s servers never saw it. That’s fine for an app page that changes once a month. It’s not fine when you ship an app in the morning and deploy the site right after, because the build can get the list from before the release. BaliSurf went live the same day I wrote this script, so I needed the fix straight away.</p><h2 id="The-fix"><a href="#The-fix" class="headerlink" title="The fix"></a>The fix</h2><p>Akamai keys the cache on the full URL, query string included, and the Lookup API ignores parameters it doesn’t know. So add one that’s different on every request:</p><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="variable constant_">URL_</span> =</span><br><span class="line">  <span class="string">`https://itunes.apple.com/lookup?id=1557125814&amp;entity=software&amp;limit=200&amp;_=<span class="subst">$&#123;<span class="built_in">Date</span>.now()&#125;</span>`</span>;</span><br></pre></td></tr></table></figure><p>Every deploy is now a cache miss and gets the current list from Apple.</p><h2 id="The-rest-of-the-script"><a href="#The-rest-of-the-script" class="headerlink" title="The rest of the script"></a>The rest of the script</h2><p>It’s about 50 lines of Node with no dependencies (built-in <code>fetch</code>), and it runs in the GitHub Actions workflow before <code>hexo generate</code>. It writes <code>source/_data/apps.json</code>, and Hexo templates read that as <code>site.data.apps</code>. Three details worth copying:</p><ul><li><strong>Never fail the build.</strong> The JSON file is committed. If Apple is down or the response looks wrong (no apps, an app with no screenshots), the script logs the error, keeps the committed file and exits 0. The site deploys anyway with slightly older data.</li><li><strong>Smaller screenshots.</strong> Lookup returns fixed-size thumbnails like <code>.../392x696bb.jpg</code>. Apple’s image CDN reads the size from the last path segment, so replacing it with <code>480x0w.webp</code> gives a 480px-wide WebP with the same aspect ratio. That change fixed the mobile LCP on the home page.</li><li><strong>Clean store links.</strong> <code>trackViewUrl</code> ends with an affiliate-style <code>?uo=4</code>. Strip it.</li></ul><figure class="highlight js"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">const</span> <span class="title function_">shot</span> = (<span class="params">u</span>) =&gt; u.<span class="title function_">replace</span>(<span class="regexp">/\/[^/]+$/</span>, <span class="string">&#x27;/480x0w.webp&#x27;</span>);</span><br><span class="line"><span class="keyword">const</span> url = r.<span class="property">trackViewUrl</span>.<span class="title function_">replace</span>(<span class="regexp">/\?uo=\d+$/</span>, <span class="string">&#x27;&#x27;</span>);</span><br></pre></td></tr></table></figure><p>That’s all of it: one public endpoint, one cache-busting parameter, and a committed fallback file.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/The-iTunes-Lookup-API-caches-for-a-day/</id>
    <link href="https://chiliec.github.io/post/The-iTunes-Lookup-API-caches-for-a-day/"/>
    <published>2026-09-30T18:00:00.000Z</published>
    <summary>
      <![CDATA[<p>The <a href="/apps/">apps page</a> on this site is built from Apple’s public iTunes Lookup API when the site deploys. That’s one request, no key, and JSON back. The catch: the response sits behind Akamai with a one-day cache, so a new release can take up to a day to show up.]]>
    </summary>
    <title>The iTunes Lookup API caches for a day, and a query param fixes it</title>
    <updated>2026-09-30T18:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="claude-code" scheme="https://chiliec.github.io/tags/claude-code/"/>
    <category term="ai" scheme="https://chiliec.github.io/tags/ai/"/>
    <category term="workflow" scheme="https://chiliec.github.io/tags/workflow/"/>
    <content>
      <![CDATA[<p>The last few posts — <a href="/post/Redesigning-my-site-with-20-AI-agents/">the site redesign</a>, <a href="/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/">ClaudeBar</a>, <a href="/post/Four-App-Store-apps-in-ten-weeks/">four App Store apps</a> — all mention the same thing in passing: “a spec, a plan, then agents”. This post is that workflow, end to end. Since April 2026 it has produced 406 specs, 444 plans and 1,171 session notes across 99 projects, so it’s not a theory. <span id="more"></span></p><p>The tool is Claude Code. The workflow has four steps and one piece of infrastructure.</p><h2 id="1-Spec-decide-what-“done”-means"><a href="#1-Spec-decide-what-“done”-means" class="headerlink" title="1. Spec: decide what “done” means"></a>1. Spec: decide what “done” means</h2><p>Every non-trivial piece of work starts as a conversation that ends in a short design document: goal, scope, what’s explicitly <em>out</em> of scope, which files change, and how we’ll know it’s done. For the redesign it was 89 lines.</p><p>This is where I spend my attention. The spec is where I choose between options (often from rendered mockups, not descriptions), cut scope, and write down constraints the agents can’t guess — “no new npm dependencies”, “motion only on three pages”, “copy may only use facts from the CV”.</p><h2 id="2-Plan-small-tasks-each-with-its-own-check"><a href="#2-Plan-small-tasks-each-with-its-own-check" class="headerlink" title="2. Plan: small tasks, each with its own check"></a>2. Plan: small tasks, each with its own check</h2><p>The spec becomes a plan: a list of tasks, each small enough for one agent in one go. Every task in a good plan has the same shape:</p><ol><li><strong>A failing check</strong> — a command that fails now and should pass after the task, with its expected output.</li><li><strong>The change</strong> — which files, often with the actual code.</li><li><strong>The same check again</strong>, now expected to pass.</li><li><strong>A commit</strong>, with the exact message.</li></ol><p>On top sits a short list of global constraints that must hold after <em>every</em> task. The redesign plan was 1,180 lines for 9 tasks. That sounds like a lot of writing, but the agents write most of it; my job is to read it and push back before any code exists — the cheapest moment to change your mind.</p><h2 id="3-Execute-in-a-fresh-session"><a href="#3-Execute-in-a-fresh-session" class="headerlink" title="3. Execute in a fresh session"></a>3. Execute in a fresh session</h2><p>Here’s a rule from my global <code>CLAUDE.md</code>: small tasks run inline, but for a large plan, <strong>stop before implementing</strong>. Save the plan, update the project notes, end the session, and start the implementation in a new one.</p><p>Two reasons. The long design conversation is expensive to carry around — every message re-reads the whole history, as I found out <a href="/post/How-Claude-Code-1M-context-burns-through-limits/">the hard way</a>. And a fresh session that knows <em>only</em> the plan is a good test of the plan: if it can’t execute from the document alone, the document isn’t done.</p><h2 id="4-Implement-and-review-with-subagents"><a href="#4-Implement-and-review-with-subagents" class="headerlink" title="4. Implement and review with subagents"></a>4. Implement and review with subagents</h2><p>The implementation session doesn’t write code itself. For each task it starts an implementer subagent, then a separate reviewer subagent that checks the result against the spec and the plan. At the end, one more reviewer looks at the whole branch.</p><p>Models are picked by the kind of work, also written down in <code>CLAUDE.md</code>:</p><ul><li><strong>Searching</strong> → the cheapest model.</li><li><strong>Mechanical edits, summaries, log triage</strong> → the mid-tier model.</li><li><strong>Reasoning that actually needs it</strong> — the final review, a hard bug → the strongest model.</li></ul><p>The whole-branch review is the one that earns its cost. In the redesign, the per-task reviewers passed everything; the final reviewer found the two real bugs, both in how two tasks interacted.</p><h2 id="The-missing-piece-memory"><a href="#The-missing-piece-memory" class="headerlink" title="The missing piece: memory"></a>The missing piece: memory</h2><p>Agents start every session from zero. Without memory, each new session re-discovers the same project, re-asks the same questions and repeats the same mistakes. I use two layers to fix that.</p><h3 id="Project-notes-obsidian-memory"><a href="#Project-notes-obsidian-memory" class="headerlink" title="Project notes: obsidian-memory"></a>Project notes: obsidian-memory</h3><p><a href="https://github.com/chiliec/obsidian-memory">obsidian-memory</a> is a small Claude Code plugin I wrote. It’s two hooks:</p><ul><li><strong>When a session ends</strong>, it reads the transcript and, if there were at least four meaningful turns, runs one small, cheap model call that writes two notes into my Obsidian vault: an immutable note for that session, and an updated <code>SUMMARY.md</code> for the project — goal, current state, key files, recent decisions, open tasks, and “context to resume”.</li><li><strong>When a session starts</strong>, it loads that project’s <code>SUMMARY.md</code> into the context.</li></ul><p>So a new session starts by knowing that the redesign is merged, which CV variants exist, and what’s still open. Specs and plans live next to the summaries, in the same vault, so they’re searchable and linked.</p><p>One design decision I’d repeat: the summarizer treats the transcript as <strong>untrusted input</strong>. A transcript can contain anything — web pages, tool output, text someone crafted to look like instructions. So the child process that writes the notes is allowed to use exactly one tool, <code>Write</code>. No shell, no reading other files, no network. If something in a transcript tries to hijack it, there’s nothing to hijack it with.</p><h3 id="Lessons-feedback-memories"><a href="#Lessons-feedback-memories" class="headerlink" title="Lessons: feedback memories"></a>Lessons: feedback memories</h3><p>The second layer is Claude Code’s own per-project memory: small files, one fact each. The most valuable kind is <em>feedback</em> — a correction I gave once, with the reason, so I don’t have to give it again. Two real examples:</p><ul><li><strong>“Find every call site before shipping.”</strong> Twice in one day a fix shipped after finding only the first matching call, or was declared “verified” from evidence that couldn’t tell it apart from another explanation. The rule now: search every spelling of the API before naming the cause, and don’t claim “verified” if the evidence is ambiguous.</li><li><strong>“Plain UI over decoration.”</strong> I kept rejecting decorative additions — a coloured ring, a gradient background — in favour of system colours and native materials. The rule now: don’t add styling nobody asked for.</li></ul><h2 id="What-I-still-do-myself"><a href="#What-I-still-do-myself" class="headerlink" title="What I still do myself"></a>What I still do myself</h2><p>Looking at this workflow from the outside, it can seem like I’ve automated myself away. The opposite happened. My work moved to both ends:</p><ul><li><strong>Before the code</strong>: choosing the direction, writing down what “done” means, deciding what’s out of scope.</li><li><strong>After the code</strong>: the questions agents escalate, the bugs that only show up on a real device, and the final call on every merge.</li></ul><p>The agents are very good at following a clear plan and at catching each other’s mistakes. They’re only as good as the plan — and the plan is still my job.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/How-I-work-with-AI-agents/</id>
    <link href="https://chiliec.github.io/post/How-I-work-with-AI-agents/"/>
    <published>2026-09-30T17:00:00.000Z</published>
    <summary>
      <![CDATA[<p>The last few posts — <a href="/post/Redesigning-my-site-with-20-AI-agents/">the site redesign</a>, <a href="/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/">ClaudeBar</a>, <a href="/post/Four-App-Store-apps-in-ten-weeks/">four App Store apps</a> — all mention the same thing in passing: “a spec, a plan, then agents”. This post is that workflow, end to end. Since April 2026 it has produced 406 specs, 444 plans and 1,171 session notes across 99 projects, so it’s not a theory.]]>
    </summary>
    <title>How I work with AI agents: specs, plans, and a memory that survives the session</title>
    <updated>2026-09-30T17:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="macos" scheme="https://chiliec.github.io/tags/macos/"/>
    <category term="swift" scheme="https://chiliec.github.io/tags/swift/"/>
    <category term="claude-code" scheme="https://chiliec.github.io/tags/claude-code/"/>
    <category term="swiftui" scheme="https://chiliec.github.io/tags/swiftui/"/>
    <content>
      <![CDATA[<p><a href="https://github.com/chiliec/ClaudeBar">ClaudeBar</a> is a tiny macOS app: an icon in the menu bar that shows how much of your Claude usage limit you have left. The idea fits in one sentence. Shipping it properly — signed, notarized, auto-updating, installable with Homebrew — took 193 commits and 32 GitHub releases between April and September 2026. This is about the part between “it works on my Mac” and “you can install it”. <span id="more"></span></p><h2 id="Why"><a href="#Why" class="headerlink" title="Why"></a>Why</h2><p>I work in Claude Code all day, and <a href="/post/How-Claude-Code-1M-context-burns-through-limits/">limits are real</a>. Checking usage meant opening a browser tab. I wanted a number in the menu bar, the way you glance at the battery.</p><h2 id="Getting-the-data"><a href="#Getting-the-data" class="headerlink" title="Getting the data"></a>Getting the data</h2><p>The first version read the <code>sessionKey</code> cookie from claude.ai and called the same private endpoint the website uses. It worked, and it was fragile: cookies expire, and pasting a cookie into an app is not something I want to ask anyone to do.</p><p>In August I moved it to OAuth. You click “Sign in”, the browser opens, you approve, and the app receives tokens through a one-shot local callback. Then it asks for usage with a normal bearer token.</p><p>Two details from that migration that cost me an evening each:</p><ul><li><strong>The callback port.</strong> I assumed a fixed port for the local redirect; the server rejected it. The real flow uses a port the OS assigns at random, so the app opens an <code>NWListener</code> on port 0 and builds the redirect URL from whatever it got.</li><li><strong><code>NWListener</code> is picky about order.</strong> Both <code>stateUpdateHandler</code> and <code>newConnectionHandler</code> must be set <em>before</em> <code>.start()</code>. Otherwise the listener starts and quietly never delivers a connection.</li></ul><p>Tokens — access plus a rotating refresh token — live in one Keychain item as a small JSON blob. The app refreshes them five minutes before expiry, and on a <code>401</code> it refreshes once and retries once. The same Keychain blob holds several accounts, so you can switch between them.</p><h2 id="Architecture-small-on-purpose"><a href="#Architecture-small-on-purpose" class="headerlink" title="Architecture: small on purpose"></a>Architecture: small on purpose</h2><ul><li><strong>SwiftUI, macOS 14+.</strong> Around 3,000 lines of app code and 2,000 lines of tests (145 of them).</li><li><strong>Two SPM targets.</strong> <code>ClaudeBarUI</code> is a library with all the models, services and views; <code>ClaudeBar</code> is a thin <code>@main</code> executable. The split exists for one reason: SwiftUI <code>#Preview</code> doesn’t work inside an executable target.</li><li><strong>Three layers.</strong> Views → an <code>@Observable</code> <code>AppState</code> → stateless services (API client, OAuth, account store, Keychain). Polling every 5 minutes, manual refresh on click.</li><li><strong>Two dependencies.</strong> Sparkle for updates, ViewInspector only in tests.</li></ul><p>One deliberate non-choice: <strong>no <code>MenuBarExtra</code></strong>. It’s the obvious SwiftUI API for menu bar apps, but it gives no control over where the panel appears, and the panel drifted every time the label’s width changed. So the status item and its panel are built by hand in an <code>AppDelegate</code>, with a fixed-width status item the panel hangs from.</p><h2 id="The-release-pipeline"><a href="#The-release-pipeline" class="headerlink" title="The release pipeline"></a>The release pipeline</h2><p>A Mac app outside the App Store must be signed with a Developer ID and notarized by Apple, or Gatekeeper refuses to open it. My <code>release.sh</code> does it in a strict order:</p><ol><li>Build, sign with hardened runtime.</li><li>Notarize with <code>notarytool</code>, staple the ticket to the <code>.app</code>.</li><li>Zip the <strong>stapled</strong> bundle — with <code>ditto</code>, not <code>zip -r</code>, which mangles the symlinks inside the bundle.</li><li>Sign the zip for Sparkle, compute the SHA-256, write the appcast entry.</li><li>Tag, upload the GitHub release, bump the Homebrew cask.</li><li>Fail the whole release if <code>spctl</code> doesn’t report “Notarized Developer ID”.</li></ol><p>It runs locally, on purpose. CI runs the tests on every push, but I decided against notarizing in CI: that would mean exporting the signing certificate, the notarization credentials and the Sparkle private key into GitHub secrets. For a one-person project, keeping them in my Keychain is the smaller risk.</p><p>Installing is one line:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">brew install --cask chiliec/tap/claudebar-menubar</span><br></pre></td></tr></table></figure><p>The cask is <code>claudebar-menubar</code>, not <code>claudebar</code>, because that name was already taken in homebrew-core. After that, Sparkle checks for updates daily.</p><p>One scary moment: when I switched to a paid Developer ID certificate, the code-signing team changed. Would existing installs refuse the next update? Reading Sparkle’s source answered it: what matters is the EdDSA update key, not the signing identity. I kept the key, and the update chain survived.</p><h2 id="One-day-five-releases"><a href="#One-day-five-releases" class="headerlink" title="One day, five releases"></a>One day, five releases</h2><p>The most educational day was v0.0.31 → v0.0.35. Three bugs, each one hidden by the previous:</p><ol><li><strong>Liquid Glass.</strong> Calls to the new <code>.glassEffect()</code> API crashed. Removed.</li><li><strong>Infinite layout.</strong> Setting <code>NSPanel.contentViewController</code> together with SwiftUI <code>sizingOptions</code> sent AutoLayout into endless re-layout until the stack ran out. I found it with a 2×3 A&#x2F;B test of the combinations, not by reading the stack trace.</li><li><strong>An invisible icon.</strong> The app launched, but the menu bar item never appeared. A synchronous Keychain read on the launch path blocked the run loop before the icon could draw. Fix: create <code>AppState</code> without loading, draw the icon first, then load accounts asynchronously.</li></ol><p>None of these showed up in the 145 tests. All of them showed up on a real Mac within minutes. For a menu bar app, the launch path <em>is</em> the product.</p><h2 id="How-it-was-built"><a href="#How-it-was-built" class="headerlink" title="How it was built"></a>How it was built</h2><p>Every feature started as a short design spec and a task-by-task plan, then went to Claude Code agents for implementation with tests first — the same approach I used for <a href="/post/Redesigning-my-site-with-20-AI-agents/">this site’s redesign</a>. The agents were at their best on well-specified work: services, tests, the release script. The hard parts were the ones in this post: an OAuth flow that behaved differently from what I assumed, AppKit layout, launch order. Those needed a person with a debugger and a real Mac.</p><p>ClaudeBar is open source: <a href="https://github.com/chiliec/ClaudeBar">github.com&#x2F;chiliec&#x2F;ClaudeBar</a>.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/</id>
    <link href="https://chiliec.github.io/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/"/>
    <published>2026-09-30T16:00:00.000Z</published>
    <summary>
      <![CDATA[<p><a href="https://github.com/chiliec/ClaudeBar">ClaudeBar</a> is a tiny macOS app: an icon in the menu bar that shows how much of your Claude usage limit you have left. The idea fits in one sentence. Shipping it properly — signed, notarized, auto-updating, installable with Homebrew — took 193 commits and 32 GitHub releases between April and September 2026. This is about the part between “it works on my Mac” and “you can install it”.]]>
    </summary>
    <title>Shipping ClaudeBar: a small macOS menu bar app, 35 releases later</title>
    <updated>2026-09-30T16:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="app-store" scheme="https://chiliec.github.io/tags/app-store/"/>
    <category term="ios" scheme="https://chiliec.github.io/tags/ios/"/>
    <category term="kotlin-multiplatform" scheme="https://chiliec.github.io/tags/kotlin-multiplatform/"/>
    <category term="swiftui" scheme="https://chiliec.github.io/tags/swiftui/"/>
    <content>
      <![CDATA[<p>Between July 8 and September 16, 2026 I took four apps from an empty repo to the App Store: <a href="https://apps.apple.com/us/app/lancar/id6795209576">Lancar</a>, <a href="https://apps.apple.com/us/app/slovo-russian-phrases/id6796900036">SLOVO</a>, <a href="https://apps.apple.com/us/app/callback-interview-prep/id6796242315">Callback</a> and <a href="https://apps.apple.com/us/app/balisurf/id6801866165">BaliSurf</a>. Three are Kotlin Multiplatform, one is pure SwiftUI. None of them has an account, analytics or ads. Here’s what each one taught me. <span id="more"></span></p><table><thead><tr><th>App</th><th>What it is</th><th>Stack</th><th>First commit → App Store</th></tr></thead><tbody><tr><td>Lancar</td><td>Indonesian vocabulary trainer</td><td>KMP + Compose</td><td>Jul 8 → Aug 6</td></tr><tr><td>SLOVO</td><td>Gamified Russian phrasebook</td><td>KMP + Compose</td><td>Jul 8 → Aug 14</td></tr><tr><td>Callback</td><td>iOS interview prep</td><td>SwiftUI + SwiftData</td><td>Jul 29 → Aug 21</td></tr><tr><td>BaliSurf</td><td>Surf verdicts for 20 reef breaks</td><td>KMP + Compose</td><td>Aug 15 → Sep 16</td></tr></tbody></table><h2 id="Rules-I-kept-for-all-four"><a href="#Rules-I-kept-for-all-four" class="headerlink" title="Rules I kept for all four"></a>Rules I kept for all four</h2><ul><li><strong>Local-first.</strong> Everything works offline. No sign-up, no server, no analytics. Progress lives on the device.</li><li><strong>Pure logic in its own layer.</strong> In the KMP apps, the domain code — scoring, spaced repetition, question generation — knows nothing about Compose, networking or the platform. That’s the part with the most tests, and the part that stays the same on iOS and Android.</li><li><strong>Release automation from day one.</strong> Uploading builds by hand four times over is how you stop shipping.</li></ul><h2 id="Lancar-the-content-is-the-product"><a href="#Lancar-the-content-is-the-product" class="headerlink" title="Lancar: the content is the product"></a>Lancar: the content is the product</h2><p>I live in Bali and learn Indonesian, so the first app was for me. Lancar is a flashcard and drill trainer: 1,796 cards in 8 modules, with audio, and a 6-box Leitner system for spaced repetition.</p><p>The code was the easy part. The content came from Anki decks, and a small Node + ffmpeg pipeline turns them into bundled JSON and audio. Once that pipeline existed, adding a module was a data change, not a code change.</p><p>Kotlin Multiplatform surprises: the iOS host is a UIKit <code>AppDelegate</code>, not SwiftUI, because Compose Multiplatform expects it. And I had to downgrade SQLDelight from 2.3.2 to 2.0.2 because of an arm64 issue. Neither fact is on the “KMP in 10 minutes” slides.</p><h2 id="SLOVO-the-migration-I-forgot"><a href="#SLOVO-the-migration-I-forgot" class="headerlink" title="SLOVO: the migration I forgot"></a>SLOVO: the migration I forgot</h2><p>SLOVO is the reverse direction: a Russian phrasebook for English speakers, with 110 real recorded phrases from <a href="https://tatoeba.org/">Tatoeba</a> (CC-BY, credited in the app), 24 lessons, XP, streaks and hearts.</p><p>Its lesson was about databases. I changed the SQLDelight schema and forgot the <code>.sqm</code> migration file. New installs worked fine; build 4 crashed on devices that had the old schema. The fix was one file. The lasting fix was a regression test, so a missing migration fails CI instead of crashing on someone’s phone.</p><p>The release pipeline from SLOVO became the template for the others. BaliSurf’s Fastfile literally starts with “Ported from chiliec&#x2F;slovo’s Fastfile”: archive a committed Xcode project whose build phase calls Gradle to build the shared framework, then upload with an App Store Connect API key — no Apple ID, no 2FA prompts.</p><h2 id="Callback-testing-the-content-not-just-the-code"><a href="#Callback-testing-the-content-not-just-the-code" class="headerlink" title="Callback: testing the content, not just the code"></a>Callback: testing the content, not just the code</h2><p>Callback is the only pure Swift app: SwiftUI, <code>@Observable</code>, SwiftData, and two local Swift packages — <code>DesignSystem</code> for the UI kit and <code>AppCore</code> for models, scoring and the review queue. It prepares iOS developers for interviews: 11 topics, 297 questions, lessons, drills and timed mock interviews. It’s also the biggest of the four, at about 10,000 lines of Swift.</p><p>The most useful tests there don’t test code at all. They test the question bank:</p><ul><li>One test checks that the correct answer isn’t in a predictable position. It failed: in display order, correct options clustered in a pattern you could learn without knowing anything. The fix was rotating the options of three questions.</li><li>Another checks that every topic and level has enough questions for a full mock interview. It caught a topic with 9 eligible questions for a target of 8 — one edit away from breaking.</li></ul><p>For releases I skipped fastlane and wrote small Ruby scripts against the App Store Connect API. That’s how I found the real reason a submission was stuck: an empty <code>copyright</code> field and an unset price schedule — not the App Privacy issue I’d suspected at first.</p><h2 id="BaliSurf-rejected-for-being-a-template"><a href="#BaliSurf-rejected-for-being-a-template" class="headerlink" title="BaliSurf: rejected for being a template"></a>BaliSurf: rejected for being a template</h2><p>BaliSurf answers one question for 20 reef breaks around Bali, Nusa Lembongan and Lombok: is it worth paddling out, and when? Forecast data comes from the free Open-Meteo marine API. The app doesn’t try to beat Windguru on raw numbers; its value is a hand-tuned rule engine that turns swell, wind and tide into a verdict for each specific spot, with 36 tests pinning its behaviour.</p><p>Apple rejected the first submission under Guideline 4.3(a) — “spam”, too similar to existing apps. Fair: from the outside, it’s another forecast app. The resubmission notes walked the reviewer through what’s original: the per-spot rules, and reef depth maps for 14 of the 20 spots, derived from Sentinel-2 satellite imagery. It was approved.</p><p>The lesson: if your app is in a crowded category, the review notes need to explain what’s yours, in plain words, before the reviewer has to guess.</p><h2 id="What-I’d-tell-myself-in-July"><a href="#What-I’d-tell-myself-in-July" class="headerlink" title="What I’d tell myself in July"></a>What I’d tell myself in July</h2><ul><li><strong>Pick one stack per app, and one pipeline for all.</strong> The code differed; the release process was copied.</li><li><strong>Test the data.</strong> In content apps, bugs live in the content as often as in the code.</li><li><strong>Plan for review.</strong> Budget days for App Store review, and write reviewer notes as if they’re part of the product.</li><li><strong>Store consoles are their own project.</strong> On Android, Lancar ships as an APK on GitHub Releases, and SLOVO and BaliSurf are still working through Google Play’s testing requirements. The code runs on both platforms; getting it into both stores is a separate job.</li></ul><p>All four apps were built with Claude Code doing much of the implementation from my specs and plans. The ideas, the content choices, the review notes, and the bugs that only a real device shows — those stayed with me.</p><p>All four apps are free on the <a href="/apps/">App Store</a>.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Four-App-Store-apps-in-ten-weeks/</id>
    <link href="https://chiliec.github.io/post/Four-App-Store-apps-in-ten-weeks/"/>
    <published>2026-09-30T15:00:00.000Z</published>
    <summary>
      <![CDATA[<p>Between July 8 and September 16, 2026 I took four apps from an empty repo to the App Store: <a href="https://apps.apple.com/us/app/lancar/id6795209576">Lancar</a>, <a href="https://apps.apple.com/us/app/slovo-russian-phrases/id6796900036">SLOVO</a>, <a href="https://apps.apple.com/us/app/callback-interview-prep/id6796242315">Callback</a> and <a href="https://apps.apple.com/us/app/balisurf/id6801866165">BaliSurf</a>. Three are Kotlin Multiplatform, one is pure SwiftUI. None of them has an account, analytics or ads. Here’s what each one taught me.]]>
    </summary>
    <title>Four App Store apps in ten weeks, solo</title>
    <updated>2026-09-30T15:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="github-actions" scheme="https://chiliec.github.io/tags/github-actions/"/>
    <category term="cv" scheme="https://chiliec.github.io/tags/cv/"/>
    <category term="rendercv" scheme="https://chiliec.github.io/tags/rendercv/"/>
    <content>
      <![CDATA[<p>My CV has no <code>.docx</code>, no Figma file, and no “final_v3_really_final.pdf”. It’s a YAML file in the same repo as this site. Every push to <code>master</code> renders it into a PDF and publishes it at a stable URL. There are four versions of it for different roles, and each one has to fit on exactly one page. Here’s how that works and why I like it. <span id="more"></span></p><h2 id="The-setup"><a href="#The-setup" class="headerlink" title="The setup"></a>The setup</h2><p>The tool is <a href="https://github.com/rendercv/rendercv">RenderCV</a>: you describe the content in YAML, and it typesets the PDF through Typst. The content looks like this:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">cv:</span></span><br><span class="line">  <span class="attr">name:</span> <span class="string">Vladimir</span> <span class="string">Babin</span></span><br><span class="line">  <span class="attr">headline:</span> <span class="string">&quot;Senior iOS/macOS Engineer · Architecture-led, agent-driven delivery · Remote since 2015&quot;</span></span><br><span class="line">  <span class="attr">sections:</span></span><br><span class="line">    <span class="attr">Experience:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="attr">company:</span> <span class="string">Independent</span></span><br><span class="line">        <span class="attr">position:</span> <span class="string">Software</span> <span class="string">Engineer</span></span><br><span class="line">        <span class="attr">start_date:</span> <span class="number">2026-07</span></span><br><span class="line">        <span class="attr">end_date:</span> <span class="string">present</span></span><br><span class="line">        <span class="attr">highlights:</span></span><br><span class="line">          <span class="bullet">-</span> <span class="string">&quot;Took four apps from idea to the App Store in two months — ...&quot;</span></span><br></pre></td></tr></table></figure><p>No layout, no fonts, no margins — just facts. The design lives separately.</p><h2 id="Four-variants-one-design"><a href="#Four-variants-one-design" class="headerlink" title="Four variants, one design"></a>Four variants, one design</h2><p>I apply to different kinds of roles: native Apple, cross-platform mobile, full-stack, AI engineering. The same experience deserves different emphasis for each, so there are four files:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">source/cv/</span><br><span class="line">├── design.yaml                     # theme, fonts, colours, margins</span><br><span class="line">├── Vladimir_Babin_CV.yaml          # main: Apple platforms + agent-driven delivery</span><br><span class="line">├── Vladimir_Babin_CV_KMP.yaml      # cross-platform mobile</span><br><span class="line">├── Vladimir_Babin_CV_FullStack.yaml</span><br><span class="line">└── Vladimir_Babin_CV_AI.yaml</span><br></pre></td></tr></table></figure><p>Each variant points to the shared design with one line:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">settings:</span></span><br><span class="line">  <span class="attr">render_command:</span></span><br><span class="line">    <span class="attr">design:</span> <span class="string">design.yaml</span></span><br></pre></td></tr></table></figure><p>At one point there were six variants, each carrying its own copy of the same 47-line design block. Change the link colour — edit six files. When I consolidated them into four and extracted the design into <code>design.yaml</code>, the commit deleted 610 lines and added 106 — and the split into content and design didn’t change a single pixel of the PDFs.</p><h2 id="The-pipeline"><a href="#The-pipeline" class="headerlink" title="The pipeline"></a>The pipeline</h2><p>The GitHub Actions workflow that builds this Hexo site also renders the CVs:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Install</span> <span class="string">RenderCV</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">pip</span> <span class="string">install</span> <span class="string">&quot;rendercv[full]==2.8&quot;</span></span><br><span class="line"></span><br><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Render</span> <span class="string">CV</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">    cd ./source/cv/</span></span><br><span class="line"><span class="string">    rendercv render Vladimir_Babin_CV.yaml</span></span><br><span class="line"><span class="string">    cp -r rendercv_output/* ../../public/cv/</span></span><br><span class="line"><span class="string">    rendercv render Vladimir_Babin_CV_KMP.yaml -pdf ../../public/cv-kmp/Vladimir_Babin_Cross_Platform_Mobile_Engineer.pdf</span></span><br><span class="line"><span class="string">    # ...and so on for each variant</span></span><br></pre></td></tr></table></figure><p>A few details that turned out to matter:</p><ul><li><strong>Pin the version.</strong> RenderCV’s CLI changes between releases; a flag that worked in one version fails in the next. <code>==2.8</code> means the CV renders the same way next year.</li><li><strong>Stable URLs.</strong> The main CV always lives at <code>/cv/Vladimir_Babin_CV.pdf</code>. That’s the link I put in job applications and profiles. Update the YAML, push, and every link I’ve ever sent now points to the new version.</li><li><strong>Don’t break old links.</strong> When I merged the iOS and TON variants into others, their URLs (<code>/cv-ios/</code>, <code>/cv-ton/</code>) became tiny HTML redirects to the successors. Someone who got the link a month ago still lands on a real CV.</li><li><strong>Publish the source.</strong> The YAML is copied next to each PDF. Anyone curious can see exactly what the CV is made of.</li></ul><h2 id="The-one-page-rule"><a href="#The-one-page-rule" class="headerlink" title="The one-page rule"></a>The one-page rule</h2><p>Every variant must be exactly one page. That constraint does more editing than I do.</p><p>Typst breaks pages atomically: add one bullet, and the whole Skills + Education block jumps to page two. So every change goes through the same loop: render all four variants, count pages, look at the PNG preview to see what spilled over, cut. The first time I fit the main CV onto one page, it was typography only — margins, 9.5pt body text, tighter line spacing — without removing a single bullet. Since then, every new line has had to push an old one out.</p><p>That’s the point. A one-page limit forces the question “is this bullet better than the one it replaces?” on every edit.</p><h2 id="Git-as-a-CV-changelog"><a href="#Git-as-a-CV-changelog" class="headerlink" title="Git as a CV changelog"></a>Git as a CV changelog</h2><p>The best part is the history. <code>git log -- source/cv</code> is a record of how I’ve been presenting myself, with reasons:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">CV: state English level as C1</span><br><span class="line">CV: tighten bullets, add verified metrics across all variants</span><br><span class="line">CV: merge to 4 variants, shared design.yaml, add Independent entry</span><br><span class="line">CV: normalize job titles across all variants</span><br><span class="line">CV: soften metrics that can&#x27;t be backed up</span><br><span class="line">Fit the CV onto a single page</span><br></pre></td></tr></table></figure><p>That “soften metrics” commit is my favourite. An earlier version claimed “reduced crash rate by ~40%” and “first TestFlight build in 2–3 weeks”. They sounded good, but I couldn’t point to a dashboard or a document that proves them. So the diff replaced them with what I can defend:</p><figure class="highlight diff"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="deletion">- Reduced crash rate by ~40% through refactoring, monitoring, and defensive coding.</span></span><br><span class="line"><span class="addition">+ Drove crash-rate reduction through crash-report triage, refactoring, and defensive coding.</span></span><br></pre></td></tr></table></figure><p>A CV in Word has no memory of why a line changed. A CV in git does, and a reviewer — human or AI — can check every claim against the diff that introduced it.</p><h2 id="Should-you-do-this"><a href="#Should-you-do-this" class="headerlink" title="Should you do this?"></a>Should you do this?</h2><p>If you edit your CV twice a year, probably not; a template in any editor is fine. But if you keep several versions, update them often, or apply to a lot of roles, treating the CV like code pays off quickly: one source of truth, a shared design, automatic publishing, and a history that explains itself.</p><p>The files are public: <a href="/cv/Vladimir_Babin_CV.pdf">the main CV</a> and <a href="/cv/Vladimir_Babin_CV.yaml">its YAML</a>.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/My-CV-is-a-YAML-file/</id>
    <link href="https://chiliec.github.io/post/My-CV-is-a-YAML-file/"/>
    <published>2026-09-30T14:00:00.000Z</published>
    <summary>
      <![CDATA[<p>My CV has no <code>.docx</code>, no Figma file, and no “final_v3_really_final.pdf”. It’s a YAML file in the same repo as this site. Every push to <code>master</code> renders it into a PDF and publishes it at a stable URL. There are four versions of it for different roles, and each one has to fit on exactly one page. Here’s how that works and why I like it.]]>
    </summary>
    <title>My CV is a YAML file</title>
    <updated>2026-09-30T14:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="claude-code" scheme="https://chiliec.github.io/tags/claude-code/"/>
    <category term="ai" scheme="https://chiliec.github.io/tags/ai/"/>
    <category term="web" scheme="https://chiliec.github.io/tags/web/"/>
    <content>
      <![CDATA[<p>The site you’re reading was redesigned yesterday. I didn’t write the code — I wrote the spec, approved a plan, and let Claude Code run it through a pipeline of subagents: 9 tasks, 20 agents, 89 minutes of active work. Here’s what that actually looked like, including the parts that didn’t go to plan. <span id="more"></span></p><h2 id="Spec-first-code-later"><a href="#Spec-first-code-later" class="headerlink" title="Spec first, code later"></a>Spec first, code later</h2><p>The work happened in two separate sessions, on purpose.</p><p><strong>Session one — design.</strong> A brainstorm with rendered mockups of several visual directions, side by side in the browser. I picked “Editorial Paper”: warm paper background, Instrument Serif headlines, JetBrains Mono labels, and real App Store screenshots of my own apps as the only imagery. Then an 89-line spec — palette, fonts, motion behaviour, performance budget, done criteria — and a 1,180-line plan split into 9 tasks, each with exact files, code and checks.</p><p>Some decisions made there, before any code existed:</p><ul><li><strong>GSAP + Lenis, no Three.js.</strong> A fanned stack of screenshots gains nothing from WebGL, and Lighthouse would pay for it.</li><li><strong>Progressive enhancement.</strong> HTML and CSS render the final state; JavaScript only adds motion when it’s allowed. <code>prefers-reduced-motion</code> exits before a single tween is created.</li><li><strong>Motion only on Home, About, Apps.</strong> Blog posts get the new fonts and colours, but stay calm.</li></ul><p><strong>Session two — execution.</strong> A fresh context that only knows the plan. This is the same lesson as in <a href="/post/How-Claude-Code-1M-context-burns-through-limits/">the 1M context post</a>: the long design conversation doesn’t need to ride along into implementation.</p><h2 id="The-pipeline"><a href="#The-pipeline" class="headerlink" title="The pipeline"></a>The pipeline</h2><p>The orchestrator session doesn’t write code. For each task it dispatches an implementer subagent, then a separate reviewer subagent that checks the result against the spec and the plan. The final review of the whole branch runs on a stronger model.</p><table><thead><tr><th>Role</th><th>Count</th><th>Model</th></tr></thead><tbody><tr><td>Implementers (tasks 1–8, perf fix, review fixes)</td><td>10</td><td>Sonnet</td></tr><tr><td>Per-task reviewers + re-review</td><td>9</td><td>Sonnet</td></tr><tr><td>Final whole-branch reviewer</td><td>1</td><td>Opus</td></tr></tbody></table><p>Result: 10 commits, 39 files changed, +730&#x2F;−227 lines. First event to last commit of the implementation: 82 minutes. Merge and deploy check: another 3.</p><h2 id="What-the-reviews-caught"><a href="#What-the-reviews-caught" class="headerlink" title="What the reviews caught"></a>What the reviews caught</h2><p>Tasks 1–8 all passed review. The interesting findings came from elsewhere.</p><p><strong>A contradiction in my own plan.</strong> The Task 5 reviewer noticed that the reduced-motion failsafe animated <code>visibility</code>, while the plan’s global constraints said “transforms and opacity only.” The agent didn’t silently pick one — it stopped and asked. I kept the failsafe; <code>visibility</code> is an instant toggle, not an animation cost.</p><p><strong>Two real bugs, found only by the final review.</strong> Per-task reviewers look at one task; the whole-branch reviewer sees how tasks interact:</p><ol><li>A GSAP stagger tween finished by leaving an inline <code>transform: translate(0, 0)</code> on the work rows. Inline style beats the stylesheet, so the CSS hover nudge on the home page was permanently dead. Fix: <code>clearProps: &#39;transform&#39;</code>.</li><li>The scroll parallax on screenshots also ran inside the horizontal-scroll strip on mobile, cropping shadows and adding vertical scroll where there should be none. Fix: gate it with <code>gsap.matchMedia()</code> to desktop widths.</li></ol><p>Neither bug would show up in a unit test. Both are the kind a human would find a week later, on their phone.</p><h2 id="Performance-an-honest-87"><a href="#Performance-an-honest-87" class="headerlink" title="Performance: an honest 87"></a>Performance: an honest 87</h2><p>The plan’s target was mobile performance ≥ 90 in Lighthouse. The first measurement of the home page: <strong>77</strong>, with LCP at 5.6 s.</p><p>The culprit was the hero screenshots: JPEGs from Apple’s CDN. The CDN serves the same image as WebP if you ask for <code>.webp</code> instead of <code>.jpg</code> in the URL — a one-line change in the build script. One screenshot went from 69 KB to 24 KB at the same size. FCP dropped from 2.3 s to 0.9 s, performance rose to <strong>87</strong>.</p><p>The remaining gap comes from the intro animation: the hero fades in, and Lighthouse only credits LCP once the element is visible. The agent laid out the trade-off and asked. I kept the animation — the intro is the point of the page.</p><p>Final numbers (mobile, performance &#x2F; accessibility &#x2F; best practices &#x2F; SEO):</p><table><thead><tr><th>Page</th><th>Mobile</th><th>Desktop</th><th>CLS</th></tr></thead><tbody><tr><td>Home</td><td>87 &#x2F; 100 &#x2F; 100 &#x2F; 100</td><td>98 &#x2F; 100 &#x2F; 100 &#x2F; 100</td><td>0</td></tr><tr><td>About</td><td>92 &#x2F; 100 &#x2F; 100 &#x2F; 100</td><td>100 &#x2F; 100 &#x2F; 100 &#x2F; 100</td><td>0</td></tr><tr><td>Apps</td><td>90 &#x2F; 100 &#x2F; 100 &#x2F; 100</td><td>100 &#x2F; 100 &#x2F; 100 &#x2F; 100</td><td>0.01</td></tr></tbody></table><h2 id="What-didn’t-go-smoothly"><a href="#What-didn’t-go-smoothly" class="headerlink" title="What didn’t go smoothly"></a>What didn’t go smoothly</h2><ul><li><strong>The browser was locked.</strong> Chrome DevTools MCP uses a shared browser profile, and another Claude Code session held it through most of the run. Live browser checks for tasks 3–8 were deferred to the final task, which worked around it with the standalone <code>lighthouse</code> CLI and an isolated profile in <code>/tmp</code>.</li><li><strong>A hanging screenshot.</strong> Headless <code>chrome --screenshot</code> hung indefinitely — twice. The workaround was Lighthouse’s own final-screenshot audit.</li><li><strong>Five minor findings parked</strong>, not fixed: a font preload on pages that don’t need it, missing <code>lang=&quot;ru&quot;</code> on archive titles, and similar. They’re written down; they’ll wait.</li></ul><h2 id="My-part"><a href="#My-part" class="headerlink" title="My part"></a>My part</h2><p>Three questions from the agents over the whole run, each with a recommended option: keep the failsafe, keep the animation, delete the merged branch. Everything else — code, reviews, measurements, fixes — happened without me.</p><p>What I actually did was the work before the code: choose the direction from rendered mockups, write down what “done” means, and cut scope (no Three.js, no motion on reading pages). The agents are good at following a plan and catching each other’s mistakes. They can’t decide what the site should be. That part is still the job.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Redesigning-my-site-with-20-AI-agents/</id>
    <link href="https://chiliec.github.io/post/Redesigning-my-site-with-20-AI-agents/"/>
    <published>2026-09-30T12:00:00.000Z</published>
    <summary>
      <![CDATA[<p>The site you’re reading was redesigned yesterday. I didn’t write the code — I wrote the spec, approved a plan, and let Claude Code run it through a pipeline of subagents: 9 tasks, 20 agents, 89 minutes of active work. Here’s what that actually looked like, including the parts that didn’t go to plan.]]>
    </summary>
    <title>Redesigning my site with 20 AI agents in 89 minutes</title>
    <updated>2026-09-30T12:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="claude-code" scheme="https://chiliec.github.io/tags/claude-code/"/>
    <category term="data" scheme="https://chiliec.github.io/tags/data/"/>
    <content>
      <![CDATA[<p>Every Claude Code session I run ends with a note: what happened, what got committed, what’s next. After <a href="/post/How-I-work-with-AI-agents/">writing about that workflow</a>, I got curious about a narrower question — what do five months of these notes say about which model I actually use for what. I wrote a small script to check. The honest answer is: not much, directly, because I never set out to record it. Here’s what the notes do carry, and the one thing they surprised me by carrying not at all. <span id="more"></span></p><h2 id="The-script"><a href="#The-script" class="headerlink" title="The script"></a>The script</h2><p><code>build-tools/session-stats.js</code> walks <code>Journal/Projects/*/sessions/*.md</code>, reads each file’s header fields, and counts. Plain Node, no dependencies, about 80 lines. Run against my Journal folder right now:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;totalSessionFiles&quot;</span><span class="punctuation">:</span> <span class="number">1176</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;totalProjectsWithSessions&quot;</span><span class="punctuation">:</span> <span class="number">86</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;sessionsPerMonth&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;2026-04&quot;</span><span class="punctuation">:</span> <span class="number">100</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;2026-05&quot;</span><span class="punctuation">:</span> <span class="number">144</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;2026-06&quot;</span><span class="punctuation">:</span> <span class="number">429</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;2026-07&quot;</span><span class="punctuation">:</span> <span class="number">29</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;2026-08&quot;</span><span class="punctuation">:</span> <span class="number">303</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;2026-09&quot;</span><span class="punctuation">:</span> <span class="number">171</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p>1,176 session notes across 86 projects that have at least one. June was the busiest month by a wide margin (429 notes); July was the quietest (29).</p><h2 id="What-a-note-actually-carries"><a href="#What-a-note-actually-carries" class="headerlink" title="What a note actually carries"></a>What a note actually carries</h2><p>Every session note has the same four header fields:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">**Summary**: ...</span><br><span class="line">**Created**: 2026-06-29 14:53</span><br><span class="line">**Updated**: 2026-06-29 14:53</span><br><span class="line">**Tags**: #debugging #windows-bash #telegram-login</span><br></pre></td></tr></table></figure><p><code>Summary</code>, <code>Created</code>, <code>Updated</code>, <code>Tags</code> — that’s it. No <code>Model</code> field. I write these notes (or rather, the agent does, at session end) to capture what happened and why, not which model handled it. So “count model mentions” isn’t a metric the notes were built to answer. It’s a grep against free text, and I should treat it that way.</p><h2 id="What-grep-finds-anyway"><a href="#What-grep-finds-anyway" class="headerlink" title="What grep finds anyway"></a>What grep finds anyway</h2><p>Run it anyway and it’s not empty, just sparse: 168 of the 1,176 files mention a model name (<code>opus</code>, <code>sonnet</code>, <code>haiku</code>, <code>fable</code>) somewhere in the body, across 32 of the 86 projects. That’s incidental — usually because a model switch was itself the notable event, not routine work.</p><p>Two examples from this site’s own project folder and from ClaudeBar’s, both already public:</p><ul><li>A note from this repo’s Journal folder, dated 2026-09-23, opens with: “Switched Claude Code model to Opus and updated CLI from 2.1.258 to 2.1.280 (environment maintenance).”</li><li>A ClaudeBar session note from 2026-06-25 records cleaning up a stale <code>CLAUDE.md</code> rule about per-skill model switching, and fixing <code>settings.json</code> so the documented default (<code>opusplan</code> — Opus for planning, Sonnet for execution) actually matched what was configured. The note is explicit that model switching is user-only: the agent doesn’t have a tool to change the session model and won’t attempt to.</li></ul><p>That’s the real pattern behind the sparse numbers: model choice shows up in notes when it’s a decision or a fix, not as a running log.</p><h2 id="What-else-holds-up"><a href="#What-else-holds-up" class="headerlink" title="What else holds up"></a>What else holds up</h2><p>Tags are free-text hashtags, a few per session, so they’re noisy, but the most common ones across all 1,176 notes are a decent proxy for what the work actually was:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">#deployment  214</span><br><span class="line">#testing      84</span><br><span class="line">#kamal        57</span><br><span class="line">#design       56</span><br><span class="line">#planning     47</span><br></pre></td></tr></table></figure><p>That skew toward deployment and testing tracks with a workflow built around spec → plan → agents → review, where “shipped and verified” is the point where a note gets written.</p><h2 id="What-the-notes-can’t-see"><a href="#What-the-notes-can’t-see" class="headerlink" title="What the notes can’t see"></a>What the notes can’t see</h2><p>I also grepped for the <a href="/post/How-Claude-Code-1M-context-burns-through-limits/">1M context change</a> I wrote about in April, the one that made the weekly limit burn in a day. Zero hits. That’s not a finding, it’s a gap: the earliest session note is from 2026-04-19, two weeks after that post. Notes only cover what happened after I started writing them, and only what felt worth writing down at the time.</p><p>The script is committed at <code>build-tools/session-stats.js</code> in this repo if you want to point it at your own <code>Journal/Projects/</code> and see what your notes actually track.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/What-86-projects-of-session-notes-actually-track/</id>
    <link href="https://chiliec.github.io/post/What-86-projects-of-session-notes-actually-track/"/>
    <published>2026-09-30T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p>Every Claude Code session I run ends with a note: what happened, what got committed, what’s next. After <a href="/post/How-I-work-with-AI-agents/">writing about that workflow</a>, I got curious about a narrower question — what do five months of these notes say about which model I actually use for what. I wrote a small script to check. The honest answer is: not much, directly, because I never set out to record it. Here’s what the notes do carry, and the one thing they surprised me by carrying not at all.]]>
    </summary>
    <title>What 86 projects of session notes actually track</title>
    <updated>2026-09-30T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="kamal" scheme="https://chiliec.github.io/tags/kamal/"/>
    <category term="docker" scheme="https://chiliec.github.io/tags/docker/"/>
    <category term="devops" scheme="https://chiliec.github.io/tags/devops/"/>
    <content>
      <![CDATA[<p>I run a personal infrastructure repo that provisions, deploys, and backs up everything I ship outside the App Store: client sites, internal bots, a couple of blockchain services, one Claude agent. No ops team, no dashboard SaaS — Kamal for deploys, SOPS + age for secrets, cron for backups. Before writing this I recounted the fleet from the repo itself instead of trusting what I remembered: 46 apps across 5 servers. <span id="more"></span></p><h2 id="The-count"><a href="#The-count" class="headerlink" title="The count"></a>The count</h2><p>The repo keeps a <code>manifest.yml</code> per server — a plain list of app names, types, and hosts, used by the backup scripts to know which volumes exist. Counting real entries instead of guessing:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">$ cat servers/*/manifest.yml | grep -c &quot;^  - name:&quot;</span><br><span class="line">49</span><br></pre></td></tr></table></figure><p>49 entries, minus the three <code>monitoring</code> stacks (a Beszel agent, not an app), gives 46: static sites, Node bots and services, one PHP app, one Go proxy, three Python services, and two blockchain validator nodes. <code>servers/</code> also holds two more boxes that carry zero apps by design — one is a third-party host where I only hold a VPN tunnel, the other is a bare Tailscale exit node with no inbound path at all. Five servers total, three of them doing the actual hosting.</p><h2 id="Deploying-with-Kamal"><a href="#Deploying-with-Kamal" class="headerlink" title="Deploying with Kamal"></a>Deploying with Kamal</h2><p>Most apps follow the same shape: a Dockerfile, a <code>config/deploy.yml</code>, and a <code>.kamal/secrets</code> file that pulls credentials out of this repo instead of holding them itself:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"># .kamal/secrets</span><br><span class="line">KAMAL_REGISTRY_PASSWORD=$(sops -d --extract &#x27;[&quot;docker_registry_password&quot;]&#x27; &quot;$INFRA/secrets/shared.enc.yaml&quot;)</span><br></pre></td></tr></table></figure><p><code>kamal setup</code> provisions the container, registers it with <code>kamal-proxy</code>, and requests a Let’s Encrypt cert over HTTP-01 — which means DNS has to resolve to the server <em>before</em> that command runs, not after. <code>kamal deploy</code> is everything after. Builds run on a remote builder over SSH rather than my laptop, so a deploy doesn’t depend on what’s plugged in at the time.</p><p>Adding a new app to the fleet is: provision a DB user if it needs one, scaffold from a static-site&#x2F;node-app&#x2F;php-app template, point DNS, deploy, then add the entry to <code>manifest.yml</code> so the nightly backup picks up its volumes. That last step is easy to forget — two apps went undocumented in a manifest for over a week after I deployed them, which meant their SQLite databases were being backed up nowhere. No error, no page, just nothing to restore if the disk died. The manifest is now the thing I check first, not last.</p><h2 id="Secrets-SOPS-and-age"><a href="#Secrets-SOPS-and-age" class="headerlink" title="Secrets: SOPS and age"></a>Secrets: SOPS and age</h2><p>Every credential in the repo is SOPS-encrypted YAML, decrypted only at deploy or backup time. One <code>age</code> key pair covers everything, and the public half sits in plain sight in the config, because that’s the point of asymmetric encryption — the recipient list doesn’t need to be a secret:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"># .sops.yaml</span><br><span class="line">creation_rules:</span><br><span class="line">  - path_regex: secrets/.*\.enc\.yaml$</span><br><span class="line">    encrypted_regex: &#x27;^(.*_password|.*_token|.*_key|.*_secret|.*_uri|.*_id|.*_json|ssh_authorized_keys|.*_blob)$&#x27;</span><br><span class="line">    age: age1...  # public key</span><br></pre></td></tr></table></figure><p>Anything matching that regex gets encrypted field-by-field; everything else — app names, hostnames, comments — stays readable, so a diff of a secrets file still tells you what changed without exposing the value. The private age key lives only on my laptop. Lose it and every secret in the repo is gone with it — there’s no recovery path, which is the tradeoff for not running a KMS for a fleet this size.</p><h2 id="When-it-breaks"><a href="#When-it-breaks" class="headerlink" title="When it breaks"></a>When it breaks</h2><p>The most recent incident was the backup job for the Claude agent I run on one of the servers. Its nightly backup used to stop every container sharing the agent’s data volume, snapshot the SQLite state while nothing could write to it, then restart everything — safe, but it held the agent down for the whole upload. On 2026-09-29 I rewrote it to skip the stop entirely: every SQLite database under the data directory gets a <code>VACUUM INTO</code> snapshot taken while the container keeps running, archived under its original path, with the rest of the directory tarred live. A file changing mid-tar now just produces tar’s exit code 1 (“some files differ”), which the script treats as expected rather than fatal, because the database state comes from the consistent snapshot, not the raw file.</p><p>Two commits landed that same day. The first excluded a Swift toolchain and a pnpm store the agent had fetched for its own coding work, after the archive regrew from 2.1 GB back to 11 GB and overflowed the tmpfs it stages to — <code>tar</code> exit 2 plus <code>age</code> exit 1 both mean the same thing, out of space, and the fix each time is <code>du -sh</code> on the data directory to find what grew. The second removed the container stop. Both are small diffs on a script that runs unattended at 4 AM; both came from reading a failed run’s log rather than guessing.</p><h2 id="Running-it-solo"><a href="#Running-it-solo" class="headerlink" title="Running it solo"></a>Running it solo</h2><p>The parts that make this workable alone aren’t clever, they’re boring on purpose: every server bootstraps from the same numbered scripts, every backup pushes a dead-man heartbeat instead of a success message (silence is the healthy state, so a page only fires when something’s actually wrong), and every credential has exactly one place it’s allowed to live in plaintext — a 600-mode file on the server itself, shipped there by one <code>make</code> target. None of that prevents incidents. It just means the fix is usually a few lines in a script I already understand, not an afternoon reconstructing what a dashboard used to show.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/46-apps-on-5-servers-with-Kamal-SOPS-and-age-solo/</id>
    <link href="https://chiliec.github.io/post/46-apps-on-5-servers-with-Kamal-SOPS-and-age-solo/"/>
    <published>2026-09-29T20:00:00.000Z</published>
    <summary>
      <![CDATA[<p>I run a personal infrastructure repo that provisions, deploys, and backs up everything I ship outside the App Store: client sites, internal bots, a couple of blockchain services, one Claude agent. No ops team, no dashboard SaaS — Kamal for deploys, SOPS + age for secrets, cron for backups. Before writing this I recounted the fleet from the repo itself instead of trusting what I remembered: 46 apps across 5 servers.]]>
    </summary>
    <title>46 apps on 5 servers with Kamal, SOPS, and age, solo</title>
    <updated>2026-09-29T20:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="claude-code" scheme="https://chiliec.github.io/tags/claude-code/"/>
    <category term="obsidian" scheme="https://chiliec.github.io/tags/obsidian/"/>
    <category term="plugin" scheme="https://chiliec.github.io/tags/plugin/"/>
    <content>
      <![CDATA[<p><a href="/post/How-I-work-with-AI-agents/">How I work with AI agents</a> mentioned a plugin I wrote so project memory survives between sessions, but only in passing. This post is the plugin itself: two hooks, a locked-down child <code>claude</code> process, and the security fix I shipped the same day as the first release. <span id="more"></span></p><p><a href="https://github.com/chiliec/obsidian-memory">obsidian-memory</a> installs as a Claude Code plugin from its own marketplace repo. It writes a project’s state into an Obsidian vault when a session ends, and reads it back in when the next one starts.</p><h2 id="The-two-hooks"><a href="#The-two-hooks" class="headerlink" title="The two hooks"></a>The two hooks</h2><p>The whole plugin is wired through one <code>hooks.json</code>:</p><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;hooks&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;SessionStart&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">      <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;hooks&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">          <span class="punctuation">&#123;</span></span><br><span class="line">            <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;command&quot;</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;command&quot;</span><span class="punctuation">:</span> <span class="string">&quot;$&#123;CLAUDE_PLUGIN_ROOT&#125;/hooks/obsidian-load.sh&quot;</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;timeout&quot;</span><span class="punctuation">:</span> <span class="number">10</span></span><br><span class="line">          <span class="punctuation">&#125;</span></span><br><span class="line">        <span class="punctuation">]</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">    <span class="attr">&quot;SessionEnd&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">      <span class="punctuation">&#123;</span></span><br><span class="line">        <span class="attr">&quot;hooks&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span></span><br><span class="line">          <span class="punctuation">&#123;</span></span><br><span class="line">            <span class="attr">&quot;type&quot;</span><span class="punctuation">:</span> <span class="string">&quot;command&quot;</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;command&quot;</span><span class="punctuation">:</span> <span class="string">&quot;$&#123;CLAUDE_PLUGIN_ROOT&#125;/hooks/obsidian-save.sh&quot;</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;timeout&quot;</span><span class="punctuation">:</span> <span class="number">300</span><span class="punctuation">,</span></span><br><span class="line">            <span class="attr">&quot;async&quot;</span><span class="punctuation">:</span> <span class="literal"><span class="keyword">true</span></span></span><br><span class="line">          <span class="punctuation">&#125;</span></span><br><span class="line">        <span class="punctuation">]</span></span><br><span class="line">      <span class="punctuation">&#125;</span></span><br><span class="line">    <span class="punctuation">]</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><p><code>SessionStart</code> gets 10 seconds and runs synchronously, because it’s just a file read. <code>SessionEnd</code> gets 300 seconds and runs <code>async: true</code>, because it spawns a model call and shouldn’t block the session from closing.</p><h2 id="SessionStart-load-the-summary"><a href="#SessionStart-load-the-summary" class="headerlink" title="SessionStart: load the summary"></a>SessionStart: load the summary</h2><p><code>obsidian-load.sh</code> reads the hook’s JSON payload for <code>cwd</code>, takes the basename as the project name, and looks for <code>&lt;vault&gt;/&lt;project&gt;/SUMMARY.md</code>. If it finds one, it emits it as <code>additionalContext</code>:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">jq -n \</span><br><span class="line">  --arg ctx <span class="string">&quot;Obsidian project summary from previous session (loaded from <span class="variable">$SUMMARY</span>):</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="string"><span class="variable">$CONTENT</span>&quot;</span> \</span><br><span class="line">  <span class="string">&#x27;&#123;hookSpecificOutput: &#123;hookEventName: &quot;SessionStart&quot;, additionalContext: $ctx&#125;&#125;&#x27;</span></span><br></pre></td></tr></table></figure><p>It no-ops silently in five cases: a recursion guard is set, the vault env var is unset, the vault directory doesn’t exist, <code>jq</code> isn’t on <code>PATH</code>, or there’s no <code>SUMMARY.md</code> for this project yet. An unconfigured install is just quiet, not broken.</p><h2 id="SessionEnd-write-the-summary"><a href="#SessionEnd-write-the-summary" class="headerlink" title="SessionEnd: write the summary"></a>SessionEnd: write the summary</h2><p><code>obsidian-save.sh</code> does more work. It reads <code>session_id</code> and <code>cwd</code> from the payload, locates the session’s transcript under <code>~/.claude/projects/</code>, and counts user&#x2F;assistant turns. Below four turns it skips — not worth a model call for a one-liner. It also checks a dedup registry keyed on <code>session_id:turn_count</code>, so a hook that fires twice for the same session doesn’t write twice, and it skips projects whose <code>SUMMARY.md</code> has <code>**Status**: archived</code>.</p><p>If none of those short-circuit it, it builds a prompt: the last ~150 turns of the transcript capped at 40KB, the project’s <code>MEMORY.md</code> if one exists (treated as authoritative, human-owned ground truth), and the existing <code>SUMMARY.md</code>. That prompt goes to a child <code>claude</code> process that writes two files: a new, immutable session note under <code>sessions/</code>, and a rewritten <code>SUMMARY.md</code> for the project.</p><h2 id="Locking-down-the-summarizer"><a href="#Locking-down-the-summarizer" class="headerlink" title="Locking down the summarizer"></a>Locking down the summarizer</h2><p>The transcript fed into that prompt is untrusted — it can contain anything a session touched: pasted text, tool output, a web page. The first version ran the child with <code>--dangerously-skip-permissions</code>. A commit review on the day of the first release flagged that as a prompt-injection RCE risk, and I fixed it in the same session, in commit <code>c523729</code>, which also bumped <code>plugin.json</code> from version 0.1.0 to 0.1.1:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">nohup</span> <span class="string">&quot;<span class="variable">$CLAUDE_BIN</span>&quot;</span> \</span><br><span class="line">  -p <span class="string">&quot;<span class="variable">$PROMPT</span>&quot;</span> \</span><br><span class="line">  --output-format text \</span><br><span class="line">  --permission-mode dontAsk \</span><br><span class="line">  --allowedTools <span class="string">&quot;Write&quot;</span> \</span><br><span class="line">  --disallowedTools <span class="string">&quot;Bash,Read,Edit,MultiEdit,NotebookEdit,WebFetch,WebSearch,Task,Agent&quot;</span> \</span><br><span class="line">  --model claude-haiku-4-5 \</span><br><span class="line">  &lt;/dev/null &gt;&gt;<span class="string">&quot;<span class="variable">$LOG</span>&quot;</span> 2&gt;&amp;1</span><br></pre></td></tr></table></figure><p><code>--permission-mode dontAsk</code> means it never blocks on an interactive prompt — it auto-denies anything not explicitly allowed. <code>--allowedTools &quot;Write&quot;</code> is the only capability it needs. The <code>--disallowedTools</code> list is belt-and-suspenders: <code>dontAsk</code> still permits read-only Bash by default, so Bash, Read, and the network tools are denied explicitly. That closes both the code-execution and the exfiltration path. The existing <code>SUMMARY.md</code> is passed into the prompt text instead of read by the child, so it doesn’t need <code>Read</code> at all. One risk is still open and documented in the README: a crafted transcript could in principle steer the one allowed <code>Write</code> to an unintended local path. The prompt marks everything between its <code>=====</code> delimiters as data, not instructions, as the mitigation.</p><p>The model doing the writing is <code>claude-haiku-4-5</code> — cheap enough that a summary per session end isn’t a cost most people would notice.</p><h2 id="What-gets-written"><a href="#What-gets-written" class="headerlink" title="What gets written"></a>What gets written</h2><p>A session note has <code>Summary</code>, <code>Created</code>&#x2F;<code>Updated</code> timestamps, 2-5 tags, and 6-15 content bullets with <code>[[wikilinks]]</code>. <code>SUMMARY.md</code> has YAML frontmatter (<code>project</code>, <code>cwd</code>, <code>status</code>, <code>tags</code>) followed by <code>## Goal</code>, <code>## Current state</code>, <code>## Key files</code>, <code>## Recent decisions</code>, <code>## Open tasks / blockers</code>, <code>## Context to resume</code>, and a <code>## Recent sessions</code> block bounded by <code>&lt;!-- SESSIONS:START --&gt;</code> &#x2F; <code>&lt;!-- SESSIONS:END --&gt;</code> markers. A shell post-processing step (not the model) rebuilds that block from the 10 most recent files in <code>sessions/</code> after every write, and truncates the whole file if it passes a 12,000-character trigger, keeping the tail intact.</p><h2 id="Installing-it"><a href="#Installing-it" class="headerlink" title="Installing it"></a>Installing it</h2><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">/plugin marketplace add chiliec/obsidian-memory</span><br><span class="line">/plugin install obsidian-memory</span><br><span class="line">/obsidian-memory-setup</span><br></pre></td></tr></table></figure><p>The setup command checks for <code>jq</code> and the <code>claude</code> CLI, asks for the vault’s Projects-root path, and writes it as <code>OBSIDIAN_MEMORY_VAULT</code> in <code>~/.claude/settings.json</code>. There’s also a manual <code>install.sh</code> fallback for anyone not on the plugin system.</p><p>Before any of that ships, <code>tests/smoke.sh</code> runs: it checks the shipped files for personal strings, validates shell syntax and JSON, and exercises both hooks against a temp vault. Ten checks, all passing, wired into GitHub Actions on every push to <code>main</code> and every pull request.</p><h2 id="The-vault-today"><a href="#The-vault-today" class="headerlink" title="The vault today"></a>The vault today</h2><p>This plugin is why I can make claims like this one. Counting fresh from the vault right now: 99 project folders under <code>Journal/Projects/</code>, holding 406 specs, 445 plans and 1,176 session notes. The session notes go back to April, two months before the plugin: the same hooks ran as local scripts in my config first. Turning them into a plugin mostly meant stripping out the personal parts: hardcoded paths and my Linear logic.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Building-a-Claude-Code-plugin-the-obsidian-memory-hooks/</id>
    <link href="https://chiliec.github.io/post/Building-a-Claude-Code-plugin-the-obsidian-memory-hooks/"/>
    <published>2026-09-27T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p><a href="/post/How-I-work-with-AI-agents/">How I work with AI agents</a> mentioned a plugin I wrote so project memory survives between sessions, but only in passing. This post is the plugin itself: two hooks, a locked-down child <code>claude</code> process, and the security fix I shipped the same day as the first release.]]>
    </summary>
    <title>Building a Claude Code plugin: the obsidian-memory hooks</title>
    <updated>2026-09-27T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="claude-code" scheme="https://chiliec.github.io/tags/claude-code/"/>
    <category term="tooling" scheme="https://chiliec.github.io/tags/tooling/"/>
    <category term="dotfiles" scheme="https://chiliec.github.io/tags/dotfiles/"/>
    <content>
      <![CDATA[<p><code>~/.claude/</code> is where Claude Code keeps its model choice, permissions, hooks and skills, and none of it is meant to be edited by hand and forgotten. I keep the real files in a private repo and symlink them into place, so every change — mine or the agent’s — is a diff I can read and revert. This is the setup, not a pitch for it. <span id="more"></span></p><h2 id="What’s-symlinked"><a href="#What’s-symlinked" class="headerlink" title="What’s symlinked"></a>What’s symlinked</h2><p><code>setup.sh</code> links seven paths from the repo into <code>~/.claude/</code>:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="built_in">link</span> CLAUDE.md          CLAUDE.md</span><br><span class="line"><span class="built_in">link</span> settings.json      settings.json</span><br><span class="line"><span class="built_in">link</span> skills             skills</span><br><span class="line"><span class="built_in">link</span> hooks/pin-model.sh     hooks/pin-model.sh</span><br><span class="line"><span class="built_in">link</span> hooks/context-guard.sh hooks/context-guard.sh</span><br><span class="line"><span class="built_in">link</span> hooks/obsidian-load.sh hooks/obsidian-load.sh</span><br><span class="line"><span class="built_in">link</span> hooks/obsidian-save.sh hooks/obsidian-save.sh</span><br></pre></td></tr></table></figure><p>Because <code>~/.claude/settings.json</code> is a symlink, not a copy, any write Claude Code itself makes to that file lands directly in the working tree. That turns out to matter, see below. One thing is deliberately <em>not</em> symlinked: my <a href="https://github.com/chiliec/claude-account">account switcher</a> is its own public repo, fetched by <code>curl</code> in <code>setup.sh</code> instead, so the auth state it manages stays local to each machine rather than living in a config repo.</p><h2 id="Four-hooks"><a href="#Four-hooks" class="headerlink" title="Four hooks"></a>Four hooks</h2><p><code>settings.json</code> wires four scripts, all in <code>hooks/</code>:</p><table><thead><tr><th>Script</th><th>Event(s)</th><th>What it does</th></tr></thead><tbody><tr><td><code>pin-model.sh</code></td><td><code>UserPromptSubmit</code></td><td>Restores the pinned default model</td></tr><tr><td><code>context-guard.sh</code></td><td><code>Stop</code>, <code>UserPromptSubmit</code></td><td>Warns when context is getting large, never blocks</td></tr><tr><td><code>obsidian-load.sh</code></td><td><code>SessionStart</code></td><td>Loads the project’s <code>SUMMARY.md</code> into context</td></tr><tr><td><code>obsidian-save.sh</code></td><td><code>SessionEnd</code></td><td>Writes <code>SUMMARY.md</code> and a session note back to the vault</td></tr></tbody></table><p>The Obsidian pair is the memory layer I wrote about in <a href="/post/How-I-work-with-AI-agents/">how I work with AI agents</a>. <code>context-guard.sh</code> reads the real context size out of the session transcript and nudges toward <code>/exit</code> at 130k tokens, which is below the roughly 166k point where Claude Code auto-compacts a 200k-token window on its own. It also warns on resume past 100k tokens after 300 seconds idle, on the theory that re-caching a cold session costs more than starting a fresh one.</p><h2 id="The-model-setting-fights-back"><a href="#The-model-setting-fights-back" class="headerlink" title="The model setting fights back"></a>The model setting fights back</h2><p><code>settings.json</code> pins <code>&quot;model&quot;: &quot;opusplan&quot;</code> — Opus for planning, Sonnet for execution, a rule that’s also spelled out in my global <code>CLAUDE.md</code>. The problem: <code>/model</code> and the harness’s own on-exit save both rewrite that same top-level key, and since the file is a symlink into the repo, the rewrite shows up as uncommitted drift I’d otherwise have to notice and revert by hand. <code>pin-model.sh</code> runs on every prompt and puts it back:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">PINNED=<span class="string">&quot;opusplan&quot;</span></span><br><span class="line">F=<span class="string">&quot;<span class="variable">$HOME</span>/.claude/settings.json&quot;</span></span><br><span class="line"></span><br><span class="line">[ -f <span class="string">&quot;<span class="variable">$F</span>&quot;</span> ] || <span class="built_in">exit</span> 0</span><br><span class="line">grep -q <span class="string">&quot;^  \&quot;model\&quot;: \&quot;<span class="variable">$PINNED</span>\&quot;,\$&quot;</span> <span class="string">&quot;<span class="variable">$F</span>&quot;</span> &amp;&amp; <span class="built_in">exit</span> 0</span><br><span class="line"></span><br><span class="line">current=$(sed -n <span class="string">&#x27;s/^  &quot;model&quot;: &quot;\(.*\)&quot;,$/\1/p&#x27;</span> <span class="string">&quot;<span class="variable">$F</span>&quot;</span>)</span><br><span class="line">[ -n <span class="string">&quot;<span class="variable">$current</span>&quot;</span> ] || <span class="built_in">exit</span> 0</span><br><span class="line"></span><br><span class="line">tmp=$(<span class="built_in">mktemp</span>)</span><br><span class="line"><span class="built_in">trap</span> <span class="string">&#x27;rm -f &quot;$tmp&quot;&#x27;</span> EXIT</span><br><span class="line">sed <span class="string">&quot;s/^  \&quot;model\&quot;: \&quot;.*\&quot;,\$/  \&quot;model\&quot;: \&quot;<span class="variable">$PINNED</span>\&quot;,/&quot;</span> <span class="string">&quot;<span class="variable">$F</span>&quot;</span> &gt; <span class="string">&quot;<span class="variable">$tmp</span>&quot;</span></span><br><span class="line"><span class="built_in">cat</span> <span class="string">&quot;<span class="variable">$tmp</span>&quot;</span> &gt; <span class="string">&quot;<span class="variable">$F</span>&quot;</span>  <span class="comment"># redirect, not mv — mv would replace the symlink with a file</span></span><br></pre></td></tr></table></figure><p>It only touches the on-disk default. A mid-session <code>/model</code> switch still runs on whatever model I picked; only the next fresh session goes back to <code>opusplan</code>. The <code>cat &gt; &quot;$F&quot;</code> instead of <code>mv</code> matters for the same reason the whole setup exists: <code>mv</code> would replace the symlink with a plain file and quietly break the link to the repo.</p><h2 id="Skills-are-generated-not-forked"><a href="#Skills-are-generated-not-forked" class="headerlink" title="Skills are generated, not forked"></a>Skills are generated, not forked</h2><p><code>skills/</code> is <a href="https://github.com/obra/superpowers">superpowers</a> — pinned in this repo at tag <code>v6.2.0</code> — plus a small overlay, and it’s never hand-edited. <code>build.sh</code> clones the pinned tag, replaces <code>skills/</code> wholesale, then reapplies the overlay via <code>overlay/customize.sh</code>:</p><ol><li>A path redirect across every <code>*.md</code>, so upstream’s generic docs convention points at my actual Obsidian vault path instead.</li><li>Four patches (<code>overlay/patches/*.patch</code>) against <code>brainstorming</code>, <code>writing-plans</code>, <code>executing-plans</code>, and <code>subagent-driven-development</code> — they add the Opus model gates and point plan&#x2F;session saves at the vault.</li><li>One shared helper file the patched skills reference for worktree edge cases, which doesn’t exist upstream.</li><li>Local skills that have no upstream equivalent — currently just <code>disk-cleanup</code>.</li></ol><p>Everything else is byte-identical to what <code>obra/superpowers</code> ships. If a future upstream release restructures a patched section, <code>build.sh</code> fails loudly instead of silently applying a stale patch, which is the point of pinning a tag rather than tracking a branch.</p><h2 id="What-the-baseline-actually-costs"><a href="#What-the-baseline-actually-costs" class="headerlink" title="What the baseline actually costs"></a>What the baseline actually costs</h2><p>I measured what reaches the model at the start of a session, once, on 2026-08-07, by reading the <code>content</code> field of the <code>SessionStart</code> attachment in a session transcript (not the attachment object — it repeats the text in both <code>content</code> and <code>stdout</code>, which doubles the apparent size):</p><table><thead><tr><th>Block</th><th>chars</th><th>Lever</th></tr></thead><tbody><tr><td>Workflow tool schema</td><td>~12,000</td><td>disabled, never invoked</td></tr><tr><td>Obsidian <code>SUMMARY.md</code></td><td>7,890</td><td>generation cap</td></tr><tr><td>Skill listing</td><td>6,018</td><td>per-skill overrides</td></tr><tr><td><code>CLAUDE.md</code> ×2</td><td>5,463</td><td>keep terse</td></tr><tr><td>Ponytail <code>SessionStart</code></td><td>5,228</td><td>only <code>off</code> removes it</td></tr><tr><td>Agent listing</td><td>2,531</td><td>plugin config</td></tr><tr><td>claude.ai connector tool names</td><td>1,262</td><td>disabled</td></tr><tr><td>MCP instructions</td><td>775</td><td>per-server</td></tr><tr><td>Bundled deferred tool names</td><td>362</td><td>—</td></tr></tbody></table><p>Roughly 25k tokens of fixed baseline (divide chars by ~3.7), out of a 200k window, leaves about 135k tokens of real working room once a safety margin is subtracted. That’s why <code>context-guard.sh</code> warns at 130k rather than closer to the actual auto-compact point — there isn’t much room past it. The one surprising result: I’d assumed a plugin’s intensity setting (<code>lite</code>&#x2F;<code>full</code>&#x2F;<code>ultra</code>) changed how much it injected into context. It doesn’t — all three levels cost about the same, because the level only swaps one table row in the prompt. Turning a block off entirely is the only lever that moves the number.</p><p>None of this is exotic. It’s a symlink farm, four small scripts, and one generated directory, kept in git so a change is a commit instead of a fact I have to remember.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/My-Claude-Code-config-is-a-git-repo/</id>
    <link href="https://chiliec.github.io/post/My-Claude-Code-config-is-a-git-repo/"/>
    <published>2026-09-26T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p><code>~/.claude/</code> is where Claude Code keeps its model choice, permissions, hooks and skills, and none of it is meant to be edited by hand and forgotten. I keep the real files in a private repo and symlink them into place, so every change — mine or the agent’s — is a diff I can read and revert. This is the setup, not a pitch for it.]]>
    </summary>
    <title>My Claude Code config is a git repo</title>
    <updated>2026-09-26T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="ios" scheme="https://chiliec.github.io/tags/ios/"/>
    <category term="godot" scheme="https://chiliec.github.io/tags/godot/"/>
    <category term="android" scheme="https://chiliec.github.io/tags/android/"/>
    <category term="ci" scheme="https://chiliec.github.io/tags/ci/"/>
    <content>
      <![CDATA[<p>I wrote about the <a href="/post/Shipping-a-Kotlin-Multiplatform-app-to-the-App-Store-with-fastlane/">fastlane and GitHub Actions pipeline</a> I use for Kotlin Multiplatform apps. <a href="https://escaperoguelike.com/">Escape Roguelike</a>, a mobile game I’m shipping in Godot 4.7, needed the same result — push a tag, get a build on TestFlight and the Play internal track — but almost none of the plumbing carries over, because there’s no Xcode project or Gradle project sitting in git to sign. Godot generates both from scratch on every export. <span id="more"></span></p><h2 id="No-project-to-check-in"><a href="#No-project-to-check-in" class="headerlink" title="No project to check in"></a>No project to check in</h2><p>The KMP pipeline edits a committed <code>.xcodeproj</code>: build number, signing settings, provisioning profile, all as diffs fastlane applies to a real file. Godot doesn’t have that file until you ask for it:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">godot --headless --path Godot --export-release iOS <span class="string">&quot;<span class="variable">$OUT</span>&quot;</span></span><br></pre></td></tr></table></figure><p>That one command reads <code>export_presets.cfg</code> and writes a full Xcode project to <code>$OUT</code>. CI runs it as its own step before fastlane touches anything, and the fastlane lane can skip re-running the export (<code>SKIP_GODOT_EXPORT=1</code>) when CI already did it. Locally, the same lane runs the export itself. Android is the same idea one level further down: Godot’s own gradle build produces the signed <code>.aab</code> directly, no separate Android Studio project involved.</p><h2 id="Signing-same-trick-as-the-KMP-post"><a href="#Signing-same-trick-as-the-KMP-post" class="headerlink" title="Signing, same trick as the KMP post"></a>Signing, same trick as the KMP post</h2><p>The App Store Connect API key setup is identical to the KMP pipeline: a <code>.p8</code> key id&#x2F;issuer id&#x2F;base64 content, no <code>match</code>, no certificates repo, no Apple ID login. CI imports the Apple Distribution certificate and Apple’s WWDR intermediates into a temporary keychain by hand:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">security create-keychain -p <span class="string">&quot;<span class="variable">$KP</span>&quot;</span> <span class="string">&quot;<span class="variable">$KEYCHAIN</span>&quot;</span></span><br><span class="line">security import <span class="string">&quot;<span class="variable">$RUNNER_TEMP</span>/dist.p12&quot;</span> -k <span class="string">&quot;<span class="variable">$KEYCHAIN</span>&quot;</span> -P <span class="string">&quot;<span class="variable">$DIST_CERT_PASSWORD</span>&quot;</span> \</span><br><span class="line">  -T /usr/bin/codesign -T /usr/bin/security</span><br><span class="line">security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k <span class="string">&quot;<span class="variable">$KP</span>&quot;</span> <span class="string">&quot;<span class="variable">$KEYCHAIN</span>&quot;</span></span><br></pre></td></tr></table></figure><p>Build numbers work the same way too: query the highest build already on TestFlight and upload one more, so Godot’s fixed build number in the export never collides.</p><h2 id="What’s-actually-different-native-plugins-over-Git-LFS"><a href="#What’s-actually-different-native-plugins-over-Git-LFS" class="headerlink" title="What’s actually different: native plugins over Git LFS"></a>What’s actually different: native plugins over Git LFS</h2><p>The game ships three small native iOS plugins compiled outside Godot (OAuth login, deep links, a system-volume read). Those <code>.xcframework</code> static archives live in Git LFS, and if LFS objects don’t hydrate, Xcode sees text pointer files and reports “unknown file type” at the link step — a confusing failure with no obvious cause. CI checks the real files before wasting a full build on it:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">git lfs fsck</span><br><span class="line"><span class="built_in">test</span> <span class="string">&quot;<span class="subst">$(LC_ALL=C head -c 7 <span class="string">&quot;<span class="variable">$archive</span>&quot;</span>)</span>&quot;</span> = <span class="string">&#x27;!&lt;arch&gt;&#x27;</span> || &#123;</span><br><span class="line">  <span class="built_in">echo</span> <span class="string">&quot;::error file=<span class="variable">$archive</span>::Expected a hydrated static archive; Git LFS pointer or invalid file found&quot;</span></span><br><span class="line">  <span class="built_in">exit</span> 1</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>!&lt;arch&gt;</code> is the magic bytes of a real static archive. A pointer file fails that check in under a second instead of failing an Xcode build five minutes in.</p><h2 id="Android-version-codes-come-from-every-track-not-one"><a href="#Android-version-codes-come-from-every-track-not-one" class="headerlink" title="Android version codes come from every track, not one"></a>Android version codes come from every track, not one</h2><p>The KMP post’s Android side reads the next version code off one track. This pipeline queries all of them, because a code used on <code>alpha</code> still blocks reuse on <code>internal</code>:</p><figure class="highlight ruby"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">codes = <span class="string">%w[internal alpha beta production]</span>.flat_map <span class="keyword">do</span> |<span class="params">track</span>|</span><br><span class="line">  google_play_track_version_codes(<span class="symbol">package_name:</span> <span class="variable constant_">PACKAGE</span>, <span class="symbol">track:</span> track, <span class="symbol">json_key_data:</span> json_key)</span><br><span class="line"><span class="keyword">end</span></span><br><span class="line">version_code = codes.max.to_i + <span class="number">1</span></span><br></pre></td></tr></table></figure><p>Godot bakes the version code into the export at build time, not at runtime, so the number has to land in <code>export_presets.cfg</code> before the export runs, not after:</p><figure class="highlight ruby"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">cfg = <span class="title class_">File</span>.read(preset_path)</span><br><span class="line">cfg.sub!(<span class="regexp">/^version\/code=\d+$/</span>, <span class="string">&quot;version/code=<span class="subst">#&#123;version_code&#125;</span>&quot;</span>)</span><br><span class="line"><span class="title class_">File</span>.write(preset_path, cfg)</span><br></pre></td></tr></table></figure><h2 id="The-CI-trigger"><a href="#The-CI-trigger" class="headerlink" title="The CI trigger"></a>The CI trigger</h2><p>Both platform workflows share the same trigger:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="attr">on:</span></span><br><span class="line">  <span class="attr">workflow_dispatch:</span></span><br><span class="line">  <span class="attr">push:</span></span><br><span class="line">    <span class="attr">tags:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">&#x27;v*&#x27;</span></span><br></pre></td></tr></table></figure><p>A tag push builds and uploads both platforms; a manual dispatch can do a build-only dry run. Both workflows set <code>concurrency.cancel-in-progress: false</code> on a repo-wide group, because the next build number and the next Play version code are both “current store max plus one” — two runs racing for that number would allocate the same one twice. Serializing them, rather than cancelling either, is the fix.</p><p>Linux runners are pinned to <code>ubuntu-24.04</code> explicitly, ahead of <code>ubuntu-latest</code> quietly becoming <code>26.04</code> on 2026-10-19.</p><h2 id="A-separate-workflow-for-assets"><a href="#A-separate-workflow-for-assets" class="headerlink" title="A separate workflow for assets"></a>A separate workflow for assets</h2><p>Downloadable asset packs get their own workflow, triggered on changes to the assets directory rather than on tags, since they don’t need a version bump to ship. It runs a Godot script that checks every asset is claimed by exactly one pack (no orphans, no duplicates), then exports and checks size:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">godot --headless --path Godot --export-pack Android /tmp/app.pck</span><br><span class="line">SIZE=$(<span class="built_in">wc</span> -c &lt; /tmp/app.pck)</span><br><span class="line"><span class="built_in">test</span> <span class="string">&quot;<span class="variable">$SIZE</span>&quot;</span> -lt 26214400 || &#123; <span class="built_in">echo</span> <span class="string">&quot;app pck <span class="variable">$SIZE</span> exceeds 25MB budget&quot;</span>; <span class="built_in">exit</span> 1; &#125;</span><br></pre></td></tr></table></figure><p>25 MB (26214400 bytes) is my own budget for the base game package; everything else ships as downloadable packs. The check fails the build the moment an asset lands in the wrong place.</p><h2 id="Store-listing-without-a-build"><a href="#Store-listing-without-a-build" class="headerlink" title="Store listing without a build"></a>Store listing without a build</h2><p>Store listing text, icon and screenshots go through their own fastlane lane and their own manually-triggered workflow, separate from the build-and-upload lane. No Godot export involved, no keystore, just the Play API pushing metadata. Same principle as the KMP post: a release and its metadata are two different operations that don’t need to block each other.</p><h2 id="What-stayed-the-same-what-didn’t"><a href="#What-stayed-the-same-what-didn’t" class="headerlink" title="What stayed the same, what didn’t"></a>What stayed the same, what didn’t</h2><p>The signing model, the API-key auth, and the “let the store tell you the next number” trick all carried over unchanged from the KMP pipeline. What’s new is entirely about Godot not leaving a native project on disk between commits: every export step regenerates what a normal mobile CI setup would just diff, and the numbers that used to live in a <code>.pbxproj</code> or <code>build.gradle</code> live in a <code>.cfg</code> file that gets rewritten before each export instead.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Shipping-a-Godot-game-to-both-stores-with-tag-based-CI/</id>
    <link href="https://chiliec.github.io/post/Shipping-a-Godot-game-to-both-stores-with-tag-based-CI/"/>
    <published>2026-09-25T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p>I wrote about the <a href="/post/Shipping-a-Kotlin-Multiplatform-app-to-the-App-Store-with-fastlane/">fastlane and GitHub Actions pipeline</a> I use for Kotlin Multiplatform apps. <a href="https://escaperoguelike.com/">Escape Roguelike</a>, a mobile game I’m shipping in Godot 4.7, needed the same result — push a tag, get a build on TestFlight and the Play internal track — but almost none of the plumbing carries over, because there’s no Xcode project or Gradle project sitting in git to sign. Godot generates both from scratch on every export.]]>
    </summary>
    <title>Shipping a Godot game to both stores with tag-based CI</title>
    <updated>2026-09-25T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="app-store" scheme="https://chiliec.github.io/tags/app-store/"/>
    <category term="google-play" scheme="https://chiliec.github.io/tags/google-play/"/>
    <category term="backend" scheme="https://chiliec.github.io/tags/backend/"/>
    <category term="privacy" scheme="https://chiliec.github.io/tags/privacy/"/>
    <content>
      <![CDATA[<p>Both app stores require that if your app lets someone create an account, it must also let them delete it. I shipped this for <a href="https://escaperoguelike.com/">Escape Roguelike</a>, a Godot game with a Hono backend, as one DELETE endpoint, one static HTML page, and a confirmation modal in the settings screen. No third-party deletion service. <span id="more"></span></p><h2 id="What-Apple-requires"><a href="#What-Apple-requires" class="headerlink" title="What Apple requires"></a>What Apple requires</h2><p><a href="https://developer.apple.com/app-store/review/guidelines/">App Store Review Guideline 5.1.1(v)</a> is direct about it:</p><blockquote><p>If your app supports account creation, you must also <a href="https://developer.apple.com/support/offering-account-deletion-in-your-app/">offer account deletion within the app</a>.</p></blockquote><p>The linked support page says the option should be easy to find, typically in account settings, and that it has to remove the account record: “only offering to temporarily deactivate or disable an account is insufficient.”</p><h2 id="What-Google-requires"><a href="#What-Google-requires" class="headerlink" title="What Google requires"></a>What Google requires</h2><p><a href="https://support.google.com/googleplay/android-developer/answer/13327111">Google Play’s account deletion policy</a> asks for two things at once, not one or the other:</p><blockquote><p>Provide users with an in-app path to delete their app account and associated data, and provide a web link resource where users can request app account deletion.</p></blockquote><p>That second part is the one people miss: a working web page, separate from the app, that anyone can use even after they uninstalled. Google’s Play Console data-safety form asks for that URL directly.</p><p>Both requirements point at the same shape: an in-app action plus a reachable deletion page. Build one thing that satisfies both.</p><h2 id="The-endpoint"><a href="#The-endpoint" class="headerlink" title="The endpoint"></a>The endpoint</h2><p>The backend is Hono on a SQLite-backed schema. Deletion is a single transaction that walks every table with an <code>account_id</code> foreign key, then removes the account row:</p><figure class="highlight ts"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Google Play / App Store require in-app account deletion. Removes every row</span></span><br><span class="line"><span class="comment">// tied to the account; the client then discards its token and starts fresh.</span></span><br><span class="line"><span class="keyword">const</span> deleteAccount = db.<span class="title function_">transaction</span>(<span class="function">(<span class="params"><span class="attr">accountId</span>: <span class="built_in">string</span></span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="keyword">for</span> (<span class="keyword">const</span> table <span class="keyword">of</span> [<span class="string">&#x27;events&#x27;</span>, <span class="string">&#x27;daily_rewards&#x27;</span>, <span class="string">&#x27;purchases&#x27;</span>, <span class="string">&#x27;entitlements&#x27;</span>, <span class="string">&#x27;saves&#x27;</span>, <span class="string">&#x27;account_providers&#x27;</span>]) &#123;</span><br><span class="line">    db.<span class="title function_">prepare</span>(<span class="string">`DELETE FROM <span class="subst">$&#123;table&#125;</span> WHERE account_id = ?`</span>).<span class="title function_">run</span>(accountId)</span><br><span class="line">  &#125;</span><br><span class="line">  db.<span class="title function_">prepare</span>(<span class="string">&#x27;DELETE FROM accounts WHERE id = ?&#x27;</span>).<span class="title function_">run</span>(accountId)</span><br><span class="line">&#125;)</span><br><span class="line"></span><br><span class="line">app.<span class="title function_">delete</span>(<span class="string">&#x27;/me&#x27;</span>, <span class="title function_">bearerAuth</span>(db), <span class="function">(<span class="params">c</span>) =&gt;</span> &#123;</span><br><span class="line">  <span class="title function_">deleteAccount</span>(c.<span class="title function_">get</span>(<span class="string">&#x27;accountId&#x27;</span>))</span><br><span class="line">  <span class="keyword">return</span> c.<span class="title function_">body</span>(<span class="literal">null</span>, <span class="number">204</span>)</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><code>DELETE /api/v1/accounts/me</code> requires the same bearer token as every other authenticated route, wipes events, daily reward claims, purchases, entitlements, saves, and linked sign-in providers in one transaction, then deletes the account row and returns 204. On the client, a 204 clears the stored token and credentials file and resets account progress. It also treats 401 and 404 as already deleted, so retrying after a half-finished attempt still succeeds.</p><h2 id="The-web-page"><a href="#The-web-page" class="headerlink" title="The web page"></a>The web page</h2><p><code>https://escaperoguelike.com/account-deletion/</code> is a static page under the same domain as the game, not a separate subdomain, so it’s obviously tied to the app rather than looking like a generic legal boilerplate page. It covers four things:</p><ol><li>How to delete from inside the game (Settings → Account → Delete account, with a confirmation step).</li><li>How to request deletion by email if the game isn’t installed anymore.</li><li>What gets deleted: account ID, login token, linked sign-in identities, saves, purchase and entitlement records, daily-reward history, and analytics events tied to the account.</li><li>What’s kept and why: aggregated statistics with no account identifiers, server access logs rotated within 30 days, and encrypted database backups that can hold a copy of deleted data for up to 30 days before they expire. Store purchase receipts stay with Apple or Google under their own policies.</li></ol><p>That last section is the part worth being explicit about. Deletion doesn’t have to mean the data vanishes from every byte of infrastructure instantly. It has to mean the account and everything tied to it is gone from the live system, and anything left over (backups, logs) is bounded and disclosed.</p><h2 id="What-it-cost"><a href="#What-it-cost" class="headerlink" title="What it cost"></a>What it cost</h2><p>One PR: a route, a transaction, a static HTML page, a settings button with a confirmation modal, and localized strings for the three languages the app ships in. Backend tests covered the endpoint two ways: that it wipes the account and every linked table, and that it requires auth. No new infrastructure, no vendor, no schema migration beyond the tables the game already had.</p><p>The same shape shows up anywhere an app or a SaaS has accounts: an endpoint that does the deletion server-side in one transaction, and a page that proves it exists independent of whether someone still has the app installed. The expensive version adds a step-up confirmation code, per-account business rules (for example, blocking a sole owner of a shared workspace from deleting until they transfer ownership), and an audit trail. Add those when you actually have shared accounts or a compliance regime that requires them, not before.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Account-deletion-is-a-store-requirement-here-is-the-cheapest-compliant-version/</id>
    <link href="https://chiliec.github.io/post/Account-deletion-is-a-store-requirement-here-is-the-cheapest-compliant-version/"/>
    <published>2026-09-24T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p>Both app stores require that if your app lets someone create an account, it must also let them delete it. I shipped this for <a href="https://escaperoguelike.com/">Escape Roguelike</a>, a Godot game with a Hono backend, as one DELETE endpoint, one static HTML page, and a confirmation modal in the settings screen. No third-party deletion service.]]>
    </summary>
    <title>Account deletion is a store requirement, here is the cheapest compliant version</title>
    <updated>2026-09-24T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="macos" scheme="https://chiliec.github.io/tags/macos/"/>
    <category term="tooling" scheme="https://chiliec.github.io/tags/tooling/"/>
    <category term="xcode" scheme="https://chiliec.github.io/tags/xcode/"/>
    <content>
      <![CDATA[<p>My MacBook has 228 GB of usable disk, and I run out of it often enough that disk cleanup is a recurring routine with its own notes. So I have the receipts: what fills it, how fast, and what I do about it. <span id="more"></span></p><h2 id="The-map"><a href="#The-map" class="headerlink" title="The map"></a>The map</h2><p><code>diskutil info</code> on the Data volume, right now:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">$ diskutil info /System/Volumes/Data | grep -E &quot;Volume (Used|Free)|Container Total&quot;</span><br><span class="line">   Container Total Space:     245.1 GB (245107195904 Bytes)</span><br><span class="line">   Volume Used Space:         210.8 GB (210836877312 Bytes)</span><br><span class="line">   Volume Free Space:         9.0 GB (8988352512 Bytes)</span><br></pre></td></tr></table></figure><p>245.1 GB in decimal units is 228 GiB, which is where my 228 figure comes from. 210 GB of it is used and 9 GB is free, which is a bad day but not an unusual one. Here’s where the big developer-tool consumers sit, from <code>du -sh</code> on the paths I check most often:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">15G   ~/Library/Containers/com.docker.docker/Data</span><br><span class="line">11G   ~/Library/Developer/CoreSimulator</span><br><span class="line">7.5G  /opt/homebrew            (mostly Cellar: 4.2G)</span><br><span class="line">7.4G  ~/Library/Application Support</span><br><span class="line">6.1G  node_modules under ~/Develop (all projects)</span><br><span class="line">1.1G  ~/Library/Application Support/Code</span><br><span class="line">1.0G  ~/Library/Developer/Xcode/DerivedData</span><br><span class="line">932M  ~/.claude</span><br><span class="line">830M  ~/.npm</span><br><span class="line">688M  ~/.cache</span><br><span class="line">554M  ~/Library/Caches/ms-playwright</span><br><span class="line">485M  ~/.gradle</span><br></pre></td></tr></table></figure><p>Leaving out Application Support (mostly app data, not cache), that’s about 44 GB of developer tooling, caches and build artifacts that can be reinstalled or rebuilt. The rest of the 210 GB is everything else on the machine.</p><h2 id="One-line-per-category"><a href="#One-line-per-category" class="headerlink" title="One line per category"></a>One line per category</h2><ul><li><strong>Docker</strong> — the VM’s disk image doesn’t shrink when you delete something inside it. <code>docker builder prune</code> and <code>docker system prune</code> are the levers.</li><li><strong>Xcode DerivedData</strong> — rebuilds itself on the next build. Always safe to delete, rarely worth doing manually because it’s small compared to everything else.</li><li><strong>CoreSimulator</strong> — every simulator device you’ve ever booted stays at full size, shut down or not, until you delete the device (not just the OS runtime).</li><li><strong>node_modules</strong> — reinstalls from a lockfile in seconds. Only worth touching for projects you’re not actively working in; 6.1 GB across every active project isn’t worth curating.</li><li><strong>Package caches</strong> (npm, pip, Homebrew, Playwright, Gradle) — each one is just downloaded artifacts, keyed by version. Safe to wipe, refills on the next install.</li><li><strong>~&#x2F;.claude</strong> — mostly <code>projects/</code>, the session transcripts Claude Code keeps per project (604 MB of the 932 MB total).</li></ul><h2 id="Why-it-keeps-coming-back"><a href="#Why-it-keeps-coming-back" class="headerlink" title="Why it keeps coming back"></a>Why it keeps coming back</h2><p>The categories above are honest but slow-moving. The fast-moving one is Docker’s build cache: one recent cleanup found 12.4 GB of it from rebuilding a single project’s image repeatedly that day — roughly a 12 GB&#x2F;day rate while that project is under active development. <code>docker builder prune</code> after a rebuild session is the fix; there’s no cache-eviction policy that does it for me.</p><p>mediaanalysisd — the background process that runs Photos’ on-device ML indexing — is the other one that moves without warning: I’ve seen it anywhere from 1.8 GB to 5.6 GB across different cleanup sessions. It’s not something I asked for, and it comes back after every clear.</p><h2 id="Full-Disk-Access-changes-what-you-can-trust"><a href="#Full-Disk-Access-changes-what-you-can-trust" class="headerlink" title="Full Disk Access changes what you can trust"></a>Full Disk Access changes what you can trust</h2><p>One thing worth knowing before you believe any <code>du</code> number on macOS: without Full Disk Access, some tools get silently truncated results. I hit this with my terminal app — <code>du -x ~</code> was reporting over a hundred paths as “Operation not permitted” and skipping them entirely, no error banner, just a smaller total than reality. Granting Full Disk Access to the terminal fixed it. If your cleanup math never seems to add up, check that first.</p><h2 id="What-Scrub-automates"><a href="#What-Scrub-automates" class="headerlink" title="What Scrub automates"></a>What Scrub automates</h2><p>The actual manual routine — run <code>du</code> on a dozen paths, decide what’s stale versus what’s mid-project, delete, repeat next week — is exactly the kind of task that should be a tool instead of a habit. I’ve been building one: Scrub, a macOS app with 11 scanners split into three safety tiers:</p><ul><li><strong>Always safe</strong>: npm, Yarn, pnpm, and Homebrew caches.</li><li><strong>Usually safe</strong>: Xcode DerivedData, Playwright, browser caches.</li><li><strong>Review needed</strong>: iOS device support files, simulator runtimes, node_modules, Trash.</li></ul><p>Each scanner reports its size, then a preview sheet lets me check or uncheck individual items before anything is deleted — nothing runs unattended. An mtime filter keeps it from flagging a <code>node_modules</code> folder that’s been touched in the last 30 days as stale, which is the mistake I’d otherwise make by hand under time pressure. It’s still a dormant side project, ad-hoc signed for my own machine rather than notarized and shipped, but the scanning and preview logic already replaces the du-and-guess part of every cleanup session I run.</p><p>The disk itself isn’t getting bigger. Until it does, this is the routine.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Where-210-GB-of-a-developer-Mac-goes/</id>
    <link href="https://chiliec.github.io/post/Where-210-GB-of-a-developer-Mac-goes/"/>
    <published>2026-09-23T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p>My MacBook has 228 GB of usable disk, and I run out of it often enough that disk cleanup is a recurring routine with its own notes. So I have the receipts: what fills it, how fast, and what I do about it.]]>
    </summary>
    <title>Where 210 GB of a developer Mac goes</title>
    <updated>2026-09-23T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="macos" scheme="https://chiliec.github.io/tags/macos/"/>
    <category term="swift" scheme="https://chiliec.github.io/tags/swift/"/>
    <category term="keychain" scheme="https://chiliec.github.io/tags/keychain/"/>
    <category term="oauth" scheme="https://chiliec.github.io/tags/oauth/"/>
    <content>
      <![CDATA[<p><a href="/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/">ClaudeBar</a> started as a single-account app: sign in once, watch the number in the menu bar. I use more than one Claude.ai account, and re-authenticating every time I wanted to check a different one defeated the point of a menu bar app. I ended up building two separate things to solve two separate account-switching problems — one inside ClaudeBar, one as a standalone CLI for Claude Code logins. <span id="more"></span></p><h2 id="One-Keychain-item-not-one-per-account"><a href="#One-Keychain-item-not-one-per-account" class="headerlink" title="One Keychain item, not one per account"></a>One Keychain item, not one per account</h2><p>ClaudeBar’s OAuth credentials used to live in a single Keychain item: one JSON blob with an access token, a refresh token and an expiry. Adding accounts meant deciding whether to give each one its own Keychain item or keep one item for all of them. I went with one item — an <code>AccountSet</code>:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">struct</span> <span class="title class_">Account</span>: <span class="title class_ inherited__">Codable</span>, <span class="title class_ inherited__">Equatable</span>, <span class="title class_ inherited__">Identifiable</span>, <span class="title class_ inherited__">Sendable</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">let</span> id: <span class="type">String</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">var</span> label: <span class="type">String</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">var</span> credentials: <span class="type">OAuthCredentials</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="keyword">struct</span> <span class="title class_">AccountSet</span>: <span class="title class_ inherited__">Codable</span>, <span class="title class_ inherited__">Equatable</span>, <span class="title class_ inherited__">Sendable</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">var</span> accounts: [<span class="type">Account</span>]</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">var</span> activeID: <span class="type">String</span>?</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>AccountStore</code> reads and writes that whole struct as one JSON string under the Keychain account key <code>oauth_accounts</code>. The first time it runs against an existing single-account install, it migrates the old lone-credential blob into a one-account <code>AccountSet</code> and deletes the legacy item. Fewer Keychain entries means fewer places a signing-identity mismatch or an ACL prompt can bite — the app already had that lesson from the <a href="/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/">dev&#x2F;release Keychain service split</a>.</p><h2 id="Refreshing-the-right-account’s-token"><a href="#Refreshing-the-right-account’s-token" class="headerlink" title="Refreshing the right account’s token"></a>Refreshing the right account’s token</h2><p>Only the active account’s credentials get refreshed and polled — the others sit in the Keychain untouched until you switch to them. The threshold is five minutes:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">/// Refresh this far ahead of expiry so a poll never races the clock.</span></span><br><span class="line"><span class="keyword">static</span> <span class="keyword">let</span> refreshWindow: <span class="type">TimeInterval</span> <span class="operator">=</span> <span class="number">300</span></span><br></pre></td></tr></table></figure><p><code>refreshUsage()</code> checks that window before every fetch, and separately, a <code>401</code> from the API triggers one refresh and one retry:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">do</span> &#123;</span><br><span class="line">    usage <span class="operator">=</span> <span class="keyword">try</span> <span class="keyword">await</span> <span class="type">ClaudeAPIClient</span>.fetchOAuthUsage(accessToken: creds.accessToken)</span><br><span class="line">&#125; <span class="keyword">catch</span> <span class="type">APIError</span>.sessionExpired &#123;</span><br><span class="line">    creds <span class="operator">=</span> <span class="keyword">try</span> <span class="keyword">await</span> refreshAndSave(creds)</span><br><span class="line">    usage <span class="operator">=</span> <span class="keyword">try</span> <span class="keyword">await</span> <span class="type">ClaudeAPIClient</span>.fetchOAuthUsage(accessToken: creds.accessToken)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>A refreshed token gets written back into that one account’s slot in the <code>AccountSet</code>, not the whole blob replaced blindly — <code>switchTo(id:)</code> and <code>removeAccount(id:)</code> both go through the same <code>persistAccounts()</code> call so the Keychain item never gets out of sync with what’s on screen.</p><h2 id="Switching-without-a-flicker"><a href="#Switching-without-a-flicker" class="headerlink" title="Switching without a flicker"></a>Switching without a flicker</h2><p><code>switchTo(id:)</code> doesn’t clear the usage numbers immediately — it keeps the previous account’s numbers on screen but marks them stale:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">func</span> <span class="title function_">switchTo</span>(<span class="params">id</span>: <span class="type">String</span>) &#123;</span><br><span class="line">    <span class="keyword">guard</span> id <span class="operator">!=</span> activeID, accounts.contains(where: &#123; <span class="variable">$0</span>.id <span class="operator">==</span> id &#125;) <span class="keyword">else</span> &#123; <span class="keyword">return</span> &#125;</span><br><span class="line">    activeID <span class="operator">=</span> id</span><br><span class="line">    <span class="comment">// Stale usage deliberately survives: it&#x27;s the old account&#x27;s, so it gets</span></span><br><span class="line">    <span class="comment">// redacted rather than shown, but keeping the layout stops the panel</span></span><br><span class="line">    <span class="comment">// resizing twice.</span></span><br><span class="line">    resetSession(keepStaleUsage: <span class="literal">true</span>)</span><br><span class="line">    <span class="keyword">try?</span> persistAccounts()</span><br><span class="line">    startPolling()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The view applies <code>.redacted(reason: .placeholder)</code> while <code>isSwitchingAccount</code> is true, and that flag only clears once the new account’s fetch finishes. Without this, switching accounts meant the panel emptying out, resizing to fit nothing, then resizing again once the new numbers landed — two visible jumps for what should read as one instant switch.</p><h2 id="The-feature-I-shipped-and-reverted"><a href="#The-feature-I-shipped-and-reverted" class="headerlink" title="The feature I shipped and reverted"></a>The feature I shipped and reverted</h2><p>For one commit, the menu bar label showed every account’s usage at once — <code>24% · 100%</code> instead of just the active account’s number. It also meant polling every account each cycle instead of just the active one. I reverted it the same day:</p><blockquote><p>The label shows the active account only, which is what the switcher is for — listing every account made the item wide and put three numbers in a spot that answers one question.</p></blockquote><p>Reverting also dropped the extra polling, back to one request per five-minute cycle regardless of how many accounts are stored.</p><h2 id="Panel-positioning"><a href="#Panel-positioning" class="headerlink" title="Panel positioning"></a>Panel positioning</h2><p>ClaudeBar’s panel <a href="/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/">isn’t built on <code>MenuBarExtra</code></a> — SwiftUI’s API gives no control over where the panel opens relative to the status item, and multiple accounts made that worse, not better: switching accounts can change the label from <code>&quot;9%&quot;</code> to <code>&quot;100%&quot;</code>, and a resizing status item drags its own anchor point sideways. The fix has two parts. First, the status item gets a fixed width, measured from the widest possible label rather than guessed:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">button.title <span class="operator">=</span> <span class="string">&quot;100%&quot;</span></span><br><span class="line">statusItem.length <span class="operator">=</span> button.intrinsicContentSize.width</span><br></pre></td></tr></table></figure><p>Second, the panel hangs from a fixed edge of that now-stable item:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">func</span> <span class="title function_">menuBarPanelOrigin</span>(</span><br><span class="line">    <span class="params">statusItem</span>: <span class="type">CGRect</span>,</span><br><span class="line">    <span class="params">panelSize</span>: <span class="type">CGSize</span>,</span><br><span class="line">    <span class="params">visibleFrame</span>: <span class="type">CGRect</span>,</span><br><span class="line">    <span class="params">gap</span>: <span class="type">CGFloat</span> <span class="operator">=</span> <span class="number">4</span></span><br><span class="line">) -&gt; <span class="type">CGPoint</span> &#123;</span><br><span class="line">    <span class="keyword">let</span> leftLimit <span class="operator">=</span> visibleFrame.minX <span class="operator">+</span> gap</span><br><span class="line">    <span class="keyword">let</span> rightLimit <span class="operator">=</span> visibleFrame.maxX <span class="operator">-</span> panelSize.width <span class="operator">-</span> gap</span><br><span class="line">    <span class="keyword">let</span> x <span class="operator">=</span> <span class="built_in">max</span>(leftLimit, <span class="built_in">min</span>(statusItem.minX, rightLimit))</span><br><span class="line">    <span class="keyword">return</span> <span class="type">CGPoint</span>(x: x, y: statusItem.minY <span class="operator">-</span> panelSize.height <span class="operator">-</span> gap)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>visibleFrame</code> clamping matters for a status item near the screen edge or on a secondary display — without it, a panel near the right edge of the menu bar would try to open partly off-screen.</p><h2 id="A-different-account-a-different-Keychain"><a href="#A-different-account-a-different-Keychain" class="headerlink" title="A different account, a different Keychain"></a>A different account, a different Keychain</h2><p>Switching <em>Claude.ai</em> accounts inside ClaudeBar solved one problem. Switching <em>Claude Code</em> CLI logins is a separate one, with its own storage: a Keychain item called <code>Claude Code-credentials</code>, plus <code>oauthAccount</code> and <code>userID</code> fields in <code>~/.claude.json</code>. Claude Code itself only ever holds one login — running <code>/login</code> with a second account silently overwrites the first.</p><p>I wrote <a href="https://github.com/chiliec/claude-account"><code>claude-account</code></a> for this: a single bash script, under 170 lines, no dependency beyond what macOS already ships (<code>security</code>, <code>jq</code>, bash).</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">$ claude-account</span><br><span class="line"> 1) * personal          me@gmail.com</span><br><span class="line"> 2)   work             me@acme.com</span><br><span class="line"> 3)   client           me@client.io</span><br><span class="line">account [1-3]: 2</span><br><span class="line">switched to &#x27;work&#x27; (me@acme.com) — start a new Claude Code session</span><br></pre></td></tr></table></figure><p><code>save</code> copies the current token into a parallel Keychain item (<code>Claude Code-credentials:&lt;name&gt;</code>) and writes the non-secret identity block — email, org name, account UUID — to <code>~/.claude/accounts/&lt;name&gt;.json</code>. <code>use</code> puts both pieces back. Tokens never touch disk, only the Keychain; the identity file is metadata only. It shipped as four commits, with CI running shellcheck on Ubuntu, since the macOS GitHub Actions image doesn’t ship it.</p><p>Two account-switching problems, two Keychain stores, two small tools — neither one needed the other’s code.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Multiple-OAuth-accounts-in-one-menu-bar-app/</id>
    <link href="https://chiliec.github.io/post/Multiple-OAuth-accounts-in-one-menu-bar-app/"/>
    <published>2026-09-22T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p><a href="/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/">ClaudeBar</a> started as a single-account app: sign in once, watch the number in the menu bar. I use more than one Claude.ai account, and re-authenticating every time I wanted to check a different one defeated the point of a menu bar app. I ended up building two separate things to solve two separate account-switching problems — one inside ClaudeBar, one as a standalone CLI for Claude Code logins.]]>
    </summary>
    <title>Multiple OAuth accounts in one menu bar app</title>
    <updated>2026-09-22T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="macos" scheme="https://chiliec.github.io/tags/macos/"/>
    <category term="sparkle" scheme="https://chiliec.github.io/tags/sparkle/"/>
    <category term="github-actions" scheme="https://chiliec.github.io/tags/github-actions/"/>
    <category term="notarization" scheme="https://chiliec.github.io/tags/notarization/"/>
    <content>
      <![CDATA[<p><a href="https://github.com/mshirlaw/notify-gcal-menu">notify-gcal-menu</a> is a menu bar app that shows a system notification before a Google Calendar event starts. It’s not mine — I contributed Sparkle auto-update and a tag-triggered release pipeline as two stacked pull requests after the maintainer approved the scope on an issue. That constraint (no shared machine, no access to the maintainer’s signing keys, has to merge cleanly into someone else’s repo) is what pushed the whole pipeline into GitHub Actions, instead of the local release script I use for <a href="/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/">ClaudeBar</a>. <span id="more"></span></p><h2 id="The-recipe"><a href="#The-recipe" class="headerlink" title="The recipe"></a>The recipe</h2><p>Everything below is downstream of one command:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">git tag v1.2.3</span><br><span class="line">git push origin v1.2.3</span><br></pre></td></tr></table></figure><p>A <code>v*</code> tag push builds the app, stamps the version, signs it with a Developer ID certificate, notarizes and staples it, publishes a GitHub release, and commits a new <code>&lt;item&gt;</code> to <code>appcast.xml</code> on <code>main</code> so existing installs see the update. No version-bump commit: <code>Info.plist</code> keeps a placeholder and the tag is the source of truth, stamped into the bundle at build time.</p><h2 id="The-workflow"><a href="#The-workflow" class="headerlink" title="The workflow"></a>The workflow</h2><p><code>.github/workflows/release.yml</code>, triggered on <code>push: tags: [&quot;v*&quot;]</code>:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br><span class="line">43</span><br><span class="line">44</span><br></pre></td><td class="code"><pre><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Write</span> <span class="string">Secrets.plist</span></span><br><span class="line">  <span class="attr">env:</span></span><br><span class="line">    <span class="attr">GOOGLE_CLIENT_ID:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GOOGLE_CLIENT_ID</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">GOOGLE_CLIENT_SECRET:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.GOOGLE_CLIENT_SECRET</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">    SECRETS=Sources/NotifyGCalMenu/Resources/Secrets.plist</span></span><br><span class="line"><span class="string">    cp &quot;$SECRETS.example&quot; &quot;$SECRETS&quot;</span></span><br><span class="line"><span class="string">    plutil -replace GoogleClientID -string &quot;$GOOGLE_CLIENT_ID&quot; &quot;$SECRETS&quot;</span></span><br><span class="line"><span class="string">    plutil -replace GoogleClientSecret -string &quot;$GOOGLE_CLIENT_SECRET&quot; &quot;$SECRETS&quot;</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Import</span> <span class="string">Developer</span> <span class="string">ID</span> <span class="string">certificate</span></span><br><span class="line">  <span class="attr">env:</span></span><br><span class="line">    <span class="attr">CERT_P12:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.DEVELOPER_ID_CERT_P12</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">CERT_PASSWORD:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.DEVELOPER_ID_CERT_PASSWORD</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">    KEYCHAIN=&quot;$RUNNER_TEMP/signing.keychain-db&quot;</span></span><br><span class="line"><span class="string">    KEYCHAIN_PASSWORD=&quot;$(uuidgen)&quot;</span></span><br><span class="line"><span class="string">    echo &quot;$CERT_P12&quot; | base64 --decode &gt; &quot;$RUNNER_TEMP/cert.p12&quot;</span></span><br><span class="line"><span class="string">    security create-keychain -p &quot;$KEYCHAIN_PASSWORD&quot; &quot;$KEYCHAIN&quot;</span></span><br><span class="line"><span class="string">    security set-keychain-settings -lut 3600 &quot;$KEYCHAIN&quot;</span></span><br><span class="line"><span class="string">    security unlock-keychain -p &quot;$KEYCHAIN_PASSWORD&quot; &quot;$KEYCHAIN&quot;</span></span><br><span class="line"><span class="string">    security import &quot;$RUNNER_TEMP/cert.p12&quot; -k &quot;$KEYCHAIN&quot; -P &quot;$CERT_PASSWORD&quot; -T /usr/bin/codesign</span></span><br><span class="line"><span class="string">    security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k &quot;$KEYCHAIN_PASSWORD&quot; &quot;$KEYCHAIN&quot; &gt;/dev/null</span></span><br><span class="line"><span class="string">    security list-keychains -d user -s &quot;$KEYCHAIN&quot; $(security list-keychains -d user | tr -d &#x27;&quot;&#x27;)</span></span><br><span class="line"><span class="string"></span></span><br><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Build,</span> <span class="string">stamp</span> <span class="string">version,</span> <span class="string">and</span> <span class="string">sign</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">./Scripts/build.sh</span> <span class="string">release</span></span><br><span class="line"></span><br><span class="line"><span class="bullet">-</span> <span class="attr">name:</span> <span class="string">Notarize</span> <span class="string">and</span> <span class="string">staple</span></span><br><span class="line">  <span class="attr">env:</span></span><br><span class="line">    <span class="attr">NOTARY_KEY_P8:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.NOTARY_API_KEY_P8</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">NOTARY_KEY_ID:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.NOTARY_API_KEY_ID</span> <span class="string">&#125;&#125;</span></span><br><span class="line">    <span class="attr">NOTARY_ISSUER_ID:</span> <span class="string">$&#123;&#123;</span> <span class="string">secrets.NOTARY_API_ISSUER_ID</span> <span class="string">&#125;&#125;</span></span><br><span class="line">  <span class="attr">run:</span> <span class="string">|</span></span><br><span class="line"><span class="string">    echo &quot;$NOTARY_KEY_P8&quot; | base64 --decode &gt; &quot;$RUNNER_TEMP/notary.p8&quot;</span></span><br><span class="line"><span class="string">    ditto -c -k --keepParent build/NotifyGCalMenu.app &quot;$RUNNER_TEMP/submission.zip&quot;</span></span><br><span class="line"><span class="string">    xcrun notarytool submit &quot;$RUNNER_TEMP/submission.zip&quot; \</span></span><br><span class="line"><span class="string">      --key &quot;$RUNNER_TEMP/notary.p8&quot; \</span></span><br><span class="line"><span class="string">      --key-id &quot;$NOTARY_KEY_ID&quot; \</span></span><br><span class="line"><span class="string">      --issuer &quot;$NOTARY_ISSUER_ID&quot; \</span></span><br><span class="line"><span class="string">      --wait</span></span><br><span class="line"><span class="string">    xcrun stapler staple build/NotifyGCalMenu.app</span></span><br><span class="line"><span class="string">    ditto -c -k --keepParent build/NotifyGCalMenu.app &quot;NotifyGCalMenu-$VERSION.zip&quot;</span></span><br><span class="line"><span class="string">    spctl --assess --type execute -vv build/NotifyGCalMenu.app</span></span><br></pre></td></tr></table></figure><p>A step ahead of all that checks <code>Info.plist</code> for the placeholder <code>SUPublicEDKey</code> and fails the job before spending ten minutes on notarization — publishing with a placeholder key would sign the appcast entry with a key no installed copy trusts.</p><p>The last step signs the notarized zip with Sparkle’s <code>sign_update</code>, creates the GitHub release with <code>gh release create</code>, and splices a new <code>&lt;item&gt;</code> into <code>appcast.xml</code> after a marker comment, then commits it back to <code>main</code>:</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">SIGN_UPDATE=<span class="string">&quot;<span class="subst">$(find .build/artifacts -name sign_update -type f | head -1)</span>&quot;</span></span><br><span class="line">SIGNATURE=<span class="string">&quot;<span class="subst">$(echo <span class="string">&quot;<span class="variable">$SPARKLE_PRIVATE_KEY</span>&quot;</span> | <span class="string">&quot;<span class="variable">$SIGN_UPDATE</span>&quot;</span> --ed-key-file - <span class="string">&quot;<span class="variable">$ZIP</span>&quot;</span>)</span>&quot;</span></span><br><span class="line">gh release create <span class="string">&quot;<span class="variable">$GITHUB_REF_NAME</span>&quot;</span> <span class="string">&quot;<span class="variable">$ZIP</span>&quot;</span> --title <span class="string">&quot;<span class="variable">$VERSION</span>&quot;</span> --generate-notes</span><br><span class="line">sed -i <span class="string">&#x27;&#x27;</span> -e <span class="string">&quot;/&lt;!-- Releases are added here/r <span class="variable">$RUNNER_TEMP</span>/item.xml&quot;</span> appcast.xml</span><br><span class="line">git commit -m <span class="string">&quot;chore: add <span class="variable">$GITHUB_REF_NAME</span> to the appcast&quot;</span> appcast.xml</span><br><span class="line">git push origin main</span><br></pre></td></tr></table></figure><p><code>sign_update</code> is located with <code>find</code> rather than hardcoded, because it ships inside the Sparkle SwiftPM artifact the build just fetched, and a layout change in a future Sparkle release shouldn’t strand an otherwise-signed build.</p><h2 id="Eight-secrets-and-CI-needs-none-of-them"><a href="#Eight-secrets-and-CI-needs-none-of-them" class="headerlink" title="Eight secrets, and CI needs none of them"></a>Eight secrets, and CI needs none of them</h2><p>The release job reads eight repository secrets: <code>GOOGLE_CLIENT_ID</code>, <code>GOOGLE_CLIENT_SECRET</code> (the app’s own OAuth client), <code>SPARKLE_PRIVATE_KEY</code> (EdDSA, from <code>generate_keys</code>), <code>DEVELOPER_ID_CERT_P12</code> and <code>DEVELOPER_ID_CERT_PASSWORD</code> (a base64-encoded <code>.p12</code> export), and <code>NOTARY_API_KEY_P8</code>, <code>NOTARY_API_KEY_ID</code>, <code>NOTARY_API_ISSUER_ID</code> (an App Store Connect API key). The separate <code>ci.yml</code> that builds every push and pull request needs none of them — <code>build.sh</code> seeds <code>Secrets.plist</code> from a committed <code>.example</code> file when the real one is missing, which is also what makes builds from forked pull requests work, since forks can’t read repository secrets.</p><h2 id="What-broke-first"><a href="#What-broke-first" class="headerlink" title="What broke first"></a>What broke first</h2><p><strong>A fresh clone couldn’t build.</strong> <code>Package.swift</code> declares <code>Secrets.plist</code> as a SwiftPM resource, but the file is gitignored, so a machine that had never run the app before failed with “type ‘Bundle’ has no member ‘module’” — not a missing-file warning, a build failure. The fix lives in <code>build.sh</code>: if the file is absent, copy it from the tracked <code>.example</code> before building.</p><p><strong><code>--deep --identifier</code> corrupted every nested Sparkle component.</strong> The original ad-hoc signing command was:</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">codesign --force --deep --identifier <span class="string">&quot;<span class="variable">$BUNDLE_ID</span>&quot;</span> -s - <span class="string">&quot;<span class="variable">$APP_BUNDLE</span>&quot;</span></span><br></pre></td></tr></table></figure><p>Checked against a scratch bundle with a real <code>Sparkle.framework</code> inside: <code>--deep</code> re-signs everything it finds with the outer <code>--identifier</code>, so <code>Updater.app</code>, both XPC services, and the framework itself all ended up stamped with the app’s own bundle ID (<code>com.notifygcalmenu.menu</code>) instead of their real ones (<code>org.sparkle-project.Sparkle.Updater</code> and the rest). Sparkle looks up its installer XPC service by bundle identifier, so a downloaded update would fail to install. The fix is inside-out signing — each nested component first, without <code>--identifier</code>, then the app bundle last:</p><figure class="highlight sh"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="string">&quot;<span class="variable">$&#123;SIGN[@]&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$SPARKLE_BUNDLED</span>/Versions/B/XPCServices/Downloader.xpc&quot;</span></span><br><span class="line"><span class="string">&quot;<span class="variable">$&#123;SIGN[@]&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$SPARKLE_BUNDLED</span>/Versions/B/XPCServices/Installer.xpc&quot;</span></span><br><span class="line"><span class="string">&quot;<span class="variable">$&#123;SIGN[@]&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$SPARKLE_BUNDLED</span>/Versions/B/Updater.app&quot;</span></span><br><span class="line"><span class="string">&quot;<span class="variable">$&#123;SIGN[@]&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$SPARKLE_BUNDLED</span>/Versions/B/Autoupdate&quot;</span></span><br><span class="line"><span class="string">&quot;<span class="variable">$&#123;SIGN[@]&#125;</span>&quot;</span> <span class="string">&quot;<span class="variable">$SPARKLE_BUNDLED</span>&quot;</span></span><br><span class="line"><span class="string">&quot;<span class="variable">$&#123;SIGN[@]&#125;</span>&quot;</span> --identifier <span class="string">&quot;<span class="variable">$BUNDLE_ID</span>&quot;</span> <span class="string">&quot;<span class="variable">$APP_BUNDLE</span>&quot;</span></span><br></pre></td></tr></table></figure><p><code>--deep</code> stays correct for <em>verifying</em> a bundle afterward (<code>codesign --verify --strict --deep</code>); it’s only signing where it does the wrong thing.</p><h2 id="Where-this-differs-from-ClaudeBar"><a href="#Where-this-differs-from-ClaudeBar" class="headerlink" title="Where this differs from ClaudeBar"></a>Where this differs from ClaudeBar</h2><p>ClaudeBar’s <code>release.sh</code> does the same four things — build, sign, notarize and staple, update the appcast — plus a Homebrew cask bump, but it runs on my own machine by hand, not in CI. That’s a deliberate choice: CI-based notarization means putting the Developer ID certificate, the notarization credentials, and the Sparkle private key into GitHub secrets, and for a one-person project keeping them in Keychain and 1Password is the smaller risk.</p><p>notify-gcal-menu doesn’t have that option. There’s no machine we both have access to, so the certificate and keys have to live somewhere reachable from a tag push, and repository secrets on someone else’s repo are that place. It also has no Homebrew cask — installing means downloading the zip from the GitHub release, so the pipeline stops at <code>gh release create</code> instead of continuing on to a tap.</p><p>One thing both pipelines get right the same way: neither commits or logs a raw secret. The Developer ID certificate goes through a throwaway keychain that’s destroyed with the runner; the notarization key and Sparkle key are piped straight into the tool that needs them and never touch a file that survives the job.</p><p>As I’m writing this, both PRs are still open — the maintainer owns generating the Sparkle key pair and enrolling in the Apple Developer Program, and merging is their call. Locally, everything except the actual signing and notarization is verified: the appcast insertion, version stamping, a fresh-clone build, and the YAML itself (<code>bash -n</code> on every <code>run</code> block, <code>shellcheck</code> clean). The one part that needs a real Developer ID certificate to prove out is the part that can only run once the maintainer flips the switch.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/A-signed-notarized-Sparkle-updated-macOS-release-from-a-git-tag/</id>
    <link href="https://chiliec.github.io/post/A-signed-notarized-Sparkle-updated-macOS-release-from-a-git-tag/"/>
    <published>2026-09-21T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p><a href="https://github.com/mshirlaw/notify-gcal-menu">notify-gcal-menu</a> is a menu bar app that shows a system notification before a Google Calendar event starts. It’s not mine — I contributed Sparkle auto-update and a tag-triggered release pipeline as two stacked pull requests after the maintainer approved the scope on an issue. That constraint (no shared machine, no access to the maintainer’s signing keys, has to merge cleanly into someone else’s repo) is what pushed the whole pipeline into GitHub Actions, instead of the local release script I use for <a href="/post/Shipping-ClaudeBar-a-macOS-menu-bar-app/">ClaudeBar</a>.]]>
    </summary>
    <title>A signed, notarized, Sparkle-updated macOS release from a git tag</title>
    <updated>2026-09-21T10:00:00.000Z</updated>
  </entry>
  <entry>
    <author>
      <name>Vladimir Babin</name>
    </author>
    <category term="swift" scheme="https://chiliec.github.io/tags/swift/"/>
    <category term="codable" scheme="https://chiliec.github.io/tags/codable/"/>
    <category term="api-design" scheme="https://chiliec.github.io/tags/api-design/"/>
    <content>
      <![CDATA[<p>The <a href="https://github.com/VIZ-Blockchain">VIZ blockchain</a> renamed “witness” to “validator” across its whole stack: RPC methods, JSON field names, everything. The JS and PHP client libraries had already been migrated. My job was to bring <code>viz-swift-lib</code>, the Swift SDK, to parity, without breaking any app that already imports it. <span id="more"></span></p><p>That last part is the constraint that shapes everything else. A rename like this touches two different audiences at once: the JSON coming over the wire, and the Swift source code calling into the library. Both need an old name to keep working for a while.</p><h2 id="Two-compatibility-problems-not-one"><a href="#Two-compatibility-problems-not-one" class="headerlink" title="Two compatibility problems, not one"></a>Two compatibility problems, not one</h2><p>On the wire: the node itself still accepts old method and field names for a deprecation window, but it now <em>returns</em> the new ones. So the library’s decoders have to accept both <code>witness</code> and <code>validator</code> keys in incoming JSON, while its encoders should only ever write the new key. There’s no reason to keep sending a name the node is phasing out.</p><p>In Swift source: any app that does <code>block.witness</code> needs to keep compiling. If a rename ships as a hard break, every downstream app needs a same-day fix or it’s stuck pinning an old version. The library needed the old identifiers to still exist, just marked deprecated, so Xcode shows a warning with the new name to switch to instead of a compile error.</p><h2 id="Dual-key-decode-canonical-only-encode"><a href="#Dual-key-decode-canonical-only-encode" class="headerlink" title="Dual-key decode, canonical-only encode"></a>Dual-key decode, canonical-only encode</h2><p>For every struct with a renamed field, I dropped the synthesized <code>Decodable</code> and wrote a manual <code>init(from:)</code> that tries the new key first and falls back to the old one. Here’s the actual code for <code>AccountValidatorVote</code> (was <code>AccountWitnessVote</code>):</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">public</span> <span class="keyword">struct</span> <span class="title class_">AccountValidatorVote</span>: <span class="title class_ inherited__">OperationType</span>, <span class="title class_ inherited__">Equatable</span> &#123;</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">var</span> account: <span class="type">String</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">var</span> validator: <span class="type">String</span></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">var</span> approve: <span class="type">Bool</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">init</span>(<span class="params">account</span>: <span class="type">String</span>, <span class="params">validator</span>: <span class="type">String</span>, <span class="params">approve</span>: <span class="type">Bool</span>) &#123;</span><br><span class="line">        <span class="keyword">self</span>.account <span class="operator">=</span> account</span><br><span class="line">        <span class="keyword">self</span>.validator <span class="operator">=</span> validator</span><br><span class="line">        <span class="keyword">self</span>.approve <span class="operator">=</span> approve</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">@available</span>(<span class="operator">*</span>, deprecated, message: <span class="string">&quot;Use init(account:validator:approve:)&quot;</span>)</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">init</span>(<span class="params">account</span>: <span class="type">String</span>, <span class="params">witness</span>: <span class="type">String</span>, <span class="params">approve</span>: <span class="type">Bool</span>) &#123;</span><br><span class="line">        <span class="keyword">self</span>.<span class="keyword">init</span>(account: account, validator: witness, approve: approve)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">@available</span>(<span class="operator">*</span>, deprecated, renamed: <span class="string">&quot;validator&quot;</span>)</span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">var</span> witness: <span class="type">String</span> &#123;</span><br><span class="line">        <span class="keyword">get</span> &#123; validator &#125;</span><br><span class="line">        <span class="keyword">set</span> &#123; validator <span class="operator">=</span> newValue &#125;</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">enum</span> <span class="title class_">CodingKeys</span>: <span class="title class_ inherited__">String</span>, <span class="title class_ inherited__">CodingKey</span> &#123;</span><br><span class="line">        <span class="keyword">case</span> account, validator, approve</span><br><span class="line">        <span class="keyword">case</span> legacyWitness <span class="operator">=</span> <span class="string">&quot;witness&quot;</span></span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">init</span>(<span class="params">from</span> <span class="params">decoder</span>: <span class="type">Decoder</span>) <span class="keyword">throws</span> &#123;</span><br><span class="line">        <span class="keyword">let</span> c <span class="operator">=</span> <span class="keyword">try</span> decoder.container(keyedBy: <span class="type">CodingKeys</span>.<span class="keyword">self</span>)</span><br><span class="line">        <span class="keyword">self</span>.account <span class="operator">=</span> <span class="keyword">try</span> c.decode(<span class="type">String</span>.<span class="keyword">self</span>, forKey: .account)</span><br><span class="line">        <span class="keyword">self</span>.approve <span class="operator">=</span> <span class="keyword">try</span> c.decode(<span class="type">Bool</span>.<span class="keyword">self</span>,   forKey: .approve)</span><br><span class="line">        <span class="keyword">self</span>.validator <span class="operator">=</span> <span class="keyword">try</span> (<span class="keyword">try?</span> c.decode(<span class="type">String</span>.<span class="keyword">self</span>, forKey: .validator))</span><br><span class="line">                      <span class="operator">??</span> c.decode(<span class="type">String</span>.<span class="keyword">self</span>, forKey: .legacyWitness)</span><br><span class="line">    &#125;</span><br><span class="line"></span><br><span class="line">    <span class="keyword">public</span> <span class="keyword">func</span> <span class="title function_">encode</span>(<span class="params">to</span> <span class="params">encoder</span>: <span class="type">Encoder</span>) <span class="keyword">throws</span> &#123;</span><br><span class="line">        <span class="keyword">var</span> c <span class="operator">=</span> encoder.container(keyedBy: <span class="type">CodingKeys</span>.<span class="keyword">self</span>)</span><br><span class="line">        <span class="keyword">try</span> c.encode(account,   forKey: .account)</span><br><span class="line">        <span class="keyword">try</span> c.encode(validator, forKey: .validator)</span><br><span class="line">        <span class="keyword">try</span> c.encode(approve,   forKey: .approve)</span><br><span class="line">    &#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Four things carry the whole migration, and this one struct has all of them: a deprecated init that forwards to the canonical one, a deprecated computed property (with a setter, so old mutating code still compiles) that forwards to the new field, a <code>legacyWitness</code> <code>CodingKeys</code> case that only the decoder touches, and an explicit <code>encode(to:)</code> that never writes it. The <code>try (try? …) ?? …</code> shape matters. The first draft of the plan had <code>(try? …) ?? c.decode(…)</code> with no outer <code>try</code>, which doesn’t compile: the right side of <code>??</code> is a throwing autoclosure, and <code>try</code> can’t go inside it on the right of a binary operator. Marking the whole expression with <code>try</code> is the version that works.</p><p>Renamed enum cases follow the same idea without needing dual keys, since <code>OperationId</code> encodes as a string and decodes through an explicit switch:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">case</span> <span class="string">&quot;witness_update&quot;</span>, <span class="string">&quot;validator_update&quot;</span>: <span class="keyword">self</span> <span class="operator">=</span> .validator_update</span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;account_witness_vote&quot;</span>, <span class="string">&quot;account_validator_vote&quot;</span>: <span class="keyword">self</span> <span class="operator">=</span> .account_validator_vote</span><br><span class="line"><span class="keyword">case</span> <span class="string">&quot;account_witness_proxy&quot;</span>, <span class="string">&quot;account_validator_proxy&quot;</span>: <span class="keyword">self</span> <span class="operator">=</span> .account_validator_proxy</span><br></pre></td></tr></table></figure><p>Encoding writes <code>&quot;\(self)&quot;</code>, so once the case is renamed, encoding the new name is automatic — only decoding needs both spellings.</p><h2 id="The-convertFromSnakeCase-trap"><a href="#The-convertFromSnakeCase-trap" class="headerlink" title="The .convertFromSnakeCase trap"></a>The <code>.convertFromSnakeCase</code> trap</h2><p>The root-level response types (<code>DynamicGlobalProperties</code>, <code>ExtendedAccount</code>) are configured with <code>JSONDecoder.keyDecodingStrategy = .convertFromSnakeCase</code>. That strategy converts a JSON key like <code>current_witness</code> to <code>currentWitness</code> <em>before</em> it’s matched against <code>CodingKeys</code> raw values. So the legacy case has to be spelled in camelCase, not snake_case, or it silently never matches:</p><figure class="highlight swift"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">enum</span> <span class="title class_">CodingKeys</span>: <span class="title class_ inherited__">String</span>, <span class="title class_ inherited__">CodingKey</span> &#123;</span><br><span class="line">    <span class="keyword">case</span> headBlockNumber, headBlockId, time, genesisTime,</span><br><span class="line">         currentValidator,</span><br><span class="line">         <span class="comment">// ...</span></span><br><span class="line">         inflationValidatorPercent,</span><br><span class="line">         <span class="comment">// ...</span></span><br><span class="line">    <span class="keyword">case</span> legacyCurrentWitness          <span class="operator">=</span> <span class="string">&quot;currentWitness&quot;</span></span><br><span class="line">    <span class="keyword">case</span> legacyInflationWitnessPercent <span class="operator">=</span> <span class="string">&quot;inflationWitnessPercent&quot;</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Easy to get backwards once, easy to forget on every struct after that, since it looks wrong next to the actual JSON.</p><h2 id="What-moved"><a href="#What-moved" class="headerlink" title="What moved"></a>What moved</h2><p>Five operation structs were renamed with a deprecated typealias each (<code>Operation.WitnessUpdate</code> → <code>ValidatorUpdate</code>, <code>AccountWitnessVote</code> → <code>AccountValidatorVote</code>, <code>AccountWitnessProxy</code> → <code>AccountValidatorProxy</code>, <code>ShutdownWitness</code> → <code>ShutdownValidator</code>, <code>WitnessReward</code> → <code>ValidatorReward</code>), matching the five renamed <code>OperationId</code> cases — the underlying integer values (6, 7, 8, 30, 42) never change, only the JSON string names do. <code>Block.swift</code> renamed <code>witness</code>&#x2F;<code>witnessSignature</code> to <code>validator</code>&#x2F;<code>validatorSignature</code> on three types. <code>API.swift</code> renamed two fields on <code>DynamicGlobalProperties</code> and three on <code>ExtendedAccount</code>. <code>Client.swift</code>‘s namespace-routing switch got old and new RPC method names both mapped to <code>validator_api</code>.</p><p>None of this needed a version bump serious enough to break anyone building against the library: every old identifier still resolves, with a deprecation warning pointing at its replacement. The only thing that doesn’t work is asking the encoder to write the old key on purpose — deprecated names decode, they don’t re-encode, which is deliberate since sending the new name is always what you want.</p><p>I split the work into a 12-task plan, one task per struct or file, each with its own commit, so a decode bug in one place couldn’t hide inside a bigger diff. The last task before merge was a compile-smoke test that references every deprecated typealias, property, and init once, marked <code>@available(*, deprecated)</code> itself so Swift doesn’t complain about the file using the very symbols it exists to protect. If a future cleanup deletes one of those symbols, this test file simply stops compiling, which is the whole point: the moment the deprecated surface actually breaks, this is the file that tells you.</p><h2 id="What-I’d-keep-doing"><a href="#What-I’d-keep-doing" class="headerlink" title="What I’d keep doing"></a>What I’d keep doing</h2><p>Grep for <code>legacyWitness</code> and <code>@available(*, deprecated, renamed:</code> to find every site that needs cleanup once the deprecation window closes, since both markers are deliberately unique strings that exist nowhere else in the codebase. The rename touched five operation types, three files of response structs, and one routing switch, and the two markers find all of it.</p>]]>
    </content>
    <id>https://chiliec.github.io/post/Renaming-a-public-Swift-API-without-breaking-anyone/</id>
    <link href="https://chiliec.github.io/post/Renaming-a-public-Swift-API-without-breaking-anyone/"/>
    <published>2026-09-20T10:00:00.000Z</published>
    <summary>
      <![CDATA[<p>The <a href="https://github.com/VIZ-Blockchain">VIZ blockchain</a> renamed “witness” to “validator” across its whole stack: RPC methods, JSON field names, everything. The JS and PHP client libraries had already been migrated. My job was to bring <code>viz-swift-lib</code>, the Swift SDK, to parity, without breaking any app that already imports it.]]>
    </summary>
    <title>Renaming a public Swift API without breaking anyone</title>
    <updated>2026-09-20T10:00:00.000Z</updated>
  </entry>
</feed>
