<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:media="http://search.yahoo.com/mrss/"><channel><title>hrih.si | Notes</title><description>Short, practical notes by Hatim Rih: one question each, with the exact config, command or code.</description><link>https://hrih.si/</link><atom:link href="https://pubsubhubbub.appspot.com/" rel="hub"/><atom:link href="https://hrih.si/notes/rss.xml" rel="self" type="application/rss+xml"/><item><title>Check a draft for AI tells, in the browser or from the command line</title><link>https://hrih.si/notes/check-a-draft-for-ai-tells/</link><guid isPermaLink="true">https://hrih.si/notes/check-a-draft-for-ai-tells/</guid><description>The checklist I edit my drafts against runs in two places, a page that marks the lines in your browser and a Python script for files and agents. The commands, and how to read the output.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;I edit every draft for this site against a list of the habits readers associate with AI writing: contrast framing, “-ing” tails, lists of three, flat rhythm, dashes. The list now runs in two places.&lt;/p&gt;
&lt;p&gt;The &lt;a href=&quot;https://hrih.si/tools/ai-tells/&quot;&gt;AI tells checker&lt;/a&gt; is the quick one. Paste a draft, and it marks the lines in the text and explains each pattern beside it. It runs in your browser, so nothing is uploaded.&lt;/p&gt;
&lt;p&gt;For files, or for an agent, the same rules live in my copywriter skill as a Python script with no dependencies:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;python&lt;/span&gt;&lt;span&gt; scripts/check_copy.py&lt;/span&gt;&lt;span&gt; draft.md&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On the checker’s own example, a launch post written with the habits on purpose, the report includes:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;  Findings: 23 (rewrite 15, cap 4, watch 4)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    L3    REWRITE  contrast framing: &quot;It&apos;s not just a calendar — it&apos;s&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    L3    REWRITE  stock phrase: &quot;In today&apos;s&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    L5    REWRITE  &apos;-ing&apos; tail: &quot;, ensuring&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    L5    REWRITE  self-answered question: &quot;The result? Calmer weeks.&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each finding has a line, a level and a hint. Strip and rewrite findings get fixed every time; cap and watch findings only matter when they repeat or cluster. The script gives no score and no verdict, on purpose: a flagged line can be right in context.&lt;/p&gt;
&lt;p&gt;If you have a sample of your own writing, compare the draft with it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;python&lt;/span&gt;&lt;span&gt; scripts/check_copy.py&lt;/span&gt;&lt;span&gt; draft.md&lt;/span&gt;&lt;span&gt; --voice&lt;/span&gt;&lt;span&gt; sample.md&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The report adds a table of sentence length, punctuation and person per 100 words for both texts and marks the rows that differ by more than 60%. Your sample also raises the dash allowance to one and a half times your own rate, so a writer who uses dashes isn’t flagged for them.&lt;/p&gt;
&lt;p&gt;In Claude Code, the skill runs the script on its own drafts. To install it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;/plugin marketplace add R-Hatim/hrih-skills&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/plugin install copywriter@hrih-skills&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every pattern, with a before and after, is in &lt;a href=&quot;https://hrih.si/articles/signs-of-ai-writing/&quot;&gt;Signs of AI writing&lt;/a&gt;.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/check-a-draft-for-ai-tells.png?v=fbf660eb" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/check-a-draft-for-ai-tells.png?v=fbf660eb" width="1200" height="630"/><category>AI writing</category><category>Editing</category><category>Claude Code</category><enclosure url="https://hrih.si/og/notes/check-a-draft-for-ai-tells.png?v=fbf660eb" length="42995" type="image/png"/></item><item><title>Check a saved patch by its changed lines, not its context</title><link>https://hrih.si/notes/check-a-saved-patch-by-its-changed-lines/</link><guid isPermaLink="true">https://hrih.si/notes/check-a-saved-patch-by-its-changed-lines/</guid><description>A release job checked five files against a saved patch, and another commit that moved the lines around its changes made the patch stop applying. Comparing only the added and removed lines kept the check strict about the job&apos;s own changes.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;To check that some files still hold exactly the changes in a saved patch, compare only the added and removed lines, from the patch and from &lt;code&gt;git diff&lt;/code&gt;. Leave the context out. It belongs to whoever touched the file last, and a commit that moves it shouldn’t stop your check:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;FILES&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;src/layouts/Base.astro src/lib/content.ts scripts/og-images.mjs scripts/content-paths.mjs src/styles/global.css&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;git&lt;/span&gt;&lt;span&gt; diff&lt;/span&gt;&lt;span&gt; --&lt;/span&gt;&lt;span&gt; $FILES &lt;/span&gt;&lt;span&gt;|&lt;/span&gt;&lt;span&gt; grep&lt;/span&gt;&lt;span&gt; -E&lt;/span&gt;&lt;span&gt; &apos;^[+-]&apos;&lt;/span&gt;&lt;span&gt; |&lt;/span&gt;&lt;span&gt; grep&lt;/span&gt;&lt;span&gt; -vE&lt;/span&gt;&lt;span&gt; &apos;^(\+\+\+|---) &apos;&lt;/span&gt;&lt;span&gt; &amp;gt;&lt;/span&gt;&lt;span&gt; /tmp/now.txt&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;grep&lt;/span&gt;&lt;span&gt; -E&lt;/span&gt;&lt;span&gt; &apos;^[+-]&apos;&lt;/span&gt;&lt;span&gt; ai-tells-shared.patch&lt;/span&gt;&lt;span&gt; |&lt;/span&gt;&lt;span&gt; grep&lt;/span&gt;&lt;span&gt; -vE&lt;/span&gt;&lt;span&gt; &apos;^(\+\+\+|---) &apos;&lt;/span&gt;&lt;span&gt; &amp;gt;&lt;/span&gt;&lt;span&gt; /tmp/saved.txt&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;cmp&lt;/span&gt;&lt;span&gt; /tmp/now.txt&lt;/span&gt;&lt;span&gt; /tmp/saved.txt&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The first &lt;code&gt;grep&lt;/code&gt; keeps the lines that start with &lt;code&gt;+&lt;/code&gt; or &lt;code&gt;-&lt;/code&gt;: the changes, plus each file’s two header lines. The second drops the headers, &lt;code&gt;--- a/…&lt;/code&gt; and &lt;code&gt;+++ b/…&lt;/code&gt;. &lt;code&gt;cmp&lt;/code&gt; exits 0 when the two lists match and names the first difference when they don’t, and &lt;code&gt;diff /tmp/saved.txt /tmp/now.txt&lt;/code&gt; shows all of it.&lt;/p&gt;
&lt;h2 id=&quot;why-the-stricter-check-would-have-failed&quot;&gt;Why the stricter check would have failed&lt;/h2&gt;
&lt;p&gt;On 30 October, the AI tells checker for hrih.si sat uncommitted in the site’s checkout, waiting for &lt;a href=&quot;https://hrih.si/notes/scheduled-tasks-stop-at-the-deploy/&quot;&gt;a release job&lt;/a&gt; on 3 November. It shared five files with other work. So the job’s first step checked those five against &lt;code&gt;ai-tells-shared.patch&lt;/code&gt;, to be sure they held this work’s changes and nobody else’s.&lt;/p&gt;
&lt;p&gt;The same evening I committed a change to the note pages’ sidebar. It replaced the two CSS rules right above the checker’s two lines in &lt;code&gt;global.css&lt;/code&gt;. The checker’s lines didn’t change, but its patch now named context that was gone:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;$ git apply --check -R ai-tells-shared.patch&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;error: patch failed: src/styles/global.css:161&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;error: src/styles/global.css: patch does not apply&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The job would have stopped on Tuesday morning over lines it never touched, although its 37 changed lines were the same as before. The patch was regenerated that night and the check switched to the changed lines. On 3 November it passed on the same 37 lines.&lt;/p&gt;
&lt;h2 id=&quot;what-it-still-catches&quot;&gt;What it still catches&lt;/h2&gt;
&lt;p&gt;It still stops the release when someone else edits one of those files without committing, because their lines show up in &lt;code&gt;git diff&lt;/code&gt; and not in the patch. It also stops when anyone changes the work’s own lines without regenerating the patch. Commits by others to the same files no longer stop it, since they become part of what &lt;code&gt;git diff&lt;/code&gt; compares against.&lt;/p&gt;
&lt;p&gt;It doesn’t tell you whether the patch would still apply. When you need that, regenerate the patch from the tree with &lt;code&gt;git diff -- $FILES &amp;gt; ai-tells-shared.patch&lt;/code&gt;, or apply it with &lt;code&gt;git apply --3way&lt;/code&gt;, which falls back on a three-way merge when the context has moved.&lt;/p&gt;
&lt;p&gt;The header filter also drops a changed line whose own text starts with &lt;code&gt;-- &lt;/code&gt; or &lt;code&gt;++ &lt;/code&gt;. A removed SQL comment, &lt;code&gt;-- old&lt;/code&gt;, reads &lt;code&gt;--- old&lt;/code&gt; in the diff. CSS and TypeScript never hit it. For SQL, match the headers by their full form instead, with &lt;code&gt;grep -vE &apos;^(\+\+\+|---) (a/|b/|/dev/null)&apos;&lt;/code&gt;.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/check-a-saved-patch-by-its-changed-lines.png?v=6c27529d" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/check-a-saved-patch-by-its-changed-lines.png?v=6c27529d" width="1200" height="630"/><category>Git</category><category>Shell</category><category>Automation</category><enclosure url="https://hrih.si/og/notes/check-a-saved-patch-by-its-changed-lines.png?v=6c27529d" length="48112" type="image/png"/></item><item><title>&quot;No space left on device&quot; during docker build: what to prune and what to keep</title><link>https://hrih.si/notes/docker-build-no-space-left-on-device/</link><guid isPermaLink="true">https://hrih.si/notes/docker-build-no-space-left-on-device/</guid><description>An image build filled a 19 GB root volume to 100%, and docker builder prune -f freed 1.44 GB of unused build cache with images and volumes untouched. The safe prune, and the two that delete what the next run needs.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Run &lt;code&gt;docker system df&lt;/code&gt; to see what’s using the space, then &lt;code&gt;docker builder prune -f&lt;/code&gt;. It removes only build cache that nothing uses and leaves images, containers and volumes alone. On 31 October, a &lt;code&gt;docker compose build&lt;/code&gt; for Rubric’s pipeline filled its VM’s disk to 100%, and that one command freed 1.44 GB and brought it back to 84%.&lt;/p&gt;
&lt;h2 id=&quot;see-where-the-space-went&quot;&gt;See where the space went&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;docker system df&lt;/code&gt; lists images, containers, local volumes and build cache, each with what it calls reclaimable. That day the build cache was 4.6 GB, 1.44 GB of it reclaimable. Here’s the same VM today:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;$ docker system df&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Images          5         2         4.721GB   3.263GB (69%)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Containers      2         2         116.7MB   0B (0%)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Local Volumes   2         0         621.7MB   621.7MB (100%)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Build Cache     19        0         3.179GB   0B&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Reclaimable means no container uses it right now. It doesn’t mean nothing needs it.&lt;/p&gt;
&lt;h2 id=&quot;the-safe-one&quot;&gt;The safe one&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;docker&lt;/span&gt;&lt;span&gt; builder&lt;/span&gt;&lt;span&gt; prune&lt;/span&gt;&lt;span&gt; -f&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;By default it deletes only dangling build cache, the layers no image refers to any more. &lt;code&gt;-f&lt;/code&gt; skips the confirmation. With &lt;code&gt;-a&lt;/code&gt;, it deletes all unused build cache, including the layers your next build would have reused, so that build runs &lt;code&gt;npm ci&lt;/code&gt; and every step after it again.&lt;/p&gt;
&lt;h2 id=&quot;the-two-that-delete-what-the-next-run-needs&quot;&gt;The two that delete what the next run needs&lt;/h2&gt;
&lt;p&gt;Rubric’s jobs are one-shot containers, started by &lt;code&gt;docker compose run --rm&lt;/code&gt; and removed when they finish. Between runs no container exists, so Docker counts everything they use as reclaimable.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docker image prune -a&lt;/code&gt; deletes every image no container uses, and between runs that includes the pipeline’s own image, the one the next scheduled job starts from.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;docker volume prune -a&lt;/code&gt; deletes every named volume no container uses. Plain &lt;code&gt;docker volume prune&lt;/code&gt; has only removed anonymous volumes since Docker 23. The pipeline keeps a checkout and a login in two named volumes, the 621.7 MB above at 100% reclaimable, and between runs no container holds them. Its database is a bind mount, &lt;code&gt;./data&lt;/code&gt;, which no prune touches. &lt;code&gt;docker system prune&lt;/code&gt; leaves volumes alone unless you add &lt;code&gt;--volumes&lt;/code&gt;, and even then it takes only anonymous ones.&lt;/p&gt;
&lt;h2 id=&quot;make-rebuilds-cheaper&quot;&gt;Make rebuilds cheaper&lt;/h2&gt;
&lt;p&gt;Order the Dockerfile so a code change reuses the dependency layer:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;COPY&lt;/span&gt;&lt;span&gt; package.json package-lock.json ./&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;RUN&lt;/span&gt;&lt;span&gt; npm ci --ignore-scripts --no-audit --no-fund&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;COPY&lt;/span&gt;&lt;span&gt; src ./src&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A change under &lt;code&gt;src/&lt;/code&gt; only rebuilds the layers from &lt;code&gt;COPY src&lt;/code&gt; on, and &lt;code&gt;npm ci&lt;/code&gt; comes from the cache, the same cache that &lt;code&gt;builder prune -a&lt;/code&gt; throws away.&lt;/p&gt;
&lt;p&gt;The real fix on this VM was the disk: &lt;a href=&quot;https://hrih.si/notes/ubuntu-server-root-volume-half-the-disk/&quot;&gt;the root volume was 19 GB&lt;/a&gt; on a 40 GB disk, and growing it gave builds 21 GB of room.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/docker-build-no-space-left-on-device.png?v=0ed24d9b" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/docker-build-no-space-left-on-device.png?v=0ed24d9b" width="1200" height="630"/><category>Docker</category><category>Linux</category><category>Self-hosting</category><enclosure url="https://hrih.si/og/notes/docker-build-no-space-left-on-device.png?v=0ed24d9b" length="47512" type="image/png"/></item><item><title>Reuse node_modules in a git worktree on Windows, and remove it safely</title><link>https://hrih.si/notes/git-worktree-windows-node-modules-junction/</link><guid isPermaLink="true">https://hrih.si/notes/git-worktree-windows-node-modules-junction/</guid><description>A directory junction gives a second worktree the main checkout&apos;s packages with no second npm install. Delete the junction before git worktree remove, because with it in place git deleted the packages it pointed to.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Create the worktree, give it the main checkout’s packages through a directory junction, and delete that junction before you remove the worktree. With the junction still in place, &lt;code&gt;git worktree remove&lt;/code&gt; deleted the packages it pointed to when I tested it, and exited 0.&lt;/p&gt;
&lt;p&gt;I needed a second worktree on 31 October. hrih.si’s main checkout had to stay exactly as it was until a release job ran on 3 November, because that job &lt;a href=&quot;https://hrih.si/notes/check-a-saved-patch-by-its-changed-lines/&quot;&gt;compared it with a saved patch&lt;/a&gt;. &lt;a href=&quot;https://hrih.si/notes/llms-txt-from-an-astro-endpoint/&quot;&gt;The llms.txt endpoint&lt;/a&gt; had to be built and tested in the meantime, so it went into a worktree on a branch of its own.&lt;/p&gt;
&lt;h2 id=&quot;create-it&quot;&gt;Create it&lt;/h2&gt;
&lt;p&gt;In PowerShell, from the main checkout:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;git worktree add &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;b llms&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;txt ..\hrih&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;site&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;llms main&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;New-Item&lt;/span&gt;&lt;span&gt; -&lt;/span&gt;&lt;span&gt;ItemType Junction &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;Path ..\hrih&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;site&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;llms\node_modules &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;Target (&lt;/span&gt;&lt;span&gt;Resolve-Path&lt;/span&gt;&lt;span&gt; .\node_modules)&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The worktree shares the repository’s history and gets its own files and branch. The junction makes its &lt;code&gt;node_modules&lt;/code&gt; the very folder the main checkout uses, so &lt;code&gt;npm run build&lt;/code&gt; and the tests run there straight away. A junction needs neither administrator rights nor Developer Mode, unlike a symbolic link.&lt;/p&gt;
&lt;p&gt;Everything that writes into &lt;code&gt;node_modules&lt;/code&gt; now writes into the main checkout’s copy. Astro and Vite keep their caches there, so both checkouts share them, and an &lt;code&gt;npm install&lt;/code&gt; in the worktree would change the main checkout’s packages too.&lt;/p&gt;
&lt;h2 id=&quot;remove-the-junction-first&quot;&gt;Remove the junction first&lt;/h2&gt;
&lt;p&gt;In a test repository with the same layout, and &lt;code&gt;node_modules&lt;/code&gt; git-ignored as usual, I removed a worktree five ways and checked whether a file in the linked packages survived:&lt;/p&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;How&lt;/th&gt;
&lt;th&gt;Exit code&lt;/th&gt;
&lt;th&gt;Packages survive&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git worktree remove&lt;/code&gt;, junction in place&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;git worktree remove --force&lt;/code&gt;, junction in place&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;[System.IO.Directory]::Delete(junction, $false)&lt;/code&gt;, then &lt;code&gt;git worktree remove&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Remove-Item&lt;/code&gt; on the junction&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Remove-Item -Recurse -Force&lt;/code&gt; on the junction&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;That was Git for Windows 2.33.1 and PowerShell 7.4. Git didn’t even ask for &lt;code&gt;--force&lt;/code&gt;: an ignored folder doesn’t count as a change, so the worktree looked clean, and git deleted its whole tree, through the junction. &lt;a href=&quot;https://github.com/git-for-windows/git/pull/6151&quot;&gt;Git for Windows 2.54.0&lt;/a&gt;, from April 2026, stops at junctions instead, and leaves the junction and the worktree folder on disk. Removing the junction first gives the same clean result on any version.&lt;/p&gt;
&lt;p&gt;So remove the link, then the worktree:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;System.IO.Directory&lt;/span&gt;&lt;span&gt;]::Delete((&lt;/span&gt;&lt;span&gt;Resolve-Path&lt;/span&gt;&lt;span&gt; ..\hrih&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;site&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;llms\node_modules).Path&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;span&gt; $false&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;git worktree remove ..\hrih&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;site&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;llms&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;git branch &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;d llms&lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt;txt&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;Directory.Delete&lt;/code&gt; with &lt;code&gt;$false&lt;/code&gt; removes the junction and never recurses, in PowerShell 7 and in Windows PowerShell 5.1 alike. PowerShell 7’s &lt;code&gt;Remove-Item&lt;/code&gt; also detaches a junction without touching its target, even with &lt;code&gt;-Recurse&lt;/code&gt;, but I haven’t tested 5.1’s. Give &lt;code&gt;Directory.Delete&lt;/code&gt; a full path, as &lt;code&gt;Resolve-Path&lt;/code&gt; does here. .NET resolves a relative path against the process’s working directory, and that isn’t always PowerShell’s current location. &lt;code&gt;git branch -d&lt;/code&gt; only deletes a branch that’s been merged.&lt;/p&gt;
&lt;p&gt;For llms.txt, the junction came out on 31 October, right after the commit on the branch, and the worktree on 3 November, once the branch was merged. The main checkout’s packages were intact both times.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/git-worktree-windows-node-modules-junction.png?v=1845ac77" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/git-worktree-windows-node-modules-junction.png?v=1845ac77" width="1200" height="630"/><category>Git</category><category>Windows</category><category>PowerShell</category><category>Node.js</category><enclosure url="https://hrih.si/og/notes/git-worktree-windows-node-modules-junction.png?v=1845ac77" length="55634" type="image/png"/></item><item><title>Why your LLM summaries credit other people&apos;s posts to your site</title><link>https://hrih.si/notes/llm-summaries-credit-posts-to-your-site/</link><guid isPermaLink="true">https://hrih.si/notes/llm-summaries-credit-posts-to-your-site/</guid><description>Rubric&apos;s summary prompts said each summary was &quot;for Hrih News&quot;, and 65 of 1,027 summaries named it, three crediting another site&apos;s post to it. Once the name was gone, none of the next 209 did.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Take your publication’s name out of the summary prompt, tell the model never to mention it, and give it the story’s site and author. Rubric’s two summary prompts said each summary was written “for Hrih News”, the name its pipeline still goes by, and 65 of the 1,027 summaries in the editions of 24 to 31 October named it. I took the name out on 31 October, and none of the 209 summaries since has used it.&lt;/p&gt;
&lt;h2 id=&quot;what-the-model-wrote&quot;&gt;What the model wrote&lt;/h2&gt;
&lt;p&gt;The usual openings were “Hrih News covers Doozy, an AI agent that answers questions in Slack threads…” and “Hrih News reports on Timestral…”. Three say outright that someone else’s post was on Hrih News. “A post on Hrih News describes a plan to use nixpkgs’ dependency chain as a blueprint for formally verifying…” is about a post on theorem.dev, and posts on brunborg.com and vhamolya.com got the same opening on 30 October. Every edition had some, between 4 and 12 a day.&lt;/p&gt;
&lt;p&gt;On Rubric, a summary’s first sentence is also the story page’s meta description, so a wrong credit can end up in search results.&lt;/p&gt;
&lt;h2 id=&quot;the-line-that-did-it&quot;&gt;The line that did it&lt;/h2&gt;
&lt;p&gt;The summaries for stories ranked 21 and below come from this system prompt. The change, in the sentences that matter:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&lt;span&gt;-&lt;/span&gt; You write a one-paragraph summary, at most 50 words, of one story for Hrih News, a daily edition for people who build technology products and companies.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;span&gt;+&lt;/span&gt; You write a one-paragraph summary, at most 50 words, of one story for a daily tech edition read by people who build technology products and companies.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;&lt;span&gt;+&lt;/span&gt; Never mention the edition or say the story appeared in it; when the source matters, name its publisher or author.&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The prompt for the top 20 opened the same way and got the same two changes, the second as one more rule in its list.&lt;/p&gt;
&lt;h2 id=&quot;the-model-only-knew-one-publication&quot;&gt;The model only knew one publication&lt;/h2&gt;
&lt;p&gt;The two prompts behaved very differently, and the user messages show why. The top 20 get the source:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;`Title: ${&lt;/span&gt;&lt;span&gt;item&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;\n&lt;/span&gt;&lt;span&gt;Site: ${&lt;/span&gt;&lt;span&gt;siteName&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;item&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;}${&lt;/span&gt;&lt;span&gt;item&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;author&lt;/span&gt;&lt;span&gt; ?&lt;/span&gt;&lt;span&gt; `&lt;/span&gt;&lt;span&gt;\n&lt;/span&gt;&lt;span&gt;Author: ${&lt;/span&gt;&lt;span&gt;item&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;author&lt;/span&gt;&lt;span&gt;}`&lt;/span&gt;&lt;span&gt; :&lt;/span&gt;&lt;span&gt; &quot;&quot;}&lt;/span&gt;&lt;span&gt;\n&lt;/span&gt;&lt;span&gt;URL: ${&lt;/span&gt;&lt;span&gt;item&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;canonical_url&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;\n\n&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;body&lt;/span&gt;&lt;span&gt;}`&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The shorter summaries get the title and the text, nothing else:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;`Title: ${&lt;/span&gt;&lt;span&gt;it&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;}&lt;/span&gt;&lt;span&gt;\n\n&lt;/span&gt;&lt;span&gt;${&lt;/span&gt;&lt;span&gt;truncateToTokens&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt; ??&lt;/span&gt;&lt;span&gt; &quot;&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;maxTokens&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt; ||&lt;/span&gt;&lt;span&gt; &quot;(no text available)&quot;}`&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Of the 65, one was in the top 20 (of 160 summaries) and 64 below it (of 867). With no site or author to go on, the only publication the model had seen was the one in its instructions, so it used that. “For Hrih News” said who the summary was for, and the model read it as where the story came from.&lt;/p&gt;
&lt;h2 id=&quot;after-the-change&quot;&gt;After the change&lt;/h2&gt;
&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Editions&lt;/th&gt;
&lt;th&gt;Summaries&lt;/th&gt;
&lt;th&gt;Naming the edition&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;24 to 31 October&lt;/td&gt;
&lt;td&gt;1,027&lt;/td&gt;
&lt;td&gt;65&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 November&lt;/td&gt;
&lt;td&gt;59&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2 November&lt;/td&gt;
&lt;td&gt;43&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3 November&lt;/td&gt;
&lt;td&gt;107&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;The change only reaches new summaries, so the 65 already published still say it. And the shorter summaries still don’t get a site or an author, so they can’t name the publisher unless the text does. Passing both, as the top 20’s call does, would close that gap too.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/llm-summaries-credit-posts-to-your-site.png?v=ed6b91d0" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/llm-summaries-credit-posts-to-your-site.png?v=ed6b91d0" width="1200" height="630"/><category>LLM</category><category>Prompting</category><category>Rubric</category><enclosure url="https://hrih.si/og/notes/llm-summaries-credit-posts-to-your-site.png?v=ed6b91d0" length="43151" type="image/png"/></item><item><title>Serve llms.txt from an Astro endpoint, built from your posts</title><link>https://hrih.si/notes/llms-txt-from-an-astro-endpoint/</link><guid isPermaLink="true">https://hrih.si/notes/llms-txt-from-an-astro-endpoint/</guid><description>An endpoint at src/pages/llms.txt.ts writes the file from the same lists as the RSS feed, so drafts never leak and every deploy keeps it current. Expect little traffic from it, one claude.ai visit in two weeks.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Add an endpoint at &lt;code&gt;src/pages/llms.txt.ts&lt;/code&gt; that builds the file from your content collections, with the same filter as your RSS feed. Astro writes it as a static &lt;code&gt;llms.txt&lt;/code&gt; at build time, a post shows up there the day it’s published and never while it’s a draft, and nobody edits the file by hand. This is hrih.si’s, shortened:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; type&lt;/span&gt;&lt;span&gt; {APIRoute} &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &apos;astro&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; {getCollection} &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &apos;astro:content&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;import&lt;/span&gt;&lt;span&gt; {writingUrl, noteUrl, listedNotes} &lt;/span&gt;&lt;span&gt;from&lt;/span&gt;&lt;span&gt; &apos;../lib/content&apos;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; line&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; `- [${&lt;/span&gt;&lt;span&gt;title&lt;/span&gt;&lt;span&gt;}](${&lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt;}): ${&lt;/span&gt;&lt;span&gt;text&lt;/span&gt;&lt;span&gt;}`&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;export&lt;/span&gt;&lt;span&gt; const&lt;/span&gt;&lt;span&gt; GET&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; APIRoute&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; async&lt;/span&gt;&lt;span&gt; ({&lt;/span&gt;&lt;span&gt;site&lt;/span&gt;&lt;span&gt;}) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; abs&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;path&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;span&gt; string&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; URL&lt;/span&gt;&lt;span&gt;(path, site).href;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; posts&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; getCollection&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;writing&apos;&lt;/span&gt;&lt;span&gt;, ({&lt;/span&gt;&lt;span&gt;data&lt;/span&gt;&lt;span&gt;}) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; !&lt;/span&gt;&lt;span&gt;data.draft &lt;/span&gt;&lt;span&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span&gt; !&lt;/span&gt;&lt;span&gt;data.sample))&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    .&lt;/span&gt;&lt;span&gt;sort&lt;/span&gt;&lt;span&gt;((&lt;/span&gt;&lt;span&gt;a&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;b&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;=&amp;gt;&lt;/span&gt;&lt;span&gt; b.data.date.&lt;/span&gt;&lt;span&gt;valueOf&lt;/span&gt;&lt;span&gt;() &lt;/span&gt;&lt;span&gt;-&lt;/span&gt;&lt;span&gt; a.data.date.&lt;/span&gt;&lt;span&gt;valueOf&lt;/span&gt;&lt;span&gt;());&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; notes&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; listedNotes&lt;/span&gt;&lt;span&gt;();&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  const&lt;/span&gt;&lt;span&gt; text&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &apos;# hrih.si&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &apos;&amp;gt; Hatim Rih builds software and writes about AI and the work of programming.&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &apos;## Articles&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;posts.&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; line&lt;/span&gt;&lt;span&gt;(post.data.title, &lt;/span&gt;&lt;span&gt;abs&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;writingUrl&lt;/span&gt;&lt;span&gt;(post)), post.data.description)), &lt;/span&gt;&lt;span&gt;&apos;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &apos;## Notes&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;notes.&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;note&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; line&lt;/span&gt;&lt;span&gt;(note.data.title, &lt;/span&gt;&lt;span&gt;abs&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;noteUrl&lt;/span&gt;&lt;span&gt;(note)), note.data.description)), &lt;/span&gt;&lt;span&gt;&apos;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &apos;## Optional&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;line&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;Feed&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;abs&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;/rss.xml&apos;&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span&gt;&apos;every essay and article in full, newest first&apos;&lt;/span&gt;&lt;span&gt;), &lt;/span&gt;&lt;span&gt;&apos;&apos;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ].&lt;/span&gt;&lt;span&gt;join&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;\n&lt;/span&gt;&lt;span&gt;&apos;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  return&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; Response&lt;/span&gt;&lt;span&gt;(text, {headers: {&lt;/span&gt;&lt;span&gt;&apos;Content-Type&apos;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&apos;text/plain; charset=utf-8&apos;&lt;/span&gt;&lt;span&gt;}});&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;};&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The shape comes from llmstxt.org: a title, a one-line summary as a quote, then sections of links with a short description each. A section called Optional holds what a tool can skip when it’s short of room. The full file also has essays and articles under separate headings, the projects, and an About section.&lt;/p&gt;
&lt;h2 id=&quot;reuse-the-feeds-filter&quot;&gt;Reuse the feed’s filter&lt;/h2&gt;
&lt;p&gt;The posts come through the same query as &lt;code&gt;/rss.xml&lt;/code&gt;, and the notes through &lt;code&gt;listedNotes()&lt;/code&gt;, the function the notes page uses. Whatever keeps a draft out of the feed keeps it out of llms.txt, and there’s no second list to forget.&lt;/p&gt;
&lt;h2 id=&quot;stop-the-release-when-it-misses-a-post&quot;&gt;Stop the release when it misses a post&lt;/h2&gt;
&lt;p&gt;Since 3 November, hrih.si’s release script reads the built file and stops if any item in either feed, or any project page in the sitemap, isn’t in it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; llms&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; readFileSync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;build/llms.txt&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;utf8&apos;&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; published&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ...&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;&apos;build/rss.xml&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;build/notes/rss.xml&apos;&lt;/span&gt;&lt;span&gt;].&lt;/span&gt;&lt;span&gt;flatMap&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;file&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    [&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;readFileSync&lt;/span&gt;&lt;span&gt;(file, &lt;/span&gt;&lt;span&gt;&apos;utf8&apos;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;matchAll&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;&amp;lt;item&amp;gt;&lt;/span&gt;&lt;span&gt;[\s\S]&lt;/span&gt;&lt;span&gt;*?&lt;/span&gt;&lt;span&gt;&amp;lt;link&amp;gt;(&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;^&lt;/span&gt;&lt;span&gt;&amp;lt;]&lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt;)&amp;lt;&lt;/span&gt;&lt;span&gt;\/&lt;/span&gt;&lt;span&gt;link&amp;gt;&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;g&lt;/span&gt;&lt;span&gt;)].&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;match&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; match[&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;])),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ...&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;...&lt;/span&gt;&lt;span&gt;readFileSync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;build/sitemap-0.xml&apos;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;&apos;utf8&apos;&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;matchAll&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;&amp;lt;loc&amp;gt;(&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;^&lt;/span&gt;&lt;span&gt;&amp;lt;]&lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt;\/&lt;/span&gt;&lt;span&gt;projects&lt;/span&gt;&lt;span&gt;\/&lt;/span&gt;&lt;span&gt;[&lt;/span&gt;&lt;span&gt;^&lt;/span&gt;&lt;span&gt;&amp;lt;]&lt;/span&gt;&lt;span&gt;+&lt;/span&gt;&lt;span&gt;\/&lt;/span&gt;&lt;span&gt;)&amp;lt;&lt;/span&gt;&lt;span&gt;\/&lt;/span&gt;&lt;span&gt;loc&amp;gt;&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;g&lt;/span&gt;&lt;span&gt;)].&lt;/span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;match&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; match[&lt;/span&gt;&lt;span&gt;1&lt;/span&gt;&lt;span&gt;]),&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;];&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; unlisted&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; published.&lt;/span&gt;&lt;span&gt;filter&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt; =&amp;gt;&lt;/span&gt;&lt;span&gt; !&lt;/span&gt;&lt;span&gt;llms.&lt;/span&gt;&lt;span&gt;includes&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`](${&lt;/span&gt;&lt;span&gt;url&lt;/span&gt;&lt;span&gt;})`&lt;/span&gt;&lt;span&gt;));&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;published.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt; ||&lt;/span&gt;&lt;span&gt; unlisted.&lt;/span&gt;&lt;span&gt;length&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;throw&lt;/span&gt;&lt;span&gt; new&lt;/span&gt;&lt;span&gt; Error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`build/llms.txt is missing ${&lt;/span&gt;&lt;span&gt;unlisted&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;join&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&apos;, &apos;&lt;/span&gt;&lt;span&gt;)&lt;/span&gt;&lt;span&gt;}`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;check-what-ai-crawlers-get&quot;&gt;Check what AI crawlers get&lt;/h2&gt;
&lt;p&gt;robots.txt can allow everything while a firewall or a CDN rule still blocks a crawler, so ask as the crawler:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; -s&lt;/span&gt;&lt;span&gt; -o&lt;/span&gt;&lt;span&gt; /dev/null&lt;/span&gt;&lt;span&gt; -w&lt;/span&gt;&lt;span&gt; &quot;%{http_code} %{content_type}\n&quot;&lt;/span&gt;&lt;span&gt; -A&lt;/span&gt;&lt;span&gt; &quot;GPTBot&quot;&lt;/span&gt;&lt;span&gt; https://hrih.si/llms.txt&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; -s&lt;/span&gt;&lt;span&gt; -o&lt;/span&gt;&lt;span&gt; /dev/null&lt;/span&gt;&lt;span&gt; -w&lt;/span&gt;&lt;span&gt; &quot;%{http_code} %{content_type}\n&quot;&lt;/span&gt;&lt;span&gt; -A&lt;/span&gt;&lt;span&gt; &quot;ClaudeBot&quot;&lt;/span&gt;&lt;span&gt; https://hrih.si/llms.txt&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Both answer &lt;code&gt;200 text/plain; charset=utf-8&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-its-worth&quot;&gt;What it’s worth&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://developers.google.com/search/docs/fundamentals/ai-optimization-guide&quot;&gt;Google’s guide to its AI features&lt;/a&gt; says Search doesn’t use llms.txt, and that keeping one neither helps nor hurts. Between 18 and 31 October, Umami counted one visit to hrih.si from claude.ai, against 131 from google.com. It isn’t a traffic source. The file costs nothing to keep current, so I keep it for the assistants and agents that look for one, and Chrome’s Lighthouse &lt;a href=&quot;https://developer.chrome.com/docs/lighthouse/agentic-browsing/llms-txt&quot;&gt;checks for it&lt;/a&gt; in its agentic browsing audits.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/llms-txt-from-an-astro-endpoint.png?v=8ddc6aa0" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/llms-txt-from-an-astro-endpoint.png?v=8ddc6aa0" width="1200" height="630"/><category>Astro</category><category>llms.txt</category><category>SEO</category><enclosure url="https://hrih.si/og/notes/llms-txt-from-an-astro-endpoint.png?v=8ddc6aa0" length="45241" type="image/png"/></item><item><title>Scheduled tasks in the Claude desktop app stop at a production deploy</title><link>https://hrih.si/notes/scheduled-tasks-stop-at-the-deploy/</link><guid isPermaLink="true">https://hrih.si/notes/scheduled-tasks-stop-at-the-deploy/</guid><description>A scheduled task runs with nobody there, and in Auto mode the permission classifier blocked its deploy.sh as &quot;Production Deploy&quot;. Schedule up to the commit, push to a branch, and send yourself the one command to run.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Schedule the job up to the commit and leave the deploy to a person. A scheduled task in the Claude desktop app runs with nobody watching, and a production deploy wants someone there: in Auto mode the permission classifier blocks it, and in Manual mode the run waits at the prompt. Mine ran in Auto mode and stopped on 3 November at 08:05, one step short of publishing three pages on hrih.si, with the reason “Production Deploy”.&lt;/p&gt;
&lt;h2 id=&quot;what-the-job-did&quot;&gt;What the job did&lt;/h2&gt;
&lt;p&gt;&lt;code&gt;ai-tells-publish&lt;/code&gt; was a one-time task, and its prompt held the whole release:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Check the tree: main level with origin, &lt;a href=&quot;https://hrih.si/notes/check-a-saved-patch-by-its-changed-lines/&quot;&gt;the shared files holding only this work’s changes&lt;/a&gt;, nobody else’s edits.&lt;/li&gt;
&lt;li&gt;Switch the three pages from draft to published, draw their social cards, run the tests and the type check.&lt;/li&gt;
&lt;li&gt;Commit the listed files and push to main.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;bash deploy/deploy.sh&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check the live pages, write the journal, send me a notice.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Steps 1 to 3 passed: 70 tests, no type errors, commit &lt;code&gt;2139922&lt;/code&gt; pushed. Step 4 was blocked. The job stopped there and didn’t look for another way round, as its prompt said to do whenever a step failed.&lt;/p&gt;
&lt;h2 id=&quot;why-the-deploy-was-blocked&quot;&gt;Why the deploy was blocked&lt;/h2&gt;
&lt;p&gt;&lt;a href=&quot;https://code.claude.com/docs/en/desktop-scheduled-tasks&quot;&gt;Each scheduled task runs in the permission mode saved with it&lt;/a&gt;, and this one was saved in Auto mode. &lt;a href=&quot;https://code.claude.com/docs/en/permission-modes&quot;&gt;In Auto mode&lt;/a&gt;, reads and edits in the project go ahead, and every other action passes a classifier first. Production deploys are on its default block list as a soft block, which you can lift by asking for the deploy in the conversation, or with an &lt;code&gt;autoMode.allow&lt;/code&gt; rule in your user settings.&lt;/p&gt;
&lt;p&gt;The task’s prompt told it to deploy and quoted my approval, and the deploy was blocked anyway. Later that morning, the same command was blocked the same way in a session I was in, until I wrote “Yes, deploy it” there.&lt;/p&gt;
&lt;p&gt;An allow rule or Bypass mode would let the job through. For a job that ships to production with nobody watching, that removes the one check I want to keep.&lt;/p&gt;
&lt;h2 id=&quot;the-state-it-left-behind&quot;&gt;The state it left behind&lt;/h2&gt;
&lt;p&gt;The worse problem was main. The commit had already switched the three pages to published, so the next deploy from any session would have shipped them, checked or not. The release went out at 08:18, from a session I was in, after the pages were checked again.&lt;/p&gt;
&lt;h2 id=&quot;the-pattern-that-works&quot;&gt;The pattern that works&lt;/h2&gt;
&lt;ul&gt;
&lt;li&gt;Schedule everything up to the commit: the checks, the tests, the cards, the commit itself.&lt;/li&gt;
&lt;li&gt;Push to a branch, not main, when the deploy may not follow, so main never holds work that hasn’t shipped.&lt;/li&gt;
&lt;li&gt;End with a notice that carries the one command to run. With ntfy:&lt;/li&gt;
&lt;/ul&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; -fsS&lt;/span&gt;&lt;span&gt; -H&lt;/span&gt;&lt;span&gt; &quot;Title: hrih.si: release ready&quot;&lt;/span&gt;&lt;span&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  -d&lt;/span&gt;&lt;span&gt; &quot;Checks passed, pushed to &amp;lt;branch&amp;gt;. Merge it, then run: bash deploy/deploy.sh&quot;&lt;/span&gt;&lt;span&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;https://ntfy.sh/&lt;/span&gt;&lt;span&gt;$NTFY_TOPIC&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The deploy is then one command in a session where you’re there to say yes.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/scheduled-tasks-stop-at-the-deploy.png?v=6cc15917" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/scheduled-tasks-stop-at-the-deploy.png?v=6cc15917" width="1200" height="630"/><category>Claude Code</category><category>Automation</category><category>Deployment</category><enclosure url="https://hrih.si/og/notes/scheduled-tasks-stop-at-the-deploy.png?v=6cc15917" length="49638" type="image/png"/></item><item><title>Ubuntu Server only uses half the disk: grow the root volume with lvextend</title><link>https://hrih.si/notes/ubuntu-server-root-volume-half-the-disk/</link><guid isPermaLink="true">https://hrih.si/notes/ubuntu-server-root-volume-half-the-disk/</guid><description>On a 40 GB disk, the Ubuntu Server installer made the root volume 19 GB and left the rest of its volume group free. One lvextend gives the volume and its ext4 filesystem all of it, while mounted.</description><pubDate>Tue, 03 Nov 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Run &lt;code&gt;sudo lvextend -r -l +100%FREE /dev/ubuntu-vg/ubuntu-lv&lt;/code&gt;. It gives the root logical volume every free extent in its volume group and grows the ext4 filesystem on it in the same step, while it’s mounted, with no reboot. On the VM that runs Rubric’s pipeline, it took &lt;code&gt;/&lt;/code&gt; from 19 GB at 84% to 38 GB at 42%.&lt;/p&gt;
&lt;h2 id=&quot;find-the-free-space&quot;&gt;Find the free space&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;lsblk&lt;/span&gt;&lt;span&gt;          # the disk, the partition LVM uses, and the volume on it&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;sudo&lt;/span&gt;&lt;span&gt; vgs&lt;/span&gt;&lt;span&gt;       # VFree: space in the volume group that no volume uses yet&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;df&lt;/span&gt;&lt;span&gt; -h&lt;/span&gt;&lt;span&gt; /&lt;/span&gt;&lt;span&gt;        # what the filesystem has&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The VM runs Ubuntu 24.04.5 LTS on a 40 GB disk. &lt;code&gt;lsblk&lt;/code&gt; showed the partition LVM uses, &lt;code&gt;sda3&lt;/code&gt;, at 38 GB and the root volume on it at 19 GB, and &lt;code&gt;vgs&lt;/code&gt; had the other half under VFree. When VFree holds what you’re missing, the one command is all it takes.&lt;/p&gt;
&lt;p&gt;If VFree is zero but &lt;code&gt;lsblk&lt;/code&gt; shows the disk bigger than the partition, which is what you see after enlarging a VM’s virtual disk, the space is outside LVM. Grow the partition and the physical volume first (&lt;code&gt;growpart&lt;/code&gt; comes with the cloud-guest-utils package), then run the same &lt;code&gt;lvextend&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;sudo&lt;/span&gt;&lt;span&gt; growpart&lt;/span&gt;&lt;span&gt; /dev/sda&lt;/span&gt;&lt;span&gt; 3&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;sudo&lt;/span&gt;&lt;span&gt; pvresize&lt;/span&gt;&lt;span&gt; /dev/sda3&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;grow-it&quot;&gt;Grow it&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;sudo&lt;/span&gt;&lt;span&gt; lvextend&lt;/span&gt;&lt;span&gt; -r&lt;/span&gt;&lt;span&gt; -l&lt;/span&gt;&lt;span&gt; +100%FREE&lt;/span&gt;&lt;span&gt; /dev/ubuntu-vg/ubuntu-lv&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;-l +100%FREE&lt;/code&gt; adds all the free extents in the volume group, so you don’t have to work out a size. &lt;code&gt;-r&lt;/code&gt; resizes the filesystem along with the volume, so there’s no separate &lt;code&gt;resize2fs&lt;/code&gt;. ext4 can grow while it’s mounted, and &lt;code&gt;/&lt;/code&gt; stayed in use the whole time. Afterwards:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;$ df -h /&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Filesystem                         Size  Used Avail Use% Mounted on&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;/dev/mapper/ubuntu--vg-ubuntu--lv   38G   15G   21G  42% /&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Growing is the easy direction. Shrinking ext4 needs it unmounted, so give the root volume what it needs rather than more than you’ll use, if you plan to keep free space for another volume or for snapshots.&lt;/p&gt;
&lt;h2 id=&quot;why-the-installer-left-half&quot;&gt;Why the installer left half&lt;/h2&gt;
&lt;p&gt;Ubuntu Server’s guided install with LVM sizes the root volume from the volume group: all of it under 10 GiB, 10 GiB up to 20, half of it up to 200 GiB, and 100 GiB above that. The rule is &lt;a href=&quot;https://github.com/canonical/subiquity/blob/main/subiquity/common/storage/sizes.py&quot;&gt;&lt;code&gt;scaled_rootfs_size&lt;/code&gt; in the installer’s source&lt;/a&gt;, and the space it leaves is meant for snapshots and for growing volumes later. A 38 GB volume group gets 19. In an autoinstall file, &lt;code&gt;sizing-policy: all&lt;/code&gt; gives the root volume everything from the start.&lt;/p&gt;
&lt;p&gt;Nothing tells you at boot. I found out when the disk filled: on 31 October &lt;a href=&quot;https://hrih.si/notes/docker-build-no-space-left-on-device/&quot;&gt;an image build took &lt;code&gt;/&lt;/code&gt; to 100%&lt;/a&gt;, the second time in three days. With 21 GB free, the same build now fits.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/ubuntu-server-root-volume-half-the-disk.png?v=51ce79ba" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/ubuntu-server-root-volume-half-the-disk.png?v=51ce79ba" width="1200" height="630"/><category>Ubuntu</category><category>LVM</category><category>Linux</category><category>Self-hosting</category><enclosure url="https://hrih.si/og/notes/ubuntu-server-root-volume-half-the-disk.png?v=51ce79ba" length="50824" type="image/png"/></item><item><title>How to give a second Bluesky account a subdomain handle</title><link>https://hrih.si/notes/bluesky-handle-on-a-subdomain/</link><guid isPermaLink="true">https://hrih.si/notes/bluesky-handle-on-a-subdomain/</guid><description>My Bluesky handle is hrih.si and Rubric&apos;s is rubric.hrih.si, two accounts with a TXT record each. The exact records and how to check them.</description><pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Each Bluesky handle needs a TXT record of its own: a subdomain handle like &lt;code&gt;@rubric.hrih.si&lt;/code&gt; gets one at &lt;code&gt;_atproto.rubric&lt;/code&gt;, because the root domain’s record does nothing for it. That’s the part that’s easy to get wrong. I run two Bluesky accounts on one domain. Mine is &lt;code&gt;@hrih.si&lt;/code&gt;, and Rubric, my daily tech edition, posts as &lt;code&gt;@rubric.hrih.si&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;one-txt-record-per-handle&quot;&gt;One TXT record per handle&lt;/h2&gt;
&lt;p&gt;Bluesky checks a domain handle by looking for a TXT record at &lt;code&gt;_atproto.&lt;/code&gt; followed by the handle. The record holds the account’s DID, the id that stays the same when the handle changes. These are mine:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;_atproto.hrih.si          TXT  &quot;did=did:plc:dx4qyo2eyyf4wqjmygtepbbh&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;_atproto.rubric.hrih.si   TXT  &quot;did=did:plc:ow3tqck73woxcn6qo4mgk4ea&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;So the subdomain’s record sits at &lt;code&gt;_atproto.rubric&lt;/code&gt;, inside the same zone. Most DNS panels take the name relative to the domain: you type &lt;code&gt;_atproto.rubric&lt;/code&gt; and the panel adds &lt;code&gt;.hrih.si&lt;/code&gt;. &lt;a href=&quot;https://bsky.social/about/blog/4-28-2023-domain-handle-tutorial&quot;&gt;Bluesky’s tutorial&lt;/a&gt; shows the same thing for a subdomain called &lt;code&gt;me&lt;/code&gt;, whose record is &lt;code&gt;_atproto.me&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;There’s no wildcard to fall back on. A made-up name like &lt;code&gt;_atproto.anything.hrih.si&lt;/code&gt; doesn’t exist, so a handle without its own record fails the DNS check. That’s also what keeps the accounts apart: each record names exactly one DID.&lt;/p&gt;
&lt;h2 id=&quot;getting-the-value&quot;&gt;Getting the value&lt;/h2&gt;
&lt;p&gt;Sign in as the account that should get the handle. Open Settings, then Account, then Handle, and choose “I have my own domain”. Type the handle, and Bluesky shows the TXT record to create, with that account’s DID in it.&lt;/p&gt;
&lt;p&gt;The value belongs to whoever is signed in. For the subdomain, that has to be the second account, not your main one.&lt;/p&gt;
&lt;h2 id=&quot;check-before-you-verify&quot;&gt;Check before you verify&lt;/h2&gt;
&lt;p&gt;DNS changes take a while to reach every resolver, so I look the record up myself before asking Bluesky to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;nslookup&lt;/span&gt;&lt;span&gt; -type=TXT&lt;/span&gt;&lt;span&gt; _atproto.rubric.hrih.si&lt;/span&gt;&lt;span&gt; 1.1.1.1&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;dig&lt;/span&gt;&lt;span&gt; +short&lt;/span&gt;&lt;span&gt; TXT&lt;/span&gt;&lt;span&gt; _atproto.rubric.hrih.si&lt;/span&gt;&lt;span&gt; @1.1.1.1&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Once a public resolver prints the &lt;code&gt;did=&lt;/code&gt; line, go back to the app and verify.&lt;/p&gt;
&lt;h2 id=&quot;the-https-method&quot;&gt;The HTTPS method&lt;/h2&gt;
&lt;p&gt;Bluesky also accepts the DID as a plain-text file at &lt;code&gt;https://&amp;lt;handle&amp;gt;/.well-known/atproto-did&lt;/code&gt;, answered with a 200. Neither of my domains has that file (both answer 404). For a subdomain that is also a website, with its own build and deploys, the DNS record is simpler: it works whether or not the site is up, and no release can drop it. Bluesky’s tutorial suggests the HTTPS route for organizations that hand out many subdomains, where a record for each gets tedious.&lt;/p&gt;
&lt;h2 id=&quot;for-an-account-a-script-posts-from&quot;&gt;For an account a script posts from&lt;/h2&gt;
&lt;p&gt;Rubric posts from a script. It signs in with an app password, created in the account’s settings and kept in the script’s environment, never with the account password. You can revoke an app password on its own, and the account carries on.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/bluesky-handle-on-a-subdomain.png?v=0dbb2d98" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/bluesky-handle-on-a-subdomain.png?v=0dbb2d98" width="1200" height="630"/><category>Bluesky</category><category>DNS</category><category>AT Protocol</category><enclosure url="https://hrih.si/og/notes/bluesky-handle-on-a-subdomain.png?v=0dbb2d98" length="46412" type="image/png"/></item><item><title>Run scheduled Docker jobs with systemd user timers instead of cron</title><link>https://hrih.si/notes/docker-jobs-with-systemd-user-timers/</link><guid isPermaLink="true">https://hrih.si/notes/docker-jobs-with-systemd-user-timers/</guid><description>Each job is a one-shot container started by a systemd user timer, with no root and no always-on scheduler. The units, the timer and the install.</description><pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Each of Rubric’s ten scheduled jobs runs as a fresh Docker container that a systemd timer starts under my user account, so it needs no root, and the container exits when the job is done. The jobs poll the sources every 30 minutes, build the edition overnight, publish it at 05:50 UTC and post about it later in the day. Since this setup went in on 28 October 2026, nothing of Rubric runs between jobs.&lt;/p&gt;
&lt;p&gt;Before that, the scheduler ran on my PC inside terminals and agent sessions, and when one closed, the scheduler went with it. The editions of 26 and 28 October were missed that way. On the VM I wanted something that starts at boot, catches up after downtime and logs every run, without a scheduler container of its own. systemd already does that.&lt;/p&gt;
&lt;h2 id=&quot;one-service-template-for-every-job&quot;&gt;One service template for every job&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# ~/.config/systemd/user/rubric@.service&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;[Unit]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;=Rubric pipeline: %i&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;[Service]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Type&lt;/span&gt;&lt;span&gt;=oneshot&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;ExecStart&lt;/span&gt;&lt;span&gt;=/usr/bin/flock --nonblock --conflict-exit-code 0 %t/rubric-%i.lock %h/app/deploy/vm/rubric %i&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;TimeoutStartSec&lt;/span&gt;&lt;span&gt;=6h&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;%i&lt;/code&gt; is whatever follows the &lt;code&gt;@&lt;/code&gt;, so &lt;code&gt;rubric@ingest.service&lt;/code&gt; runs the &lt;code&gt;ingest&lt;/code&gt; job. &lt;code&gt;Type=oneshot&lt;/code&gt; tells systemd the job is over when the command exits. &lt;code&gt;flock&lt;/code&gt; holds a lock in the user’s runtime directory (&lt;code&gt;%t&lt;/code&gt;) while the job runs. A second start of the same job finds the lock taken and exits 0 straight away, so a slow edition never runs twice. &lt;code&gt;TimeoutStartSec=6h&lt;/code&gt; is the ceiling for a run that hangs.&lt;/p&gt;
&lt;p&gt;The wrapper it calls is a few lines of shell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;#!/bin/sh&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;set&lt;/span&gt;&lt;span&gt; -eu&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;cd&lt;/span&gt;&lt;span&gt; &quot;$(&lt;/span&gt;&lt;span&gt;dirname&lt;/span&gt;&lt;span&gt; &quot;$(&lt;/span&gt;&lt;span&gt;readlink&lt;/span&gt;&lt;span&gt; -f&lt;/span&gt;&lt;span&gt; &quot;&lt;/span&gt;&lt;span&gt;$0&lt;/span&gt;&lt;span&gt;&quot;)&quot;)/../..&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;exec&lt;/span&gt;&lt;span&gt; docker&lt;/span&gt;&lt;span&gt; compose&lt;/span&gt;&lt;span&gt; run&lt;/span&gt;&lt;span&gt; --rm&lt;/span&gt;&lt;span&gt; -T&lt;/span&gt;&lt;span&gt; pipeline&lt;/span&gt;&lt;span&gt; npx&lt;/span&gt;&lt;span&gt; tsx&lt;/span&gt;&lt;span&gt; src/cli.ts&lt;/span&gt;&lt;span&gt; &quot;&lt;/span&gt;&lt;span&gt;$@&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;run --rm&lt;/code&gt; starts a new container from the image and deletes it afterwards. &lt;code&gt;-T&lt;/code&gt; skips the pseudo-terminal, which a timer doesn’t have.&lt;/p&gt;
&lt;h2 id=&quot;one-timer-per-job&quot;&gt;One timer per job&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;# ~/.config/systemd/user/rubric-due.timer&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;[Unit]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Description&lt;/span&gt;&lt;span&gt;=Rubric: the daily edition&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;[Timer]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;OnCalendar&lt;/span&gt;&lt;span&gt;=*-*-* 00:05:00 UTC&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;OnCalendar&lt;/span&gt;&lt;span&gt;=*-*-* 05:45:00 UTC&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;OnCalendar&lt;/span&gt;&lt;span&gt;=*-*-* *:10,25,40,55:00 UTC&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;AccuracySec&lt;/span&gt;&lt;span&gt;=1s&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Persistent&lt;/span&gt;&lt;span&gt;=true&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;Unit&lt;/span&gt;&lt;span&gt;=rubric@due.service&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;[Install]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;WantedBy&lt;/span&gt;&lt;span&gt;=timers.target&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Each &lt;code&gt;OnCalendar&lt;/code&gt; line adds a schedule. &lt;code&gt;Persistent=true&lt;/code&gt; starts a run that was missed while the machine was off as soon as it’s back. A VM that was down at 05:45 still publishes. The source poll’s timer leaves it out on purpose: a missed poll isn’t worth running late, because the edition’s backfill covers that window anyway. &lt;code&gt;AccuracySec&lt;/code&gt; defaults to a minute, and the edition’s timer sets one second.&lt;/p&gt;
&lt;h2 id=&quot;install-as-your-user-with-lingering&quot;&gt;Install as your user, with lingering&lt;/h2&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;units&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;${&lt;/span&gt;&lt;span&gt;XDG_CONFIG_HOME&lt;/span&gt;&lt;span&gt;:-&lt;/span&gt;&lt;span&gt;$HOME&lt;/span&gt;&lt;span&gt;/&lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;span&gt;config&lt;/span&gt;&lt;span&gt;}/systemd/user&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;mkdir&lt;/span&gt;&lt;span&gt; -p&lt;/span&gt;&lt;span&gt; &quot;&lt;/span&gt;&lt;span&gt;$units&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;cp&lt;/span&gt;&lt;span&gt; deploy/vm/rubric@.service&lt;/span&gt;&lt;span&gt; deploy/vm/rubric-&lt;/span&gt;&lt;span&gt;*&lt;/span&gt;&lt;span&gt;.timer&lt;/span&gt;&lt;span&gt; &quot;&lt;/span&gt;&lt;span&gt;$units&lt;/span&gt;&lt;span&gt;/&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;systemctl&lt;/span&gt;&lt;span&gt; --user&lt;/span&gt;&lt;span&gt; daemon-reload&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;systemctl&lt;/span&gt;&lt;span&gt; --user&lt;/span&gt;&lt;span&gt; enable&lt;/span&gt;&lt;span&gt; --now&lt;/span&gt;&lt;span&gt; rubric-due.timer&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;loginctl&lt;/span&gt;&lt;span&gt; enable-linger&lt;/span&gt;&lt;span&gt; &quot;$(&lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt; -un&lt;/span&gt;&lt;span&gt;)&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The units belong to your user, so installing them needs no root. The last line is the one that’s easy to miss. Without lingering, your user’s systemd starts at login and stops at logout, and the timers stop with it. With lingering on, they start at boot and keep running with nobody logged in.&lt;/p&gt;
&lt;h2 id=&quot;compose-and-logs&quot;&gt;Compose and logs&lt;/h2&gt;
&lt;p&gt;The compose service sits behind a profile, so &lt;code&gt;docker compose up&lt;/code&gt; never starts it. That way no long-running container sits next to the timers and runs a job a second time:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;services&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  pipeline&lt;/span&gt;&lt;span&gt;:&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    build&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;.&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    profiles&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;span&gt;jobs&lt;/span&gt;&lt;span&gt;]&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The catch is the build. A bare &lt;code&gt;docker compose build&lt;/code&gt; then reports “No services to build” and leaves the old image in place, so name the service: &lt;code&gt;docker compose build pipeline&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;To see what ran and what’s next:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;systemctl&lt;/span&gt;&lt;span&gt; --user&lt;/span&gt;&lt;span&gt; list-timers&lt;/span&gt;&lt;span&gt; &apos;rubric-*&apos;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;journalctl&lt;/span&gt;&lt;span&gt; --user&lt;/span&gt;&lt;span&gt; -u&lt;/span&gt;&lt;span&gt; &apos;rubric@*&apos;&lt;/span&gt;&lt;span&gt; -f&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;journalctl&lt;/span&gt;&lt;span&gt; --user&lt;/span&gt;&lt;span&gt; -u&lt;/span&gt;&lt;span&gt; rubric@due&lt;/span&gt;&lt;span&gt; --since&lt;/span&gt;&lt;span&gt; today&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Every job logs under its own unit name. Watch the disk when you rebuild. On 29 October &lt;a href=&quot;https://hrih.si/notes/docker-build-no-space-left-on-device/&quot;&gt;an image build filled the VM’s disk to 100%&lt;/a&gt;, and &lt;code&gt;docker builder prune&lt;/code&gt; got 1.16 GB back.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/docker-jobs-with-systemd-user-timers.png?v=c5ca36a1" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/docker-jobs-with-systemd-user-timers.png?v=c5ca36a1" width="1200" height="630"/><category>Docker</category><category>systemd</category><category>Linux</category><category>Self-hosting</category><enclosure url="https://hrih.si/og/notes/docker-jobs-with-systemd-user-timers.png?v=c5ca36a1" length="47287" type="image/png"/></item><item><title>Use your own domain as a Mastodon handle without running a server</title><link>https://hrih.si/notes/mastodon-handle-on-your-own-domain/</link><guid isPermaLink="true">https://hrih.si/notes/mastodon-handle-on-your-own-domain/</guid><description>One static WebFinger file and two headers let people find a Mastodon account at your own domain. The file, plus the Caddy and nginx config.</description><pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;A static WebFinger file and two response headers let people find &lt;code&gt;@rubricdaily@mastodon.social&lt;/code&gt; as &lt;code&gt;@rubric@rubric.hrih.si&lt;/code&gt;, with no Mastodon server. That’s Rubric’s account, named &lt;code&gt;rubricdaily&lt;/code&gt; because &lt;code&gt;@rubric&lt;/code&gt; was taken on mastodon.social, and I wanted people to find it on the domain the site already has.&lt;/p&gt;
&lt;h2 id=&quot;how-mastodon-finds-an-account&quot;&gt;How Mastodon finds an account&lt;/h2&gt;
&lt;p&gt;When someone searches for &lt;code&gt;@rubric@rubric.hrih.si&lt;/code&gt;, their server asks the domain:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;GET https://rubric.hrih.si/.well-known/webfinger?resource=acct:rubric@rubric.hrih.si&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The answer is a small JSON document called a JRD. Its &lt;code&gt;self&lt;/code&gt; link points at the account’s ActivityPub actor, and its &lt;code&gt;subject&lt;/code&gt; names the account. When the &lt;code&gt;subject&lt;/code&gt; isn’t the handle that was asked for, Mastodon sends a second WebFinger request to the server named in the &lt;code&gt;subject&lt;/code&gt;, and that answer has to point at the same actor. That second request is what lets a domain you own vouch for an account that lives somewhere else. &lt;a href=&quot;https://docs.joinmastodon.org/spec/webfinger/&quot;&gt;Mastodon’s WebFinger docs&lt;/a&gt; describe both steps.&lt;/p&gt;
&lt;h2 id=&quot;the-file&quot;&gt;The file&lt;/h2&gt;
&lt;p&gt;The file is a copy of what mastodon.social already says about the account. Fetch it:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; -s&lt;/span&gt;&lt;span&gt; &quot;https://mastodon.social/.well-known/webfinger?resource=acct:rubricdaily@mastodon.social&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Save the answer unchanged as &lt;code&gt;.well-known/webfinger&lt;/code&gt; in the site’s public folder. Rubric’s is 505 bytes on one line, spread out here:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;subject&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;acct:rubricdaily@mastodon.social&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;aliases&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;https://mastodon.social/@rubricdaily&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;https://mastodon.social/ap/users/117518336990487890&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ],&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;links&quot;&lt;/span&gt;&lt;span&gt;: [&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;rel&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;http://webfinger.net/rel/profile-page&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;type&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;text/html&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;href&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;https://mastodon.social/@rubricdaily&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;rel&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;self&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;type&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;application/activity+json&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;href&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;https://mastodon.social/ap/users/117518336990487890&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    },&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;rel&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;http://ostatus.org/schema/1.0/subscribe&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;template&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;https://mastodon.social/authorize_interaction?uri={uri}&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  ]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Nothing in it gets edited: the &lt;code&gt;subject&lt;/code&gt; stays the real account, and the actor stays on mastodon.social.&lt;/p&gt;
&lt;h2 id=&quot;the-two-headers&quot;&gt;The two headers&lt;/h2&gt;
&lt;p&gt;The file has no extension, so the server won’t send the right type on its own. It needs &lt;code&gt;Content-Type: application/jrd+json&lt;/code&gt;, and &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; so that web clients on other origins can read it. In Caddy, inside the site block:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;@webfinger path /.well-known/webfinger&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;header @webfinger Content-Type &quot;application/jrd+json&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;header @webfinger Access-Control-Allow-Origin &quot;*&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;file_server&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;In nginx, Rubric sets the CORS header at the server level from a &lt;code&gt;map&lt;/code&gt; on the path, and gives the file its type in a location of its own:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;map&lt;/span&gt;&lt;span&gt; $&lt;/span&gt;&lt;span&gt;uri&lt;/span&gt;&lt;span&gt; $cors_origin {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    default&lt;/span&gt;&lt;span&gt;                  &quot;&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    /.well-known/webfinger   &lt;/span&gt;&lt;span&gt;&quot;*&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;server&lt;/span&gt;&lt;span&gt; {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    add_header &lt;/span&gt;&lt;span&gt;Access-Control-Allow-Origin $cors_origin always;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    location&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; /.well-known/webfinger &lt;/span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        types&lt;/span&gt;&lt;span&gt; { }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        default_type &lt;/span&gt;&lt;span&gt;application/jrd+json;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;        try_files &lt;/span&gt;&lt;span&gt;$uri &lt;/span&gt;&lt;span&gt;=404&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;I left the location without an &lt;code&gt;add_header&lt;/code&gt; of its own on purpose: nginx drops the server-level &lt;code&gt;add_header&lt;/code&gt; lines in any location that sets one, and the CORS header would go with them.&lt;/p&gt;
&lt;p&gt;Check both headers from outside:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; -sI&lt;/span&gt;&lt;span&gt; &quot;https://rubric.hrih.si/.well-known/webfinger?resource=acct:rubric@rubric.hrih.si&quot;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;It should answer 200 with &lt;code&gt;content-type: application/jrd+json&lt;/code&gt; and &lt;code&gt;access-control-allow-origin: *&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id=&quot;what-a-static-file-cant-do&quot;&gt;What a static file can’t do&lt;/h2&gt;
&lt;p&gt;A static server ignores the query string, so every name at the domain gets the same answer: &lt;code&gt;acct:nobody@rubric.hrih.si&lt;/code&gt; resolves to Rubric’s account just as &lt;code&gt;acct:rubric@rubric.hrih.si&lt;/code&gt; does. That makes it one account per domain: if you want a second one there, you need a server that reads &lt;code&gt;resource&lt;/code&gt;. The file also answers a request with no &lt;code&gt;resource&lt;/code&gt; at all, where the WebFinger spec, &lt;a href=&quot;https://www.rfc-editor.org/rfc/rfc7033&quot;&gt;RFC 7033&lt;/a&gt;, asks for a 400. Mastodon always sends one, so it hasn’t mattered.&lt;/p&gt;
&lt;p&gt;It’s an alias, not a server. The account, its posts and its followers stay on mastodon.social, and the domain is one more way to find them.&lt;/p&gt;
&lt;p&gt;To link the two the other way, Rubric’s footer carries the profile link with &lt;code&gt;rel=&quot;me&quot;&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&amp;lt;&lt;/span&gt;&lt;span&gt;a&lt;/span&gt;&lt;span&gt; href&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;https://mastodon.social/@rubricdaily&quot;&lt;/span&gt;&lt;span&gt; rel&lt;/span&gt;&lt;span&gt;=&lt;/span&gt;&lt;span&gt;&quot;me&quot;&lt;/span&gt;&lt;span&gt;&amp;gt;Mastodon&amp;lt;/&lt;/span&gt;&lt;span&gt;a&lt;/span&gt;&lt;span&gt;&amp;gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;List the site in one of the profile’s fields, and Mastodon marks that field verified once it finds the &lt;code&gt;rel=&quot;me&quot;&lt;/code&gt; link back.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/mastodon-handle-on-your-own-domain.png?v=c72a27df" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/mastodon-handle-on-your-own-domain.png?v=c72a27df" width="1200" height="630"/><category>Mastodon</category><category>WebFinger</category><category>Self-hosting</category><enclosure url="https://hrih.si/og/notes/mastodon-handle-on-your-own-domain.png?v=c72a27df" length="48403" type="image/png"/></item><item><title>Post to X and LinkedIn from a script without paying for the X API</title><link>https://hrih.si/notes/post-to-x-and-linkedin-without-the-x-api/</link><guid isPermaLink="true">https://hrih.si/notes/post-to-x-and-linkedin-without-the-x-api/</guid><description>X&apos;s API charges $0.20 for a post with a link. Buffer&apos;s free plan posts to X and LinkedIn from code through its GraphQL API. The calls I use.</description><pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;Rubric posts to X and LinkedIn through Buffer’s GraphQL API on the free plan, so it never pays X’s $0.20 for a post with a link. Every day at 13:00 UTC, it picks three stories from its top twenty and posts them to my LinkedIn and my X account. The script never talks to either network: it sends one GraphQL request per post to Buffer, which holds both accounts and publishes the post at once.&lt;/p&gt;
&lt;h2 id=&quot;why-not-the-networks-own-apis&quot;&gt;Why not the networks’ own APIs&lt;/h2&gt;
&lt;p&gt;X’s API is pay-per-use. Its &lt;a href=&quot;https://docs.x.com/x-api/getting-started/pricing&quot;&gt;pricing page&lt;/a&gt; lists $0.015 to create a post and $0.20 to create one that contains a URL, and every post I send has a link.&lt;/p&gt;
&lt;p&gt;LinkedIn’s self-serve API can post to your own profile, with access tokens that last 60 days. Refresh tokens are only for LinkedIn’s approved partners, so someone has to sign in again every two months. Posting as a company page needs a separate API that LinkedIn reviews.&lt;/p&gt;
&lt;p&gt;Buffer connects both accounts through its own app, and since May 2026 it has offered a GraphQL API with personal keys on every plan. The &lt;a href=&quot;https://buffer.com/pricing&quot;&gt;free plan&lt;/a&gt; covers 3 channels, 10 scheduled posts per channel and one API key, good for 1,000 requests per channel a month. Posting once a day uses about 30.&lt;/p&gt;
&lt;h2 id=&quot;find-the-channel-ids&quot;&gt;Find the channel ids&lt;/h2&gt;
&lt;p&gt;Connect the accounts in Buffer’s app, then create a key in its API settings. Every call is a POST to &lt;code&gt;https://api.buffer.com&lt;/code&gt; with the key as a Bearer token. Two queries list what the key can post to:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;account&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;organizations&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt; name&lt;/span&gt;&lt;span&gt; } } }&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;query&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;$input&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;ChannelsInput&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  channels&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;input&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;$input&lt;/span&gt;&lt;span&gt;) { &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt; name&lt;/span&gt;&lt;span&gt; service&lt;/span&gt;&lt;span&gt; type&lt;/span&gt;&lt;span&gt; displayName&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The second takes &lt;code&gt;{&quot;input&quot;: {&quot;organizationId&quot;: &quot;&amp;lt;organization id&amp;gt;&quot;}}&lt;/code&gt; as its variables. X shows up with the service name &lt;code&gt;twitter&lt;/code&gt;. Keep the two channel ids next to the key, because the script needs nothing else.&lt;/p&gt;
&lt;h2 id=&quot;post&quot;&gt;Post&lt;/h2&gt;
&lt;p&gt;One mutation per post:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;mutation&lt;/span&gt;&lt;span&gt; (&lt;/span&gt;&lt;span&gt;$input&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;CreatePostInput&lt;/span&gt;&lt;span&gt;!&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  createPost&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;input&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;$input&lt;/span&gt;&lt;span&gt;) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    __typename&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ...&lt;/span&gt;&lt;span&gt; on&lt;/span&gt;&lt;span&gt; PostActionSuccess&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;post&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;id&lt;/span&gt;&lt;span&gt; } }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    ...&lt;/span&gt;&lt;span&gt; on&lt;/span&gt;&lt;span&gt; MutationError&lt;/span&gt;&lt;span&gt; { &lt;/span&gt;&lt;span&gt;message&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;with these variables:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;{&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;input&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;channelId&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;&amp;lt;channel id&amp;gt;&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;text&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;The post&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;schedulingType&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;automatic&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;mode&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;shareNow&quot;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;mode: &quot;shareNow&quot;&lt;/code&gt; skips the queue and publishes right away. For LinkedIn, I add a link card to the input, with the page’s social image as its thumbnail:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&quot;metadata&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;linkedin&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;linkAttachment&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;url&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;https://rubric.hrih.si/editions/2026-10-30/&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;title&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;…&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;description&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;…&quot;&lt;/span&gt;&lt;span&gt;,&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;      &quot;thumbnail&quot;&lt;/span&gt;&lt;span&gt;: { &lt;/span&gt;&lt;span&gt;&quot;url&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;https://rubric.hrih.si/og/2026-10-30.png&quot;&lt;/span&gt;&lt;span&gt; }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;On X the link goes in a reply, so the input carries a thread. The thread lists every post including the first one, which repeats the &lt;code&gt;text&lt;/code&gt;:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;&quot;metadata&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  &quot;twitter&quot;&lt;/span&gt;&lt;span&gt;: {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;    &quot;thread&quot;&lt;/span&gt;&lt;span&gt;: [{ &lt;/span&gt;&lt;span&gt;&quot;text&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;The post&quot;&lt;/span&gt;&lt;span&gt; }, { &lt;/span&gt;&lt;span&gt;&quot;text&quot;&lt;/span&gt;&lt;span&gt;: &lt;/span&gt;&lt;span&gt;&quot;The reply, with the link&quot;&lt;/span&gt;&lt;span&gt; }]&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  }&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The same request from a shell:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;curl&lt;/span&gt;&lt;span&gt; -s&lt;/span&gt;&lt;span&gt; https://api.buffer.com&lt;/span&gt;&lt;span&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  -H&lt;/span&gt;&lt;span&gt; &quot;Authorization: Bearer &lt;/span&gt;&lt;span&gt;$BUFFER_API_KEY&lt;/span&gt;&lt;span&gt;&quot;&lt;/span&gt;&lt;span&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  -H&lt;/span&gt;&lt;span&gt; &quot;Content-Type: application/json&quot;&lt;/span&gt;&lt;span&gt; \&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  -d&lt;/span&gt;&lt;span&gt; &apos;{&quot;query&quot;:&quot;mutation ($input: CreatePostInput!) { createPost(input: $input) { __typename ... on PostActionSuccess { post { id } } ... on MutationError { message } } }&quot;,&quot;variables&quot;:{&quot;input&quot;:{&quot;channelId&quot;:&quot;&amp;lt;channel id&amp;gt;&quot;,&quot;text&quot;:&quot;Hello from a script&quot;,&quot;schedulingType&quot;:&quot;automatic&quot;,&quot;mode&quot;:&quot;shareNow&quot;}}}&apos;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;Buffer can refuse a post without the request failing. The answer then has &lt;code&gt;__typename&lt;/code&gt; set to &lt;code&gt;MutationError&lt;/code&gt; and a &lt;code&gt;message&lt;/code&gt;, so check &lt;code&gt;__typename&lt;/code&gt; as well as the &lt;code&gt;errors&lt;/code&gt; array GraphQL uses for a bad query.&lt;/p&gt;
&lt;h2 id=&quot;what-you-give-up&quot;&gt;What you give up&lt;/h2&gt;
&lt;p&gt;Buffer answers with its own post id, not the post’s address on X or LinkedIn, so the script can’t link to what it just posted. You also add a company in the middle that holds your tokens. For me that beats paying per post: the first real posts went out on 28 October 2026, and Buffer took the link card and the thread exactly as shown here.&lt;/p&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/post-to-x-and-linkedin-without-the-x-api.png?v=e1efa559" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/post-to-x-and-linkedin-without-the-x-api.png?v=e1efa559" width="1200" height="630"/><category>Buffer</category><category>X API</category><category>LinkedIn</category><category>GraphQL</category><enclosure url="https://hrih.si/og/notes/post-to-x-and-linkedin-without-the-x-api.png?v=e1efa559" length="50824" type="image/png"/></item><item><title>tar &quot;Cannot open: No such file or directory&quot; in Docker when the folder exists on your machine</title><link>https://hrih.si/notes/tar-cannot-open-in-docker/</link><guid isPermaLink="true">https://hrih.si/notes/tar-cannot-open-in-docker/</guid><description>A git-ignored folder existed on my PC and not in the container, so tar failed, the job said done and the live check said live. The three fixes.</description><pubDate>Fri, 30 Oct 2026 00:00:00 GMT</pubDate><content:encoded>&lt;p&gt;tar failed in the container because the folder it writes into, &lt;code&gt;data/deploy/&lt;/code&gt;, is git-ignored: it existed on my PC and nowhere in the container, and creating it before tar runs fixed it. On 29 October, Rubric’s edition was published at 05:50 UTC, the morning email went out with a link to it, and at 06:10 my live check reported the edition live. It wasn’t. The page answered 404 until I deployed it by hand at 09:31 UTC, and the first of the three things that went wrong was this line in the job’s log:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;tar -czf ../../app/data/deploy/site-….tar.gz: Cannot open: No such file or directory&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;a-folder-that-only-existed-on-my-pc&quot;&gt;A folder that only existed on my PC&lt;/h2&gt;
&lt;p&gt;The deploy builds the site, packs it with &lt;code&gt;tar -czf&lt;/code&gt; into &lt;code&gt;data/deploy/&lt;/code&gt; and uploads the archive to the server. On my PC that folder existed: it’s git-ignored, and an earlier run had created it. The day before, the jobs had moved into containers on a VM, and there the folder existed nowhere. &lt;code&gt;.dockerignore&lt;/code&gt; keeps &lt;code&gt;data&lt;/code&gt; out of the image, and the data volume is mounted at &lt;code&gt;/data&lt;/code&gt;, not &lt;code&gt;/app/data&lt;/code&gt;. tar doesn’t create the folder for the file it writes, and when that folder is missing you get exactly this error.&lt;/p&gt;
&lt;p&gt;The fix is one line before tar, plus a test that archives into a folder that doesn’t exist yet. Simplified:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;// before&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;tar&quot;&lt;/span&gt;&lt;span&gt;, [&lt;/span&gt;&lt;span&gt;&quot;-czf&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;relative&lt;/span&gt;&lt;span&gt;(dir, file), &lt;/span&gt;&lt;span&gt;&quot;.&quot;&lt;/span&gt;&lt;span&gt;], { cwd: dir });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;// after&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;mkdirSync&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;dirname&lt;/span&gt;&lt;span&gt;(file), { recursive: &lt;/span&gt;&lt;span&gt;true&lt;/span&gt;&lt;span&gt; });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;await&lt;/span&gt;&lt;span&gt; run&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;&quot;tar&quot;&lt;/span&gt;&lt;span&gt;, [&lt;/span&gt;&lt;span&gt;&quot;-czf&quot;&lt;/span&gt;&lt;span&gt;, &lt;/span&gt;&lt;span&gt;relative&lt;/span&gt;&lt;span&gt;(dir, file), &lt;/span&gt;&lt;span&gt;&quot;.&quot;&lt;/span&gt;&lt;span&gt;], { cwd: dir });&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;h2 id=&quot;a-step-that-reported-done&quot;&gt;A step that reported done&lt;/h2&gt;
&lt;p&gt;That error should have stopped the morning. It didn’t, because the upload sat inside the publish step, wrapped in a try/catch:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;} &lt;/span&gt;&lt;span&gt;catch&lt;/span&gt;&lt;span&gt; (err) {&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  run.log.&lt;/span&gt;&lt;span&gt;error&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;`site deploy failed: ${&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;err&lt;/span&gt;&lt;span&gt; as&lt;/span&gt;&lt;span&gt; Error&lt;/span&gt;&lt;span&gt;).&lt;/span&gt;&lt;span&gt;message&lt;/span&gt;&lt;span&gt;}`&lt;/span&gt;&lt;span&gt;);&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;  built &lt;/span&gt;&lt;span&gt;+=&lt;/span&gt;&lt;span&gt; &quot; · site deploy FAILED&quot;&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;}&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;The step logged the failure, wrote &lt;code&gt;site deploy FAILED&lt;/code&gt; into its summary line and finished. The scheduler saw a finished step and had nothing to retry. The email went out in the same step, linking to a page that wasn’t there.&lt;/p&gt;
&lt;p&gt;The deploy is now a stage of its own after the publish. It fails like any other stage, alerts on its first failure only, and the scheduler runs it again every quarter hour for six hours after the publish. The email waits until the upload has landed.&lt;/p&gt;
&lt;h2 id=&quot;a-check-that-trusted-the-database&quot;&gt;A check that trusted the database&lt;/h2&gt;
&lt;p&gt;This is the part worth copying. The live check asked my own database:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;if&lt;/span&gt;&lt;span&gt; (row?.status &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; &quot;published&quot;&lt;/span&gt;&lt;span&gt;) &lt;/span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; `edition ${&lt;/span&gt;&lt;span&gt;date&lt;/span&gt;&lt;span&gt;} is live`&lt;/span&gt;&lt;span&gt;;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;code&gt;published&lt;/code&gt; meant the edition was committed and pushed. It never meant a reader could open the page, and the check treated the two as one. Now it asks the site:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;const&lt;/span&gt;&lt;span&gt; res&lt;/span&gt;&lt;span&gt; =&lt;/span&gt;&lt;span&gt; await&lt;/span&gt;&lt;span&gt; fetch&lt;/span&gt;&lt;span&gt;(url, { method: &lt;/span&gt;&lt;span&gt;&quot;HEAD&quot;&lt;/span&gt;&lt;span&gt;, redirect: &lt;/span&gt;&lt;span&gt;&quot;manual&quot;&lt;/span&gt;&lt;span&gt;, signal: AbortSignal.&lt;/span&gt;&lt;span&gt;timeout&lt;/span&gt;&lt;span&gt;(&lt;/span&gt;&lt;span&gt;20_000&lt;/span&gt;&lt;span&gt;) });&lt;/span&gt;&lt;/span&gt;
&lt;span&gt;&lt;span&gt;return&lt;/span&gt;&lt;span&gt; { live: res.status &lt;/span&gt;&lt;span&gt;===&lt;/span&gt;&lt;span&gt; 200&lt;/span&gt;&lt;span&gt;, status: res.status, url };&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;A missing edition page answers 404 to a HEAD request just as it does to a GET, so a 200 means a reader gets the page. &lt;code&gt;redirect: &quot;manual&quot;&lt;/code&gt; counts a redirect as not live, so the URL needs its trailing slash. Without it, the server answers 308.&lt;/p&gt;
&lt;p&gt;A row that says “published” is the pipeline’s own account of what it did. Readers get the page, so the page is what gets checked, and nothing that links to the edition trusts the database alone any more. The social posts at 08:00 and 13:00 ask the page first, and while it isn’t 200 they post nothing and send me an alert.&lt;/p&gt;
&lt;h2 id=&quot;one-more-race&quot;&gt;One more race&lt;/h2&gt;
&lt;p&gt;The 06:10 check and the 08:00 posts also hit a smaller bug that morning. Two job containers pulled the same git checkout in the same second and broke each other’s fetch (“Cannot fast-forward to multiple branches”). Each pull now waits up to two minutes for a lock on the shared volume:&lt;/p&gt;
&lt;pre&gt;&lt;code&gt;&lt;span&gt;&lt;span&gt;flock&lt;/span&gt;&lt;span&gt; -w&lt;/span&gt;&lt;span&gt; 120&lt;/span&gt;&lt;span&gt; /site/.pull.lock&lt;/span&gt;&lt;span&gt; git&lt;/span&gt;&lt;span&gt; -C&lt;/span&gt;&lt;span&gt; /site&lt;/span&gt;&lt;span&gt; pull&lt;/span&gt;&lt;span&gt; --ff-only&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;
</content:encoded><dc:creator>Hatim Rih</dc:creator><media:content url="https://hrih.si/og/notes/tar-cannot-open-in-docker.png?v=c08aead8" medium="image" type="image/png" width="1200" height="630"/><media:thumbnail url="https://hrih.si/og/notes/tar-cannot-open-in-docker.png?v=c08aead8" width="1200" height="630"/><category>Docker</category><category>Debugging</category><category>Deployment</category><enclosure url="https://hrih.si/og/notes/tar-cannot-open-in-docker.png?v=c08aead8" length="50923" type="image/png"/></item></channel></rss>