Published on [Permalink]
Reading time: 8 minutes

Things you can't regenerate go quietly wrong

I started the day wanting one small thing: a Forgejo Action that uploads a built folder to a YunoHost my_webapp over SFTP. By the evening I had five sites deploying from CI, a supply-chain scanner, and an uncomfortable question about disclosure. That sounds like scope creep. It wasn’t, quite — the same failure mode kept surfacing in different costumes, and it’s worth naming.

Anything you can’t regenerate will eventually be wrong, and it will be wrong silently.

The image that lied for six hours

Three of my satire sites carried “GmbH” in the company name. I’m a private individual; GmbH is a protected legal designation in Germany. Removing it was mechanical: fourteen occurrences in one repo, five in each of the others, page titles, OG metadata, security.txt, footer copyright. Build, verify, deploy, done by lunchtime.

Except every link shared to LinkedIn or Mastodon still said GmbH, because the Open Graph image is a PNG, and grep doesn’t read pixels. When I finally looked at it, there it was in 64px grey.

The interesting part isn’t that I missed it. It’s why it stayed wrong: that image was committed by hand in the repo’s initial commit, with no generator anywhere. Nobody could regenerate it — not me, not anyone. It could only ever be replaced by someone opening a design tool and matching the original by eye. So it sat there, contradicting the site it belonged to.

