Stopping the heroless-post gap from coming back (a guard, not a nag)
A while back I did a boring, satisfying chore: I found 80 blog posts on my site that had no hero image and gave every one of them a picture. It felt like progress. It wasn’t, not really. A one-time cleanup fixes today and promises nothing about tomorrow, and a couple of months later I went looking and found the gap had quietly reopened. Four published posts, no hero.
This is the story of the second fix, the one that actually holds.
Why the gap kept coming back
My blog is an Astro site. Post metadata lives in frontmatter, validated by a Zod schema. Here is the relevant line:
heroImage: z.string().optional(),
Optional. That word is the whole problem. My publishing workflow checks that the fields a post has are valid, but an absent optional field is valid by definition. So a post with no hero sails through every check, gets committed, deploys, and goes live looking a little naked. Nothing ever complains. The only way I found out was by going and counting.
The obvious fix is to make the field required:
heroImage: z.string(), // tempting, wrong
I didn’t do that, and I want to explain why, because it’s the decision the rest of the work hangs on. A required field fails the build for every post missing a hero, including drafts I’m still writing that are dated next week and nowhere near ready. I’d be blocked on an image before I’d finished the prose. The schema can’t tell “published and broken” apart from “not done yet.” I needed a check that could.
The guard
The distinction I actually care about is: is this post live? A post is live when its pubDate is today or earlier. Future-dated posts are drafts and none of the guard’s business. That one filter is the whole idea.
const today = new Date().toISOString().slice(0, 10);
for (const file of files) {
const { data } = matter(await readFile(join(BLOG_DIR, file), 'utf8'));
const pubDate = String(data.pubDate ?? '').slice(0, 10);
// Only enforce on published posts; drafts are exempt.
if (!pubDate || pubDate > today) continue;
const hero = typeof data.heroImage === 'string' ? data.heroImage.trim() : '';
if (!hero) violations.push(file.replace(/\.md$/, ''));
}
Note the .trim(). The schema accepts an empty string, so heroImage: "" would pass a naive presence check while being exactly as broken as no hero at all. Treat empty as missing.
It runs as a pre-commit hook and again in CI. Locally it stops a heroless post before it’s ever committed. In CI it’s the backstop for anything that skips the hook. Same script both places.
The part I almost got wrong
My CI runs the hooks against the entire repo, every file, every time (pre-commit run --all-files). That’s a good default and it has a sharp edge: a brand-new guard, pointed at a corpus with pre-existing violations, fails on day one. I already had one hook dodging this. My banned-words check is explicitly skipped in CI for exactly this reason, because scanning the full history turns up old phrasings I’ve made peace with.
I could have copied that and slapped a SKIP on the new guard. I didn’t, and this is the bit I’d underline for anyone building something similar. Skipping in CI would have meant shipping a guard that doesn’t guard. The four broken posts would sit there, permanently exempt, and the check would only ever catch new mistakes while lying about the current state. So I did the less clever thing: I fixed the four posts first, so the guard could run against everything with zero exceptions and pass honestly. A guard with an asterisk isn’t a guard.
The helper, and one genuine surprise
To fix the four (and to make setting a hero a one-liner going forward), I wrote a small helper. Give it a slug and an image, from a URL or a local file, and it downloads the picture, writes the webp sibling with the same sharp settings the build already uses, and inserts the frontmatter line:
node scripts/set-hero-image.mjs <slug> <image-url-or-path>
I deliberately kept it a text edit, not a parse-and-rewrite. Round-tripping the frontmatter through a YAML library would have reflowed quote styles and reordered keys across all four files, burying a one-line change in a noisy diff. A targeted insert keeps the diff to the single line that changed.
The surprise was the count. I expected two broken posts and found four. One of the extra two wasn’t even committed. It was a draft sitting in my working tree, never pushed, with a backdated pubDate that made the guard treat it as live. My first reaction was that the guard had a false positive. My second, better reaction was that the guard was right and I was wrong: a post dated in the past is telling the world it’s published, and if I ever commit it, it ships heroless. The tool caught a mistake I hadn’t made yet. That’s the good kind of annoying.
What I’d take from this
The pattern I keep relearning: a checklist tells you what you’re supposed to do, a guard makes the wrong thing impossible to commit. I also updated my publish workflow to set a hero as a step, and that’s genuinely useful, but it’s a note to my future self and my future self is forgetful. The load-bearing piece is the twelve lines that fail the build. Everything else is convenience.
And when you add a guard to a codebase that already has violations, resist the skip. Clean the violations first so the guard can be absolute. The moment it has an exception is the moment it stops meaning anything.
Related reading
The formatter that never checked its own scripts folder
A deploy gate that ran, passed, and never once looked at the directory holding the script that runs before the build starts.
Retiring the staging environment
A second container, a separate monitor, an 11% workflow failure rate, and zero evidence it ever caught anything production deploys didn't. The audit that ended in deletion.
Two triggers, one KV id: why preview builds were dark on imperfectsystems.com
Chasing down why non-production branch builds never ran on Cloudflare Workers Builds - a KV namespace nobody asked for, and a trigger model I'd misread.