<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>CI/CD on PHP Boy Scout</title><link>https://phpboyscout.uk/tags/ci-cd/</link><description>Recent content in CI/CD on PHP Boy Scout</description><generator>Hugo -- gohugo.io</generator><language>en-gb</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 19 Jul 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/tags/ci-cd/index.xml" rel="self" type="application/rss+xml"/><item><title>Stop installing the same tools on every pipeline job</title><link>https://phpboyscout.uk/stop-installing-the-same-tools-on-every-pipeline-job/</link><pubDate>Sun, 19 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/stop-installing-the-same-tools-on-every-pipeline-job/</guid><description>&lt;img src="https://phpboyscout.uk/stop-installing-the-same-tools-on-every-pipeline-job/cover-stop-installing-the-same-tools-on-every-pipeline-job.png" alt="Featured image of post Stop installing the same tools on every pipeline job" /&gt;&lt;p&gt;Every Go and Rust pipeline I run starts the same way, and for a long time the first thing each job did was waste a minute of my life. Before it could lint or test or scan, it had to go and &lt;em&gt;fetch the tools to do those things&lt;/em&gt;: &lt;code&gt;cargo binstall&lt;/code&gt; for nextest, cargo-deny, cargo-audit and llvm-cov, a &lt;code&gt;go install&lt;/code&gt; for govulncheck, and so on. Every job. Every push. Every branch. Every Renovate bump.&lt;/p&gt;
&lt;p&gt;On a hosted runner you&amp;rsquo;d barely notice. On mine you very much do, because mine is a single self-hosted box in the corner doing all of this with a small concurrency limit. That&amp;rsquo;s the budget I work within, and it changes the maths. A minute of tool-fetching isn&amp;rsquo;t a minute, it&amp;rsquo;s a minute multiplied by every job in every pipeline, all contending for the same runner, all doing the &lt;em&gt;identical&lt;/em&gt; download-and-compile that the job before it just did and threw away. It was, comfortably, the single biggest recurring time sink in the whole setup, and none of it was the actual work.&lt;/p&gt;
&lt;p&gt;The fix is the obvious one once you stop treating each job as an island: bake the tools into the image. There&amp;rsquo;s now a &lt;code&gt;dev-tools&lt;/code&gt; image with the whole kit already installed, and the Go and Rust components default to it. The per-job installs are gone. A job starts and the tools are simply &lt;em&gt;there&lt;/em&gt;. It&amp;rsquo;s the largest recurring-time win I&amp;rsquo;ve made on that runner, and it&amp;rsquo;s not close.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s the part worth slowing down for, though, because it&amp;rsquo;s where I&amp;rsquo;d grab your sleeve if you were about to go and copy this: what you &lt;em&gt;can&amp;rsquo;t&lt;/em&gt; bake.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;d think you&amp;rsquo;d just throw everything in the image and be done. But there&amp;rsquo;s a line, and it runs between binaries and toolchain components. A tool like nextest or govulncheck is a standalone binary: it doesn&amp;rsquo;t care which Rust or Go toolchain the project uses, so baking it in is free and correct. A &lt;code&gt;rustup component&lt;/code&gt;, though, like clippy or rustfmt, is tied to a &lt;em&gt;specific&lt;/em&gt; toolchain. And a consumer&amp;rsquo;s repo can pin its own toolchain with a &lt;code&gt;rust-toolchain.toml&lt;/code&gt;, which silently overrides whatever toolchain the image was built against. Bake clippy for toolchain A into the image, let a project ask for toolchain B, and you&amp;rsquo;ve got a clippy that doesn&amp;rsquo;t match the compiler it&amp;rsquo;s checking against. So &lt;code&gt;rustup component add&lt;/code&gt; deliberately stays at runtime. You bake what&amp;rsquo;s toolchain-independent and you leave what isn&amp;rsquo;t. The rule is small but it&amp;rsquo;s the difference between a cache that helps and a cache that lies.&lt;/p&gt;
&lt;p&gt;Which brings me to the bit I got wrong, because I always get one bit wrong.&lt;/p&gt;
&lt;p&gt;The first cut of the image, v0.1.0, ran as a non-root user. That&amp;rsquo;s the textbook call. You don&amp;rsquo;t run containers as root if you can avoid it, every hardening guide says so, and I&amp;rsquo;d internalised it well enough to do it without thinking. It promptly broke every Go and Rust job. Permission-denied reading the module cache. Permission-denied on &lt;code&gt;/etc/machine-id&lt;/code&gt;. The works.&lt;/p&gt;
&lt;p&gt;The cause is one of those collisions between two reasonable things. GitLab&amp;rsquo;s Docker executor mounts and owns its caches in a particular way, and that ownership assumes the job can write where it expects to. A non-root user in the image doesn&amp;rsquo;t line up with how those cache volumes are owned, so the &amp;ldquo;secure&amp;rdquo; default walks straight into permission errors on the exact paths the build needs. v0.1.1 reverts the image to root.&lt;/p&gt;
&lt;p&gt;I don&amp;rsquo;t love writing that sentence. Reverting to root as a &amp;ldquo;fix&amp;rdquo; reads like giving up on a security control. But the reality is that the security posture of a build image and the ownership model of the executor&amp;rsquo;s caches are two separate systems, and on a self-hosted GitLab runner the second one wins whether you like it or not. The textbook default was textbook for a context that isn&amp;rsquo;t this one. Better to know exactly why root is required here, and write it down, than to ship a non-root image that fails every pipeline and call it secure.&lt;/p&gt;
&lt;p&gt;Bake the shared work in once and you stop paying for it on every job, which claws back more runner time than any other single change I&amp;rsquo;ve made. Just don&amp;rsquo;t get carried away and bake in &lt;em&gt;everything&lt;/em&gt;, because a toolchain component isn&amp;rsquo;t a binary, and a hardened default stops being free the moment it fights the executor underneath it. Both of those I found out by breaking my own pipelines, which is, I&amp;rsquo;m reliably told, the traditional way.&lt;/p&gt;</description></item><item><title>The build gate these sites never had</title><link>https://phpboyscout.uk/the-build-gate-these-sites-never-had/</link><pubDate>Mon, 06 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-build-gate-these-sites-never-had/</guid><description>&lt;img src="https://phpboyscout.uk/the-build-gate-these-sites-never-had/cover-the-build-gate-these-sites-never-had.png" alt="Featured image of post The build gate these sites never had" /&gt;&lt;p&gt;I run three Hugo sites now. This blog, my dad&amp;rsquo;s B&amp;amp;B, and the vanlife site my wife and I are slowly putting together. All three build with Hugo and deploy to GitLab Pages, and until recently all three had the same blind spot: the build only ever ran on &lt;code&gt;main&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Which means that if I pushed something that broke the build, a typo in a shortcode, a cross-link to a page that doesn&amp;rsquo;t exist, a theme bump that didn&amp;rsquo;t take, I found out &lt;em&gt;after&lt;/em&gt; it landed. The change was already merged, sitting on the default branch, and the deploy was the thing that fell over. No gate, no warning, nothing telling me on the merge request that the site wouldn&amp;rsquo;t build. Just a red pipeline on &lt;code&gt;main&lt;/code&gt; and a site that had just stopped updating.&lt;/p&gt;
&lt;h2 id="three-copies-of-the-same-job"&gt;Three copies of the same job
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;ve &lt;a class="link" href="https://phpboyscout.uk/ci-you-include-not-copy/" &gt;written before&lt;/a&gt; about pulling shared CI into components you &lt;em&gt;include&lt;/em&gt; rather than copy-paste, and this was a textbook case of why. Each of the three sites had its own hand-rolled &lt;code&gt;pages&lt;/code&gt; job: the same pinned &lt;code&gt;hugomods/hugo&lt;/code&gt; image, the same &lt;code&gt;GIT_DEPTH: 0&lt;/code&gt;, the same nightly rebuild, all written out three times and drifting apart a little more with every tweak. I already had a &lt;code&gt;zensical-pages&lt;/code&gt; component for the docs framework I use elsewhere, but nothing covered a plain Hugo build.&lt;/p&gt;
&lt;p&gt;So I wrote one: a &lt;code&gt;hugo-pages&lt;/code&gt; component in my shared &lt;code&gt;cicd&lt;/code&gt; project, and pointed all three sites at it.&lt;/p&gt;
&lt;h2 id="the-gate-they-were-missing"&gt;The gate they were missing
&lt;/h2&gt;&lt;p&gt;The interesting part isn&amp;rsquo;t the deduplication, it&amp;rsquo;s what the component fixes. It splits the work into two jobs, and that split is the entire reason the component exists.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;hugo-build&lt;/code&gt; builds the site to &lt;code&gt;public/&lt;/code&gt;, and it carries an explicit rule so it runs in merge-request pipelines too:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$CI_COMMIT_TAG&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;never&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;&amp;#34;$[[ inputs.mr_gate ]]&amp;#34; == &amp;#34;true&amp;#34;&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;on_success&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That second line is the bit the old jobs never had. Push a broken Hugo build now and it shows up red on the merge request, where you can see it before merging, instead of sailing onto &lt;code&gt;main&lt;/code&gt; and leaving the deploy as the only thing left to fall over. It&amp;rsquo;s &lt;code&gt;interruptible: true&lt;/code&gt;, so pushing a fix cancels the in-flight run rather than queuing behind it. The deploy is a separate job, &lt;code&gt;pages&lt;/code&gt;, which only runs where it should:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;pages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;GIT_STRATEGY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;none&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;rules&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$CI_PIPELINE_SOURCE == &amp;#34;merge_request_event&amp;#34;&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;when&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;never&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$CI_COMMIT_BRANCH == &amp;#34;$[[ inputs.deploy_branch ]]&amp;#34;&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$CI_PIPELINE_SOURCE == &amp;#34;schedule&amp;#34;&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;It takes the artifact &lt;code&gt;hugo-build&lt;/code&gt; already produced and republishes it, with &lt;code&gt;GIT_STRATEGY: none&lt;/code&gt; because there&amp;rsquo;s nothing to clone. Build everywhere, catch the breakage early; deploy only from &lt;code&gt;main&lt;/code&gt; (and one other place, which is where it gets interesting). The component is pinned at &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/65bd094158de5eb2d6389f607cf02ae282d633a7/templates/hugo-pages.yml" target="_blank" rel="noopener"
 &gt;v0.13.0&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="the-one-component-that-wants-to-run-on-a-schedule"&gt;The one component that wants to run on a schedule
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit I like. Every other component in my &lt;code&gt;cicd&lt;/code&gt; project carries a leading guard that says &lt;em&gt;never run on a schedule&lt;/em&gt;, because a nightly Renovate schedule firing a release job or a deploy is exactly the kind of pointless busywork you don&amp;rsquo;t want.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;hugo-pages&lt;/code&gt; does the opposite, on purpose. It deploys on &lt;code&gt;schedule&lt;/code&gt;, and the reason is sitting in plain sight in the component itself:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-mysql" data-lang="mysql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Schedule note: unlike every other component (which carries a
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# `schedule → never` guard ...), hugo-pages DELIBERATELY deploys on
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# `schedule`. A nightly pipeline schedule is the mechanism by which
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# future-dated, non-draft posts go live once their date arrives (Hugo
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# excludes future-dated content from `production` builds until then).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# The schedule IS the feature.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;When I write a post and date it next Tuesday, Hugo&amp;rsquo;s production build simply doesn&amp;rsquo;t include it yet, because its date is in the future. Nothing publishes it on the day except a build that runs &lt;em&gt;on&lt;/em&gt; the day and finds that the date has now arrived. That&amp;rsquo;s the nightly schedule. It&amp;rsquo;s not noise to be guarded against here, it&amp;rsquo;s the publish button. The post you&amp;rsquo;re reading went live exactly this way: written days early, dated forward, and pushed out by a scheduled pipeline the morning its date came up.&lt;/p&gt;
&lt;p&gt;One codebase, two flatly opposite rules about the same trigger, and knowing which site wants which is the actual design.&lt;/p&gt;
&lt;h2 id="a-gate-you-can-switch-off"&gt;A gate you can switch off
&lt;/h2&gt;&lt;p&gt;There was one wrinkle. The new MR build gate is the right default for a docs site with contributors, but for these three blogs it was friction. I run them trunk-based: I work on &lt;code&gt;main&lt;/code&gt;, I audit locally, I push. A mandatory merge-request build for a workflow that doesn&amp;rsquo;t really use merge requests is a job that mostly just sits there.&lt;/p&gt;
&lt;p&gt;So the gate is a boolean you can turn off:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;mr_gate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;type&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;boolean&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;default&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Left at its default, &lt;code&gt;hugo-build&lt;/code&gt; runs everywhere and the gate is on, which is the safe posture for anyone adopting the component fresh. Set it to &lt;code&gt;false&lt;/code&gt;, as this blog does, and &lt;code&gt;hugo-build&lt;/code&gt; falls through to running only where it deploys, matching the classic Pages-only-on-&lt;code&gt;main&lt;/code&gt; setup the sites had before, but now without three copies of the YAML to maintain. Safe by default, loose by choice, and the choice is one line.&lt;/p&gt;
&lt;h2 id="where-it-leaves-things"&gt;Where it leaves things
&lt;/h2&gt;&lt;p&gt;A reusable component instead of three drifting copies, a build gate that catches a broken site before it merges, a schedule that publishes tomorrow&amp;rsquo;s post on time rather than firing for nothing, and an opt-out for the trunk-based sites that don&amp;rsquo;t want the gate. None of it is clever. It just means the next time I fat-finger a shortcode, I find out on the merge request and not from my dad asking why the B&amp;amp;B site is showing a stack trace.&lt;/p&gt;</description></item><item><title>The secret that wasn't on my branch</title><link>https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</link><pubDate>Sat, 04 Jul 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</guid><description>&lt;img src="https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/cover-the-secret-that-wasnt-on-my-branch.png" alt="Featured image of post The secret that wasn't on my branch" /&gt;&lt;p&gt;My CI went red on a secret I&amp;rsquo;d never committed.&lt;/p&gt;
&lt;p&gt;Not a close call, not a near-miss I&amp;rsquo;d half-forgotten about. gitleaks, the secret scanner, failed a merge request of mine on a private key that was not on my branch, was not in my change, and as far as I could tell had nothing to do with me at all. The job was adamant. I was baffled. Somewhere in between was a lesson about what a secret scanner actually scans.&lt;/p&gt;
&lt;h2 id="prove-it-isnt-yours"&gt;Prove it isn&amp;rsquo;t yours
&lt;/h2&gt;&lt;p&gt;First rule of being accused: don&amp;rsquo;t get defensive, get evidence. The scanner pointed at a couple of commits carrying a test private key and a PEM block in a spec document. I genuinely didn&amp;rsquo;t recognise them, but &amp;ldquo;I don&amp;rsquo;t recognise it&amp;rdquo; is a feeling, not a fact, and feelings don&amp;rsquo;t reopen a pipeline.&lt;/p&gt;
&lt;p&gt;Git will tell you the truth if you ask it precisely. The question is: are these flagged commits actually part of my branch?&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git merge-base --is-ancestor &amp;lt;flagged-sha&amp;gt; HEAD
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That asks &amp;ldquo;is this commit an ancestor of where I am?&amp;rdquo;. The answer came back no. The commits the scanner was choking on were not in my history. They weren&amp;rsquo;t mine.&lt;/p&gt;
&lt;p&gt;So whose were they? A bit of digging turned them up on a completely separate, still-unmerged branch, where someone (me, a few days earlier, on a different feature) had committed a throwaway test key and a PEM example in a spec, on purpose, as fixtures. Deliberate, harmless, and nowhere near the branch under review. And yet here they were, failing a merge request that had never touched them.&lt;/p&gt;
&lt;h2 id="what-gitleaks-actually-scans"&gt;What gitleaks actually scans
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the bit I&amp;rsquo;d taken for granted. I assumed &lt;code&gt;gitleaks detect&lt;/code&gt; scanned &lt;em&gt;my change&lt;/em&gt;. It doesn&amp;rsquo;t. With no further instruction it scans the whole history reachable in the checkout it&amp;rsquo;s handed.&lt;/p&gt;
&lt;p&gt;And the checkout it&amp;rsquo;s handed is where the second half of the surprise lives. GitLab runners default to &lt;code&gt;GIT_STRATEGY: fetch&lt;/code&gt;, which reuses the runner&amp;rsquo;s working directory between jobs rather than cloning fresh every time. It&amp;rsquo;s faster, and most of the time you never notice. But it means a shared runner accumulates refs from every branch it has ever built. My MR&amp;rsquo;s job happened to run on a runner that had, at some point, built that other branch, so the fixtures were sitting right there in the local object store, fair game for a scanner walking the whole graph.&lt;/p&gt;
&lt;p&gt;So gitleaks did exactly what I&amp;rsquo;d asked it to do, which was &amp;ldquo;scan everything&amp;rdquo;, and &amp;ldquo;everything&amp;rdquo; turned out to be a great deal more than my change. It walked the lot and dutifully reported fixtures from a branch I wasn&amp;rsquo;t even proposing to merge. The scanner wasn&amp;rsquo;t wrong. My idea of what it was looking at was.&lt;/p&gt;
&lt;h2 id="unblock-now-fix-properly-after"&gt;Unblock now, fix properly after
&lt;/h2&gt;&lt;p&gt;Two problems on two timescales. I needed the MR to merge today, and I needed this to never happen again. Those want different fixes.&lt;/p&gt;
&lt;p&gt;The immediate one: tell gitleaks those specific fixtures are known and intended. They&amp;rsquo;re test material, they&amp;rsquo;re meant to be there, so they go in the &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/75f87c5/.gitleaks.toml#L27-L28" target="_blank" rel="noopener"
 &gt;allowlist&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c"&gt;# ...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;internal/cmd/keys/keys_test\.go&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s1"&gt;&amp;#39;&amp;#39;&amp;#39;docs/development/specs/2026-06-08-keys-mint-command\.md&amp;#39;&amp;#39;&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That unblocks the merge. It does nothing about the root cause, which is that the scan was looking at the wrong commits in the first place. Allowlisting individual false positives as they crop up is closing the stable door after the horse has bolted, one horse at a time, forever.&lt;/p&gt;
&lt;p&gt;The real fix lives in the shared CI component, not in any one repo. Scope the scan to the commits the merge request actually introduces (&lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/52b5482/templates/go-security.yml#L105-L125" target="_blank" rel="noopener"
 &gt;cicd v0.10.3&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;if [ -n &amp;#34;$CI_MERGE_REQUEST_DIFF_BASE_SHA&amp;#34; ]; then&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitleaks detect --source . --verbose --redact \&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;--&lt;span class="l"&gt;log-opts=&amp;#34;$CI_MERGE_REQUEST_DIFF_BASE_SHA..$CI_COMMIT_SHA&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;else&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitleaks detect --source . --verbose --redact&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="l"&gt;fi&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;&lt;code&gt;--log-opts&lt;/code&gt; is handed straight through to &lt;code&gt;git log&lt;/code&gt;, so that range, base-of-the-MR to tip, is the exact set of commits the merge request adds and nothing else. On a merge request the scan now sees only what you&amp;rsquo;re proposing to merge. Off a merge request (a plain branch or a tag pipeline) it falls back to the full scan, because there you genuinely do want the lot. The before-and-after in the job log tells the whole story: the entire accumulated history on one side, the handful of commits you actually wrote on the other.&lt;/p&gt;
&lt;h2 id="fixing-the-fix"&gt;Fixing the fix
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a tax on touching CI shell, and I paid it. The change went into the go, rust and tofu security templates. Go and tofu went green. Rust failed.&lt;/p&gt;
&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/52b5482/templates/rust-security.yml#L118-L145" target="_blank" rel="noopener"
 &gt;rust template&lt;/a&gt; had built its optional &lt;code&gt;--config&lt;/code&gt; flag the clever way, inline:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;... &lt;span class="k"&gt;$(&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;--config &lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="k"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;As a command &lt;em&gt;argument&lt;/em&gt;, that substitution is harmless: its exit status is thrown away, so nobody cares that the test inside returns false when there&amp;rsquo;s no config. But when I rewrote the block and reached for the same pattern as an &lt;em&gt;assignment&lt;/em&gt;, &lt;code&gt;VAR=$(... &amp;amp;&amp;amp; ...)&lt;/code&gt;, it became a different animal. An assignment takes the exit status of the command substitution, and under &lt;code&gt;set -e&lt;/code&gt; a non-zero status anywhere aborts the job. So on every run where the config was empty, which was most of them, the test returned false, the assignment inherited that false, and &lt;code&gt;set -e&lt;/code&gt; killed the job stone dead. Same &lt;code&gt;$(...)&lt;/code&gt;, two completely different fates, decided entirely by whether it sat to the right of an &lt;code&gt;=&lt;/code&gt; or got handed to a command as an argument. Go and tofu never used the assignment form, so only rust fell down the hole.&lt;/p&gt;
&lt;p&gt;The fix was to stop being clever and write the boring &lt;code&gt;if&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nv"&gt;GITLEAKS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; -n &lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nv"&gt;GITLEAKS_CONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;--config &lt;/span&gt;&lt;span class="nv"&gt;$CONFIG&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Boring shell is good shell.&lt;/p&gt;
&lt;h2 id="what-a-scanner-is-actually-looking-at"&gt;What a scanner is actually looking at
&lt;/h2&gt;&lt;p&gt;The whole mess came from one unspoken assumption: that a tool called to scan &amp;ldquo;my change&amp;rdquo; was scanning my change. It was scanning a checkout, and a checkout on a shared runner is a much bigger, messier thing than the diff in front of you. None of the pieces were broken. gitleaks did its job, &lt;code&gt;GIT_STRATEGY: fetch&lt;/code&gt; did its job, my fixtures were exactly where I&amp;rsquo;d left them. They just added up to a red pipeline that had nothing to do with the code I was trying to ship. I&amp;rsquo;d spent a good chunk of the day proving my innocence to a scanner that was only ever doing as it was told&amp;hellip; and the one thing I&amp;rsquo;d actually got wrong was being sure I already knew what it was looking at.&lt;/p&gt;</description></item><item><title>Sign your own binaries with go-tool-base, part 6: sign every release with GoReleaser</title><link>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/</link><pubDate>Mon, 22 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/</guid><description>&lt;img src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/cover-sign-your-own-binaries-with-go-tool-base-part-6.png" alt="Featured image of post Sign your own binaries with go-tool-base, part 6: sign every release with GoReleaser" /&gt;&lt;p&gt;By now you&amp;rsquo;ve got all the pieces lying on the bench. A KMS key that signs but
never hands over its private half (Part 2). A CI role you can assume over OIDC
with no stored credentials (Part 3). A public key you&amp;rsquo;ve minted and published
(Part 4), and embedded in the binary (Part 5). What you don&amp;rsquo;t have yet is the
bit that makes it routine: a release that signs itself, every time, without you
remembering to do anything. That&amp;rsquo;s this part. We wire signing into the
tagged-release pipeline so that pushing a &lt;code&gt;v*&lt;/code&gt; tag is the whole ceremony.&lt;/p&gt;
&lt;p&gt;This is the part of the &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base/" &gt;signing series&lt;/a&gt;
where the chain stops being a set of commands you run by hand and becomes
something the pipeline does for you. We&amp;rsquo;re using &lt;a class="link" href="https://goreleaser.com/" target="_blank" rel="noopener"
 &gt;GoReleaser&lt;/a&gt;,
which already builds your binaries, writes a checksums file and cuts the
release. It needs one extra trick: sign the checksums on the way out, through
the KMS key, using credentials that only exist for the length of the job. And
you don&amp;rsquo;t hand-write that trick into your release config. &lt;code&gt;gtb&lt;/code&gt; does.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;ll need a working GoReleaser setup releasing a go-tool-base CLI, plus the
KMS key, the signer role and the embedded public key from the earlier parts.&lt;/p&gt;
&lt;h2 id="one-command-wires-the-signing-in"&gt;One command wires the signing in
&lt;/h2&gt;&lt;p&gt;In &lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-5/" &gt;Part 5&lt;/a&gt;
you ran &lt;code&gt;gtb enable signing&lt;/code&gt; to turn on the &lt;em&gt;verifying&lt;/em&gt; side: embed the key,
check every update against it. Now you give that same command the key the
release pipeline should &lt;em&gt;sign&lt;/em&gt; with, and it wires the producing side too:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;gtb &lt;span class="nb"&gt;enable&lt;/span&gt; signing --key-id alias/acme-release-signing-v1
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That records the KMS key in your &lt;code&gt;.gtb/manifest.yaml&lt;/code&gt; and regenerates
&lt;code&gt;.goreleaser.yaml&lt;/code&gt; with a &lt;code&gt;signs:&lt;/code&gt; block that calls &lt;code&gt;gtb sign&lt;/code&gt;. The WKD email
and everything else you set in Part 5 stay exactly as they were; you&amp;rsquo;re adding
the key, not starting over. Because the block is generated, you don&amp;rsquo;t hand-edit
the release config any more than you hand-edit the embed wiring. Change the key
later (a new region, a rotated alias) and you re-run the command, not the YAML.&lt;/p&gt;
&lt;p&gt;The generated block looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;signs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;checksums&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;cmd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gtb&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--ci&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;sign&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--backend&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;aws-kms&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--kms-region&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;eu-west-2&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--key-id&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;alias/acme-release-signing-v1&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--public-key&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;internal/trustkeys/keys/signing-key-v1.asc&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;--output&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;${signature}&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="s2"&gt;&amp;#34;${artifact}&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;artifacts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;checksum&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;${artifact}.sig&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;output&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The region defaults to &lt;code&gt;eu-west-2&lt;/code&gt; and the public key to the
&lt;code&gt;internal/trustkeys/keys/signing-key-v1.asc&lt;/code&gt; you embedded in Part 5; pass
&lt;code&gt;--kms-region&lt;/code&gt; or &lt;code&gt;--public-key&lt;/code&gt; if yours differ. The backend defaults to
&lt;code&gt;aws-kms&lt;/code&gt;, which is the one that matters in CI.&lt;/p&gt;
&lt;h2 id="why-only-the-checksums"&gt;Why only the checksums
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;artifacts: checksum&lt;/code&gt; is the line doing the load-bearing work. It tells
GoReleaser to run the signing command once, over the checksums manifest only,
not over every binary and archive in the release.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s why that&amp;rsquo;s enough. GoReleaser builds your binaries, then writes a
&lt;code&gt;checksums.txt&lt;/code&gt; listing the SHA-256 of each one. Every artefact in the release
is named in that file by its hash. So if you sign the manifest, you&amp;rsquo;ve
transitively vouched for everything it lists: change a single byte of any
binary and its hash no longer matches the line in &lt;code&gt;checksums.txt&lt;/code&gt;, and the
moment you alter &lt;code&gt;checksums.txt&lt;/code&gt; to cover for that, the signature over it
breaks. One signature, the entire release covered, through the hash chain. The
per-binary build stays completely untouched, which keeps reproducible builds
reproducible. &lt;code&gt;signature: &amp;quot;${artifact}.sig&amp;quot;&lt;/code&gt; names the output
&lt;code&gt;checksums.txt.sig&lt;/code&gt;, the detached, ASCII-armored OpenPGP signature your tool
looks for on every self-update.&lt;/p&gt;
&lt;h2 id="no-shim-just-gtb"&gt;No shim, just &lt;code&gt;gtb&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;If you go reading go-tool-base&amp;rsquo;s own release config, you&amp;rsquo;ll find its &lt;code&gt;signs:&lt;/code&gt;
block points at a &lt;code&gt;scripts/sign-release.sh&lt;/code&gt; shim rather than calling &lt;code&gt;gtb&lt;/code&gt;
directly. Yours doesn&amp;rsquo;t, and the difference is worth understanding.&lt;/p&gt;
&lt;p&gt;go-tool-base is signing the very binary it&amp;rsquo;s in the middle of building, so it
can&amp;rsquo;t use an installed &lt;code&gt;gtb&lt;/code&gt; to do it. Its shim runs &lt;code&gt;go run ./cmd/gtb&lt;/code&gt; to
build a throwaway signer from source. It also reads the key id, public key and
region from environment variables, because the one config has to serve every
build. Your tool has neither problem. It calls the &lt;code&gt;gtb&lt;/code&gt; you already installed,
and the generator already knows your key id, region and public-key path because
you just told it. So there&amp;rsquo;s nothing for a shim to abstract: the whole
invocation goes straight into &lt;code&gt;args:&lt;/code&gt;, where you can read it.&lt;/p&gt;
&lt;p&gt;The one thing you do still need is &lt;code&gt;gtb&lt;/code&gt; on the release runner&amp;rsquo;s &lt;code&gt;PATH&lt;/code&gt;.
Install it in a &lt;code&gt;before_script&lt;/code&gt;, or bake it into your CI image, the same way
you would any other release tool.&lt;/p&gt;
&lt;h2 id="where-the-credentials-come-from"&gt;Where the credentials come from
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;gtb sign&lt;/code&gt; asks KMS to sign. KMS will only oblige if the caller has
credentials, and the whole point of &lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;keyless CI&lt;/a&gt;
is that there are no stored credentials to leak. So they&amp;rsquo;re minted on the fly.&lt;/p&gt;
&lt;p&gt;On GitLab, the release job declares an &lt;code&gt;id_tokens:&lt;/code&gt; block. GitLab injects a
short-lived OIDC token (a JWT) into the job, the &lt;code&gt;before_script&lt;/code&gt; writes it to a
file, and the AWS SDK&amp;rsquo;s default credential chain picks it up from there. No
&lt;code&gt;aws&lt;/code&gt; CLI call, no &lt;code&gt;assume-role-with-web-identity&lt;/code&gt; you write yourself: set
&lt;code&gt;AWS_ROLE_ARN&lt;/code&gt; and &lt;code&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/code&gt; and the SDK does the
web-identity exchange the first time &lt;code&gt;gtb sign&lt;/code&gt; touches KMS:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;goreleaser&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id_tokens&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_WEB_IDENTITY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;aud&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;sts.amazonaws.com&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;variables&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_REGION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;eu-west-2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_ROLE_ARN&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;arn:aws:iam::…:role/acme-release-signing-v1-signer&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;AWS_WEB_IDENTITY_TOKEN_FILE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;/tmp/oidc-token&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;before_script&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="l"&gt;echo &amp;#34;$AWS_WEB_IDENTITY_TOKEN&amp;#34; &amp;gt; &amp;#34;$AWS_WEB_IDENTITY_TOKEN_FILE&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The key id and region live in the generated &lt;code&gt;signs:&lt;/code&gt; block now, not here, so CI
only supplies the AWS credentials. &lt;code&gt;AWS_REGION&lt;/code&gt; still earns its place: the
&lt;code&gt;--kms-region&lt;/code&gt; flag points the KMS client at the key, while the SDK uses
&lt;code&gt;AWS_REGION&lt;/code&gt; for the STS exchange that mints the credentials in the first place.
The &lt;code&gt;aud&lt;/code&gt; has to match the audience the signer role&amp;rsquo;s trust policy expects
(Part 3 set this up; for the OIDC provider go-tool-base uses, that&amp;rsquo;s
&lt;code&gt;sts.amazonaws.com&lt;/code&gt;). On GitHub the moving parts are the same, you just let
&lt;code&gt;aws-actions/configure-aws-credentials&lt;/code&gt; do the token-to-credentials dance
instead of writing the file yourself.&lt;/p&gt;
&lt;p&gt;Those credentials are scoped tight. The signer role&amp;rsquo;s trust policy pins it to
this project&amp;rsquo;s tag pipelines, so even if the role ARN leaked, nothing but a
release tag on your repo can assume it.&lt;/p&gt;
&lt;h2 id="dont-sign-when-theres-nothing-to-sign-with"&gt;Don&amp;rsquo;t sign when there&amp;rsquo;s nothing to sign with
&lt;/h2&gt;&lt;p&gt;A local &lt;code&gt;goreleaser release --snapshot&lt;/code&gt;, or a CI run that isn&amp;rsquo;t a release, has
no OIDC token and no business reaching for KMS. GoReleaser is told to skip the
whole signing step in that case: the release job runs with &lt;code&gt;--skip=sign&lt;/code&gt; unless
the web-identity token is present. So a non-release build never so much as
looks at KMS, which is exactly what you want when you&amp;rsquo;re iterating on your
laptop. The signing only fires on the real thing.&lt;/p&gt;
&lt;h2 id="cut-a-release"&gt;Cut a release
&lt;/h2&gt;&lt;p&gt;With all of that in place, releasing is one push:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-bash" data-lang="bash"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git tag v1.4.0
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;git push origin v1.4.0
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The tag pipeline fires. GoReleaser builds the binaries, writes &lt;code&gt;checksums.txt&lt;/code&gt;,
calls &lt;code&gt;gtb sign&lt;/code&gt;, &lt;code&gt;gtb&lt;/code&gt; asks KMS to sign over OIDC, and &lt;code&gt;checksums.txt.sig&lt;/code&gt;
lands next to the manifest. Both get attached to the release. Nobody typed a
signing command and no private key was anywhere near the runner.&lt;/p&gt;
&lt;p&gt;&lt;img alt="Wiring the release signing with gtb enable signing --key-id" class="gallery-image" data-flex-basis="360px" data-flex-grow="150" height="800" loading="lazy" sizes="(max-width: 767px) calc(100vw - 30px), (max-width: 1023px) 700px, (max-width: 1279px) 950px, 1232px" src="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-6/demo-sign-release.gif" width="1200"&gt;

&lt;/p&gt;
&lt;h2 id="a-two-person-gate-if-you-want-one"&gt;A two-person gate, if you want one
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a window worth thinking about: a compromised CI runner during a release
could, in principle, ride the OIDC credentials to get one malicious thing
signed. You can shut that window with an approval gate in front of the signing
job. On GitLab, a protected environment with a required approval (or a manual
job) makes the release wait for a second pair of eyes; on GitHub, &amp;ldquo;required
reviewers&amp;rdquo; on the release environment does the same. It&amp;rsquo;s optional, and it adds
friction to every release, so weigh it against how exposed your runners are. For
a lot of projects the OIDC scoping alone is enough; for anything where a forged
release would be a genuine incident, the gate is cheap insurance.&lt;/p&gt;
&lt;h2 id="where-this-leaves-you"&gt;Where this leaves you
&lt;/h2&gt;&lt;p&gt;Every release you cut from here on carries a signature made by a key you control
and verifiable by anyone, and you got there by pushing a tag. That closes the
loop the series has been building toward: the production side now actually
&lt;em&gt;produces&lt;/em&gt; signed releases.&lt;/p&gt;
&lt;p&gt;Which means you can finally pull the trigger on the bit Part 5 left primed. We
embedded the public key and left enforcement off, because turning it on before
you ship signatures would brick every update. Now that signatures are shipping,
&lt;a class="link" href="https://phpboyscout.uk/sign-your-own-binaries-with-go-tool-base-part-7/" &gt;Part 7&lt;/a&gt;
deals with the part everyone skips and nobody can afford to: rotating the key,
and getting yourself out of trouble if one ever goes bad, without locking your
users out.&lt;/p&gt;</description></item><item><title>Three traps release-plz sets for a Rust workspace</title><link>https://phpboyscout.uk/three-traps-release-plz-workspace/</link><pubDate>Fri, 05 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/three-traps-release-plz-workspace/</guid><description>&lt;img src="https://phpboyscout.uk/three-traps-release-plz-workspace/cover-three-traps-release-plz-workspace.png" alt="Featured image of post Three traps release-plz sets for a Rust workspace" /&gt;&lt;p&gt;I wrote up the two days I lost releasing a seventeen-crate workspace to crates.io
as &lt;a class="link" href="https://phpboyscout.uk/same-config-two-answers/" &gt;a war story&lt;/a&gt;, wrong
turns and all. This is the other half: the field guide, so you don&amp;rsquo;t have to lose
the same two days.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://release-plz.dev" target="_blank" rel="noopener"
 &gt;release-plz&lt;/a&gt; is a genuinely good tool, and none of what
follows is a bug. It&amp;rsquo;s three behaviours that are entirely within its design and
will still ambush you the moment you point it at a Cargo &lt;em&gt;workspace&lt;/em&gt; rather than a
single crate. Mildest first, because the third is the one that actually ate my
release.&lt;/p&gt;
&lt;h2 id="first-what-release-plz-is-doing"&gt;First, what release-plz is doing
&lt;/h2&gt;&lt;p&gt;In one line: it&amp;rsquo;s release-please for cargo. It keeps a Release MR open, bumps your
versions and per-crate changelogs from your Conventional Commits, and when that MR
merges it publishes every crate to crates.io and tags the release. On a workspace
where N crates all share one version, &amp;ldquo;the release&amp;rdquo; is N publishes and N tag
operations. Hold on to that N. It&amp;rsquo;s hiding behind all three traps.&lt;/p&gt;
&lt;h2 id="trap-1-the-default-tag-template-is-built-for-one-crate-not-a-workspace"&gt;Trap 1: the default tag template is built for one crate, not a workspace
&lt;/h2&gt;&lt;p&gt;You will reach for one tag per version, and for me it was more than tidiness. I
wanted to ship the whole framework as a single release: one &lt;code&gt;v0.5.1&lt;/code&gt; covering all
seventeen crates, because that was the compatibility promise I wanted to make.
Use the crates that share a version and they&amp;rsquo;re guaranteed to work together. A
single tag felt like the natural way to say &amp;ldquo;this is one coherent release of the
whole thing&amp;rdquo; (and it didn&amp;rsquo;t hurt that the repo already had a &lt;code&gt;v0.5.0&lt;/code&gt; tag from
before release-plz, so one unified tag also looked like continuity). So you either
set this, or, worse, you leave &lt;code&gt;git_tag_name&lt;/code&gt; unset assuming the default does
something workspace-aware:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Here&amp;rsquo;s the catch. release-plz&amp;rsquo;s default &lt;code&gt;git_tag_name&lt;/code&gt; &lt;em&gt;is&lt;/em&gt; &lt;code&gt;v{{ version }}&lt;/code&gt;, and
release-plz tags &lt;strong&gt;per crate&lt;/strong&gt;. So the first crate publishes and creates the tag
&lt;code&gt;v0.5.1&lt;/code&gt;. The second crate publishes and tries to create &lt;code&gt;v0.5.1&lt;/code&gt; again:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to create git tag &amp;#39;v0.5.1&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;By the time you read that error, the first crate (and on a retry, the next, and
the next) is already live on crates.io, and crates.io publishes are forever.
Leaving the line out doesn&amp;rsquo;t save you, because the default is the same
single-crate-shaped template. This is the trap I walked straight into on the
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f6de975/release-plz.toml#L20-L21" target="_blank" rel="noopener"
 &gt;release commit&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="trap-2-one-release-for-the-whole-workspace-isnt-a-setting-its-a-category-error"&gt;Trap 2: &amp;ldquo;one release for the whole workspace&amp;rdquo; isn&amp;rsquo;t a setting, it&amp;rsquo;s a category error
&lt;/h2&gt;&lt;p&gt;The natural next thought is &amp;ldquo;fine, I&amp;rsquo;ll keep one tag but configure release-plz to
roll the crates into a single release.&amp;rdquo; There&amp;rsquo;s no knob for that, and chasing one
is a waste of an afternoon. release-plz&amp;rsquo;s model is per-crate all the way down:
per-crate tags, per-crate GitLab/GitHub releases, per-crate changelogs. &amp;ldquo;One
unified release for the whole workspace&amp;rdquo; isn&amp;rsquo;t an option it withholds, it&amp;rsquo;s a
shape it doesn&amp;rsquo;t have.&lt;/p&gt;
&lt;p&gt;So you stop fighting it and
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/release-plz.toml#L21-L22" target="_blank" rel="noopener"
 &gt;set the per-crate templates explicitly&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }} v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now each crate gets its own tag (&lt;code&gt;rtb-assets-v0.5.1&lt;/code&gt;, &lt;code&gt;rtb-config-v0.5.1&lt;/code&gt;, and so
on) and its own release. It&amp;rsquo;s more objects per version than you wanted, but it&amp;rsquo;s
the grain the tool works in, and once you accept that the collisions stop.&lt;/p&gt;
&lt;p&gt;This is where I had to pull apart two things I&amp;rsquo;d quietly merged in my head: the
version and the tag. The compatibility promise I cared about, that crates sharing
a version work together, is carried by the &lt;em&gt;version&lt;/em&gt;, and release-plz keeps every
crate on the one workspace version no matter how it tags them. The tag is just a
label pointing at a commit. I&amp;rsquo;d wanted a single tag to mean &amp;ldquo;one coherent
framework release&amp;rdquo;, but the coherence was always in the shared version number, not
in the tag. Once that landed, seventeen tags stopped feeling like seventeen
releases of seventeen different things and started looking like what they are:
seventeen labels on one versioned release. The version is not the tag. If you still want
one human-facing narrative for the whole thing, keep a hand-written root
&lt;code&gt;CHANGELOG.md&lt;/code&gt; alongside the generated per-crate ones, rather than trying to make
release-plz aggregate.&lt;/p&gt;
&lt;h2 id="trap-3-a-release-reads-its-config-from-the-release-commit-not-head"&gt;Trap 3: a release reads its config from the release commit, not HEAD
&lt;/h2&gt;&lt;p&gt;This is the small one, and the one that cost me the most, because it makes the fix
for Trap 1 look like it isn&amp;rsquo;t working.&lt;/p&gt;
&lt;p&gt;When release-plz runs a &lt;code&gt;release&lt;/code&gt;, it does not read &lt;code&gt;release-plz.toml&lt;/code&gt; from your
working tree. It reads it from the &lt;strong&gt;release commit&lt;/strong&gt;, the commit that first
introduced the version it&amp;rsquo;s releasing. So picture the obvious recovery: you hit
the tag collision, you realise your template is wrong, you fix it in a follow-up
commit and push to main. Your fix is real. It&amp;rsquo;s committed. It&amp;rsquo;s on the default
branch. And it is completely ignored, because the version hasn&amp;rsquo;t changed, so the
release commit release-plz reads from is still the old one with the old template.&lt;/p&gt;
&lt;p&gt;I didn&amp;rsquo;t take this on faith. With the corrected per-crate template sitting on
&lt;code&gt;HEAD&lt;/code&gt;, the CI release job still tried to create the unified tag, pinned to the
&lt;em&gt;old&lt;/em&gt; commit:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to create git tag &amp;#39;v0.5.1&amp;#39; with ref &amp;#39;f6de975...&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That &lt;code&gt;ref&lt;/code&gt; is the release commit, not the HEAD that held my fix. And the cruel
part: &lt;code&gt;release-plz release --dry-run&lt;/code&gt; on your laptop reads your &lt;em&gt;working-directory&lt;/em&gt;
config, so it renders the shiny new per-crate tags and tells you you&amp;rsquo;re sorted. CI
runs the real thing against the release commit and does something else entirely.
Same config file, two different answers depending on who&amp;rsquo;s asking, which is why
the war story has &lt;a class="link" href="https://phpboyscout.uk/same-config-two-answers/" &gt;the title it does&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The operational rule that falls out of this: &lt;strong&gt;any release-plz config change that
affects how a release behaves has to ride along with a version bump, or it does
not apply.&lt;/strong&gt; A &amp;ldquo;fix-up&amp;rdquo; commit on its own is a no-op.&lt;/p&gt;
&lt;h2 id="if-you-set-one-thing"&gt;If you set one thing
&lt;/h2&gt;&lt;p&gt;If you run release-plz on a multi-crate workspace and you change a single line
from the defaults, make it the tag template:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;And set it &lt;em&gt;before&lt;/em&gt; your first release, not during it, so it&amp;rsquo;s already in the
commit that introduces the version, because that&amp;rsquo;s the only commit a release will
ever read it from. Everything else here follows from two facts: the grain is
per-crate, and CI reads history while your laptop reads your working tree. Trust
the history.&lt;/p&gt;
&lt;p&gt;None of this is release-plz misbehaving. Every bit of it is documented and
deliberate. It just isn&amp;rsquo;t where you&amp;rsquo;ll think to look until it has published six
crates you can&amp;rsquo;t take back, which is roughly how I came to know it so well.&lt;/p&gt;</description></item><item><title>Same config, two answers</title><link>https://phpboyscout.uk/same-config-two-answers/</link><pubDate>Wed, 03 Jun 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/same-config-two-answers/</guid><description>&lt;img src="https://phpboyscout.uk/same-config-two-answers/cover-same-config-two-answers.png" alt="Featured image of post Same config, two answers" /&gt;&lt;p&gt;Let me confess a small heresy first, because it&amp;rsquo;s the reason any of this
happened. After a career spent as a branching man, gitflow, gitlabflow, a
tidy &lt;code&gt;develop&lt;/code&gt; branch and a careful dance of merges, I&amp;rsquo;ve come round to
trunk-based development. I resisted it for years. It felt like working without
a net.&lt;/p&gt;
&lt;p&gt;What changed my mind was working solo with an AI pair. The branch ceremony that
earns its keep on a team of eight is just drag when it&amp;rsquo;s me and a model at
two in the morning. So I&amp;rsquo;ve softened on &amp;ldquo;main is always deployable&amp;rdquo; and let the
trunk act as the develop branch, with tagged releases as the actual source of
truth. For compiled languages, where the artefact you ship is a built, tagged
thing and not whatever&amp;rsquo;s on a server right now, that finally clicks.&lt;/p&gt;
&lt;p&gt;I&amp;rsquo;d already rolled this out on my Go and Terraform projects with
&lt;a class="link" href="https://github.com/apricote/releaser-pleaser" target="_blank" rel="noopener"
 &gt;releaser-pleaser&lt;/a&gt;, a GitLab-native
take on release-please: a bot keeps a Release MR open, and merging it cuts the
tag. It&amp;rsquo;s the same model I wrote about when
&lt;a class="link" href="https://phpboyscout.uk/reviewed-then-applied/" &gt;the infra repo moved to plan-on-merge, apply-on-tag&lt;/a&gt;.
Lovely. Then I came to do the same for rust-tool-base, and Rust, being Rust,
&lt;a class="link" href="https://phpboyscout.uk/rust-tool-base-the-same-idea/" &gt;had opinions&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="rust-brings-its-own-toolchain"&gt;Rust brings its own toolchain
&lt;/h2&gt;&lt;p&gt;releaser-pleaser is happy to tag a repo and write a release. What it does not do
is &lt;code&gt;cargo publish&lt;/code&gt; seventeen crates to crates.io in dependency order. Rust&amp;rsquo;s
release story isn&amp;rsquo;t &amp;ldquo;push a tag and let a runner build a binary&amp;rdquo;, it&amp;rsquo;s a whole
publishing pipeline with a public registry at the end of it, and that registry
has rules of its own. So for the Rust workspace I reached for the tool built for
exactly that job: &lt;a class="link" href="https://release-plz.dev" target="_blank" rel="noopener"
 &gt;release-plz&lt;/a&gt;. Same Release-MR shape,
but it understands cargo, versions every crate, and publishes the lot.&lt;/p&gt;
&lt;p&gt;That was the right call. Getting it to actually do it was where I spent two days
I&amp;rsquo;d quite like back.&lt;/p&gt;
&lt;h2 id="the-gauntlet-before-the-gun"&gt;The gauntlet before the gun
&lt;/h2&gt;&lt;p&gt;Before I got anywhere near the interesting failure, there was a run of CI
papercuts, the sort where every fix politely reveals the next one. GitLab checks
out a detached HEAD, and release-plz wants to be on a branch (&amp;ldquo;HEAD does not
point to a branch&amp;rdquo;), so you re-attach. Then the default &lt;code&gt;CI_JOB_TOKEN&lt;/code&gt; can&amp;rsquo;t push
to a protected repo, so you point the remote at a real token. Then release-plz
assumes you&amp;rsquo;re on GitHub and errors that the repo &amp;ldquo;is not hosted in GitHub&amp;rdquo;, so
you tell it &lt;code&gt;--forge gitlab&lt;/code&gt;. Then it refuses to run at all because the &lt;code&gt;pages&lt;/code&gt;
job left a &lt;code&gt;public/&lt;/code&gt; directory lying about and the working tree is &amp;ldquo;dirty&amp;rdquo;, so
you stop pulling artefacts into the job.&lt;/p&gt;
&lt;p&gt;Five merge requests before the thing would even &lt;em&gt;start&lt;/em&gt; doing its actual job.
You can read the
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/.gitlab-ci.yml#L379-L409" target="_blank" rel="noopener"
 &gt;scar tissue in the &lt;code&gt;before_script&lt;/code&gt;&lt;/a&gt;;
every line in it is a fix for something on that list. None of it was hard.
It was just death by a thousand cuts, and I was feeling quite smug by the time
it finally reached the publish step.&lt;/p&gt;
&lt;p&gt;I should not have been.&lt;/p&gt;
&lt;h2 id="tag-v051-already-exists"&gt;&amp;ldquo;Tag v0.5.1 already exists&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;My &lt;code&gt;release-plz.toml&lt;/code&gt; asked for
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f6de975/release-plz.toml#L20-L21" target="_blank" rel="noopener"
 &gt;one tag per release&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That felt obviously right. It matched the repo&amp;rsquo;s existing &lt;code&gt;v0.5.0&lt;/code&gt; tag, it&amp;rsquo;s how
a single-crate project tags, and the crates all share one workspace version
anyway. One version, one tag. What&amp;rsquo;s to argue with?&lt;/p&gt;
&lt;p&gt;release-plz, that&amp;rsquo;s what. It tags &lt;em&gt;per crate&lt;/em&gt;. So it publishes a crate, creates
the tag, publishes the next crate, and tries to create the same tag again:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;INFO published rtb-assets 0.5.1
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ERROR failed to release package
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Caused by:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 0: failed to create git tag &amp;#39;v0.5.1&amp;#39; with ref &amp;#39;f6de975a75...&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 1: Response body: { &amp;#34;message&amp;#34;: &amp;#34;Tag v0.5.1 already exists&amp;#34; }
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; 2: HTTP status client error (400 Bad Request) ... /repository/tags
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The collision is annoying. What makes it a proper trap is the half-second before
it: &lt;code&gt;published rtb-assets 0.5.1&lt;/code&gt;. That happened. On crates.io. For keeps. A
crates.io publish is forever, there is no unpublish, only a yank that still
leaves the name and version burned. So every time my flaky pipeline limped one
crate further and then fell over on the tag, it left another crate live on the
public registry that I could never take back. By the time the dust settled, six
of the seventeen were out there: &lt;code&gt;rtb-assets&lt;/code&gt; and &lt;code&gt;rtb-config&lt;/code&gt;, then on a later
retry &lt;code&gt;rtb-credentials&lt;/code&gt; and &lt;code&gt;rtb-error&lt;/code&gt;, then &lt;code&gt;rtb-app&lt;/code&gt; and &lt;code&gt;rtb-redact&lt;/code&gt;. Two
more permanent crates per failed run.&lt;/p&gt;
&lt;h2 id="i-assumed-the-default"&gt;I assumed the default
&lt;/h2&gt;&lt;p&gt;My first fix was the clever one, and it deserves to be on display because it&amp;rsquo;s
the whole lesson in miniature. I deleted the &lt;code&gt;git_tag_name&lt;/code&gt; line. My reasoning:
per-crate tags are release-plz&amp;rsquo;s native model, so surely its &lt;em&gt;default&lt;/em&gt; does the
right thing without me spelling it out. I was confident enough to write it into
the commit message: &amp;ldquo;per-crate tags/releases (release-plz defaults).&amp;rdquo;&lt;/p&gt;
&lt;p&gt;The next run collided on &lt;code&gt;v0.5.1&lt;/code&gt;, exactly as before.&lt;/p&gt;
&lt;p&gt;Because release-plz&amp;rsquo;s default &lt;code&gt;git_tag_name&lt;/code&gt; is not per-crate. It&amp;rsquo;s the unified
&lt;code&gt;v{{ version }}&lt;/code&gt;. I had deleted a line that said the wrong thing and replaced it
with a default that said the &lt;em&gt;same&lt;/em&gt; wrong thing, then congratulated myself for
tidiness. If I&amp;rsquo;d spent thirty seconds on the configuration reference instead of
thirty seconds being clever, I&amp;rsquo;d have read that in black and white.&lt;/p&gt;
&lt;h2 id="same-config-two-answers"&gt;Same config, two answers
&lt;/h2&gt;&lt;p&gt;So I read the manual, and set it explicitly:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-toml" data-lang="toml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_tag_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }}-v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nx"&gt;git_release_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;{{ package }} v{{ version }}&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;On my laptop, a dry run rendered exactly the per-crate tags I wanted. In CI, the
very next run published another crate and then created the tag &lt;code&gt;v0.5.1&lt;/code&gt;. The
unified one. The wrong one. The one I had just, demonstrably, on main,
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/7afc42e/release-plz.toml#L21-L22" target="_blank" rel="noopener"
 &gt;replaced&lt;/a&gt;.
