Skip to content
Go back

130 Changes In One Commit — Then I Tried To Break It 🔬

A change enters from the left and passes through five ordered gates before shipping: structural assertions, formatting baseline, typecheck and build, rendered output, and external URL checks. Each gate has a dashed grey rejection arrow dropping to a shared Stop and investigate bar below, which carries the note to read the artifact on disk rather than the summary in the terminal. The final box is dark and labelled Ship. A footnote observes that the rendered-output gate is the one that keeps earning its place, because a green build will happily compile broken markdown.

The job

I wanted every post on this site to link out to the projects it actually talks about. Not for anyone’s benefit in particular — just because an article that names thirty tools and links none of them is a bit rude to the reader, and because search engines quietly notice when a page references the world and points nowhere.

Twenty-eight posts. Roughly forty-five distinct products between them: Astro, Tailwind, Netlify, Cloudflare, Fedora, Fish, Starship, Ghostty, ripgrep, tldr, Pagefind, btrfs, NextDNS, half a dozen others. Nineteen of those posts had zero outbound links. Some had been sitting there for months naming things and never once saying where to find them.

So I scripted it. A set of rules — link the first unlinked mention, skip anything already linked, skip anything inside code — and one commit at the end of it: one hundred and thirty links, twenty-eight files, done in a single pass.

Then I spent the next hour trying to prove it wrong.

That second part is the part worth writing down.

Bug one: the anchor inside the anchor

The first rule I set was simple enough: link the first unlinked mention of each product, in prose only. Skip anything already inside a link, skip anything inside backticks or a code fence, and never link the same product twice in the same post.

The obvious hazard is the code part. These are Linux articles. They are full of fd . and bat README.md and dnf system-upgrade. A naive text replacement happily turns a command into a hyperlink, which is a small disaster — the reader copies it, the link underline makes the command look different from what they’ll type, and the page reads like it was assembled by someone who has never opened a terminal.

So I excluded code. That part held.

The part I nearly missed is subtler. Markdown links have two halves: the text you see, and the destination behind it. My exclusion covered the destination. It did not cover the text.

Which meant that a sentence like this:

Read the full writeup in From A Lifelong Windows User To A Happy Fedora Convert

was perfectly eligible for the word “Windows” sitting in the middle of that anchor text. Wrap it and you get nested, broken markdown — brackets inside brackets, a link inside a link, and a rendering engine doing something creative and unhelpful with the result.

Twelve such anchors existed across the archive. Not hypothetical. Twelve real ones, all of which would have shipped.

The check that caught it took about ten seconds to write and has since paid for itself several times over: before you wrap a match, confirm it does not already sit inside a link span. It is the kind of check that feels paranoid until the first time it fires.

Bug two: the report that lied to me

The second bug is more embarrassing, because it wasn’t in the content. It was in my own tooling, and it looked me straight in the eye and told me everything was fine.

I had written a script to plan the internal cross-links. It printed a tidy summary to the terminal:

POSTS 29
LINKS 30
PROBLEMS 0

Zero problems. I almost believed it.

Then I opened the file it had written to disk. Thirty rows, and roughly half of them were duplicates. Several were self-links — one post cheerfully pointing at itself. The counts in the summary were computed from a different list than the one that got written, so the two disagreed, and the summary was the one I happened to be reading.

Nothing in the output told me. The number that would have told me was sitting one column over in a file I only opened because something felt off.

I rewrote it so the script does the validation itself — assert no self-links, assert no repeated source-to-target pair, assert the text at each computed offset still matches what it expected to find there. That last one is the important one. It fires when the underlying data has moved underneath you, which is exactly the failure mode that produced a half-applied edit to two files before an assertion stopped it dead.

The lesson isn’t “don’t trust your tools.” It’s that a summary and an artifact are different things, and when they disagree, the artifact is the one telling the truth.

Five gates, in order

Here’s the sequence a change passes through before it goes anywhere near this site. I run it in this order because each gate is cheaper than the one after it, and catching a broken bracket before a three-minute build saves three minutes every time it fires.

