<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>PHP Boy Scout — CI/CD, and getting things released</title><link>https://phpboyscout.uk/topics/ci-cd/</link><description>Shared pipeline components instead of copied YAML, one pinned toolchain image, and releases that go through review like any other change.</description><generator>Hugo</generator><language>en-GB</language><copyright>Matt Cockayne</copyright><lastBuildDate>Sun, 02 Aug 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://phpboyscout.uk/topics/ci-cd/index.xml" rel="self" type="application/rss+xml"/><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 isPermaLink="true">https://phpboyscout.uk/ci-you-include-not-copy/</guid><category>cicd</category><category>infrastructure</category><category>Pioneering</category><description>Copied .gitlab-ci.yml files drift out of sync. How GitLab CI/CD Components let a repo include and version-pin a shared pipeline instead.</description><content:encoded>&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;</content:encoded></item><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 isPermaLink="true">https://phpboyscout.uk/stop-installing-the-same-tools-on-every-pipeline-job/</guid><category>cicd</category><category>Pioneering</category><description>Nearly every CI job began by fetching and compiling the same tools. Baking them into one image instead, and what that saved per pipeline.</description><content:encoded>&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;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/one-image-for-the-whole-toolchain/</guid><category>cicd</category><category>infrastructure</category><category>Pioneering</category><description>Baking OpenTofu, tflint and trivy into a single version-pinned CI image, and publishing the scanned tarball with crane to avoid a kaniko rebuild.</description><content:encoded>&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;</content:encoded></item><item><title>A release is just another merge request</title><link>https://phpboyscout.uk/a-release-is-just-another-merge-request/</link><pubDate>Fri, 31 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/a-release-is-just-another-merge-request/</guid><category>cicd</category><category>Pioneering</category><description>Moving off tag-on-merge releases, where a release is a side effect of merging, to a model where the release is itself a reviewable change.</description><content:encoded>&lt;p&gt;For a long time my releases happened by accident. Not literally, but close enough. The setup was tag-on-merge, the semantic-release way: you merge to the release branch, the tooling reads your commits, works out the next version, and a tag pops out the other end. A release happens. You didn&amp;rsquo;t &lt;em&gt;do&lt;/em&gt; a release, exactly. You merged a thing, and a release fell out of it.&lt;/p&gt;
&lt;p&gt;That feels great right up until you ask an awkward question: where, in that flow, do the tests run?&lt;/p&gt;
&lt;p&gt;Because a release that&amp;rsquo;s a side-effect of a merge rides whatever pipeline that merge triggers, which is the main-branch pipeline. And your quality gates, the lint and the tests and the security scans, are then in an uncomfortable spot. Either they run on that same main-branch pipeline, which means they fire &lt;em&gt;after&lt;/em&gt; the merge that already triggered the release, and you&amp;rsquo;re checking the work after you&amp;rsquo;ve shipped it. Or they ran back on the merge request and you&amp;rsquo;re trusting that, hoping nobody merged something red, hoping the MR pipeline and the main pipeline actually agree about what &amp;ldquo;passing&amp;rdquo; means. It&amp;rsquo;s closing the stable door a moment after the horse has bolted, been packaged, tagged, and handed to the public.&lt;/p&gt;
&lt;p&gt;I lived with that longer than I&amp;rsquo;d like, because it mostly works. Most merges are fine. The gates mostly did run on the MR. &amp;ldquo;Mostly&amp;rdquo; is doing an awful lot of load-bearing in those sentences, though, and &amp;ldquo;mostly&amp;rdquo; is not what you want underneath the thing that decides whether unreviewed code becomes a versioned release.&lt;/p&gt;
&lt;p&gt;The fix turned out to be a change of model, not a patch. I moved the estate to releaser-pleaser, which does releases as a &lt;em&gt;Release-PR&lt;/em&gt;. Instead of a tag falling out of your feature merge, a bot keeps a pull request open, the one that bumps the version and assembles the changelog, and the release only happens when &lt;em&gt;that&lt;/em&gt; PR is merged. Which sounds like a small bit of plumbing rearranged, and that&amp;rsquo;s the whole of it, because it changes what a release &lt;em&gt;is&lt;/em&gt;. The release stops being a consequence of merging your feature and becomes its own explicit merge request.&lt;/p&gt;
&lt;p&gt;And here&amp;rsquo;s the thing I was really after: a merge request runs the gates. My cicd templates default their rules to &lt;code&gt;$CI_PIPELINE_SOURCE == &amp;quot;merge_request_event&amp;quot;&lt;/code&gt;, so lint, test and security are things that happen &lt;em&gt;to merge requests&lt;/em&gt;. The moment the release is a merge request, the release is gated, automatically, by the exact same checks as every other change, with no special handling at all. Nothing becomes a release without going through the door that everything else goes through. I didn&amp;rsquo;t have to bolt a gate onto the release process. I made the release a merge request and it inherited every gate I already had.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a knock-on that I didn&amp;rsquo;t expect to care about as much as I do. Once the release is a proper PR off the main branch, the old &lt;code&gt;develop&lt;/code&gt; intermediary I&amp;rsquo;d been carrying had nothing left to do, so it went, and the whole thing collapsed into trunk-based development. That&amp;rsquo;s a conversion I came to from the other direction entirely, and it has &lt;a class="link" href="https://phpboyscout.uk/same-config-two-answers/" &gt;its own story&lt;/a&gt;, but the release model and the branching model turned out to be the same decision wearing two hats. A simpler branch structure and a gated release fell out of one change.&lt;/p&gt;
&lt;p&gt;I rolled it across everything rather than one repo, because a delivery model that&amp;rsquo;s different in each project is its own kind of tax. Every &lt;code&gt;terraform-aws-*&lt;/code&gt; module, &lt;code&gt;cicd&lt;/code&gt; itself, the lot, all flipped to trunk-based with releaser-pleaser, with the reusable pieces pulled into &lt;a class="link" href="https://phpboyscout.uk/ci-you-include-not-copy/" &gt;shared cicd components&lt;/a&gt; so the gating lives in one place and not in twelve. (The Rust side went to release-plz, a different tool with the same Release-PR idea and a war story all its own.) The wrapping of those components, and the schedule gotcha that came with them, is &lt;a class="link" href="https://phpboyscout.uk/the-component-that-fired-on-every-schedule/" &gt;a tale I&amp;rsquo;ve already told&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;What it comes down to is a question I&amp;rsquo;d never quite asked out loud: should a release be allowed to skip the queue? With tag-on-merge I&amp;rsquo;d answered yes, a release was a privileged thing that happened off to the side of my normal review-and-gate flow. The Release-PR model answers no, and I think no is right. A release is a change to your project like any other. It should have to stand in the same line, pass the same checks, and earn its way to main exactly like the smallest bugfix does. Make it a merge request, and it does.&lt;/p&gt;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/same-config-two-answers/</guid><category>cicd</category><category>rust-tool-base</category><category>Pioneering</category><description>Publishing a seventeen-crate Rust workspace to crates.io, and why the same release configuration produced two different answers.</description><content:encoded>&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;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/three-traps-release-plz-workspace/</guid><category>cicd</category><category>rust-tool-base</category><category>Pioneering</category><description>Three traps release-plz sets for a Rust workspace, starting with a default tag template that collides the moment you have more than one crate.</description><content:encoded>&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;</content:encoded></item><item><title>The version pin you can't lower</title><link>https://phpboyscout.uk/the-version-pin-you-cant-lower/</link><pubDate>Sun, 02 Aug 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-version-pin-you-cant-lower/</guid><category>krites</category><category>Pioneering</category><description>A standard-library security fix forced a Go version bump, and then the release failed because the build image shipped an older toolchain.</description><content:encoded>&lt;p&gt;A security fix landed in the Go standard library, the kind you don&amp;rsquo;t get to think very hard about. You bump &lt;code&gt;go.mod&lt;/code&gt; to the patched toolchain, 1.26.4, and you move on. Or you would, if your release didn&amp;rsquo;t immediately fall over the moment you tagged it.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s what greeted me when I cut the next krites release. The before-hooks, the &lt;code&gt;go mod tidy&lt;/code&gt; and friends that run ahead of the actual build, died on the spot:&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;go.mod requires go &amp;gt;= 1.26.4 (running go 1.26.3; GOTOOLCHAIN=local)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/div&gt;&lt;p&gt;The goreleaser image I was releasing from, a perfectly current &lt;code&gt;v2.16.0&lt;/code&gt;, bundled Go 1.26.3. One patch version behind. And &lt;code&gt;GOTOOLCHAIN=local&lt;/code&gt;, which is the sensible default for a release because it means &amp;ldquo;use exactly the Go that&amp;rsquo;s installed, don&amp;rsquo;t go fetching anything mid-build&amp;rdquo;, was doing precisely its job: it saw &lt;code&gt;go.mod&lt;/code&gt; asking for a toolchain it didn&amp;rsquo;t have, and refused to pretend otherwise.&lt;/p&gt;
&lt;p&gt;Now, the fix that&amp;rsquo;s quickest to reach for is the one you have to talk yourself out of. The error is a version mismatch, and the fastest way to make a version mismatch go away is to change one of the versions. The image has 1.26.3? Fine, drop &lt;code&gt;go.mod&lt;/code&gt; back to 1.26.3 and the red goes green. Except think about what that actually does. The whole reason &lt;code&gt;go.mod&lt;/code&gt; says 1.26.4 is that 1.26.4 carries a security fix I need. Lowering the pin to placate the build image doesn&amp;rsquo;t resolve the tension, it surrenders to it. It un-applies the security update to make a pipeline pass, which is about the worst trade in software, and it&amp;rsquo;s the one sitting right there with a green tick on it.&lt;/p&gt;
&lt;p&gt;So that pin is one you can&amp;rsquo;t lower. Which leaves making the &lt;em&gt;build&lt;/em&gt; find the right toolchain instead of making the requirement smaller.&lt;/p&gt;
&lt;p&gt;The quick version is one flag: flip &lt;code&gt;GOTOOLCHAIN&lt;/code&gt; from &lt;code&gt;local&lt;/code&gt; to &lt;code&gt;auto&lt;/code&gt;. Where &lt;code&gt;local&lt;/code&gt; means &amp;ldquo;only ever use what&amp;rsquo;s installed&amp;rdquo;, &lt;code&gt;auto&lt;/code&gt; means &amp;ldquo;if &lt;code&gt;go.mod&lt;/code&gt; asks for a newer toolchain than you&amp;rsquo;ve got, go and fetch that one and use it for this build&amp;rdquo;. So the release runs on the image&amp;rsquo;s 1.26.3, hits the &lt;code&gt;go.mod&lt;/code&gt; directive, fetches 1.26.4 and carries on. The mismatch is resolved by upgrading at build time rather than by downgrading the requirement, which is the right direction for it to resolve in.&lt;/p&gt;
&lt;p&gt;The better version, which is where krites ended up, is to not have the gap at all. The release now runs on &lt;a class="link" href="https://phpboyscout.uk/stop-installing-the-same-tools-on-every-pipeline-job/" &gt;the dev-tools image&lt;/a&gt; I bake my CI tools into, and that image tracks the Go version my code actually targets, 1.26.4. When the toolchain in the image and the toolchain in &lt;code&gt;go.mod&lt;/code&gt; are the same thing by construction, there&amp;rsquo;s nothing to fetch and no flag to set, because there&amp;rsquo;s no disagreement to paper over.&lt;/p&gt;
&lt;p&gt;The toolchain your code &lt;em&gt;requires&lt;/em&gt; and the toolchain your build image &lt;em&gt;ships&lt;/em&gt; are two different facts, and they drift apart every time a point release with a security fix lands in one a good while before it reaches the other. When they disagree, the instinct to make them agree by lowering the requirement is exactly backwards: the requirement is the thing that&amp;rsquo;s right, and the environment is the thing that&amp;rsquo;s behind. The pin only ever moves one way. The build image is what had to catch up to it, not the code learning to want less.&lt;/p&gt;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/from-allow-failure-to-blocking/</guid><category>cicd</category><category>rust-tool-base</category><category>Pioneering</category><description>Turning three advisory CI jobs into real gates once they were trustworthy enough to block a merge, and how to tell when they are.</description><content:encoded>&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;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/the-build-gate-these-sites-never-had/</guid><category>cicd</category><category>Pioneering</category><description>Three Hugo sites each hand-rolled a near-identical deploy job that only ever ran on merge, so nothing ever checked the build before it landed.</description><content:encoded>&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;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/two-bugs-that-taught-me-the-rules/</guid><category>cicd</category><category>infrastructure</category><category>Pioneering</category><description>Two CI and infrastructure bugs with one shape: a GitLab job with no rules block skips merge requests, and an import block runs only once.</description><content:encoded>&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;</content:encoded></item><item><title>The component that fired on every schedule</title><link>https://phpboyscout.uk/the-component-that-fired-on-every-schedule/</link><pubDate>Sat, 25 Jul 2026 00:00:00 +0000</pubDate><guid isPermaLink="true">https://phpboyscout.uk/the-component-that-fired-on-every-schedule/</guid><category>cicd</category><category>Pioneering</category><description>A CI component gated on the default branch fired on every Renovate schedule too, because a scheduled run is also on the default branch.</description><content:encoded>&lt;p&gt;I run two release drivers across my estate, because the two language ecosystems have different best-in-class tools and I&amp;rsquo;d rather use each than force one to do both. The Go projects use releaser-pleaser; the Rust ones use release-plz. That split is a story of its own. This is about something smaller and more annoying that fell out of it: a release driver that kept going off when it had no business going off.&lt;/p&gt;
&lt;p&gt;releaser-pleaser ships as a GitLab CI component, and rather than reinvent it I started from Apricote&amp;rsquo;s upstream one. It does its job. But it decides &lt;em&gt;when&lt;/em&gt; to run by gating on the branch: &lt;code&gt;branch == &amp;quot;&amp;lt;default&amp;gt;&amp;quot;&lt;/code&gt;, which is a perfectly sensible way to say &amp;ldquo;only manage releases on the main branch.&amp;rdquo; The trouble is that a Renovate schedule also runs on the default branch. A scheduled pipeline and a push-to-default pipeline both satisfy &lt;code&gt;branch == default&lt;/code&gt;, so the component couldn&amp;rsquo;t tell them apart. Every time Renovate woke up to check for dependency updates, the release driver fired right alongside it. Wasted pipeline minutes at best, and at worst a release driver doing release-shaped things on a schedule nobody asked it to.&lt;/p&gt;
&lt;p&gt;The tempting fix, adding a rule to each job to bail out on scheduled runs, just spreads the same workaround everywhere. I could add an override in the consuming repo, a little extra &lt;code&gt;rules:&lt;/code&gt; stanza to exclude schedules, and move on. And then do it again in the next repo. And the next. Every project that used the component would carry its own copy of the same patch, and the day Apricote changed something upstream, I&amp;rsquo;d be reconciling that change against a dozen hand-rolled overrides scattered across the estate. That&amp;rsquo;s not a fix, it&amp;rsquo;s a debt with interest.&lt;/p&gt;
&lt;p&gt;What actually fixes it is to stop consuming the upstream component directly and &lt;em&gt;wrap&lt;/em&gt; it. I now have my own releaser-pleaser component that uses Apricote&amp;rsquo;s image underneath but bakes in the org&amp;rsquo;s conventions, the schedule guard included, at the wrapper layer. The repos consume my component. The gating is correct in exactly one place. When upstream changes, there&amp;rsquo;s one wrapper to reconcile, not a dozen repos. The principle is the one I keep coming back to as the person maintaining shared CI for a whole estate: when you don&amp;rsquo;t control a dependency&amp;rsquo;s gating, don&amp;rsquo;t patch it everywhere it&amp;rsquo;s used. Wrap it once, put your conventions in the wrapper, and let everything consume that.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s a sharp little gotcha buried in this that&amp;rsquo;s worth pulling out, because it bites everyone building release automation on GitLab eventually. When a job pushes a tag using &lt;code&gt;CI_JOB_TOKEN&lt;/code&gt;, that tag &lt;em&gt;will not&lt;/em&gt; trigger a downstream pipeline. It&amp;rsquo;s a deliberate loop-prevention in GitLab: a job&amp;rsquo;s own token isn&amp;rsquo;t allowed to kick off more pipelines, or you&amp;rsquo;d have CI triggering CI triggering CI. Which is fine until your release flow is &amp;ldquo;the driver pushes a tag, and the tag is supposed to start the release pipeline.&amp;rdquo; Push that tag with the job token and the release pipeline simply never fires, and you&amp;rsquo;re left staring at a tag that exists and a release that didn&amp;rsquo;t happen, with nothing in the logs to explain it. The fix is to push the tag with a token that &lt;em&gt;is&lt;/em&gt; allowed to trigger pipelines, but the failure mode is silent, so you have to know it&amp;rsquo;s there.&lt;/p&gt;
&lt;p&gt;This is roughly as thrilling as sorting the recycling: running releases across a handful of repos with a couple of different drivers, on a runner I keep small on purpose. But it goes wider than CI, really. The third time you paste the same workaround into a new place, it stops being a workaround. It&amp;rsquo;s a feature you haven&amp;rsquo;t admitted to yet.&lt;/p&gt;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/why-we-left-github-for-gitlab/</guid><category>infrastructure</category><category>go-tool-base</category><category>Pioneering</category><description>An accidental major version bump was the last straw after months of unreliability. Why a Go framework moved off GitHub, and what moved with it.</description><content:encoded>&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;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/no-access-keys-in-ci/</guid><category>cicd</category><category>infrastructure</category><category>Pioneering</category><description>Replacing long-lived AWS access keys in CI with OIDC federation, so the pipeline mints a short-lived token instead of holding a secret.</description><content:encoded>&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;</content:encoded></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 isPermaLink="true">https://phpboyscout.uk/the-secret-that-wasnt-on-my-branch/</guid><category>security</category><category>cicd</category><category>go-tool-base</category><category>Pioneering</category><description>A secret scanner failed a merge request over a test key and a documentation PEM that the change did not contain. Scoping a scan properly.</description><content:encoded>&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;</content:encoded></item></channel></rss>