Same &lt;code&gt;release-plz.toml&lt;/code&gt;, two completely different answers depending on who was
asking.&lt;/p&gt;
&lt;p&gt;That one took me an embarrassingly long time to see. release-plz does not read
your config from the working tree when it runs a &lt;em&gt;release&lt;/em&gt;. It reads it from the
&lt;strong&gt;release commit&lt;/strong&gt;, the commit that introduced the version it&amp;rsquo;s releasing. My
version was still &lt;code&gt;0.5.1&lt;/code&gt;, set days earlier on a commit that still carried the
unified template. You can see it in the failure: the tag it tries to create is
pinned to &lt;code&gt;ref 'f6de975...'&lt;/code&gt;, an old commit, not the HEAD that held my fix.
Every edit I made at the tip of main was real, committed, and utterly invisible
to the release of 0.5.1, because no version bump had created a fresh release
commit for it to read. My fix was correct and inert at the same time. The
dry run read my working directory and looked perfect; CI read history and did
something else.&lt;/p&gt;
&lt;p&gt;There is no config change that rescues an in-flight release. The version was
already out, half-published, tagged wrong, and pointed at a commit I couldn&amp;rsquo;t
edit without bumping the version, which I couldn&amp;rsquo;t cleanly do with six crates
already live.&lt;/p&gt;
&lt;h2 id="doing-it-the-way-id-have-done-it-a-year-ago"&gt;Doing it the way I&amp;rsquo;d have done it a year ago
&lt;/h2&gt;&lt;p&gt;So I stopped. Three retries deep, each one a seventy-minute CI cycle thrown at an
opaque mismatch, six crates already immovable on crates.io, and a tooling problem
I now understood well enough to know the tool was never going to dig me out of
&lt;em&gt;this particular&lt;/em&gt; hole. The question quietly changed from &amp;ldquo;why is it doing this?&amp;rdquo;
to &amp;ldquo;am I going to keep grinding, or finish this the way I would have before I had
clever tooling?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;I went manual. &lt;code&gt;cargo publish&lt;/code&gt;, the remaining eleven, by hand, in dependency
order: the leaf crates first and the &lt;code&gt;rust-tool-base&lt;/code&gt; umbrella dead last,
because it depends on all of them. crates.io rate-limits new crate names, so
after a burst it simply made me wait, a roughly half-hour pause in the middle
while the registry caught its breath and I caught mine. Then one &lt;code&gt;v0.5.1&lt;/code&gt; tag,
cut by hand, and one GitLab release to match the convention. The next CI run came
up green, for the gloriously dull reason that there was nothing left to do:
every crate published, the tag already there.&lt;/p&gt;
&lt;h2 id="stop-being-clever-and-rtfm"&gt;Stop being clever and RTFM
&lt;/h2&gt;&lt;p&gt;The tool was never broken. Every single thing it did was documented behaviour I
hadn&amp;rsquo;t bothered to read: that the default tag template is unified, that the model
is per-crate, that a release reads its config from the release commit and not
from HEAD. I assumed my way past the manual three times in a row, and each
assumption cost me real, permanent state on a public registry that doesn&amp;rsquo;t take
returns.&lt;/p&gt;
&lt;p&gt;And that&amp;rsquo;s the part that actually stung, because I should have known better than
most. I wasn&amp;rsquo;t a beginner here. I knew the Release-MR pattern cold, I&amp;rsquo;d shipped it
half a dozen times with releaser-pleaser on my Go and Terraform repos. That
familiarity &lt;em&gt;was&lt;/em&gt; the trap. I trusted the pattern and skipped the tool, on the
lazy assumption that something I understood well in one tool would behave the same
in the next. release-plz carries the same design, but it&amp;rsquo;s a different tool, with
its own defaults and its own idea of where the config lives. The pattern came
across fine. The mechanics didn&amp;rsquo;t, and I never thought to check.&lt;/p&gt;
&lt;p&gt;So here&amp;rsquo;s the lesson, written down in the hope it sticks this time: no matter how
familiar I am with a pattern or a design, the moment I switch the tool that
implements it, reading the manual is paramount. The familiarity is exactly what
tempts you to skip it, and exactly why you can&amp;rsquo;t. (The narrower, more practical
one, while I&amp;rsquo;m here: a config change that affects how a release behaves has to
travel &lt;em&gt;with&lt;/em&gt; a version bump, or it sits there looking applied and doing nothing.)&lt;/p&gt;
&lt;p&gt;release-plz is genuinely good, and every release since has gone out clean on the
first try, the way &lt;a class="link" href="https://phpboyscout.uk/from-allow-failure-to-blocking/" &gt;the rest of the CI now does&lt;/a&gt;.
I just had to stop being clever long enough to read how it actually works. RTFM.
I&amp;rsquo;ll get it tattooed eventually.&lt;/p&gt;</description></item><item><title>From allow_failure to blocking</title><link>https://phpboyscout.uk/from-allow-failure-to-blocking/</link><pubDate>Sat, 30 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/from-allow-failure-to-blocking/</guid><description>&lt;img src="https://phpboyscout.uk/from-allow-failure-to-blocking/cover-from-allow-failure-to-blocking.png" alt="Featured image of post From allow_failure to blocking" /&gt;&lt;p&gt;There&amp;rsquo;s a special kind of CI job that everyone on a team quietly learns to
ignore: the one marked &lt;code&gt;allow_failure: true&lt;/code&gt;. It runs, it goes red, the
pipeline goes green anyway, and after the third time you stop looking at it. I
inherited six of those when I moved rust-tool-base&amp;rsquo;s CI to GitLab. Over a few
days I turned three of them into real gates, and the interesting part was never
the YAML. It was working out which ones had earned the right to block, and
which hadn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="what-allow_failure-actually-buys-you"&gt;What allow_failure actually buys you
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;allow_failure: true&lt;/code&gt; is genuinely useful, and quietly corrosive. It lets a job
report a problem without stopping the pipeline, which is exactly right for a
check that&amp;rsquo;s noisy, not yet stable, or guarding against something you can&amp;rsquo;t fix
this minute. The trouble is that a warning nobody is forced to act on is a
warning nobody acts on. Leave a job advisory long enough and it becomes
scenery: red, ignored, pointless. So an advisory check is really a promise,
&amp;ldquo;I&amp;rsquo;ll make this blocking once it&amp;rsquo;s trustworthy&amp;rdquo;, and a promise you only ever
mean to keep is just a lie you haven&amp;rsquo;t noticed yet.&lt;/p&gt;
&lt;p&gt;When I &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/2213f8e/.gitlab-ci.yml" target="_blank" rel="noopener"
 &gt;migrated rust-tool-base from GitHub Actions to GitLab CI&lt;/a&gt;,