I sampled the original’s palette out of the committed PNG (#131c2f navy, indigo-600 stripe), wrote a 100-line script that reproduces it, and now make og rebuilds it. The other two sites still have hand-made images. Their artwork happens to be correct today. That’s not the same as being maintainable.

The Impressum welded into markup

Same shape, different file. The shared Impressum for all my domains was a single index.html with the legal text embedded in <p> tags. Legal text changes — that’s not a hypothetical, it’s the point of an Impressum. And changing it meant editing HTML.

So the text became data: content/impressum.toml, an ordered array of sections, the covered domains as a list. A build script renders it through a template. Twenty-four tests assert that every covered domain and every section body in full survives into the output, so a template refactor can’t silently publish a shorter Impressum than the one that was reviewed.

Two template bugs surfaced immediately, both from autoescaping: my injected <br> was being escaped into visible &lt;br&gt;, and the credit links were rendering as literal markup. Neither would have been obvious by eye. That’s the argument for tests on a legal document — not correctness of the words, which is a lawyer’s job, but proof that the words that were approved are the words being served.

While I was in there I found the domains list didn’t include the three satire sites, all of which link their footers to that exact Impressum. They pointed at a document that, by its own text, didn’t cover them.

The deploys that never deleted anything

The action shipped in copy mode first, deliberately: copy never deletes, so a broken build can’t wipe a live site. Safe, and I flipped all five sites to sync later the same day.

The first sync run failed on three sites. That was the ceiling working — I’d set max-delete: 10 as a tripwire, and it reported that each web root held more than ten files the current build no longer produced. Months of accumulated _next/static/ chunks, because copy mode never removes anything and every Next.js build emits freshly hashed filenames.

Then I got it wrong in the other direction. Ten is also too low for steady state, for exactly the same reason: a routine content change rewrites a dozen chunk names, so an ordinary deploy legitimately deletes a dozen files. I raised three sites to 60 and — of course — forgot the fourth. It failed on the next framework upgrade, which rewrote every chunk at once.

Worth being honest about what the ceiling actually buys: --max-delete is not atomic. rclone deletes up to the limit and then aborts. It’s a tripwire, not a wall. The real guard is the script refusing to sync from an empty source directory.

The watcher that said “nothing moved”

Two days ago I wrote about building a small cross-forge watcher, because a maintainer answered one of my reports at 05:50 and nothing told me. I called the failure mode “I acted on stale information without knowing it.”

Today it reported 13 watched · nothing moved. A maintainer’s review had been sitting on one of my pull requests since the previous evening.

The cause is one line:

comments=int(payload.get("comments", 0)),

On a GitHub pull request, comments counts only issue-style comments. Inline review feedback lives in a separate field, review_comments, which my watcher never reads. So the single most important signal on an upstream contribution — a maintainer actually reviewing your code — was structurally invisible.

Five of the thirteen watched items are pull requests. Two carry review comments it can’t see.

A watcher that stays quiet while a maintainer waits on you is worse than no watcher, because it manufactures confidence. I wrote a tool to fix a problem and gave the problem a place to hide.

Tags you can’t audit

The supply-chain half of the day started with a number: each site pulls 425 npm packages. npm audit reported 11 vulnerabilities. osv-scanner reported 64 across 11 packages — six times as many, with fixed versions and a (dev) marker that npm audit doesn’t give you.

Most of them don’t matter here, and it’s worth saying why rather than pretending otherwise. These are static exports: the deploy uploads plain HTML, CSS and JS. There’s no server-side runtime, so a “request smuggling in rewrites” advisory has nothing to smuggle into. Gating CI on high would have reded every build on sixty mostly-unreachable findings, and everyone would have learned to ignore the job inside a week.

The findings that did matter were the reachable ones: Next.js itself, a production dependency, 28 findings at max CVSS 8.6, with fixes available. One upgrade took the repo from 64 findings to 23.

But the real exposure isn’t the packages a visitor might reach. It’s that the build job executes 425 third-party packages while holding a live SFTP password that can write to the web root. Two of those packages run install scripts. Any of them could gain one tomorrow.

Which brings us to the part I’d been doing wrong all along, in my own repos. Every action was pinned to a mutable tagactions/checkout@v4, setup-node@v4, my own @v1. That’s precisely how Trivy was compromised in March: TeamPCP force-pushed malicious code to 76 of 77 version tags in aquasecurity/trivy-action and all seven in setup-trivy. Anyone pinned to a tag received it. Anyone pinned to a commit SHA did not.

I’d been applying lockfile discipline to 425 npm packages and none at all to the handful of actions that run before them, with more privilege. Everything is pinned to a SHA now.

The uncomfortable detail from that incident isn’t the breach — it’s that Aqua handled disclosure well, rotated their secrets, and it still didn’t hold, because the rotation wasn’t atomic and the attacker kept access to the newly issued credentials. Competent response, correct instinct, wrong sequencing. If you write a rotation playbook, the first step isn’t “rotate the secrets.” It’s “revoke the ability to mint new ones.”

The policy that landed the same afternoon

Forgejo merged a change today requiring contributors to confirm conformance with their AI Agreement, explicitly to reduce “PRs authored by coding agents.” It’s stricter than most: no AI-generated works of authorship at all, and all communication — including comments on issues and pull requests — must not be AI-generated. Translation and spellcheck excepted.

Hours earlier I had posted a reply on a YunoHost pull request that was drafted by an AI agent, under my name, with no disclosure.

Strictly, no rule was broken. Forgejo’s agreement binds Forgejo’s repositories; that comment went to YunoHost, which has no published policy — I looked. But “no rule against it” and “fine” aren’t the same thing, and the maintainer on the other end is a human who reasonably assumed he was talking to one.

I don’t have a settled answer yet. What I have is a ticket, which is at least better than deciding it differently every time by accident. The default I’m inclined toward: draft with the machine, review and post in your own words, check the project’s policy before contributing. That’s compatible with every policy I found, including Forgejo’s, without needing an exception.

The thread

The og.png, the Impressum, the accumulated chunks, the watcher, the mutable tags — same shape every time. Something existed that could only be replaced, never rebuilt: an image nobody could regenerate, text welded into markup, a remote directory nobody mirrored, a signal nobody fetched, a tag that could move under you.

None of it announced itself. The site said one thing and its link previews said another for six hours. The watcher said “nothing moved” while someone waited. That’s the tax on unreproducible artifacts, and it’s paid in silence.

The fix is boring and always the same: make the thing regenerable, then check that what you’re serving matches what generated it. make og. make build. A test asserting dist/ isn’t stale. A pinned SHA instead of a tag.

Nine issues opened today. The one I’d fix first isn’t any of the security work — it’s the watcher, because a tool that lies to you quietly is worse than the manual process it replaced.

Mastodon