Four Months Away: Upgrading emdash From 0.1.0 to 0.32.0
August 9, 2026. We built this site in April on emdash 0.1.0, wrote four posts about the experience, and then did not touch it for four months. This week we came back and caught up.
The version gap was 0.1.0 to 0.32.0. That sounds alarming and mostly was not. The uncomfortable part was that nearly everything actually broken on the site turned out to be ours, not emdash's, and had been broken the entire time.
What emdash shipped while we were not looking
31 minor versions and 1,165 commits between early April and August 5. In that window the project went from a promising beta to 11,500 GitHub stars, a 78-page documentation site, a marketing site, a playground, and a Discord. It now describes itself as the CMS for you and your agent.
The changes that matter for a small content site:
The MCP server went from the 33 tools ours exposed to 53, with no removals and no renames, and it has been on by default since 0.4.0. We run this entire site through MCP from Claude Code, so that is the surface we care about most. The useful additions are media_upload, structured error codes instead of a generic failure string, taxonomy assignment as part of a content write, and cursor pagination.
Rich text fields accept Markdown now, since 0.22.0. In April, writing a post through MCP meant hand-assembling Portable Text: nested objects with _type, children and marks. Links through that path did not work at all, so we wrote posts without them. This post was submitted as Markdown and converted server-side.
Scheduled publishing became real in 0.19.0. It used to piggyback on incoming HTTP requests, which is fine for a busy site and useless for a quiet one. It is now a Worker cron trigger.
Core absorbed the boring essentials: sitemaps, robots.txt, redirects with loop detection, per-entry SEO, revisions, menus, and scheduled backups to object storage. On WordPress that list is four more plugins from four more vendors, each with its own update cadence and its own CVE feed.
There is also a plugin registry built on AT Protocol identity. It is the most interesting thing in the release notes and the least ready: it indexes five packages, its design RFC has been open since April, and the package every docs page tells you to configure returns a 404 on npm. We left it alone.
The upgrade was boring, which is the right outcome
We expected the version gap to be the hard part. It was not.
The code change was four dependency lines, three lines of Worker config, and one new file. The only non-obvious dependency was @cloudflare/workers-types: wrangler floats forward and now requires v5, and that mismatch is what actually blocks the install. Nothing else fought.
The database was the real risk. emdash has no migrate command and no dry run. Migrations apply automatically inside the first HTTP request after a deploy, with a 30 second budget. We were 22 migrations behind.
So we rehearsed. We copied production into a throwaway database, pointed a separate Worker at it, and made the first request ourselves. All 22 migrations applied in 9.98 seconds with the data intact. Then we did it for real.
One thing we could not do: wrangler d1 export refuses any database containing FTS5 virtual tables, which is most databases with a search index. The documented backup path does not exist for us, and nothing tells you that until you run it. We wrote a logical export instead, reading the schema out of sqlite_master and doing one SELECT per table, and took a Time Travel bookmark as a second recovery path.
The part we did not expect
Four of the five entries in our own known-bugs document were false.
The worst was a build patch. In April we found a real emdash bug: plugin route handlers never received the email pipeline, so form notifications silently did nothing. We reported it, wrote a sed patch against the built bundle, and wired it into our deploy script.
Upstream fixed it about a week later and shipped it in 0.2.0.
Our patch script did not fail when its target disappeared. It printed "Already patched or pattern not found" and exited 0. The deploy script kept calling it between build and deploy for four months, and it did nothing, and nothing ever said so. Small bug, general lesson: a repair step that fails open is worse than no repair step, because it looks like coverage.
The rest of the list had the same shape. The workaround we had adopted for publishing content was fixed in 0.2.0. The Portable Text link problem was fixed in 0.22.0. One entry we had written up as an emdash bug was us misreading the API. And a 456-line comments component we wrote from scratch duplicated one that emdash had exported since the exact version we were pinned to. We had even published a post here saying emdash ships no comments component. It did. We had not looked.
Three things that were broken, all of them ours
Upgrading fixed none of these, because none of them was emdash's fault.
Every unknown URL returned HTTP 508. Not 404. Our catch-all route redirected misses to /404, no 404 page existed, and the redirect landed back in the same catch-all. Adding a 404 page was not enough on its own, because /404 is itself a single-segment path that the catch-all matches first, so the loop survived. The fix was to stop redirecting and render the not-found body in place.
The contact form's spam protection had never worked. We sent the honeypot field at the top level of the request. The forms plugin validates with a schema that keeps only formId and data, so the field was stripped before the check ran, and the check reads inside data anyway. The form was configured for honeypot protection and had none. That is the likeliest explanation for the 27 submissions sitting in storage.
Our Postmark API token was compiled into the deployed Worker. We read it at build time with Vite's loadEnv and passed it as a plugin option, which writes the literal value into the bundle that gets uploaded. It now lives in plugin key-value storage and is read at delivery time, and the old token has been rotated and revoked. emdash fixed this same class of bug for its own secrets in 0.31.0, and the advice in that changeset is the right rule: anything that has been inside a bundle is burned.
The moment we nearly made it worse
After the migration deploy we queried the database to confirm. It reported the old migration count. The site was already serving new code.
Read literally, that says the upgrade half-applied, and the obvious responses are to re-run it or restore from the bookmark. Both would have been destructive.
It was a read replica serving pre-write state. A second query returned the correct number. The rule we wrote down: never conclude a write failed from a single read, and confirm structurally, by checking whether the thing the newest migration creates actually exists.
What we changed about how we work
Three rules came out of this, and none of them is emdash-specific.
First, wrangler deploy builds from the working directory and never consults git. Our entire SEO pass had been live for four months while existing only as uncommitted files on one machine. If that machine had died, the running site would have been unrecoverable from the repository. Checking git status is now part of deploying.
Second, a patch step in a pipeline must fail loudly when the thing it patches is gone.
Third, and the most immediately useful for anything on Cloudflare: wrangler versions upload publishes to a preview URL on real production bindings without routing any traffic to it. We used it for the Astro 7 upgrade that followed this one, and it is strictly better than the throwaway-Worker rehearsal we had built by hand.
Would we do it again
Yes, and we would check in more often than every four months.
The upgrade itself was cheap. The cost of not looking was a rotted set of workarounds, a document full of confident wrong statements, a published post asserting something false, and three live defects we had shipped ourselves. None of that came from the version gap. It came from never re-reading our own notes against reality.
The CMS is in good shape. Our maintenance of it was not.
Comments
No comments yet. Be the first to share your thoughts.
Leave a comment