the move landed six jobs as &lt;code&gt;allow_failure: true&lt;/code&gt;: the macOS and Windows tests,
the integration tests, &lt;code&gt;cargo-audit&lt;/code&gt;, &lt;code&gt;trivy&lt;/code&gt;, and coverage. That wasn&amp;rsquo;t
laziness. A migration is the wrong moment to also be fighting flaky gates. But
it left me holding six promises to either keep or admit I wasn&amp;rsquo;t going to.&lt;/p&gt;
&lt;h2 id="a-check-earns-the-right-to-block"&gt;A check earns the right to block
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the rule I settled on. A check earns the right to fail your build when
two things are true: it&amp;rsquo;s &lt;em&gt;meaningful&lt;/em&gt; (a red result is a real problem, not
noise) and it&amp;rsquo;s &lt;em&gt;reliable&lt;/em&gt; (it goes red only when there genuinely is a problem,
and it can actually run to completion). Flip a check to blocking before both
hold and you haven&amp;rsquo;t raised the bar, you&amp;rsquo;ve taught the team to force-merge past
red, which is worse than no gate at all, because now the red means nothing.&lt;/p&gt;
&lt;p&gt;Three of my six crossed that line within a few days. Three deliberately didn&amp;rsquo;t.
The reasons are the whole story.&lt;/p&gt;
&lt;h2 id="trivy-blocked-once-there-was-nothing-to-block-on"&gt;trivy: blocked once there was nothing to block on
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/f9cab20/.gitlab-ci.yml#L247-256" target="_blank" rel="noopener"
 &gt;&lt;code&gt;trivy&lt;/code&gt;&lt;/a&gt;
