Drafts moved out of git
The first version of buildbench had drafts and published posts in the same place: markdown files in content/blog/, with a draft: true frontmatter flag. Astro built the site twice — once with drafts excluded (the public site at buildbench.dev), once with them included (a Cloudflare-Access-protected drafts subdomain). Promoting was a script that flipped the flag and pushed.
It worked. It was also wrong.
Every time I — or the autonomous Stop-hook runner — created a draft, GitHub Actions ran npm ci, astro check, astro build, and wrangler pages deploy — twice, in parallel, across both projects. Forty-five seconds of CI per draft. The public site got rebuilt every single time, even though its output was, by definition, identical: drafts are excluded from it. Pure waste.
That was friction. And it was friction in exactly the wrong place: on the cheapest, most-frequent operation. You want low cost on creation and editing, high cost on publishing — that’s the value asymmetry. I had it inverted.
The fix was to decouple drafts from the build entirely. Drafts now live in a Cloudflare KV namespace, served by a Worker at drafts.buildbench.dev. Creating one is a POST /api/drafts/<slug> — about a second. Editing is a PUT. Discarding is a DELETE. The drafts site renders on request: pulls the JSON from KV, runs the body through marked, returns HTML. There’s no build step.
Publishing stays expensive on purpose. Click the Publish button on a draft, or run ./scripts/promote.sh <slug>. Both call the same worker endpoint. The worker fetches the draft from KV, commits the markdown to the GitHub repo via the Contents API, and deletes from KV. The push triggers GitHub Actions; the public site rebuilds; ~30 seconds later it’s live. One trip through the pipeline, only when something is actually shipping.
The architectural shift is small to describe and disproportionately nice to use. Three observations from the migration:
Auth is the surprising work. The drafts subdomain has Cloudflare Access on it — PIN for the human UI. The API path needs automated access. I solved it with an Access Service Token bypass policy on drafts.buildbench.dev/api. PIN sessions and service tokens both reach the worker; the worker doesn’t authenticate, it trusts the edge. That’s the right separation, but it took two iterations — my first attempt checked headers that Access strips before forwarding.
Eventual consistency is real. A POST to KV followed immediately by a LIST returns the empty list. After ~30 seconds the new key shows up. Single-key reads are immediate; list iteration isn’t. Worth knowing before you smoke-test.
Two storage layers feels heavier than it is. Drafts in KV, published in git. I worried this would split-brain. In practice the worker is a thin enough boundary that it never matters: you don’t think about where a post lives, you think about whether you’ve shipped it. The boundary is the verb.
The CMS path is now the default for both the autonomous Stop-hook runner and the /blog slash command. Git holds only what I’ve deliberately published. The next post that lands in content/blog/ will be there because I clicked a button — not because the agent guessed.