<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wc="https://wilhelm.codes/ns/feed/1.0"><channel><title>Wilhelm Codes · Woodpecker</title><link>https://wilhelm.codes/tags/woodpecker/</link><description>Slinger of nibbles, bits and bytes. Over 25 years of professional experience as a software engineer. Love making glowing rectangles go "beep-boop".</description><generator>Hugo</generator><language>en-US</language><managingEditor>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</managingEditor><webMaster>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</webMaster><lastBuildDate>Sun, 30 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://wilhelm.codes/tags/woodpecker/index.xml" rel="self" type="application/rss+xml"/><item><title>Why I Rebuilt Plant Smart</title><link>https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/</link><pubDate>Sun, 30 Aug 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/</guid><category>hugo</category><category>plantsm.art</category><category>woodpecker</category><category>performance</category><wc:kind>post</wc:kind><description>Back in June I wrote a little blurb that said, in its entirety, &amp;ldquo;I should update Plant🌱Smart.&amp;rdquo; Bolder and more visionary words have never been written. So bold, in fact, I went right back to demolishing this blog instead and let the poor thing sit for another two months.
Well, I finally did the thing.</description><content:encoded><![CDATA[<p>Back in June I wrote <a href="https://wilhelm.codes/bits/blurb-i-should-update-plantsmart/">a little blurb</a> that said, in its entirety, &ldquo;I should update <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span>.&rdquo; Bolder and more visionary words have never been written. So bold, in fact, I went right back to <a href="https://wilhelm.codes/blog/some-long-overdue-demolition/">demolishing this blog</a> instead and let the poor thing sit for another two months.</p>
<p>Well, I finally did the thing.</p>
<p>Three and a half years ago I wrote <a href="https://wilhelm.codes/blog/why-i-built-plant-smart/">Why I Built <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span></a>. A free and searchable database of plants that are toxic to pets. I built it over a holiday break to answer a simple question, &ldquo;Can I bring this plant home without endangering the fluffy little gremlin that runs our household?&rdquo; It was also my excuse to learn SvelteKit, TypeScript, TailwindCSS and Vite, because my frontend peers wouldn&rsquo;t shut the hell up about them.</p>
<p>The site then chugged along quietly and untouched for years, which sounds like a compliment to its architecture and very much is not one to the maintainer.</p>
<h2 id="the-toolchain-aged-like-fine-milk">
  <a class="heading-link" href="#the-toolchain-aged-like-fine-milk">The toolchain aged like fine milk.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The thing no one really thinks about when they build something like this with shiny new frontend tools is that it doesn&rsquo;t rust, it <em>curdles</em>. When I finally came back to give <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span> a fresh coat of paint, the build no longer ran. Not &ldquo;a few deprecation warnings&rdquo; didn&rsquo;t run. It didn&rsquo;t run at all. Two years of an unattended <code>package.json</code> had fermented into something of a cheese-like consistency.</p>
<p>My original requirements list from 2022 said the site must be fast, static, ( almost ) free to run and open with its data. Nowhere did it say &ldquo;must still compile in two years&rdquo;.</p>
<p>So, I decided to spend a few weeks off and on to throw out the toolchain and give it a much-needed refresh. Just like this site, Hugo now builds the entire thing. There&rsquo;s a single hand-written stylesheet and a few small scripts of delicious vanilla JavaScript. No <code>npm install</code>, because there is no longer anything to install. If I disappear for another two years ( likely ), the site will build exactly the same the day I come back.</p>
<h2 id="first-things-first-absolute-parity">
  <a class="heading-link" href="#first-things-first-absolute-parity">First things first, absolute parity.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The rebuild had one rule that saved me from myself: the first pass had to be a 1:1 replica of the old site. It needed the same pages, filters, URLs and quirks. The pass was meant to be boring on purpose. I had to resist the temptation of doing both the replatforming and redesigning all in one go.</p>
<p>My primary constraint was the plant data. <span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span>&rsquo;s whole gimmick is what I called a &ldquo;dumb API&rdquo;. The entire primary database lives in <a href="https://plantsm.art/api/plants.json">one JSON file</a> with other, smaller, supplemental files you could use as lookup tables. Since this was all hosted on Cloudflare&rsquo;s CDN it was effectively free to use for others without keys or annoying rate limits. The old derivations were originally done by a pile of Go magefiles. The new site had to reproduce them exactly, down to the deliberately unsorted first-appearance ordering, or every downstream consumer would notice.</p>
<p>Hugo, it turns out, can do all of it. A content adapter reads the database and births every plant page directly with no intermediate files. A handful of templates re-implement the old Go derivation logic and regenerate the API on every build. I diffed the generated files against the originals and they came back value-identical on the first honest attempt.</p>
<p>Then came my favourite commit of the whole project:</p>
<div class="code-block">
  <pre tabindex="0"><code>retire the sveltekit stack: remove src, magefiles and the node toolchain
566 files changed, 112 insertions(+), 9905 deletions(-)</code></pre>
</div>
<p>I deleted nearly ten thousand lines and the site did more than before. This is every developer&rsquo;s <em>favourite</em> kind of commit.</p>
<h2 id="i-made-the-site-cute-on-purpose">
  <a class="heading-link" href="#i-made-the-site-cute-on-purpose">I made the site cute on purpose.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>With the replica shipped, the redesign could actually be fun. The old site was fine but it was very much &ldquo;2022 Tailwind starter&rdquo; vibe. A lot of cool greys, hard shadows and gradients on anything that stood still. Meanwhile the logo, a randomised squishy little pastel potted plant with a face, deserved better surroundings.</p>
<p>So, I set out to make the logo the design brief. Cream paper instead of white with warm espresso ink instead of a dull grey. Sage and moss greens with honey and clay accents were all lifted straight from the icon set. Every shadow is tinted warm because plain black shadows on cream look like stickers. The corners got a bit rounder and the controls became pills. The whole thing is set in <a href="https://fonts.google.com/specimen/Fredoka">Fredoka</a>, a rounded display face that weighs about <code>30kB</code> which I thought fit perfectly with the cute little mascot.</p>
<p>Here are a few before and after shots of the main content pages, with a bonus shot of the new pill navigation that smaller screens finally got:</p>
<div class="strip">
  
  <button type="button" class="zoom" aria-label="Zoom image: The homepage before the redesign.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/home-before_hu_c134c356ab255f31.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/home-before_hu_c134c356ab255f31.webp 1x, /blog/why-i-rebuilt-plant-smart/home-before_hu_fd4c4f2261904a3.webp 2x"
    
    width="337"
    height="224"
    alt="The homepage before the redesign."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/home-before_hu_bd376468f433ee37.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The homepage as it currently stands.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/home-after_hu_7bf504f00e2d4b7b.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/home-after_hu_7bf504f00e2d4b7b.webp 1x, /blog/why-i-rebuilt-plant-smart/home-after_hu_18e93a68df4a3045.webp 2x"
    
    width="326"
    height="224"
    alt="The homepage as it currently stands."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/home-after_hu_e3c3f0257d1fdaa7.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The plant details page before the redesign.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/details-before_hu_778178bb15ddf6e2.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/details-before_hu_778178bb15ddf6e2.webp 1x, /blog/why-i-rebuilt-plant-smart/details-before_hu_47cac0840311163a.webp 2x"
    
    width="235"
    height="224"
    alt="The plant details page before the redesign."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/details-before_hu_a7d36e9ec06698d9.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The plant details page as it currently stands.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/details-after_hu_be1325861b1ce8de.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/details-after_hu_be1325861b1ce8de.webp 1x, /blog/why-i-rebuilt-plant-smart/details-after_hu_5bc6a433aa239b54.webp 2x"
    
    width="140"
    height="224"
    alt="The plant details page as it currently stands."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/details-after_hu_672f659284e5d144.webp"
    
    
  />
  
    </button>
  <button type="button" class="zoom" aria-label="Zoom image: The new pill navigation for mobile users.">
      <img
    src="https://wilhelm.codes/blog/why-i-rebuilt-plant-smart/mobile-pill-navigation_hu_292dde7b92e8dcc8.webp"
    srcset="/blog/why-i-rebuilt-plant-smart/mobile-pill-navigation_hu_292dde7b92e8dcc8.webp 1x, /blog/why-i-rebuilt-plant-smart/mobile-pill-navigation_hu_18b41e7ae3eacdf6.webp 2x"
    
    width="102"
    height="224"
    alt="The new pill navigation for mobile users."
    loading="lazy"
    decoding="async"
    data-full="/blog/why-i-rebuilt-plant-smart/mobile-pill-navigation_hu_9d3bd352027c355a.webp"
    
    
  />
  
    </button>

</div>

<h2 id="the-data-enrichments-will-continue-until-morella-improves">
  <a class="heading-link" href="#the-data-enrichments-will-continue-until-morella-improves">The data enrichments will continue until &ldquo;Morella&rdquo; improves.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Get it? No? Ok, I&rsquo;ll see myself out.</p>
<p>The database had severity ratings for every plant, computed from symptom groupings and the old site displayed them precisely nowhere. Now, every card has a severity pill rated from Mild to Critical. The pills are also clickable filters on the main page&rsquo;s plant cards. Search happens as you type and if you fumble the Latin a bit, a little <a href="https://en.wikipedia.org/wiki/Levenshtein_distance">Levenshtein</a> pass offers &ldquo;Did you mean <em>Lilium</em>?&rdquo;. I thought this was a little bit more helpful than what the old site did after literally joking about spelling mistakes in its empty state.</p>
<p>A few other mentionable updates:</p>
<ul>
<li>A <a href="https://plantsm.art/safe/">safe plants list</a>, because the site only ever told you what <em>not</em> to buy. Every entry has to clear the ASPCA non-toxic list and be absent from my own toxic database before it&rsquo;s allowed in.</li>
<li>A <a href="https://plantsm.art/symptoms/">symptom finder</a> that allows you to perform a plant search backwards. You can click on multiple symptoms and it&rsquo;ll filter accordingly.</li>
<li>An emergency strip on every plant page with the poison-control hotlines. Unfortunately, for now, these are US numbers. Localising them by region is on the roadmap.</li>
<li>Wikipedia descriptions for nearly every plant credited under CC BY-SA.</li>
<li>Around 3,000 common-name redirects, so <code>/plant/peace-lily/</code> finally lands somewhere useful.</li>
<li>Because it made sense, the whole thing is now a PWA and works completely offline. So, now you can use the site in a greenhouse with no signal, which is precisely where this site is most likely to get used.</li>
</ul>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>My friends can no longer accuse me of trying to teach people how to poison their neighbour's pets.</p>
  </blockquote>
  <figcaption>
    <cite>J'Accuse...!</cite>
  </figcaption>
</figure>

<p>Once I started auditing the data properly, I started to see a few additional gaps. There were several plants with multiple entries under different, but similar, names. I deduped and merged the symptom data and also updated the severity groupings to make more sense.</p>
<p>I also quickly realised that merging <code>seizure</code> into <code>seizures</code> would have silently promoted over a hundred plants to &ldquo;Critical&rdquo;. The severity groups treated those two spellings as <em>different levels</em>. The little cleaner script I threw together caught it during a dry run. The script grew a rule about never merging across severity levels as a result. The plants that genuinely deserved promotion got one. For instance <a href="https://plantsm.art/plant/ageratina-altissima/">white snakeroot</a>, the plant that killed Lincoln&rsquo;s mother, was somehow rated &ldquo;Severe&rdquo;. The script properly re-rated it to &ldquo;Critical&rdquo;.</p>
<p>The enrichment didn&rsquo;t stop there. I sourced proper taxonomy for every record via GBIF, per-plant toxicity principles with a glossary and little world maps showing their native origins.</p>
<h2 id="there-was-also-quite-a-bit-of-fat-to-trim">
  <a class="heading-link" href="#there-was-also-quite-a-bit-of-fat-to-trim">There was also quite a bit of fat to trim.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Somewhere in all this I noticed every page weighed over <code>200kB</code>. This included pages that were basically just copy. The culprits were painfully self-inflicted. There was an <code>85kB</code> inline script shipping all ten animated logo SVGs to every single page. The same animal icon inlined seventy times on the front page. Oh, and let&rsquo;s not forget my personal favourite, the attributions page carrying <code>149kB</code> of pure <em>indentation</em> that a template loop had quietly emitted. I was shipping whitespace at scale.</p>
<p>The fixes were old-school and very satisfying. A single fingerprinted and cached JS bundle, SVG sprites so each drawing only ships once and one well-placed whitespace trim yielded the following results:</p>
<table>
	<thead>
			<tr>
					<th>page</th>
					<th>before</th>
					<th>after</th>
			</tr>
	</thead>
	<tbody>
			<tr>
					<td>home</td>
					<td>247 KB</td>
					<td>56 KB</td>
			</tr>
			<tr>
					<td>attributions</td>
					<td>238 KB</td>
					<td>24 KB</td>
			</tr>
			<tr>
					<td>safe plants</td>
					<td>270 KB</td>
					<td>33 KB</td>
			</tr>
			<tr>
					<td>plant pages</td>
					<td>114 KB</td>
					<td>36 KB</td>
			</tr>
	</tbody>
</table>
<p>No framework was harmed, or indeed present, in the making of this facelift.</p>
<h2 id="we-ship-from-the-homelab-now">
  <a class="heading-link" href="#we-ship-from-the-homelab-now">We ship from the homelab now.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The last box to tick from the original post was the bittersweet one. In 2022 I wanted the project &ldquo;open-source and community-driven&rdquo; on GitHub. In practice, the community contribution was me, at midnight, fixing my own typos. So, the repository followed this blog into my homelab using the exact same moving parts as outlined in <a href="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/">My Blog Now Ships From My Homelab!</a>. It just made sense after this site&rsquo;s migration. The <em>data</em> remains as open as ever through the API, which was always the part worth opening. That will never change.</p>
<p>While migrating I had a small panic about the 315MB of plant photos sitting in my working tree, imagining every CI build hauling them across the network. Turns out my 2023 self had already gitignored them and the entire repository clones at 7.5MB. Every once in a blue moon past me does something right. I&rsquo;ve decided to let sleeping dogs lie and not investigate what else he buried in there.</p>
<h2 id="was-the-juice-worth-the-squeeze">
  <a class="heading-link" href="#was-the-juice-worth-the-squeeze">Was the juice worth the squeeze?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Very much so. The site is faster, cuter and finally says the most important thing it knows on every card: <em>how</em> dangerous is this plant, not just <em>whether</em>. It builds from nothing with a single binary and deploys itself when I push. The data is cleaner and more robust than it has ever been and enriched well past what 2022 me had the patience for.</p>
<p>But the honest win is the same one as the blog demolition. The whole thing is now boring to operate and boring is what lets a hobby project survive its maintainer&rsquo;s attention span.</p>
<p>If you have a plant-curious pet, <a href="https://plantsm.art"><span class="wm-plant">Plant</span>🌱<span class="wm-smart">Smart</span></a> is free, the <a href="https://plantsm.art/api/">API</a> is free and the <a href="https://plantsm.art/safe/">safe list</a> might even let you buy something nice for yourself. And if four years from now there&rsquo;s a third post in this series, may it be shorter than this one.</p>
<figure class="pull-quote">
  <span class="pull-quote-mark" aria-hidden="true"><span class="icon "><svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 256 256"><g fill="currentColor"><path d="M108 72v72H40a8 8 0 0 1-8-8V72a8 8 0 0 1 8-8h60a8 8 0 0 1 8 8m108-8h-60a8 8 0 0 0-8 8v64a8 8 0 0 0 8 8h68V72a8 8 0 0 0-8-8" opacity=".2"/><path d="M100 56H40a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80H40V72h60Zm116-80h-60a16 16 0 0 0-16 16v64a16 16 0 0 0 16 16h60v8a32 32 0 0 1-32 32a8 8 0 0 0 0 16a48.05 48.05 0 0 0 48-48V72a16 16 0 0 0-16-16m0 80h-60V72h60Z"/></g></svg></span></span>
  <blockquote>
    <p>The data enrichments will continue until "Morella" improves.</p>
  </blockquote>
  <figcaption>
    <cite>C'mon, this was a GREAT pun!</cite>
  </figcaption>
</figure>

<p>Anyways, adios&hellip;</p>]]></content:encoded></item><item><title>My Blog Now Ships From My Homelab!</title><link>https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/</link><pubDate>Wed, 26 Aug 2026 00:00:00 +0000</pubDate><author>0xdeadbeef@devilmayco.de (Wilhelm Murdoch)</author><guid>https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/</guid><category>hugo</category><category>woodpecker</category><category>forgejo</category><category>cloudflare</category><category>homelab</category><wc:kind>post</wc:kind><description>If you&amp;rsquo;ve given GitHub&amp;rsquo;s status page a quick glance recently, you&amp;rsquo;ll immediately see that it&amp;rsquo;s been going through it. Every outage is a small reminder that maybe we shouldn&amp;rsquo;t rely too much on single points of failure we can&amp;rsquo;t directly control.
So, having been bitten several times by intermittent outages and service disruptions, I figured a small personal win would be to migrate the repository for this site to the homelab. Now, it lives on my own Forgejo forge and the builds run on my own Woodpecker cluster. The only things that leave the network are static build artefacts that get pushed up to Cloudflare Pages.
The switch was surprisingly straightforward and took less than 30 minutes overall.</description><content:encoded><![CDATA[<p>If you&rsquo;ve given GitHub&rsquo;s status page a quick glance recently, you&rsquo;ll immediately see that it&rsquo;s been <em>going through it</em>. Every outage is a small reminder that maybe we shouldn&rsquo;t rely too much on single points of failure we can&rsquo;t directly control.</p>
<p>So, having been bitten several times by intermittent outages and service disruptions, I figured a small personal win would be to migrate the repository for <em>this</em> site to the homelab. Now, it lives on my own <a href="https://forgejo.org">Forgejo</a> forge and the builds run on my own <a href="https://woodpecker-ci.org">Woodpecker</a> cluster. The only things that leave the network are static build artefacts that get pushed up to Cloudflare Pages.</p>
<p>The switch was surprisingly straightforward and took less than 30 minutes overall.</p>
<p>Before anyone reaches for the comment box, no, you don&rsquo;t need a homelab for any of this. Forgejo and a Woodpecker cluster ( server and at least one agent ) will happily run on a spare mini PC, an old laptop, that Raspberry Pi you bought with the best of intentions or even as a single Docker Compose file running on the machine you use every day.</p>
<h2 id="how-complex-can-this-possibly-be">
  <a class="heading-link" href="#how-complex-can-this-possibly-be">How complex can this possibly be?<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The first useful thing I did was ask myself what GitHub was <em>actually</em> doing for me when it comes to managing this site. After some mulling about, I could only really list the following:</p>
<ul>
<li>Hosting a git repository.</li>
<li>Hosting the comment threads, via <a href="https://giscus.app">giscus</a> Discussions.</li>
</ul>
<p>That&rsquo;s pretty much it. I don&rsquo;t use Actions and GitHub alone never built this site. <a href="https://pages.cloudflare.com">Cloudflare Pages</a> watched the <code>main</code> branch and did the building itself. This is something I covered back in <a href="https://wilhelm.codes/blog/my-blog-publishing-setup/">My Blog Publishing Setup</a>. GitHub&rsquo;s one job was holding a bunch of markdown files.</p>
<p>That&rsquo;s a strange thing to accept as a single point of failure.</p>
<h2 id="the-new-shape-of-things">
  <a class="heading-link" href="#the-new-shape-of-things">The new shape of things.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The replacement looks like this:</p>
<ul>
<li>I push to Forgejo, which lives inside my homelab network.</li>
<li>Forgejo fires off a message to the Woodpecker Server over a webhook. Also inside the network.</li>
<li>The Woodpecker Server assigns the job to an available Woodpecker Agent, which clones the repo, builds the site with Hugo and pushes the result to Cloudflare Pages with <a href="https://developers.cloudflare.com/workers/wrangler/">wrangler</a>.</li>
<li>Cloudflare serves the files at the edge, exactly as before.</li>
</ul>
<p>I just added support for mermaid diagrams, so of course I&rsquo;m not going to let the opportunity to show it off slide:</p>
<figure class="diagram"><svg id="d-702390838d2d" width="100%" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="max-width: 865px; background-color: transparent;" viewBox="-50 -10 865 421" role="graphics-document document" aria-roledescription="sequence"><g><rect x="615" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="C" rx="3" ry="3" class="actor actor-bottom"/><text x="690" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="690" dy="0">Pages</tspan></text></g><g><rect x="400" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-bottom"/><text x="475" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="475" dy="0">Woodpecker</tspan></text></g><g><rect x="200" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="F" rx="3" ry="3" class="actor actor-bottom"/><text x="275" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="275" dy="0">Forgejo</tspan></text></g><g><rect x="0" y="335" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-bottom"/><text x="75" y="367.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">The desk</tspan></text></g><g><line id="actor3" x1="690" y1="65" x2="690" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="C" data-et="life-line" data-id="C"/><g id="root-3" data-et="participant" data-type="participant" data-id="C"><rect x="615" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="C" rx="3" ry="3" class="actor actor-top"/><text x="690" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="690" dy="0">Pages</tspan></text></g></g><g><line id="actor2" x1="475" y1="65" x2="475" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="W" data-et="life-line" data-id="W"/><g id="root-2" data-et="participant" data-type="participant" data-id="W"><rect x="400" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="W" rx="3" ry="3" class="actor actor-top"/><text x="475" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="475" dy="0">Woodpecker</tspan></text></g></g><g><line id="actor1" x1="275" y1="65" x2="275" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="F" data-et="life-line" data-id="F"/><g id="root-1" data-et="participant" data-type="participant" data-id="F"><rect x="200" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="F" rx="3" ry="3" class="actor actor-top"/><text x="275" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="275" dy="0">Forgejo</tspan></text></g></g><g><line id="actor0" x1="75" y1="65" x2="75" y2="335" class="actor-line 200" stroke-width="0.5px" stroke="#999" name="D" data-et="life-line" data-id="D"/><g id="root-0" data-et="participant" data-type="participant" data-id="D"><rect x="0" y="0" fill="#eaeaea" stroke="#666" width="150" height="65" name="D" rx="3" ry="3" class="actor actor-top"/><text x="75" y="32.5" dominant-baseline="central" alignment-baseline="central" class="actor actor-box" style="text-anchor: middle; font-size: 16px; font-weight: 400;"><tspan x="75" dy="0">The desk</tspan></text></g></g><style>#d-702390838d2d{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;fill:#32d74b;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#d-702390838d2d .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#d-702390838d2d .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#d-702390838d2d .error-icon{fill:#202225;}#d-702390838d2d .error-text{fill:#e3e3de;stroke:#e3e3de;}#d-702390838d2d .edge-thickness-normal{stroke-width:1px;}#d-702390838d2d .edge-thickness-thick{stroke-width:3.5px;}#d-702390838d2d .edge-pattern-solid{stroke-dasharray:0;}#d-702390838d2d .edge-thickness-invisible{stroke-width:0;fill:none;}#d-702390838d2d .edge-pattern-dashed{stroke-dasharray:3;}#d-702390838d2d .edge-pattern-dotted{stroke-dasharray:2;}#d-702390838d2d .marker{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .marker.cross{stroke:#8b8b84;}#d-702390838d2d svg{font-family:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace;font-size:14px;}#d-702390838d2d p{margin:0;}#d-702390838d2d .actor{stroke:#3c3f43;fill:#1e1f22;stroke-width:1;}#d-702390838d2d rect.actor.outer-path[data-look="neo"]{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d rect.note[data-look="neo"]{stroke:hsl(52.6829268293, 60%, 93.9215686275%);fill:#fff5ad;filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d text.actor&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .actor-line{stroke:#8b8b84;}#d-702390838d2d .innerArc{stroke-width:1.5;stroke-dasharray:none;}#d-702390838d2d .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#8b8b84;}#d-702390838d2d .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#8b8b84;}#d-702390838d2d [id$="-arrowhead"] path{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .sequenceNumber{fill:#74747b;}#d-702390838d2d [id$="-sequencenumber"]{fill:#8b8b84;}#d-702390838d2d [id$="-crosshead"] path{fill:#8b8b84;stroke:#8b8b84;}#d-702390838d2d .messageText{fill:#32d74b;stroke:none;}#d-702390838d2d .labelBox{stroke:#3c3f43;fill:#1e1f22;filter:none;}#d-702390838d2d .labelText,#d-702390838d2d .labelText&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .loopText,#d-702390838d2d .loopText&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .sectionTitle,#d-702390838d2d .sectionTitle&gt;tspan{fill:#32d74b;stroke:none;}#d-702390838d2d .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:#3c3f43;fill:#3c3f43;}#d-702390838d2d .note{stroke:hsl(52.6829268293, 60%, 93.9215686275%);fill:#fff5ad;}#d-702390838d2d .noteText,#d-702390838d2d .noteText&gt;tspan{fill:#333;stroke:none;font-weight:normal;}#d-702390838d2d .activation0{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .activation1{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .activation2{fill:#202225;stroke:hsl(216, 7.2463768116%, 3.5294117647%);}#d-702390838d2d .actorPopupMenu{position:absolute;}#d-702390838d2d .actorPopupMenuPanel{position:absolute;fill:#1e1f22;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#d-702390838d2d .actor-man circle,#d-702390838d2d line{fill:#1e1f22;stroke-width:2px;}#d-702390838d2d g rect.rect{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));stroke:#3c3f43;}#d-702390838d2d .node .neo-node{stroke:#3c3f43;}#d-702390838d2d [data-look="neo"].node rect,#d-702390838d2d [data-look="neo"].cluster rect,#d-702390838d2d [data-look="neo"].node polygon{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].swimlane.cluster rect{filter:none;}#d-702390838d2d [data-look="neo"].node path{stroke:url(#d-702390838d2d-gradient);stroke-width:1px;}#d-702390838d2d [data-look="neo"].node .outer-path{filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].node .neo-line path{stroke:#3c3f43;filter:none;}#d-702390838d2d [data-look="neo"].node circle{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].node circle .state-start{fill:#000000;}#d-702390838d2d [data-look="neo"].icon-shape .icon{fill:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d [data-look="neo"].icon-shape .icon-neo path{stroke:url(#d-702390838d2d-gradient);filter:drop-shadow( 1px 2px 2px rgba(185,185,185,1));}#d-702390838d2d :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g/><defs><symbol id="d-702390838d2d-computer" width="24" height="24"><path transform="scale(.5)" d="M2 2v13h20v-13h-20zm18 11h-16v-9h16v9zm-10.228 6l.466-1h3.524l.467 1h-4.457zm14.228 3h-24l2-6h2.104l-1.33 4h18.45l-1.297-4h2.073l2 6zm-5-10h-14v-7h14v7z"/></symbol></defs><defs><symbol id="d-702390838d2d-database" fill-rule="evenodd" clip-rule="evenodd"><path transform="scale(.5)" d="M12.258.001l.256.004.255.005.253.008.251.01.249.012.247.015.246.016.242.019.241.02.239.023.236.024.233.027.231.028.229.031.225.032.223.034.22.036.217.038.214.04.211.041.208.043.205.045.201.046.198.048.194.05.191.051.187.053.183.054.18.056.175.057.172.059.168.06.163.061.16.063.155.064.15.066.074.033.073.033.071.034.07.034.069.035.068.035.067.035.066.035.064.036.064.036.062.036.06.036.06.037.058.037.058.037.055.038.055.038.053.038.052.038.051.039.05.039.048.039.047.039.045.04.044.04.043.04.041.04.04.041.039.041.037.041.036.041.034.041.033.042.032.042.03.042.029.042.027.042.026.043.024.043.023.043.021.043.02.043.018.044.017.043.015.044.013.044.012.044.011.045.009.044.007.045.006.045.004.045.002.045.001.045v17l-.001.045-.002.045-.004.045-.006.045-.007.045-.009.044-.011.045-.012.044-.013.044-.015.044-.017.043-.018.044-.02.043-.021.043-.023.043-.024.043-.026.043-.027.042-.029.042-.03.042-.032.042-.033.042-.034.041-.036.041-.037.041-.039.041-.04.041-.041.04-.043.04-.044.04-.045.04-.047.039-.048.039-.05.039-.051.039-.052.038-.053.038-.055.038-.055.038-.058.037-.058.037-.06.037-.06.036-.062.036-.064.036-.064.036-.066.035-.067.035-.068.035-.069.035-.07.034-.071.034-.073.033-.074.033-.15.066-.155.064-.16.063-.163.061-.168.06-.172.059-.175.057-.18.056-.183.054-.187.053-.191.051-.194.05-.198.048-.201.046-.205.045-.208.043-.211.041-.214.04-.217.038-.22.036-.223.034-.225.032-.229.031-.231.028-.233.027-.236.024-.239.023-.241.02-.242.019-.246.016-.247.015-.249.012-.251.01-.253.008-.255.005-.256.004-.258.001-.258-.001-.256-.004-.255-.005-.253-.008-.251-.01-.249-.012-.247-.015-.245-.016-.243-.019-.241-.02-.238-.023-.236-.024-.234-.027-.231-.028-.228-.031-.226-.032-.223-.034-.22-.036-.217-.038-.214-.04-.211-.041-.208-.043-.204-.045-.201-.046-.198-.048-.195-.05-.19-.051-.187-.053-.184-.054-.179-.056-.176-.057-.172-.059-.167-.06-.164-.061-.159-.063-.155-.064-.151-.066-.074-.033-.072-.033-.072-.034-.07-.034-.069-.035-.068-.035-.067-.035-.066-.035-.064-.036-.063-.036-.062-.036-.061-.036-.06-.037-.058-.037-.057-.037-.056-.038-.055-.038-.053-.038-.052-.038-.051-.039-.049-.039-.049-.039-.046-.039-.046-.04-.044-.04-.043-.04-.041-.04-.04-.041-.039-.041-.037-.041-.036-.041-.034-.041-.033-.042-.032-.042-.03-.042-.029-.042-.027-.042-.026-.043-.024-.043-.023-.043-.021-.043-.02-.043-.018-.044-.017-.043-.015-.044-.013-.044-.012-.044-.011-.045-.009-.044-.007-.045-.006-.045-.004-.045-.002-.045-.001-.045v-17l.001-.045.002-.045.004-.045.006-.045.007-.045.009-.044.011-.045.012-.044.013-.044.015-.044.017-.043.018-.044.02-.043.021-.043.023-.043.024-.043.026-.043.027-.042.029-.042.03-.042.032-.042.033-.042.034-.041.036-.041.037-.041.039-.041.04-.041.041-.04.043-.04.044-.04.046-.04.046-.039.049-.039.049-.039.051-.039.052-.038.053-.038.055-.038.056-.038.057-.037.058-.037.06-.037.061-.036.062-.036.063-.036.064-.036.066-.035.067-.035.068-.035.069-.035.07-.034.072-.034.072-.033.074-.033.151-.066.155-.064.159-.063.164-.061.167-.06.172-.059.176-.057.179-.056.184-.054.187-.053.19-.051.195-.05.198-.048.201-.046.204-.045.208-.043.211-.041.214-.04.217-.038.22-.036.223-.034.226-.032.228-.031.231-.028.234-.027.236-.024.238-.023.241-.02.243-.019.245-.016.247-.015.249-.012.251-.01.253-.008.255-.005.256-.004.258-.001.258.001zm-9.258 20.499v.01l.001.021.003.021.004.022.005.021.006.022.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.023.018.024.019.024.021.024.022.025.023.024.024.025.052.049.056.05.061.051.066.051.07.051.075.051.079.052.084.052.088.052.092.052.097.052.102.051.105.052.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.048.144.049.147.047.152.047.155.047.16.045.163.045.167.043.171.043.176.041.178.041.183.039.187.039.19.037.194.035.197.035.202.033.204.031.209.03.212.029.216.027.219.025.222.024.226.021.23.02.233.018.236.016.24.015.243.012.246.01.249.008.253.005.256.004.259.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.021.224-.024.22-.026.216-.027.212-.028.21-.031.205-.031.202-.034.198-.034.194-.036.191-.037.187-.039.183-.04.179-.04.175-.042.172-.043.168-.044.163-.045.16-.046.155-.046.152-.047.148-.048.143-.049.139-.049.136-.05.131-.05.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.053.083-.051.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.05.023-.024.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.023.01-.022.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.127l-.077.055-.08.053-.083.054-.085.053-.087.052-.09.052-.093.051-.095.05-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.045-.118.044-.12.043-.122.042-.124.042-.126.041-.128.04-.13.04-.132.038-.134.038-.135.037-.138.037-.139.035-.142.035-.143.034-.144.033-.147.032-.148.031-.15.03-.151.03-.153.029-.154.027-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.01-.179.008-.179.008-.181.006-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.006-.179-.008-.179-.008-.178-.01-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.027-.153-.029-.151-.03-.15-.03-.148-.031-.146-.032-.145-.033-.143-.034-.141-.035-.14-.035-.137-.037-.136-.037-.134-.038-.132-.038-.13-.04-.128-.04-.126-.041-.124-.042-.122-.042-.12-.044-.117-.043-.116-.045-.113-.045-.112-.046-.109-.047-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.05-.093-.052-.09-.051-.087-.052-.085-.053-.083-.054-.08-.054-.077-.054v4.127zm0-5.654v.011l.001.021.003.021.004.021.005.022.006.022.007.022.009.022.01.022.011.023.012.023.013.023.015.024.016.023.017.024.018.024.019.024.021.024.022.024.023.025.024.024.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.052.11.051.114.051.119.052.123.05.127.051.131.05.135.049.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.044.171.042.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.022.23.02.233.018.236.016.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.012.241-.015.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.048.139-.05.136-.049.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.051.051-.049.023-.025.023-.024.021-.025.02-.024.019-.024.018-.024.017-.024.015-.023.014-.023.013-.024.012-.022.01-.023.01-.023.008-.022.006-.022.006-.022.004-.021.004-.022.001-.021.001-.021v-4.139l-.077.054-.08.054-.083.054-.085.052-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.049-.105.048-.106.047-.109.047-.111.046-.114.045-.115.044-.118.044-.12.044-.122.042-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.035-.143.033-.144.033-.147.033-.148.031-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.025-.161.024-.162.023-.163.022-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.011-.178.009-.179.009-.179.007-.181.007-.182.005-.182.004-.184.003-.184.002h-.37l-.184-.002-.184-.003-.182-.004-.182-.005-.181-.007-.179-.007-.179-.009-.178-.009-.176-.011-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.022-.162-.023-.161-.024-.159-.025-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.031-.146-.033-.145-.033-.143-.033-.141-.035-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.04-.126-.041-.124-.042-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.047-.105-.048-.102-.049-.1-.049-.097-.05-.095-.051-.093-.051-.09-.051-.087-.053-.085-.052-.083-.054-.08-.054-.077-.054v4.139zm0-5.666v.011l.001.02.003.022.004.021.005.022.006.021.007.022.009.023.01.022.011.023.012.023.013.023.015.023.016.024.017.024.018.023.019.024.021.025.022.024.023.024.024.025.052.05.056.05.061.05.066.051.07.051.075.052.079.051.084.052.088.052.092.052.097.052.102.052.105.051.11.052.114.051.119.051.123.051.127.05.131.05.135.05.139.049.144.048.147.048.152.047.155.046.16.045.163.045.167.043.171.043.176.042.178.04.183.04.187.038.19.037.194.036.197.034.202.033.204.032.209.03.212.028.216.027.219.025.222.024.226.021.23.02.233.018.236.017.24.014.243.012.246.01.249.008.253.006.256.003.259.001.26-.001.257-.003.254-.006.25-.008.247-.01.244-.013.241-.014.237-.016.233-.018.231-.02.226-.022.224-.024.22-.025.216-.027.212-.029.21-.03.205-.032.202-.033.198-.035.194-.036.191-.037.187-.039.183-.039.179-.041.175-.042.172-.043.168-.044.163-.045.16-.045.155-.047.152-.047.148-.048.143-.049.139-.049.136-.049.131-.051.126-.05.123-.051.118-.052.114-.051.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.052.07-.051.065-.051.06-.051.056-.05.051-.049.023-.025.023-.025.021-.024.02-.024.019-.024.018-.024.017-.024.015-.023.014-.024.013-.023.012-.023.01-.022.01-.023.008-.022.006-.022.006-.022.004-.022.004-.021.001-.021.001-.021v-4.153l-.077.054-.08.054-.083.053-.085.053-.087.053-.09.051-.093.051-.095.051-.097.05-.1.049-.102.048-.105.048-.106.048-.109.046-.111.046-.114.046-.115.044-.118.044-.12.043-.122.043-.124.042-.126.041-.128.04-.13.039-.132.039-.134.038-.135.037-.138.036-.139.036-.142.034-.143.034-.144.033-.147.032-.148.032-.15.03-.151.03-.153.028-.154.028-.156.027-.158.026-.159.024-.161.024-.162.023-.163.023-.165.021-.166.02-.167.019-.169.018-.169.017-.171.016-.173.015-.173.014-.175.013-.175.012-.177.01-.178.01-.179.009-.179.007-.181.006-.182.006-.182.004-.184.003-.184.001-.185.001-.185-.001-.184-.001-.184-.003-.182-.004-.182-.006-.181-.006-.179-.007-.179-.009-.178-.01-.176-.01-.176-.012-.175-.013-.173-.014-.172-.015-.171-.016-.17-.017-.169-.018-.167-.019-.166-.02-.165-.021-.163-.023-.162-.023-.161-.024-.159-.024-.157-.026-.156-.027-.155-.028-.153-.028-.151-.03-.15-.03-.148-.032-.146-.032-.145-.033-.143-.034-.141-.034-.14-.036-.137-.036-.136-.037-.134-.038-.132-.039-.13-.039-.128-.041-.126-.041-.124-.041-.122-.043-.12-.043-.117-.044-.116-.044-.113-.046-.112-.046-.109-.046-.106-.048-.105-.048-.102-.048-.1-.05-.097-.049-.095-.051-.093-.051-.09-.052-.087-.052-.085-.053-.083-.053-.08-.054-.077-.054v4.153zm8.74-8.179l-.257.004-.254.005-.25.008-.247.011-.244.012-.241.014-.237.016-.233.018-.231.021-.226.022-.224.023-.22.026-.216.027-.212.028-.21.031-.205.032-.202.033-.198.034-.194.036-.191.038-.187.038-.183.04-.179.041-.175.042-.172.043-.168.043-.163.045-.16.046-.155.046-.152.048-.148.048-.143.048-.139.049-.136.05-.131.05-.126.051-.123.051-.118.051-.114.052-.11.052-.106.052-.101.052-.096.052-.092.052-.088.052-.083.052-.079.052-.074.051-.07.052-.065.051-.06.05-.056.05-.051.05-.023.025-.023.024-.021.024-.02.025-.019.024-.018.024-.017.023-.015.024-.014.023-.013.023-.012.023-.01.023-.01.022-.008.022-.006.023-.006.021-.004.022-.004.021-.001.021-.001.021.001.021.001.021.004.021.004.022.006.021.006.023.008.022.01.022.01.023.012.023.013.023.014.023.015.024.017.023.018.024.019.024.02.025.021.024.023.024.023.025.051.05.056.05.06.05.065.051.07.052.074.051.079.052.083.052.088.052.092.052.096.052.101.052.106.052.11.052.114.052.118.051.123.051.126.051.131.05.136.05.139.049.143.048.148.048.152.048.155.046.16.046.163.045.168.043.172.043.175.042.179.041.183.04.187.038.191.038.194.036.198.034.202.033.205.032.21.031.212.028.216.027.22.026.224.023.226.022.231.021.233.018.237.016.241.014.244.012.247.011.25.008.254.005.257.004.26.001.26-.001.257-.004.254-.005.25-.008.247-.011.244-.012.241-.014.237-.016.233-.018.231-.021.226-.022.224-.023.22-.026.216-.027.212-.028.21-.031.205-.032.202-.033.198-.034.194-.036.191-.038.187-.038.183-.04.179-.041.175-.042.172-.043.168-.043.163-.045.16-.046.155-.046.152-.048.148-.048.143-.048.139-.049.136-.05.131-.05.126-.051.123-.051.118-.051.114-.052.11-.052.106-.052.101-.052.096-.052.092-.052.088-.052.083-.052.079-.052.074-.051.07-.052.065-.051.06-.05.056-.05.051-.05.023-.025.023-.024.021-.024.02-.025.019-.024.018-.024.017-.023.015-.024.014-.023.013-.023.012-.023.01-.023.01-.022.008-.022.006-.023.006-.021.004-.022.004-.021.001-.021.001-.021-.001-.021-.001-.021-.004-.021-.004-.022-.006-.021-.006-.023-.008-.022-.01-.022-.01-.023-.012-.023-.013-.023-.014-.023-.015-.024-.017-.023-.018-.024-.019-.024-.02-.025-.021-.024-.023-.024-.023-.025-.051-.05-.056-.05-.06-.05-.065-.051-.07-.052-.074-.051-.079-.052-.083-.052-.088-.052-.092-.052-.096-.052-.101-.052-.106-.052-.11-.052-.114-.052-.118-.051-.123-.051-.126-.051-.131-.05-.136-.05-.139-.049-.143-.048-.148-.048-.152-.048-.155-.046-.16-.046-.163-.045-.168-.043-.172-.043-.175-.042-.179-.041-.183-.04-.187-.038-.191-.038-.194-.036-.198-.034-.202-.033-.205-.032-.21-.031-.212-.028-.216-.027-.22-.026-.224-.023-.226-.022-.231-.021-.233-.018-.237-.016-.241-.014-.244-.012-.247-.011-.25-.008-.254-.005-.257-.004-.26-.001-.26.001z"/></symbol></defs><defs><symbol id="d-702390838d2d-clock" width="24" height="24"><path transform="scale(.5)" d="M12 2c5.514 0 10 4.486 10 10s-4.486 10-10 10-10-4.486-10-10 4.486-10 10-10zm0-2c-6.627 0-12 5.373-12 12s5.373 12 12 12 12-5.373 12-12-5.373-12-12-12zm5.848 12.459c.202.038.202.333.001.372-1.907.361-6.045 1.111-6.547 1.111-.719 0-1.301-.582-1.301-1.301 0-.512.77-5.447 1.125-7.445.034-.192.312-.181.343.014l.985 6.238 5.394 1.011z"/></symbol></defs><defs><marker id="d-702390838d2d-arrowhead" refX="7.9" refY="5" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M -1 0 L 10 5 L 0 10 z"/></marker></defs><defs><marker id="d-702390838d2d-crosshead" markerWidth="15" markerHeight="8" orient="auto" refX="4" refY="4.5"><path fill="none" stroke="#000000" stroke-width="1pt" d="M 1,2 L 6,7 M 6,2 L 1,7" style="stroke-dasharray: 0, 0;"/></marker></defs><defs><marker id="d-702390838d2d-filled-head" refX="15.5" refY="7" markerWidth="20" markerHeight="28" orient="auto"><path d="M 18,7 L9,13 L14,7 L9,1 Z"/></marker></defs><defs><marker id="d-702390838d2d-sequencenumber" refX="15" refY="15" markerWidth="60" markerHeight="40" orient="auto"><circle cx="15" cy="15" r="6"/></marker></defs><defs><marker id="d-702390838d2d-solidTopArrowHead" refX="7.9" refY="7.25" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 8 L 0 8 z"/></marker></defs><defs><marker id="d-702390838d2d-solidBottomArrowHead" refX="7.9" refY="0.75" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 10 0 L 0 8 z"/></marker></defs><defs><marker id="d-702390838d2d-stickTopArrowHead" refX="7.5" refY="7" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 0 L 7 7" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><defs><marker id="d-702390838d2d-stickBottomArrowHead" refX="7.5" refY="0" markerUnits="userSpaceOnUse" markerWidth="12" markerHeight="12" orient="auto-start-reverse"><path d="M 0 7 L 7 0" stroke="black" stroke-width="1.5" fill="none"/></marker></defs><text x="174" y="80" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">git push ( main )</text><line x1="76" y1="109" x2="271" y2="109" class="messageLine0" data-et="message" data-id="i0" data-from="D" data-to="F" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="374" y="124" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">webhook</text><line x1="276" y1="153" x2="471" y2="153" class="messageLine0" data-et="message" data-id="i1" data-from="F" data-to="W" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="476" y="168" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">diagrams, changelog, hugo --gc</text><path d="M 476,197 C 536,187 536,227 476,217" class="messageLine0" data-et="message" data-id="i2" data-from="W" data-to="W" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="581" y="242" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">wrangler pages deploy</text><line x1="476" y1="271" x2="686" y2="271" class="messageLine0" data-et="message" data-id="i3" data-from="W" data-to="C" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="fill: none;"/><text x="384" y="286" text-anchor="middle" dominant-baseline="middle" alignment-baseline="middle" class="messageText" dy="1em" style="font-size: 16px; font-weight: 400;">the same site, but newer</text><line x1="689" y1="315" x2="79" y2="315" class="messageLine1" data-et="message" data-id="i4" data-from="C" data-to="D" stroke-width="2" stroke="none" marker-end="url(#d-702390838d2d-arrowhead)" style="stroke-dasharray: 3, 3; fill: none;"/></svg></figure><p>The part I want you to notice is the direction of travel. Under the old setup, Cloudflare reached <em>into</em> GitHub to pull my source. Under the new one, nothing reaches in at all. The forge and the CI server accept no traffic from the internet. The only packet that crosses the network boundary is an <em>outbound</em> upload of compiled static assets.</p>
<p>That reversal is the entire security model which was pretty much the point. What looks like &ldquo;the old way but more steps&rdquo; allows me to control every aspect of the pipeline up to <em>just</em> before we ship the build artefacts. A push-based deploy means self-hosting your forge requires exposing exactly nothing. No reverse proxy gymnastics, no VPN for the webhook and no port forwarding required.</p>
<p>If my homelab bursts into flames mid-afternoon ( a real possibility as I <em>do</em> live in Queensland, Australia ) Cloudflare keeps serving the last deploy and nobody visiting the site would ever know. That being said, nobody actually <em>does</em> visit this site, so&hellip;</p>
<h2 id="a-small-intermission">
  <a class="heading-link" href="#a-small-intermission">A small intermission.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A short aside, because it&rsquo;s a question <em>I&rsquo;d</em> ask. My internal services hang off a domain I genuinely own, but resolving it depends on where you&rsquo;re standing. Inside the network a locally hosted 3-node <a href="https://technitium.com/dns/">Technitium</a> cluster runs the hosted zone and the router sends all DNS requests through it. Initially, I used this for network-wide adblock, which works an absolute treat. It also means the forge and the CI cluster resolve to their internal addresses. Outside the network, the public zone points at a landing page hosted elsewhere.</p>
<p>Same hostname and two completely different worlds depending on which side of the wall you&rsquo;re on. <a href="https://en.wikipedia.org/wiki/Split-horizon_DNS">Split-horizon DNS</a> is nothing new, but it&rsquo;s what lets me use real TLS-friendly hostnames for internal services without those services ever being reachable from the internet.</p>
<p>If I <em>did</em> decide to open some services to the outside world, like my Navidrome server, I could use an overlay network like <a href="https://netbird.io">Netbird</a> to provide friends and family access via a familiar naming convention without ever opening a port on the router.</p>
<h2 id="the-part-cloudflare-doesnt-clearly-advertise">
  <a class="heading-link" href="#the-part-cloudflare-doesnt-clearly-advertise">The part Cloudflare doesn&rsquo;t clearly advertise.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>A Pages project is either connected to a git provider or it accepts direct uploads. Never both. As long as your project is wired to GitHub, <code>wrangler pages deploy</code> will be rejected and you will get screamed at.</p>
<p>Disconnect the git integration in the project settings and the project quietly converts to &ldquo;Direct Upload&rdquo; mode. It keeps its name, its custom domain and its whole deployment history. It just stops watching a repository and starts waiting for you to hand it those delicious files. While you&rsquo;re in there, make sure the production branch is properly specified: <code>main</code> in my case. This matters because Wrangler tags each upload with a branch name. An upload whose branch matches the production branch goes live on the site. An upload under any other name becomes a preview deployment with its own URL instead.</p>
<p>After that, the deploy is one command requiring two secrets:</p>
<ul>
<li>An API token scoped to <code>Cloudflare Pages: Edit</code></li>
<li>Your Cloudflare account ID.</li>
</ul>
<p>Both go into the Woodpecker Server as repository secrets.</p>
<h2 id="finally-our-favourite-yaml">
  <a class="heading-link" href="#finally-our-favourite-yaml">Finally, our favourite! YAML.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The whole thing is just one file named <code>.woodpecker.yml</code> that squats in the repository root.</p>
<div class="code-block">
  <div class="code-head">
    <span class="code-file"></span>
    <span class="code-lang">YAML</span>
  </div>
  <div class="highlight"><pre tabindex="0" class="chroma"><code class="language-yaml" data-lang="yaml"><span class="line"><span class="cl"><span class="nt">when</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span>- <span class="nt">event</span><span class="p">:</span><span class="w"> </span><span class="p">[</span><span class="l">push, manual]</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">branch</span><span class="p">:</span><span class="w"> </span><span class="l">main</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">clone</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">git</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">woodpeckerci/plugin-git</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">settings</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">partial</span><span class="p">:</span><span class="w"> </span><span class="kc">false</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">tags</span><span class="p">:</span><span class="w"> </span><span class="kc">true</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="nt">steps</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">diagrams</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">minlag/mermaid-cli:11.16.1</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./bin/render-diagrams</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">build</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">hugomods/hugo:debian-git-0.163.3</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HUGO_ENV</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">HUGO_ENVIRONMENT</span><span class="p">:</span><span class="w"> </span><span class="l">production</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">git config --global --add safe.directory &#39;*&#39;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">./bin/changelog</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="l">hugo --gc</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">  </span><span class="nt">deploy</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">image</span><span class="p">:</span><span class="w"> </span><span class="l">node:22-slim</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">environment</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">CLOUDFLARE_API_TOKEN</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">from_secret</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare_api_token</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span><span class="nt">CLOUDFLARE_ACCOUNT_ID</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="nt">from_secret</span><span class="p">:</span><span class="w"> </span><span class="l">cloudflare_account_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">commands</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="p">&gt;-</span><span class="sd">
</span></span></span><span class="line"><span class="cl"><span class="sd">        npx --yes wrangler@4 pages deploy public
</span></span></span><span class="line"><span class="cl"><span class="sd">        --project-name=wilhelm-codes
</span></span></span><span class="line"><span class="cl"><span class="sd">        --branch=main
</span></span></span><span class="line"><span class="cl"><span class="sd">        --commit-hash=&#34;${CI_COMMIT_SHA}&#34;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nt">when</span><span class="p">:</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">      </span>- <span class="nt">event</span><span class="p">:</span><span class="w"> </span><span class="l">push</span></span></span></code></pre></div>
</div>
<h3 id="only-build-off-main">
  <a class="heading-link" href="#only-build-off-main">Only build off <code>main</code>.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>I test design changes and new entries locally, so a push to any other branch doesn&rsquo;t start a pipeline.</p>
<h3 id="we-want-a-full-clone-not-a-shallow-one">
  <a class="heading-link" href="#we-want-a-full-clone-not-a-shallow-one">We want a full clone, not a shallow one.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>This site&rsquo;s <a href="https://wilhelm.codes/changelog/">changelog page</a> renders the git log and Hugo&rsquo;s <code>enableGitInfo</code> reads commit dates for every page. This is something I wrote about in <a href="https://wilhelm.codes/blog/a-changelog-that-builds-itself/">A Changelog That Builds Itself</a>. CI systems love shallow clones and a shallow clone here means I can&rsquo;t convey the full historical picture on the page. Worse, <code>enableGitInfo</code> derives every article&rsquo;s dates from that same truncated log, so they all come out wrong besides. Setting <code>partial: false</code> makes Woodpecker fetch the entire history.</p>
<h3 id="diagrams-render-before-anything-else">
  <a class="heading-link" href="#diagrams-render-before-anything-else">Diagrams render before anything else.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Articles on this site can embed <a href="https://mermaid.js.org">Mermaid</a> diagrams, like the sequence diagram further up the page. They render to static SVG at build time, not in your browser, so the site ships zero diagram JavaScript. A small script hashes each diagram&rsquo;s source, renders anything that has no matching SVG yet and prunes any SVG nothing references any more. I run it locally and commit the SVGs alongside the content, which means on a clean commit this step does precisely nothing. It exists as a safety net for the day I inevitably forget.</p>
<p>So when I said the diagram was rendered by the pipeline it describes, I meant it literally. If I ever edit that diagram and forget to render it, this step is what quietly saves the deploy.</p>
<h3 id="the-build-step-is-a-stock-hugo-image">
  <a class="heading-link" href="#the-build-step-is-a-stock-hugo-image">The build step is a stock Hugo image.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p>Nothing is installed on the agent itself unless you count Docker image pulls. The image pins the exact Hugo version, ships the extended edition and includes <code>git</code> and <code>bash</code>. Both of which the changelog script that generates the data requires. The <code>safe.directory</code> line exists because the cloned workspace is owned by a different user than the one inside the container and <code>git</code> these days refuses to read a repository it thinks you stole.</p>
<h3 id="the-deploy-step-is-a-stock-node-image">
  <a class="heading-link" href="#the-deploy-step-is-a-stock-node-image">The deploy step is a stock Node image.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h3>
<p><code>npx</code> pulls Wrangler at run time. Wrangler then reads the two secrets from the environment and pushes <code>public/</code> up to Pages and that&rsquo;s pretty much it. Dependencies are snagged and cached at build time, which means nothing to maintain on the agents. Either step&rsquo;s version gets bumped by editing one line.</p>
<div class="callout callout-info">
  I already have <a href="https://distribution.github.io">distribution</a> hosted in the homelab for my local Docker images. I could easily configure it to support <a href="https://distribution.github.io/distribution/recipes/mirror/">pull-through caching</a>, so I don&rsquo;t keep having to pull directly from Docker Hub every time a build agent&rsquo;s local cache expires.
</div>

<p>Complete parity with what Cloudflare&rsquo;s own build pipeline was doing, except now I can read every line of it and make it do whatever I need.</p>
<h2 id="i-did-run-into-some-speed-bumps-along-the-way">
  <a class="heading-link" href="#i-did-run-into-some-speed-bumps-along-the-way">I did run into some speed bumps along the way.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>For the record, it took only three tries to get the pipeline to a &ldquo;green&rdquo; state.</p>
<figure class="img-single">
    <button type="button" class="zoom" aria-label="Zoom image: A Woodpecker Server build history screenshot showing the first 3 attempts.">
      <img
  src="https://wilhelm.codes/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_195442048d80ae9b.webp"
  srcset="/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_195442048d80ae9b.webp 736w, /blog/my-blog-now-ships-from-my-homelab/three-tries_hu_ad889cca69fa6327.webp 1104w, /blog/my-blog-now-ships-from-my-homelab/three-tries_hu_132d2f011cbace60.webp 1472w"
  sizes="(max-width: 48rem) 100vw, 736px"
  width="736"
  height="232"
  alt="A Woodpecker Server build history screenshot showing the first 3 attempts."
  loading="lazy"
  decoding="async"
  data-full="/blog/my-blog-now-ships-from-my-homelab/three-tries_hu_8fa8b30bf4601afd.webp"
  data-credit="Third time&#39;s a charm."
  
/>

    </button>
    
      <figcaption>
        
          Third time&#39;s a charm.
        
      </figcaption>
    
  </figure>
<p>In order of discovery:</p>
<ul>
<li>The plain version tags of the Hugo image <em>are</em> the extended edition. The <code>reg-</code> prefix means regular and none of them include <code>git</code> unless the tag says <code>git</code>. The tag you guess first ( <code>exts-&lt;version&gt;</code> ) doesn&rsquo;t exist. It&rsquo;s an odd convention, which is why it threw me off.</li>
<li>Woodpecker substitutes <code>${VAR}</code> expressions in the YAML <em>before</em> the shell ever runs. Anything it doesn&rsquo;t know becomes an empty string. My <code>safe.directory &quot;${CI_WORKSPACE}&quot;</code> quietly became <code>safe.directory &quot;&quot;</code>. Hence the asterisk / glob in the YAML.</li>
<li>The changelog script opens with <code>#!/usr/bin/env bash</code> and the default Hugo images are Alpine. <code>env: can't execute 'bash'</code> is the whole error message you get, so I just went with the Debian variants instead which include <code>bash</code> out of the box.</li>
</ul>
<p>None of these are hard problems, but worth pointing out.</p>
<h2 id="im-not-deleting-the-old-repository-completely">
  <a class="heading-link" href="#im-not-deleting-the-old-repository-completely">I&rsquo;m not deleting the old repository&hellip; completely.<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>The GitHub repository still exists if only to provide a place for the giscus integration to store comments. It offloads most of this functionality to GitHub&rsquo;s Discussions feature. Delete the repo and every comment ever left on this site goes with it; all 2 of them.</p>
<p>It no longer needed the full source history to do that job either. It was ported on over to my local forge with Forgejo&rsquo;s native GitHub repository migration feature. So, I force-pushed the whole history into a black hole, leaving a single commit containing only a README. The remaining husk also has a second job coming; it&rsquo;ll eventually host the companion code for tutorial content, so readers can clone examples without ever touching my forge.</p>
<p>Each service kept exactly the job it&rsquo;s uniquely good at. Cloudflare serves files from the edge better than my house ever could and no fucking way am I publicly opening ports on a router. I&rsquo;d rather lick the rim of a pub toilet. GitHub Discussions gives commenters an identity they already have. Everything else now lives at home.</p>
<h2 id="in-closing-">
  <a class="heading-link" href="#in-closing-">In closing &hellip;<span class="heading-anchor" aria-hidden="true">#</span>
  </a>
</h2>
<p>Was any of this necessary? For a static blog that deploys in under a minute, probably not. The old setup worked fine and if yours does too, keep it. I&rsquo;m not here to convince anyone.</p>
<p>There&rsquo;s a special kind of satisfaction in watching a pipeline run end to end on hardware you can physically point at, knowing the only cloud dependency left is the one actually earning its keep. The next GitHub outage can happen without me. At least for <em>this</em> project anyway.</p>]]></content:encoded></item></channel></rss>