scans the dependency tree for HIGH and CRITICAL advisories. It went across as
advisory for an honest reason: the &lt;code&gt;Cargo.lock&lt;/code&gt; at migration time already
carried two known HIGH/CRITICAL advisories I hadn&amp;rsquo;t cleared yet, a
path-traversal in &lt;code&gt;gix-validate&lt;/code&gt; and a DNS-rebinding issue in &lt;code&gt;rmcp&lt;/code&gt;. Make
trivy blocking with those sitting there and the pipeline is red from day one,
over problems I already knew about and was already fixing. So it stayed
advisory until the dependency bumps cleared both, and then the &lt;code&gt;allow_failure&lt;/code&gt;
line came out. The gate never changed. The tree underneath it got clean enough
to stand on.&lt;/p&gt;
&lt;h2 id="integration-tests-blocked-once-it-could-actually-run"&gt;integration-tests: blocked once it could actually run
&lt;/h2&gt;&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/193f380/.gitlab-ci.yml#L200-226" target="_blank" rel="noopener"
 &gt;integration tests&lt;/a&gt;
stand up a real Gitea in a Docker-in-Docker service and talk to it. They were
advisory for a different reason: they couldn&amp;rsquo;t reliably run. dind needs a
privileged runner, and the suite was resolving the container host with a
hardcoded &lt;code&gt;127.0.0.1&lt;/code&gt; that didn&amp;rsquo;t hold everywhere. Blocking a job that fails
for infrastructure reasons rather than code reasons is the fastest way to make
people distrust the entire pipeline. So the fix wasn&amp;rsquo;t in the YAML, it was
making the thing dependable: &lt;code&gt;privileged&lt;/code&gt; set on the runner, and the host
resolved through the test library&amp;rsquo;s own &lt;code&gt;get_host()&lt;/code&gt; instead of a hardcoded
address. Once it ran the same way every time, it earned the gate.&lt;/p&gt;
&lt;h2 id="coverage-blocked-once-it-could-run-at-all-then-once-it-cleared-the-bar"&gt;coverage: blocked once it could run at all, then once it cleared the bar
&lt;/h2&gt;&lt;p&gt;Coverage is the two-step one, and my favourite, because it nearly didn&amp;rsquo;t make
it for a thoroughly undramatic reason: it ran out of memory. &lt;code&gt;cargo llvm-cov&lt;/code&gt;
instruments every test binary, and linking hundreds of instrumented object
files needs more RAM than the shared medium runner had, so the job bus-errored
on the link. I tagged it onto a larger runner, and then the shared SaaS runners
were switched off entirely, so the tag matched nothing and the job sat pending
forever.&lt;/p&gt;
&lt;p&gt;The fix was a &lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/193f380/.gitlab-ci.yml#L200-226" target="_blank" rel="noopener"
 &gt;self-hosted homelab runner&lt;/a&gt;
