The editor commit button is a deploy button
This was supposed to be a straightforward feature: let me edit a post’s metadata and body in blog-manager’s editor, then commit that draft back to the blog’s repo. What made it interesting was a question I almost glossed over.
What I was trying to do
Blog-manager already had a working draft flow: open a post, pull its frontmatter and body from GitHub into a draft row, edit it in the browser, autosave to the database. What it didn’t have was a way to actually publish those edits. This was the missing piece: validate the draft, write it back to the file on GitHub, resync the Post record, clean up the draft.
The question that reshaped the plan
While planning the sync-vs-async question (should the commit run inline in the request, or get handed off to a background job like most of this app’s GitHub writes), I stopped on one line: how do we “commit a draft,” exactly? Committing to main triggers a deployment.
That reframing was the right one. I’d been treating “commit to the blog’s default branch” as a git operation with git-operation stakes. It isn’t. Both vdaluz.com and imperfectsystems.com deploy via Cloudflare Workers Builds, which auto-deploys on every push to main. A commit from this editor is a live, immediate, one-way production deploy, not a background sync job with a retry queue.
Once that was settled, running the commit synchronously became the obvious choice. A background job means you click “Commit,” the page says something noncommittal, and thirty seconds later either your post is live or something silently failed. For an action with deploy consequences, that gap is a liability. Running it inline means the request either returns a commit SHA or a specific, actionable error, before you walk away thinking it worked.
The UI reflects that: the commit button carries a confirm dialog that says outright, “Commit and deploy live to
What I built
The committer does five things in order: refuse if the blog’s scan is currently running (avoid racing a concurrent GitHub read), validate the draft’s metadata, refetch the live file and compare its SHA against the one the draft was opened against (conflict detection with no extra column, since a SHA match at commit time is itself proof the draft’s starting point still matches GitHub), diff only the fields the user actually touched so untouched YAML formatting survives verbatim, then PUT the file, resync the Post’s columns from the new frontmatter, and destroy the draft.
That “only the changed fields” detail mattered more than I expected. The draft’s frontmatter round-trips through a JSON column, so a stored pubDate comes back as a String, while a fresh parse of the live file produces a real Date. A naive equality check would see those as different and rewrite the date’s YAML formatting even when the user never touched it. I wrote a test for exactly that, then deliberately broke the fix to confirm the test actually caught it before trusting it.
What the review caught
Three independent review angles converged on the same line, which is usually a sign something real is there: the new validator checked a draft’s full affiliates list against the fixed set of known programs. But this app has an existing carve-out that deliberately lets a post keep a legacy affiliate program that predates the fixed list, without ever validating it. My new validation path didn’t know about that carve-out, so any post carrying a legacy affiliate would fail to commit even for an edit that never touched affiliates at all. Fixed by validating only the intersection with the known set, while still writing the full stored value (legacy program included) back to the file. Added a regression test with a real legacy-affiliate fixture to pin it.
Two smaller things came out of the same pass: the draft loader’s move onto the shared frontmatter parser changed behavior for a file with a present-but-blank frontmatter block, from silently tolerating it to raising, and the controller’s rescue clause hadn’t been updated to catch the new exception type, so that case was a 500 instead of a friendly redirect. And the post-commit resync logic had drifted into a near-duplicate of the scanner’s own field-mapping code, so I pulled it into one shared method both call.
Worth naming as a general lesson: a second write path that validates the full merged state instead of just what changed will break any established “preserve the legacy value, don’t validate it” carve-out elsewhere in the code. Wrote that up as its own knowledge-base note since it’s a shape worth watching for beyond this one feature.
What I didn’t verify
Dev data intentionally has no write-capable GitHub token, so I never exercised a real successful commit end to end - a real PUT, a real deploy, a real Post resync from an actual commit response. That path is covered by fake-client unit tests only. Browser verification confirmed the UI wiring and the guard/conflict/error paths against a real (auth-failing) request. The first live commit through this flow will be the first time the full happy path runs for real.
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.
Global tag operations, and the concurrency guard that wasn't
A batch rename that reported success while posts failed, a limits_concurrency key that never actually shared a lock, and 465 real tags with a live casing duplicate to test against.