What happens when a job broadcasts to nobody
This issue was supposed to close a loop that’s been open in blog-manager for a while: the Pexels image picker lets you stage a hero image on a post, but nothing ever pushed that image - or its required attribution - into the actual live blog post. You’d pick a photo, the app would remember it, and then… nothing. A comment in the code literally said “Applied to the live article by the direct-repo backfill” next to a feature that didn’t exist yet.
What I was trying to do
Two things, in two different repos. First, vdaluz.com needed a place to put attribution data in its frontmatter and a way to render it - “Photo by X on Pexels” under the hero image. Second, blog-manager needed to actually write that data into the live post’s markdown file on GitHub, using the write capability from the previous issue.
What I built
On the vdaluz.com side: an optional heroImageCredit field in the content schema, and a render block that shows the credit line inside the same content wrapper as the hero image. That placement isn’t arbitrary - Medium’s story importer only picks up images from inside the extracted text region, so anything outside it silently disappears when a post gets imported to Medium. The credit line piggybacks on that same constraint for free: since it renders in the same block, Medium’s importer picks it up automatically when it scrapes the live page. No code needed on the Medium side at all.
Dev.to is the opposite case. It builds its post body from the raw markdown file, not the rendered page, so a credit line that only exists as template-rendered HTML never reaches it. That one needed an explicit fix - prepending a markdown-formatted credit line to the body before it goes out.
On the blog-manager side: a service that patches the live file’s frontmatter - insertion only, never touching the existing content - plus a background job that runs it, mirroring the same retry/discard pattern as the app’s Dev.to and Medium publish jobs.
Decisions I made and why
Insertion-only, not a general rewriter. I could have written something that parses the whole frontmatter block, merges in new keys, and re-serializes it. I didn’t, because a full YAML round-trip risks silently reformatting every existing field - reordering keys, changing quote styles - which shows up as unwanted diff noise on every post it touches. Since every post this runs against comes from a “missing hero” queue (meaning the file provably has no heroImage key yet), the safe move was to only ever append the two new keys and leave every existing line untouched, byte for byte. A proper lossless frontmatter editor is explicitly a separate, later piece of work - I didn’t want to accidentally build a worse version of it as a side effect of this issue.
A guard that checks the file, not the database. Before writing, the service re-checks whether the live file already has a heroImage key - using what it just fetched from GitHub, not what the database thinks. That distinction turned out to matter almost immediately (see below).
What surprised me
I don’t have a scratch or test blog in this app - only the two real ones. So the only way to manually verify the write path was to point it at the real vdaluz.com repo, using its real (but read-only, for now) GitHub token, with the expectation that it would fail predictably at the write step and I’d get to watch the failure handling work correctly.
It failed - but not the way I expected. Instead of a permission error, it hit the “this file already has a hero image” guard. The database said this post had no hero image yet. The actual live file did. Somewhere along the way, the two had drifted out of sync - probably a post that got a hero image added by hand, outside the tool, before a rescan happened to notice. The exact guard I’d written specifically to distrust stale database state caught a real instance of that stale state on the very first live post I tried it against, and refused to touch the file. Nothing got corrupted. That felt like a good sign that the caution was worth the extra code.
The other surprise came out of review, not out of running the code. An automated pass I ran before merging flagged that the page this whole feature is built around - a batch view for working through posts missing a hero image - never actually subscribes to updates from the background job. The button would fire, the job would run, the commit would succeed against GitHub, and the one page you’d actually be using to do this work in bulk would just… sit there unchanged until you manually reloaded it. Turbo’s broadcast mechanism doesn’t error when there’s no listener - it just quietly goes nowhere. The post’s detail page had the subscription; the batch page didn’t. Same partial, two different places it gets rendered, only one of them had ever been wired up to listen. Easy to miss, since the click itself still “worked” - it just wasn’t the version of working that showed you anything.
What’s next
Whoever picks up self-hosted hero images or the Unsplash/Openverse providers gets to build on top of this rather than starting from a “staged but never committed” state. The one thing still outside the app entirely: the GitHub tokens for both blogs are still read-only. Actually writing anything for real means going into GitHub’s UI and re-scoping a token by hand - there’s no API for that part.
Related reading
The normalization bug that only shows up on tags made of nothing
A strip-based normalizer meets an all-punctuation tag: empty string as a hash key, wrong-tag substitution, and an autocomplete that matches everything. Three symptoms, one root cause.
The same button choice cost me a bigger bug than expected
Embedding the hero flow in the editor looked like the smaller option - until 'replace' met 166 real files that had never gone through the insertion-only path, and a migration with no backfill.
The editor commit button is a deploy button
Committing a draft to main auto-deploys the blog. Once that clicked, sync-vs-async stopped being a style question - plus the legacy-affiliate carve-out a new validator almost broke.