with the RAM the instrumented link actually needs. I moved coverage there but
kept it advisory &lt;em&gt;for one run&lt;/em&gt;, to confirm the box could finish the build
before I trusted it. It did, at
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/1c9e589/.gitlab-ci.yml#L286-320" target="_blank" rel="noopener"
 &gt;73.22% line coverage&lt;/a&gt;,
so I set the gate to fail under 70% and made it blocking. Three points of
headroom: enough that ordinary churn won&amp;rsquo;t trip it, tight enough that a real
drop will. A coverage gate pinned to the current number is a tripwire that
fires on the very next commit; set it a touch below and it catches regressions
instead of normal life.&lt;/p&gt;
&lt;h2 id="the-three-i-left-advisory-on-purpose"&gt;The three I left advisory, on purpose
&lt;/h2&gt;&lt;p&gt;The point was never &amp;ldquo;block everything&amp;rdquo;. Three jobs are still &lt;code&gt;allow_failure&lt;/code&gt; in
&lt;a class="link" href="https://gitlab.com/phpboyscout/rust-tool-base/-/blob/d3c23fc/.gitlab-ci.yml" target="_blank" rel="noopener"
 &gt;the current pipeline&lt;/a&gt;,
deliberately. The macOS and Windows tests run on SaaS runners that bill by the
minute; they&amp;rsquo;re worth running, not worth blocking every merge of a Linux-first
project over a quota I&amp;rsquo;m choosing to ration. And &lt;code&gt;cargo-audit&lt;/code&gt; stays advisory
because &lt;code&gt;cargo-deny&lt;/code&gt; already does the blocking advisory check: cargo-audit is a
second opinion from a different database, and a second opinion that can veto
isn&amp;rsquo;t a second opinion, it&amp;rsquo;s a duplicate gate that will eventually disagree with
the first and block you on the difference.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the same rule from the other side. Those three haven&amp;rsquo;t earned the right
to block, because blocking them would cost more than it ever caught.&lt;/p&gt;
&lt;h2 id="the-upshot"&gt;The upshot
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;allow_failure: true&lt;/code&gt; is fine as a waiting room and corrosive as a destination.
Every advisory check is a promise to make it blocking once it&amp;rsquo;s both meaningful
and reliable, and the job is to keep the promise or admit you won&amp;rsquo;t. trivy
earned its gate when the advisories cleared, the integration tests when they
ran the same way every time, coverage when it had a runner with enough memory
and a threshold set just below the current mark. The three I left advisory
earned that standing too, by costing more to block than they&amp;rsquo;d catch. The YAML
is one deleted line per job. Knowing which line to delete, and when, is the
whole skill.&lt;/p&gt;</description></item><item><title>Two bugs that taught me the rules</title><link>https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/</link><pubDate>Wed, 20 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/</guid><description>&lt;img src="https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/cover-two-bugs-that-taught-me-the-rules.png" alt="Featured image of post Two bugs that taught me the rules" /&gt;&lt;p&gt;Some bugs are interesting because they&amp;rsquo;re subtle. These two were interesting because they were the exact opposite&amp;hellip; in each case the tool had a hard rule I simply didn&amp;rsquo;t know about, and its error message couldn&amp;rsquo;t be bothered to tell me what that rule was. Both came out of building the infrastructure toolchain, both cost me a good deal more time than they had any right to, and both are the sort of thing that looks blindingly obvious the moment you know it and utterly baffling until you do.&lt;/p&gt;
&lt;p&gt;So here they are, written down, partly to save you the bother and partly so I don&amp;rsquo;t go and forget them myself.&lt;/p&gt;
&lt;h2 id="bug-one-the-rule-less-job-that-skips-your-merge-requests"&gt;Bug one: the rule-less job that skips your merge requests
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;cicd&lt;/code&gt; gate components, in their first cut, shipped with no &lt;code&gt;rules:&lt;/code&gt; block. They were dead simple jobs: lint, scan, validate. No conditions, because they should just always run. Obviously.&lt;/p&gt;
&lt;p&gt;They ran on branch pipelines. On merge requests, they didn&amp;rsquo;t run at all! The gates that were the entire point of the components were simply absent from the one place you&amp;rsquo;d most want to see them&amp;hellip; the merge request.&lt;/p&gt;
&lt;p&gt;The cause is a GitLab CI rule that&amp;rsquo;s remarkably easy to go years without ever learning: a job with no &lt;code&gt;rules:&lt;/code&gt; block runs only on branch and tag pipelines. It does not run on merge-request pipelines. So &amp;ldquo;no conditions&amp;rdquo; doesn&amp;rsquo;t mean &amp;ldquo;runs everywhere&amp;rdquo; at all. It means &amp;ldquo;runs everywhere except a merge request&amp;rdquo;, which is about the least intuitive default I can think of.&lt;/p&gt;
&lt;p&gt;The fix is faintly absurd, and that&amp;rsquo;s exactly what makes it stick. You add an &lt;em&gt;unconditional&lt;/em&gt; rule: &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/v0.5.0/templates/tofu-lint.yml#L38" target="_blank" rel="noopener"
 &gt;&lt;code&gt;rules: [{ when: on_success }]&lt;/code&gt;&lt;/a&gt;. The content of that rule does precisely nothing. It always matches. What actually matters is that the job now &lt;em&gt;has&lt;/em&gt; a &lt;code&gt;rules:&lt;/code&gt; block at all, because merely having one is what makes a job eligible for merge-request pipelines. A rule whose content is meaningless, added solely so the block exists. That&amp;rsquo;s the fix. I&amp;rsquo;ll admit I stared at it for a moment.&lt;/p&gt;
&lt;h2 id="bug-two-the-import-block-that-only-works-at-the-root"&gt;Bug two: the import block that only works at the root
&lt;/h2&gt;&lt;p&gt;The second one came from &lt;code&gt;terraform-aws-security-baseline&lt;/code&gt;. The &lt;code&gt;account-hardening&lt;/code&gt; module needed to adopt a resource that already existed in the account, which is exactly what OpenTofu&amp;rsquo;s &lt;code&gt;import {}&lt;/code&gt; block is for. So an &lt;code&gt;import&lt;/code&gt; block went into the &lt;code&gt;account-hardening&lt;/code&gt; module, right next to the resource it was adopting. The natural home for it, surely.&lt;/p&gt;
&lt;p&gt;OpenTofu disagreed, and rejected it outright. The rule: an &lt;code&gt;import&lt;/code&gt; block is only allowed in the &lt;em&gt;root&lt;/em&gt; module. It can&amp;rsquo;t live inside a child module. A module that wants one of its own resources imported can&amp;rsquo;t declare that import itself&amp;hellip; the import has to be declared up at the root, and the root caller does the adopting.&lt;/p&gt;
&lt;p&gt;The fix was to take the &lt;code&gt;import&lt;/code&gt; block out of the module and document caller-side adoption instead. The module describes the resource, and the root configuration that calls the module is where the &lt;code&gt;import&lt;/code&gt; actually lives.&lt;/p&gt;
&lt;h2 id="the-shape-they-share"&gt;The shape they share
&lt;/h2&gt;&lt;p&gt;Two unrelated bugs, in two completely different tools, and the same shape sitting underneath both of them.&lt;/p&gt;
&lt;p&gt;In each case the tool has a hard structural rule. Where a block is allowed to live. What makes a job eligible for a particular kind of pipeline. And in each case the error told me the tool was unhappy without telling me &lt;em&gt;which&lt;/em&gt; rule I&amp;rsquo;d broken, so the obvious next move (debugging my own logic) was the wrong move entirely. There was nothing wrong with the logic. The thing was simply in a place the tool doesn&amp;rsquo;t allow, or missing a block the tool quietly insists on.&lt;/p&gt;
&lt;p&gt;The lasting lesson here isn&amp;rsquo;t the two specific rules, useful as they are to know. It&amp;rsquo;s the reflex. When something that should obviously work just doesn&amp;rsquo;t, and the error is unhelpful, stop debugging your logic and start suspecting a structural rule about &lt;em&gt;where&lt;/em&gt; something is allowed to be, or &lt;em&gt;whether&lt;/em&gt; a thing is eligible in the first place. GitLab CI and OpenTofu both have a handful of these, and you mostly learn them the hard way, by tripping over them. Knowing the shape of the category at least means the next one costs you an hour instead of a whole afternoon.&lt;/p&gt;
&lt;h2 id="worth-remembering"&gt;Worth remembering
&lt;/h2&gt;&lt;p&gt;Two bugs from building the toolchain, one shape. A GitLab CI job with no &lt;code&gt;rules:&lt;/code&gt; block runs on branches and tags but silently not on merge requests, and the fix is an unconditional &lt;code&gt;rules:&lt;/code&gt; block whose content does nothing and whose mere existence is the entire point. An OpenTofu &lt;code&gt;import&lt;/code&gt; block gets rejected inside a child module, because imports are only legal at the root, so the caller adopts and the module just describes.&lt;/p&gt;
&lt;p&gt;Neither error named the rule it was enforcing, and that&amp;rsquo;s the category to watch for. When sound logic fails against an unhelpful error, suspect a structural rule about where a thing may live or whether it&amp;rsquo;s even eligible&amp;hellip; not a bug in what you actually wrote. It&amp;rsquo;ll save you an afternoon. It certainly cost me a couple.&lt;/p&gt;</description></item><item><title>Reviewed, then applied</title><link>https://phpboyscout.uk/reviewed-then-applied/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/reviewed-then-applied/</guid><description>&lt;img src="https://phpboyscout.uk/reviewed-then-applied/cover-reviewed-then-applied.png" alt="Featured image of post Reviewed, then applied" /&gt;&lt;p&gt;The genuinely dangerous moment in infrastructure-as-code isn&amp;rsquo;t the apply. It&amp;rsquo;s the gap between the plan a human read and approved, and the change that actually runs a moment later. If those two are different computations (and by default they are) then nobody really reviewed the thing that touched your account. The &lt;code&gt;infra&lt;/code&gt; repo closes that gap from both ends.&lt;/p&gt;
&lt;h2 id="the-gap-between-reviewed-and-ran"&gt;The gap between &amp;ldquo;reviewed&amp;rdquo; and &amp;ldquo;ran&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s the moment in infrastructure-as-code where things go wrong.&lt;/p&gt;
&lt;p&gt;Someone opens a merge request. CI runs &lt;code&gt;tofu plan&lt;/code&gt; and the output is there to review: these three resources change, this one is destroyed. A human reads it, decides it&amp;rsquo;s correct, approves, merges. Then &lt;code&gt;apply&lt;/code&gt; runs.&lt;/p&gt;
&lt;p&gt;The trap is in what &lt;code&gt;apply&lt;/code&gt; actually applies. If &lt;code&gt;apply&lt;/code&gt; does its own fresh &lt;code&gt;tofu plan&lt;/code&gt; and then applies &lt;em&gt;that&lt;/em&gt;, the change that runs is not necessarily the change that was reviewed. State can have moved. A provider can have drifted. Someone else can have applied something in between. The reviewed plan and the applied change are two separate computations done at two different moments, and every difference between those moments is a change nobody looked at.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; closes that gap from both ends.&lt;/p&gt;
&lt;h2 id="plan-as-an-artifact"&gt;Plan as an artifact
&lt;/h2&gt;&lt;p&gt;The first end is making the reviewed plan and the applied plan the &lt;em&gt;same object&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/v0.5.0/templates/tofu-plan.yml" target="_blank" rel="noopener"
 &gt;&lt;code&gt;tofu-plan&lt;/code&gt; component&lt;/a&gt; runs the plan and saves it. It writes &lt;code&gt;tfplan.cache&lt;/code&gt;, OpenTofu&amp;rsquo;s binary plan file, as a CI artifact. It also writes &lt;code&gt;tfplan.json&lt;/code&gt;, which GitLab renders as a plan widget right in the merge request: the add, change and destroy summary, there to review without leaving the MR.&lt;/p&gt;
&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/blob/v0.5.0/templates/tofu-apply.yml" target="_blank" rel="noopener"
 &gt;&lt;code&gt;tofu-apply&lt;/code&gt; component&lt;/a&gt; then does &lt;em&gt;not&lt;/em&gt; re-plan. It applies that saved &lt;code&gt;tfplan.cache&lt;/code&gt;. And OpenTofu itself enforces the safety net: applying a stale plan file, one captured against a state that has since moved, is rejected by the tool. So what reaches the account is provably the plan that was reviewed, or it&amp;rsquo;s nothing at all. There&amp;rsquo;s no third option where something unreviewed slips through.&lt;/p&gt;