One: structural assertions on the source. Brackets balanced in every file. No [[ anywhere. Nothing linked inside a fence. No empty link text. This is pure string logic — no compilation, no network, runs in under a second across all twenty-nine posts, and it catches the entire class of markdown damage.

Two: the formatting baseline. Prettier reports warnings, and the count is not zero and never will be. What matters is that it doesn’t move. Before the link pass the baseline was forty-nine warnings. After one hundred and thirty links across twenty-eight files, it was still forty-nine. If that number had gone to fifty, something in my change had introduced a formatting problem the linter wanted to flag, and I would want to know which file and why.

Three: typecheck and build. Zero errors, zero warnings, clean exit. The build is the gate people think of first, and it’s actually the least useful of the three so far — a build happily compiles a page with broken markdown links in it, because broken markdown is still valid markdown. It catches crashes, not nonsense.

Four: the rendered output. This is the one people skip. There is a real difference between what a source file contains and what a browser ends up displaying, and the difference is where a surprising amount of damage hides. So after the build I parse the HTML that actually shipped and assert things about it — that no link ever rendered inside a <code> element, that every internal /posts/ href resolves to a real page in the output, that the anchor text survived the round trip intact.

Five: the external world. Every outbound URL gets fetched before I commit to it. Three hundred and twenty-six unique external links, fetched in parallel with a browser user-agent. Two hundred and eighty-eight came back clean. The rest I had to account for individually rather than wave through: share buttons that need session parameters, LinkedIn’s standing 999 for bot traffic on a profile that’s genuinely real, and GitLab plus another major site both refusing scripted requests outright.

That last case is worth sitting with. Three URLs returned hard errors to my checks and were all completely fine. Two of them I confirmed by driving a real browser and reading the loaded document’s title back. The third turned out to resolve and complete a TCP handshake on port 443 just fine — the failure was an artefact of my sandbox’s network path, not the page.

A green checkmark from a tool that can’t actually reach the thing it’s checking is worse than no checkmark, because it ends the investigation.

The check that keeps catching me

If I had to keep exactly one, it would be the rendered-output check.

Source-level verification asks “did I write the right thing.” Rendered verification asks “did the reader get the right thing,” and those are different questions with different answers. The build pipeline mangles text on its way to the browser: syntax highlighting splits tokens into separate spans, templates inject their own markup, a heading gets processed twice, and a comment that was perfectly legible in the source arrives in the DOM as four unrelated fragments.

Every serious content bug I have shipped in the last year was visible in the rendered output and invisible in the source. Every single one.

So the rule I’ve settled on is blunt: if I can’t assert something about the bytes that actually get served, I don’t consider it verified. “It looks right in the editor” is a feeling, not a check.

What I still do by hand

A few things deliberately stay outside the automation, because automating them would make the whole system less trustworthy rather than more.

Deciding what’s actually good. The first version of my cross-linking script wanted to hyperlink the words “nothing”, “later”, “never” and “actually” to unrelated posts. Every URL it proposed was valid. Every structural check passed. It was also, obviously, garbage — the precise texture of a link farm, and worse than having no internal links at all. No assertion catches that. I read the list, rejected it, and rebuilt the filter until the anchors were words like aliases, toolchain and nameserver, which a reader might actually want to follow.

Judging whether something is dead. A URL that returns a 404 is a fact. A URL that returns a 403 is an argument, and sometimes it’s a bot filter. Treating every non-200 as broken would have deleted three links to pages that are entirely fine.

Deciding when the change is too big. One hundred and thirty links across twenty-eight files is a large change. I’d have been far more comfortable with twenty-eight commits. I shipped it as one because the alternative meant either a lot of tedious ceremony or a series of half-finished states live on the site, and I judged the second to be the worse risk. That call isn’t derived from any check.

Closing

The useful shift is where the bottleneck sits.

Producing a change at this size is now close to free, and it always was the easy half. Proving it was safe took considerably longer, and every minute of it was the kind of work that used to be spread thin — a skim here, a glance there, a shrug at the diff. Underneath a large batch change there is nowhere to skim and nowhere to shrug, so either you build real gates or you ship on faith.

I’d rather build the gates. They’re mostly boring string functions, a baseline number, and the discipline to go look at what actually got served.

If you’ve got a check that’s caught something a review missed, I’d genuinely like to hear it — the ones above are all borrowed.


Share this post on:

Previous Post
Four Scanners Looked At My Blog From A Fedora Terminal — Three Were Telling The Truth 🔍