&lt;h2 id="applying-is-a-human-decision"&gt;Applying is a human decision
&lt;/h2&gt;&lt;p&gt;The second end is &lt;em&gt;when&lt;/em&gt; apply runs.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; is trunk-based: it dropped the &lt;code&gt;develop&lt;/code&gt; branch and works on &lt;code&gt;main&lt;/code&gt;. But a naive trunk setup auto-applies every push to &lt;code&gt;main&lt;/code&gt;, which means there&amp;rsquo;s no human gate at all, just whatever the last merge happened to contain.&lt;/p&gt;
&lt;p&gt;So the gate is built explicitly. &lt;code&gt;releaser-pleaser&lt;/code&gt; keeps a release merge request open against &lt;code&gt;main&lt;/code&gt;. Ordinary merges to &lt;code&gt;main&lt;/code&gt; run plans but apply nothing. The apply happens only when a person &lt;em&gt;merges the release MR&lt;/em&gt;. Merging it cuts a release tag, and the tag pipeline is what runs &lt;code&gt;tofu-apply&lt;/code&gt;, against the plan banked by the latest &lt;code&gt;main&lt;/code&gt; pipeline.&lt;/p&gt;
&lt;p&gt;The effect is that the act of applying to the account is the deliberate, visible act of merging the release request. Nothing reaches the account because a commit landed. It reaches the account because a person decided a release should go out and merged it. (Which, after the &lt;a class="link" href="https://phpboyscout.uk/why-we-left-github-for-gitlab/" &gt;accidental &lt;code&gt;v2.0.0&lt;/code&gt;&lt;/a&gt; that kicked off the whole GitLab move, is a discipline I&amp;rsquo;d freshly relearned the value of.)&lt;/p&gt;
&lt;h2 id="the-guard-on-the-gate"&gt;The guard on the gate
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s one more piece, because a gate is only as good as its precondition.&lt;/p&gt;
&lt;p&gt;A &lt;code&gt;verify-main-plan&lt;/code&gt; job blocks the release MR from being mergeable unless the latest &lt;code&gt;main&lt;/code&gt; pipeline is green. You can&amp;rsquo;t cut a release, and therefore can&amp;rsquo;t apply, on top of a &lt;code&gt;main&lt;/code&gt; whose plan didn&amp;rsquo;t even succeed. The human gate has its own gate: the thing you&amp;rsquo;re about to merge has to be standing on a known-good plan before you&amp;rsquo;re allowed to merge it.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;The risk in infrastructure-as-code is the gap between the plan a human reviewed and the change that runs, because a re-plan at apply time is a different computation from the one that was approved.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;infra&lt;/code&gt; closes it twice over. &lt;code&gt;tofu-plan&lt;/code&gt; saves the plan as a &lt;code&gt;tfplan.cache&lt;/code&gt; artifact and renders it as a merge-request widget; &lt;code&gt;tofu-apply&lt;/code&gt; applies that exact artifact, and OpenTofu rejects it outright if the state has moved underneath it. And applying is gated on a human merging a &lt;code&gt;releaser-pleaser&lt;/code&gt; release request, not on a push, with a &lt;code&gt;verify-main-plan&lt;/code&gt; check making sure that request can only be merged on top of a green plan. What gets applied is what was reviewed, when a person decided it should be.&lt;/p&gt;</description></item><item><title>CI you include, not copy</title><link>https://phpboyscout.uk/ci-you-include-not-copy/</link><pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/ci-you-include-not-copy/</guid><description>&lt;img src="https://phpboyscout.uk/ci-you-include-not-copy/cover-ci-you-include-not-copy.png" alt="Featured image of post CI you include, not copy" /&gt;&lt;p&gt;Every infrastructure repo runs the same CI: lint the OpenTofu, scan it, validate it, plan, apply. The first repo, you write that &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; by hand. The second, you copy it. By the third, you&amp;rsquo;ve got three copies of the same pipeline quietly drifting apart, which is the exact problem you&amp;rsquo;d never tolerate in application code. The &lt;code&gt;cicd&lt;/code&gt; repo is the fix, and it&amp;rsquo;s just the library-first instinct pointed at the pipeline.&lt;/p&gt;
&lt;h2 id="the-gitlab-ciyml-you-keep-copying"&gt;The &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; you keep copying
&lt;/h2&gt;&lt;p&gt;The infrastructure repos in this series all run the same CI gate jobs: format and validate the OpenTofu, lint it, scan it for security issues and secrets, and on the deploy side, plan and apply.&lt;/p&gt;
&lt;p&gt;The first repo, you write that &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; by hand. The second repo needs the same jobs, so you copy it. The third repo, you copy it again. Now there are three copies of the same pipeline, and they do what copies always do. They drift. A fix you make in one repo&amp;rsquo;s CI doesn&amp;rsquo;t reach the other two. A tightened scan rule lands in the repo you were working in and nowhere else. It&amp;rsquo;s the copy-paste problem, exactly as it shows up in application code, just written in YAML and therefore that bit easier to pretend isn&amp;rsquo;t code.&lt;/p&gt;
&lt;h2 id="gitlab-has-a-feature-for-exactly-this"&gt;GitLab has a feature for exactly this
&lt;/h2&gt;&lt;p&gt;GitLab CI/CD Components are the answer to that problem. A component is a reusable, versioned piece of pipeline that you publish, and other projects pull in with an &lt;code&gt;include:&lt;/code&gt; pinned to a version:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;include&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;component&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;gitlab.com/phpboyscout/cicd/tofu-lint@v0.5.0&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s a library import, for pipeline. The component has a defined interface, a version, and a home in GitLab&amp;rsquo;s CI/CD Catalog. A consuming repo includes it instead of carrying its own copy, and when the component improves, the consumer moves a version pin rather than re-copying YAML.&lt;/p&gt;
&lt;h2 id="why-a-monorepo-of-components"&gt;Why a monorepo of components
&lt;/h2&gt;&lt;p&gt;The &lt;a class="link" href="https://gitlab.com/phpboyscout/cicd/-/tree/v0.5.0/templates" target="_blank" rel="noopener"
 &gt;&lt;code&gt;cicd&lt;/code&gt; repo&lt;/a&gt; holds all of the components together: &lt;code&gt;tofu-lint&lt;/code&gt;, &lt;code&gt;tofu-security&lt;/code&gt;, &lt;code&gt;tofu-validate&lt;/code&gt;, &lt;code&gt;tofu-plan&lt;/code&gt;, &lt;code&gt;tofu-apply&lt;/code&gt;, and more. One project, not one project per component.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s a deliberate call, and the reason is how GitLab versions things. A version is a tag, and a tag belongs to a &lt;em&gt;project&lt;/em&gt;. A component&amp;rsquo;s version is its project&amp;rsquo;s tag. So a monorepo of components, versioned together as one tag stream, is the natural unit: a consumer pins &lt;code&gt;@v0.5.0&lt;/code&gt; and gets a known-good &lt;em&gt;set&lt;/em&gt; of components that were tested together, rather than juggling a separate version for each one.&lt;/p&gt;
&lt;h2 id="authoring-discipline"&gt;Authoring discipline
&lt;/h2&gt;&lt;p&gt;A component is a file under &lt;code&gt;templates/&lt;/code&gt;, and it opens with a &lt;code&gt;spec: inputs:&lt;/code&gt; block: the typed inputs, their defaults, the component&amp;rsquo;s public interface.&lt;/p&gt;
&lt;p&gt;The discipline that keeps the library usable is that a component must be consumer-agnostic. It never hardcodes a token, and it never names a particular consumer&amp;rsquo;s variable. Inputs have sensible defaults, and a consuming repo overrides them. A component that reaches out and assumes something about the repo including it is a component that works in one repo and surprises the next. An authoring guide in the repo keeps that consistent across everyone who adds a component.&lt;/p&gt;
&lt;h2 id="the-self-test-you-cannot-fully-write"&gt;The self-test you cannot fully write
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;cicd&lt;/code&gt; repo tests its own components with a self-test pipeline. It&amp;rsquo;s worth knowing where that self-test stops.&lt;/p&gt;
&lt;p&gt;When a repo tests its own components by running them in child pipelines, GitLab masks &lt;code&gt;$CI_PIPELINE_SOURCE&lt;/code&gt; as &lt;code&gt;parent_pipeline&lt;/code&gt;. A component&amp;rsquo;s &lt;code&gt;rules:&lt;/code&gt;, which often branch on the pipeline source to behave differently for a merge request than for a branch or a tag, therefore can&amp;rsquo;t be exercised honestly by the self-test: the source they&amp;rsquo;d branch on has been flattened. The self-test covers what it can, and the component &lt;code&gt;rules:&lt;/code&gt; are, in the end, validated by real consumers using them for real. That&amp;rsquo;s a genuine limit, and naming it is better than pretending the self-test proves more than it does. (It&amp;rsquo;s also, not coincidentally, the exact &lt;code&gt;rules:&lt;/code&gt; quirk that bit me in &lt;a class="link" href="https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/" &gt;one of the two bugs&lt;/a&gt; I closed the series with.)&lt;/p&gt;
&lt;h2 id="the-same-instinct-again"&gt;The same instinct, again
&lt;/h2&gt;&lt;p&gt;This blog keeps circling the same instinct. go-tool-base exists because the same CLI scaffolding kept getting rewritten, so it was &lt;a class="link" href="https://phpboyscout.uk/introducing-go-tool-base/" &gt;extracted into a library&lt;/a&gt;. &lt;code&gt;cicd&lt;/code&gt; is that instinct pointed at the pipeline: the same gate jobs kept getting copied between repos, so they were extracted into a versioned, included library.&lt;/p&gt;
&lt;p&gt;Stop copy-pasting. Publish, version, include. It&amp;rsquo;s true for CLI code, and it turns out to be just as true for the YAML that builds and ships it.&lt;/p&gt;
&lt;h2 id="the-gist"&gt;The gist
&lt;/h2&gt;&lt;p&gt;Every infrastructure repo needs the same CI, and copying the &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; between them produces copies that drift apart. GitLab CI/CD Components fix it: reusable, versioned pipeline that a repo &lt;code&gt;include:&lt;/code&gt;s and pins, instead of carrying its own copy.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;cicd&lt;/code&gt; is a monorepo of those components, versioned together as one tag stream, because GitLab tags a project and a component&amp;rsquo;s version is its project&amp;rsquo;s tag. Components are authored consumer-agnostic, with typed &lt;code&gt;spec: inputs:&lt;/code&gt; and no hardcoded assumptions, and their &lt;code&gt;rules:&lt;/code&gt; are validated by real use because the self-test can&amp;rsquo;t see the pipeline source. It&amp;rsquo;s the library-first instinct, applied to CI: publish it once, include it everywhere, fix it in one place.&lt;/p&gt;</description></item><item><title>One image for the whole toolchain</title><link>https://phpboyscout.uk/one-image-for-the-whole-toolchain/</link><pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/one-image-for-the-whole-toolchain/</guid><description>&lt;img src="https://phpboyscout.uk/one-image-for-the-whole-toolchain/cover-one-image-for-the-whole-toolchain.png" alt="Featured image of post One image for the whole toolchain" /&gt;&lt;p&gt;Every CI gate job across the infrastructure repos reaches for the same pile of tools: OpenTofu, tflint, trivy, checkov, gitleaks, terraform-docs, the AWS CLI. Installing that pile per job is both slow and quietly dangerous, because nothing pins it consistently. &lt;code&gt;infra-tools&lt;/code&gt; is the obvious fix (one image, one source of truth for versions), but two of its build decisions are less obvious and worth a look: it publishes with &lt;code&gt;crane&lt;/code&gt; instead of a second build, and it deliberately lets its own vulnerability scan fail.&lt;/p&gt;
&lt;h2 id="the-same-pile-of-tools-in-every-repo"&gt;The same pile of tools, in every repo
&lt;/h2&gt;&lt;p&gt;Every infrastructure repo in this series runs the same CI gate jobs: format and validate the OpenTofu, lint it, scan it for security problems and secrets, check the docs. Those jobs need a specific set of tools, and it&amp;rsquo;s the same set in every repo.&lt;/p&gt;
&lt;p&gt;Install them per job and you pay twice. You pay in time, because every pipeline downloads and installs the whole set again. And you pay in drift, because unless every repo pins every tool identically, the repos slowly diverge on which version of trivy or tflint they actually run, and a check that passes in one repo fails in another for no reason anyone can see.&lt;/p&gt;
&lt;h2 id="one-image-one-source-of-truth"&gt;One image, one source of truth
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;infra-tools&lt;/code&gt; is the answer: a single Debian-based container image with the whole toolchain baked in. Every CI job in every repo uses it with one &lt;code&gt;image:&lt;/code&gt; line.&lt;/p&gt;
&lt;p&gt;The real value isn&amp;rsquo;t the convenience. It&amp;rsquo;s that the image is the &lt;em&gt;one place&lt;/em&gt; tool versions are pinned. The Go-based tools are pinned in a &lt;code&gt;mise.toml&lt;/code&gt;. &lt;code&gt;checkov&lt;/code&gt;, which has no mise plugin, is pinned in a requirements file installed with pipx. The AWS CLI is pinned by a build argument. Three mechanisms, because the tools come from three kinds of source, but one image, and every pin wired to Renovate so a version bump arrives as a reviewable pull request. There&amp;rsquo;s exactly one answer to &amp;ldquo;what version of trivy does the toolchain use&amp;rdquo;, and it lives here.&lt;/p&gt;
&lt;h2 id="publishing-with-crane-not-a-second-build"&gt;Publishing with crane, not a second build
&lt;/h2&gt;&lt;p&gt;A build-pipeline detail that took a real bug to discover.&lt;/p&gt;
&lt;p&gt;The pipeline builds the image with kaniko, which builds images without a privileged Docker daemon, something that matters a great deal on shared CI runners. Then it scans the image, then it publishes it.&lt;/p&gt;
&lt;p&gt;The obvious way to write the publish stage is &amp;ldquo;build the image and push it&amp;rdquo;. But kaniko has no mode for &amp;ldquo;just push this tarball I already built&amp;rdquo;. A second kaniko invocation re-executes the entire Dockerfile from the top, including a second &lt;code&gt;mise install&lt;/code&gt;, which makes a fresh round of calls to GitHub&amp;rsquo;s API to fetch tools. GitHub&amp;rsquo;s anonymous API limit is low and shared by IP, so on a CI runner that second install reliably trips a &lt;code&gt;403&lt;/code&gt; rate-limit. (Yes, another &lt;code&gt;403&lt;/code&gt;. They do get everywhere.)&lt;/p&gt;
&lt;p&gt;So the publish stage doesn&amp;rsquo;t rebuild. It &lt;a class="link" href="https://gitlab.com/phpboyscout/images/infra-tools/-/blob/v0.2.0/.gitlab-ci.yml#L115" target="_blank" rel="noopener"
 &gt;uses &lt;code&gt;crane&lt;/code&gt;&lt;/a&gt; to push the exact image tarball the build stage already produced. The image is built once. And because the published bytes are the same bytes the scan stage scanned, there&amp;rsquo;s no gap between &amp;ldquo;the image we checked&amp;rdquo; and &amp;ldquo;the image we shipped&amp;rdquo;.&lt;/p&gt;
&lt;h2 id="soft-failing-the-scanner-on-purpose"&gt;Soft-failing the scanner on purpose
&lt;/h2&gt;&lt;p&gt;The decision that looks wrong until you see the reasoning: the pipeline scans the image with trivy, and trivy is allowed to fail without failing the pipeline.&lt;/p&gt;
&lt;p&gt;A vulnerability scanner that doesn&amp;rsquo;t gate the build sounds like a scanner switched off. It isn&amp;rsquo;t. It&amp;rsquo;s a scanner pointed at something it can&amp;rsquo;t helpfully gate.&lt;/p&gt;
&lt;p&gt;The tools in the image are prebuilt Go binaries. trivy inspects them, reads the version of the Go runtime each was compiled with, and reports every known CVE in that Go runtime. Those findings are real, but they aren&amp;rsquo;t &lt;em&gt;mine&lt;/em&gt; to fix. The only fix is the upstream tool rebuilding itself against a patched Go. With seven such tools in the image, at any given moment one of them is usually a little behind on its Go version.&lt;/p&gt;
&lt;p&gt;A hard gate would mean the image becomes unpublishable whenever any single upstream lags, over a CVE in code I don&amp;rsquo;t own and can&amp;rsquo;t patch. That&amp;rsquo;s not a security control; it&amp;rsquo;s a way to be unable to ship. So the scan is &lt;code&gt;allow_failure&lt;/code&gt;. The findings stay fully visible, and the residual count is genuinely useful as a &lt;em&gt;metric&lt;/em&gt; for how far behind upstream the toolchain has drifted. It just doesn&amp;rsquo;t block shipping an image whose only &amp;ldquo;vulnerabilities&amp;rdquo; are other people&amp;rsquo;s build timelines.&lt;/p&gt;
&lt;h2 id="what-it-comes-down-to"&gt;What it comes down to
&lt;/h2&gt;&lt;p&gt;The infrastructure repos all run the same CI gate jobs, needing the same tools, so &lt;code&gt;infra-tools&lt;/code&gt; bakes the whole toolchain into one image and pins every version in one place, wired to Renovate.&lt;/p&gt;
&lt;p&gt;Two build choices are worth copying. The publish stage uses &lt;code&gt;crane&lt;/code&gt; to push the already-built, already-scanned tarball, because a second kaniko build would re-run &lt;code&gt;mise install&lt;/code&gt; and hit GitHub&amp;rsquo;s anonymous rate limit, and because pushing the scanned bytes means shipping exactly what was checked. And the trivy scan is deliberately &lt;code&gt;allow_failure&lt;/code&gt;, because it reports Go-runtime CVEs in prebuilt upstream binaries that no change to this repo can fix, so a hard gate would only make the image unshippable over someone else&amp;rsquo;s lag.&lt;/p&gt;</description></item><item><title>A 403 you can't fix in IAM</title><link>https://phpboyscout.uk/a-403-you-cant-fix-in-iam/</link><pubDate>Thu, 14 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/a-403-you-cant-fix-in-iam/</guid><description>&lt;img src="https://phpboyscout.uk/a-403-you-cant-fix-in-iam/cover-a-403-you-cant-fix-in-iam.png" alt="Featured image of post A 403 you can't fix in IAM" /&gt;&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/no-access-keys-in-ci/" &gt;The OIDC post&lt;/a&gt; explained the handshake that lets a GitLab pipeline deploy to AWS with no stored key. This is the story of the first time I got it wrong, and spent an afternoon fixing the wrong thing. The error was a flat 403 from AWS, and the maddening part is that no amount of editing the IAM policy was ever going to fix it.&lt;/p&gt;
&lt;h2 id="a-403-on-the-first-real-run"&gt;A 403 on the first real run
&lt;/h2&gt;&lt;p&gt;The OIDC post covered the handshake: GitLab CI mints a signed token, AWS exchanges it for short-lived credentials against a role whose trust policy names the pipeline. During the GitLab migration I wired exactly that up for the &lt;code&gt;infra&lt;/code&gt; repo, including a trust policy condition meant to let merge-request pipelines run a plan.&lt;/p&gt;
&lt;p&gt;The first merge request that should have triggered &lt;code&gt;tofu-plan&lt;/code&gt; didn&amp;rsquo;t run it. The job failed, and the error from AWS was a flat &lt;code&gt;AccessDenied&lt;/code&gt;. A 403.&lt;/p&gt;
&lt;h2 id="the-instinct-and-why-it-wastes-an-afternoon"&gt;The instinct, and why it wastes an afternoon
&lt;/h2&gt;&lt;p&gt;The instinct on an IAM 403 is immediate and almost always right: the policy&amp;rsquo;s wrong, so go and edit the policy. Tighten the condition. Loosen the condition. Check the wildcard. Re-read the &lt;code&gt;sub&lt;/code&gt; pattern character by character.&lt;/p&gt;
&lt;p&gt;All of that was wasted, and it was wasted for a reason that took me far too long to see. The trust policy wasn&amp;rsquo;t matching the &lt;em&gt;wrong&lt;/em&gt; value. It was matching a value that &lt;em&gt;does not exist&lt;/em&gt;. No amount of editing a condition makes it match a thing that&amp;rsquo;s never present.&lt;/p&gt;
&lt;h2 id="what-is-actually-in-the-token"&gt;What is actually in the token
&lt;/h2&gt;&lt;p&gt;GitLab&amp;rsquo;s OIDC token has a &lt;code&gt;sub&lt;/code&gt; claim that encodes the pipeline&amp;rsquo;s context, and part of that encoding is a &lt;code&gt;ref_type&lt;/code&gt;. I&amp;rsquo;d assumed &lt;code&gt;ref_type&lt;/code&gt; could be &lt;code&gt;branch&lt;/code&gt;, &lt;code&gt;tag&lt;/code&gt;, or &lt;code&gt;mr&lt;/code&gt;, because a pipeline can certainly be a branch pipeline, a tag pipeline, or a merge-request pipeline. So the trust policy, for the plan job, matched a &lt;code&gt;sub&lt;/code&gt; containing &lt;code&gt;ref_type:mr&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;That assumption was wrong. GitLab&amp;rsquo;s &lt;code&gt;ref_type&lt;/code&gt; is &lt;code&gt;branch&lt;/code&gt; or &lt;code&gt;tag&lt;/code&gt;. That&amp;rsquo;s the entire set. There is no &lt;code&gt;mr&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A merge-request pipeline doesn&amp;rsquo;t run against a merge-request ref. It runs against the source &lt;em&gt;branch&lt;/em&gt;. So its token&amp;rsquo;s &lt;code&gt;sub&lt;/code&gt; carries &lt;code&gt;ref_type:branch&lt;/code&gt;, like any other branch pipeline. The trust policy condition asked for &lt;code&gt;ref_type:mr&lt;/code&gt;, GitLab never puts &lt;code&gt;mr&lt;/code&gt; in a token, the condition was therefore never true, and every merge-request pipeline got a 403. Forever, until the policy stopped asking for a claim that isn&amp;rsquo;t real.&lt;/p&gt;
&lt;h2 id="the-fix-and-the-lesson-worth-more-than-the-fix"&gt;The fix, and the lesson worth more than the fix
&lt;/h2&gt;&lt;p&gt;The fix is small once it&amp;rsquo;s visible: match &lt;code&gt;ref_type:branch&lt;/code&gt; and narrow it down by branch name or project path instead. An afternoon of policy edits, and the actual change is one word.&lt;/p&gt;
&lt;p&gt;The lesson is the part worth keeping. When an OIDC trust fails, the useful question is never &amp;ldquo;is my policy clever enough&amp;rdquo;. It&amp;rsquo;s &amp;ldquo;what&amp;rsquo;s &lt;em&gt;actually in the token&lt;/em&gt;&amp;rdquo;. An OIDC trust policy can only ever match the claims the identity provider genuinely asserts, and the gap between what a provider asserts and what you &lt;em&gt;assumed&lt;/em&gt; it asserts is precisely where this class of bug lives.&lt;/p&gt;
&lt;p&gt;So the move, when an OIDC handshake 403s, is to get hold of a real token and decode it. Look at the actual &lt;code&gt;sub&lt;/code&gt;, the actual claims, the actual values. Match what&amp;rsquo;s there. A 403 that survives every sensible edit to the policy is usually not a policy that&amp;rsquo;s too loose or too strict. It&amp;rsquo;s a policy matching a claim that was never going to be in the token.&lt;/p&gt;
&lt;h2 id="the-habit-it-left-behind"&gt;The habit it left behind
&lt;/h2&gt;&lt;p&gt;I wired an OIDC trust policy to let merge-request pipelines plan, by matching a &lt;code&gt;sub&lt;/code&gt; claim with &lt;code&gt;ref_type:mr&lt;/code&gt;. The first real merge request got a 403, and no edit to the policy fixed it, because GitLab&amp;rsquo;s &lt;code&gt;ref_type&lt;/code&gt; is only ever &lt;code&gt;branch&lt;/code&gt; or &lt;code&gt;tag&lt;/code&gt;. A merge-request pipeline runs on a branch ref, so the &lt;code&gt;mr&lt;/code&gt; value the policy demanded was never in any token.&lt;/p&gt;
&lt;p&gt;The fix was one word. The habit it left behind is the valuable bit: when an OIDC trust fails, stop editing the policy and go and read a real token. A trust policy can only match what the provider actually asserts, and &amp;ldquo;what I assumed it asserts&amp;rdquo; is where the 403 was hiding the whole time. (If this shape of bug feels familiar by the end of the series, that&amp;rsquo;s not an accident: I &lt;a class="link" href="https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/" &gt;come back to it&lt;/a&gt; with two more from exactly the same family.)&lt;/p&gt;</description></item><item><title>Why go-tool-base left GitHub for GitLab</title><link>https://phpboyscout.uk/why-we-left-github-for-gitlab/</link><pubDate>Mon, 11 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/why-we-left-github-for-gitlab/</guid><description>&lt;img src="https://phpboyscout.uk/why-we-left-github-for-gitlab/cover-why-we-left-github-for-gitlab.png" alt="Featured image of post Why go-tool-base left GitHub for GitLab" /&gt;&lt;p&gt;A botched version bump made me stop and actually look at where go-tool-base lived, and I didn&amp;rsquo;t much like what I saw. GitHub had spent months quietly falling over, and when Mitchell Hashimoto (GitHub user #1299, no less) publicly walked Ghostty off the platform, it stopped feeling like just my problem. I&amp;rsquo;ve been a GitLab fan for years, so the move was less a leap and more an overdue nudge. This is the &lt;em&gt;why&lt;/em&gt;, not the &lt;em&gt;how&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="it-started-with-a-wrong-number"&gt;It started with a wrong number
&lt;/h2&gt;&lt;p&gt;Every migration has a trigger, and mine was embarrassingly small. A commit landed on &lt;code&gt;main&lt;/code&gt; carrying a &lt;code&gt;BREAKING CHANGE:&lt;/code&gt; footer it didn&amp;rsquo;t really deserve. Semantic-release did exactly what it&amp;rsquo;s told to do with that footer: it cut a major version. go-tool-base lurched from the v1 line straight to v2.0.0, and a chain of things that keyed off the version went sideways with it.&lt;/p&gt;
&lt;p&gt;It was fixable. It wasn&amp;rsquo;t a disaster. But it was the kind of small, stupid breakage that makes you stop and actually &lt;em&gt;look&lt;/em&gt; at your setup instead of just patching it and moving on. And when I looked, the version bump wasn&amp;rsquo;t the thing that bothered me. It was everything around it.&lt;/p&gt;
&lt;h2 id="the-platform-had-been-quietly-failing"&gt;The platform had been quietly failing
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;d been losing time to GitHub for months. Not dramatically. No single outage you&amp;rsquo;d write home about, just a steady drip of Actions queues that wouldn&amp;rsquo;t drain, pull requests that wouldn&amp;rsquo;t merge, the occasional morning where the thing simply wasn&amp;rsquo;t there. You absorb it. You re-run the job. You make a coffee and try again. You tell yourself it&amp;rsquo;s a blip.&lt;/p&gt;
&lt;p&gt;The trouble with a steady drip is that you stop counting it. It becomes weather.&lt;/p&gt;
&lt;h2 id="the-canary-left-the-mine"&gt;The canary left the mine
&lt;/h2&gt;&lt;p&gt;Then, in late April, &lt;a class="link" href="https://mitchellh.com/" target="_blank" rel="noopener"
 &gt;Mitchell Hashimoto&lt;/a&gt; (co-founder of HashiCorp, creator of Vagrant, Terraform and the Ghostty terminal) published &lt;a class="link" href="https://mitchellh.com/writing/ghostty-leaving-github" target="_blank" rel="noopener"
 &gt;&lt;em&gt;Ghostty Is Leaving GitHub&lt;/em&gt;&lt;/a&gt;, and &lt;em&gt;The Register&lt;/em&gt; &lt;a class="link" href="https://www.theregister.com/software/2026/04/29/mitchell-hashimoto-says-github-no-longer-for-serious-work/5227505" target="_blank" rel="noopener"
 &gt;picked it up&lt;/a&gt; a day later under the headline &amp;ldquo;GitHub &amp;rsquo;no longer a place for serious work&amp;rsquo;&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;This is not a man with a casual relationship to GitHub. He&amp;rsquo;s, by his own account, user #1299, joined February 2008. He called it &amp;ldquo;the place that has made me the most happy&amp;rdquo;. And he still wrote this:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;This is no longer a place for serious work if it just blocks you out for hours per day, every day.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;The detail that landed hardest for me wasn&amp;rsquo;t a quote, it was a habit. He&amp;rsquo;d kept a journal for a month, marking an &amp;ldquo;X&amp;rdquo; on every day a GitHub outage had cost him working time. &lt;em&gt;Almost every day had an X.&lt;/em&gt; Reading that, I realised I&amp;rsquo;d been having the same month. I&amp;rsquo;d just never been disciplined enough to write it down. He&amp;rsquo;d turned my vague &amp;ldquo;it&amp;rsquo;s been flaky lately&amp;rdquo; into a row of crosses on a calendar.&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;I want to ship software and it doesn&amp;rsquo;t want me to ship software.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;When the person who&amp;rsquo;s been on the platform for eighteen years and &lt;em&gt;loves&lt;/em&gt; it says that out loud, it stops being your private grumble. It&amp;rsquo;s the canary, and the canary has stopped singing.&lt;/p&gt;
&lt;h2 id="why-gitlab-and-not-just-somewhere-else"&gt;Why GitLab, and not just &amp;ldquo;somewhere else&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;Being annoyed at GitHub is a reason to leave. It is not, on its own, a reason to pick a destination. The destination has to be a positive choice.&lt;/p&gt;
&lt;p&gt;For me GitLab was an easy one, because I&amp;rsquo;ve been a fan for years. Long enough, in fact, to have &lt;em&gt;also&lt;/em&gt; been a reliable grumbler about their pricing tiers, which is how you know it&amp;rsquo;s a real relationship and not a honeymoon. What I&amp;rsquo;ve always rated is the model: GitLab treats source hosting, CI/CD, the package registry, releases and Pages as &lt;em&gt;one integrated product&lt;/em&gt;, not a marketplace of bolted-on parts you assemble yourself.&lt;/p&gt;
&lt;p&gt;That integration is the actual prize. On the old setup, &amp;ldquo;CI&amp;rdquo; meant a folder of separate GitHub Actions workflow files, each pinned, each its own little world. On GitLab it&amp;rsquo;s a single &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/blob/5c78fc9/.gitlab-ci.yml#L7" target="_blank" rel="noopener"
 &gt;&lt;code&gt;.gitlab-ci.yml&lt;/code&gt; pipeline&lt;/a&gt; with proper stages (lint, test, security, docs, release) and the release stage talks to the built-in package registry and Pages without me wiring up a single external credential. The CI job that builds the project can authenticate to the things the project needs &lt;em&gt;because they&amp;rsquo;re the same platform&lt;/em&gt;.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a second-order benefit too. A migration is a rare licence to fix things you&amp;rsquo;d never otherwise touch. Moving gave me the cover to reset go-tool-base&amp;rsquo;s versioning cleanly (back to a sensible &lt;code&gt;v0.x&lt;/code&gt; line, the accidental &lt;code&gt;v2.0.0&lt;/code&gt; left behind as a cautionary tale) and to move the module path to its new home in one deliberate change rather than a thousand apologetic ones.&lt;/p&gt;
&lt;h2 id="what-im-not-going-to-claim"&gt;What I&amp;rsquo;m not going to claim
&lt;/h2&gt;&lt;p&gt;I&amp;rsquo;m not going to tell you GitHub is finished, or that GitLab never has a bad day, because it does, everyone does. This isn&amp;rsquo;t a teardown. GitHub gave go-tool-base a perfectly good home for its first year, and the archived mirror is still sitting there, read-only, pointing anyone who finds it at the new place.&lt;/p&gt;
&lt;p&gt;What changed is simpler than a grand verdict. The friction crossed a line, someone I respect said the quiet part loudly enough that I couldn&amp;rsquo;t keep filing it under &amp;ldquo;weather&amp;rdquo;, and the place I&amp;rsquo;d have moved to anyway was sitting right there with a better model. Sometimes the prudent move and the move you secretly wanted turn out to be the same move, and you just need a wrong version number to give you permission.&lt;/p&gt;
&lt;h2 id="boiling-it-down"&gt;Boiling it down
&lt;/h2&gt;&lt;p&gt;go-tool-base moved from GitHub to GitLab in May 2026. The proximate cause was a self-inflicted version-bump mess; the real cause was months of GitHub unreliability that I&amp;rsquo;d stopped consciously noticing until Mitchell Hashimoto&amp;rsquo;s very public departure named it for me. GitLab was a positive pick, not just an escape hatch: its integrated CI/CD, registry, releases and Pages are one product rather than a kit, and that integration is genuinely worth having. The migration also bought a clean versioning restart as a bonus.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;ve been absorbing a steady drip of friction and telling yourself it&amp;rsquo;s normal: try the calendar trick. Mark the X&amp;rsquo;s for a month. The page will tell you something you already half-know.&lt;/p&gt;</description></item><item><title>No access keys in CI</title><link>https://phpboyscout.uk/no-access-keys-in-ci/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/no-access-keys-in-ci/</guid><description>&lt;img src="https://phpboyscout.uk/no-access-keys-in-ci/cover-no-access-keys-in-ci.png" alt="Featured image of post No access keys in CI" /&gt;&lt;p&gt;A long-lived AWS access key, sitting in a CI system, is just about the single credential I&amp;rsquo;d most like to be rid of. It&amp;rsquo;s powerful, it never expires unless someone remembers to rotate it (nobody remembers to rotate it), and it lives in one of the most attractive targets in the whole supply chain. For infrastructure that&amp;rsquo;s eventually going to hold a release-signing key, it&amp;rsquo;s exactly the wrong place to start. So the &lt;code&gt;phpboyscout&lt;/code&gt; infrastructure has no AWS access key in CI at all. None.&lt;/p&gt;
&lt;h2 id="the-access-key-you-dont-want"&gt;The access key you don&amp;rsquo;t want
&lt;/h2&gt;&lt;p&gt;A CI pipeline that runs &lt;code&gt;tofu apply&lt;/code&gt; against AWS needs AWS credentials. The traditional way to give it some is an IAM user with an access key pair, pasted into the CI system as a masked variable.&lt;/p&gt;
&lt;p&gt;Look at what that key is. It&amp;rsquo;s long-lived: it works until someone remembers to rotate it, and rotating it is a chore, so mostly nobody does. It&amp;rsquo;s powerful: it can apply infrastructure, so it can do nearly anything. And it&amp;rsquo;s sitting in a CI system, which is one of the most attractive targets in your whole supply chain. You&amp;rsquo;ve taken your highest-value credential and stored a permanent copy of it in a place built for running automated jobs.&lt;/p&gt;
&lt;p&gt;For infrastructure that&amp;rsquo;s going to hold a release-signing key, that&amp;rsquo;s precisely the wrong starting point. So the &lt;code&gt;phpboyscout&lt;/code&gt; infrastructure has no AWS access key in CI at all. Not a well-guarded one. None.&lt;/p&gt;
&lt;h2 id="federation-instead-of-a-stored-secret"&gt;Federation instead of a stored secret
&lt;/h2&gt;&lt;p&gt;The replacement is OIDC federation, and the shape of it is worth walking through, because it&amp;rsquo;s genuinely different from &amp;ldquo;a secret, but better&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;A modern CI platform can mint an OIDC token. GitLab does this with an &lt;code&gt;id_tokens:&lt;/code&gt; block: at job time, GitLab issues a short-lived JSON Web Token, signed by GitLab, that asserts a set of facts. This is project X. This is pipeline Y. This is running on ref Z, of this type.&lt;/p&gt;
&lt;p&gt;AWS can consume that. The &lt;code&gt;sts:AssumeRoleWithWebIdentity&lt;/code&gt; call takes such a token and, if it satisfies an IAM role&amp;rsquo;s trust policy, returns short-lived AWS credentials for that role. The trust policy is where the control lives: it names GitLab as a trusted token issuer, and it constrains the token&amp;rsquo;s &lt;code&gt;sub&lt;/code&gt; claim so that only the specific project, and the specific refs, you intend can assume the role.&lt;/p&gt;
&lt;p&gt;Put it together: the pipeline asks GitLab for a token, hands it to AWS, and gets back credentials that last about an hour and are scoped to one role. Nothing long-lived is stored anywhere. The credential exists only for the job that needs it, and it can&amp;rsquo;t be stolen from a CI variable store, because it was never in one.&lt;/p&gt;
&lt;h2 id="two-halves-of-one-handshake"&gt;Two halves of one handshake
&lt;/h2&gt;&lt;p&gt;That handshake is built by two of the repos in this series, each owning one side.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://phpboyscout.uk/the-bootstrap-that-does-almost-nothing/" &gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt;&lt;/a&gt; builds the AWS half, in its &lt;a class="link" href="https://gitlab.com/phpboyscout/terraform-aws-bootstrap/-/blob/v0.2.0/modules/automation-iam/main.tf#L99" target="_blank" rel="noopener"
 &gt;&lt;code&gt;automation-iam&lt;/code&gt; module&lt;/a&gt;: it registers GitLab as an OIDC identity provider in the account, and it creates the automation role with the trust policy that decides which pipelines may assume it.&lt;/p&gt;
&lt;p&gt;The CI components build the consuming half: the &lt;code&gt;id_tokens:&lt;/code&gt; block that asks GitLab for the JWT, and then simply letting the AWS provider&amp;rsquo;s own credential chain perform the exchange. The pipeline doesn&amp;rsquo;t call &lt;code&gt;sts&lt;/code&gt; by hand. It presents the token; the SDK does the rest.&lt;/p&gt;
&lt;h2 id="the-gotcha-dont-set-a-profile"&gt;The gotcha: don&amp;rsquo;t set a profile
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s one quiet way to break this, and a stack can look completely correct while doing it.&lt;/p&gt;
&lt;p&gt;The AWS SDK finds credentials by walking a chain of sources in order. The web-identity path, the one that uses the OIDC token, is one link in that chain. It triggers off environment variables the CI sets up automatically.&lt;/p&gt;
&lt;p&gt;But if the &lt;code&gt;aws&lt;/code&gt; provider block has a hardcoded &lt;code&gt;profile = &amp;quot;...&amp;quot;&lt;/code&gt;, the SDK takes the &lt;em&gt;profile&lt;/em&gt; link of the chain instead, and never reaches the web-identity link. A &lt;code&gt;profile&lt;/code&gt; line is the sort of thing that ends up in a provider block from someone&amp;rsquo;s local development setup, where it&amp;rsquo;s exactly right. Committed and run in CI, it silently short-circuits the federation. The pipeline either fails to find credentials, or finds the wrong ones.&lt;/p&gt;
&lt;p&gt;The rule is simple once you know it: the provider block that runs in CI must not name a &lt;code&gt;profile&lt;/code&gt;. Leave the chain free to find the web identity. It&amp;rsquo;s the kind of bug that teaches you to be precise about &lt;em&gt;which&lt;/em&gt; link of the credential chain you&amp;rsquo;re actually relying on.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;Giving CI an AWS access key means storing your most powerful, longest-lived credential in one of your most exposed systems. OIDC federation removes it entirely. The CI platform mints a short-lived signed token, AWS exchanges it via &lt;code&gt;AssumeRoleWithWebIdentity&lt;/code&gt; for hour-long credentials against a role whose trust policy names the exact pipeline, and nothing permanent is stored.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;terraform-aws-bootstrap&lt;/code&gt; builds the AWS side, the identity provider and the trust policy; the CI components build the consuming side, the token request. The one trap is a hardcoded &lt;code&gt;profile&lt;/code&gt; in the provider block, which short-circuits the SDK&amp;rsquo;s credential chain before it reaches the web-identity path. Get that right, and a pipeline deploys to AWS as a verifiable, short-lived identity, with no key to steal.&lt;/p&gt;</description></item><item><title>OpenSSF Scorecard graded my supply chain</title><link>https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</link><pubDate>Tue, 14 Apr 2026 00:00:00 +0000</pubDate><guid>https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/</guid><description>&lt;img src="https://phpboyscout.uk/openssf-scorecard-graded-my-supply-chain/cover-openssf-scorecard-graded-my-supply-chain.png" alt="Featured image of post OpenSSF Scorecard graded my supply chain" /&gt;&lt;p&gt;I turned OpenSSF Scorecard on expecting a pat on the head. go-tool-base is a security-minded project, I&amp;rsquo;m careful, surely the robot would agree. The robot did not agree. It handed back a report card with a fair bit of red ink, and the most pointed finding on it wasn&amp;rsquo;t about my code at all. It was about me.&lt;/p&gt;
&lt;h2 id="a-linter-for-the-things-you-dont-call-code"&gt;A linter for the things you don&amp;rsquo;t call code
&lt;/h2&gt;&lt;p&gt;Scorecard is an automated set of checks that grades a repository&amp;rsquo;s supply-chain hygiene: are your CI dependencies pinned, are your workflow tokens least-privilege, is your branch protected, do commits get reviewed. It&amp;rsquo;s a linter, but pointed at the part of the project you don&amp;rsquo;t usually think of as code, the build and release machinery and the practices around them. And like any good linter, its value is mostly in catching the things you&amp;rsquo;d swear you&amp;rsquo;d already got right.&lt;/p&gt;
&lt;p&gt;Three of its findings were worth the price of admission on their own.&lt;/p&gt;
&lt;h2 id="pin-the-actions-you-dont-control"&gt;Pin the actions you don&amp;rsquo;t control
&lt;/h2&gt;&lt;p&gt;The first was about how go-tool-base&amp;rsquo;s GitHub Actions referenced other actions. Like nearly everyone, I&amp;rsquo;d written &lt;code&gt;uses: actions/checkout@v6&lt;/code&gt;. Scorecard doesn&amp;rsquo;t like that, and it&amp;rsquo;s right not to.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;@v6&lt;/code&gt; is a &lt;em&gt;tag&lt;/em&gt;, and a tag is mutable. Whoever controls that action can move &lt;code&gt;v6&lt;/code&gt; to point at different code tomorrow, and your CI will pick it up silently on the next run. For an action that runs in a job holding your repository token, that&amp;rsquo;s a supply-chain hole the width of a barn door: compromise the tag, compromise every pipeline that trusts it. The fix is to pin to an immutable commit SHA, with the human-readable version left as a comment, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/5500b92" target="_blank" rel="noopener"
 &gt;which is exactly what I changed&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;- &lt;span class="w"&gt; &lt;/span&gt;- &lt;span class="nt"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/setup-go@v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;+ - uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;+ - uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c"&gt;# v6&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Now the action is frozen at bytes I reviewed. Dependabot still bumps the SHA when a real new version lands, so I get updates as reviewable pull requests rather than as silent tag movements. The pin doesn&amp;rsquo;t stop me updating. It stops me updating &lt;em&gt;without noticing&lt;/em&gt;.&lt;/p&gt;
&lt;h2 id="give-the-workflow-token-the-least-it-can-do"&gt;Give the workflow token the least it can do
&lt;/h2&gt;&lt;p&gt;The second finding was about permissions. My workflow declared its token permissions at the top, once, for the whole file:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;contents&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;security-events&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;id-token&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;write&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;That reads as careful, and it&amp;rsquo;s still too broad, because top-level permissions apply to &lt;em&gt;every job in the workflow&lt;/em&gt;. A job that only needs to read the repo is now also holding &lt;code&gt;id-token: write&lt;/code&gt; and &lt;code&gt;security-events: write&lt;/code&gt;, for no reason other than that some &lt;em&gt;other&lt;/em&gt; job in the same file needed them. Scorecard rejects exactly this, and the fix is to default the whole workflow to read-only and grant write narrowly, &lt;a class="link" href="https://gitlab.com/phpboyscout/go-tool-base/-/commit/c41e557" target="_blank" rel="noopener"
 &gt;in the job that actually needs it&lt;/a&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-yaml" data-lang="yaml"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="nt"&gt;permissions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="l"&gt;read-all&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;Write permissions moved down into the single job that uses them. It&amp;rsquo;s the same least-privilege instinct that runs through everything else in these projects, just applied to a CI token instead of an IAM role: a credential should be able to do the one thing it&amp;rsquo;s for, and nothing else, no matter how convenient the broad grant looked.&lt;/p&gt;
&lt;h2 id="the-finding-that-was-about-me"&gt;The finding that was about me
&lt;/h2&gt;&lt;p&gt;The third one stung, because there was no YAML to fix. Scorecard&amp;rsquo;s Code-Review check scores how consistently changes are reviewed before they land, and mine scored badly for the most embarrassing possible reason: I&amp;rsquo;d set up branch protection on &lt;code&gt;main&lt;/code&gt;, and then, being the solo maintainer in a hurry, I&amp;rsquo;d been merrily bypassing it to push straight to &lt;code&gt;main&lt;/code&gt; whenever it suited me.&lt;/p&gt;
&lt;p&gt;So I had a rule, written down and enforced by the platform, that I was personally and routinely ignoring. Scorecard noticed, totted up the unreviewed commits, and graded me on it. There&amp;rsquo;s something properly humbling about a robot reading your git history and pointing out that the person breaking your security policy most often is &lt;em&gt;you&lt;/em&gt;. The fix wasn&amp;rsquo;t code. It was going through a pull request like everyone else, even when &amp;ldquo;everyone else&amp;rdquo; is just me on a different day.&lt;/p&gt;
&lt;h2 id="the-bottom-line"&gt;The bottom line
&lt;/h2&gt;&lt;p&gt;OpenSSF Scorecard is a linter for your supply chain, and like any linter it&amp;rsquo;s most useful when it tells you something you were sure you&amp;rsquo;d already handled. It dinged go-tool-base for referencing actions by mutable tag instead of pinned SHA, for granting workflow-token write permissions at the top level where every job inherited them, and for a Code-Review score I&amp;rsquo;d earned fair and square by bypassing my own branch protection.&lt;/p&gt;
&lt;p&gt;The first two were quick, satisfying changes with a clear security story. The third was the one that stuck, because the tool I&amp;rsquo;d added to grade the project ended up grading the maintainer, and was entirely right to. Turn it on. Brace yourself a little.&lt;/p&gt;</description></item></channel></rss>