Publishing, versions and rollback
Publishing is the only event that changes what a customer’s page loads. Review does not. Approval does not. Uploading a built zip does not — not until central repoints its row at the new version prefix. Everything before that moment is preparation; everything after it is cache propagation.
This page covers that moment: what the publish transaction writes, how the change reaches pages that are already cached, and how to undo it. The pipeline that leads up to it is From your laptop to a customer page; the rules your zip must satisfy are Packaging and publishing rules.
What publishing does
An administrator drives four legs, in order. Only the third changes anything a customer sees.
- Upload. The built zip’s publishable files are PUT one at a time into the immutable
key prefix
{env}/{slug}/{version}/, rootmanifest.jsonlast. Until that last object exists, nothing is live and the same zip can be retried safely. - Verify. The dashboard fetches the published
manifest.jsonback from its public URL with a cache-buster and refuses to continue unless it serves and itsid/versionmatch the submission. “Published” means observed serving, not uploaded. - Repoint. Central records the verified URL and manifest in one database transaction. This is the switch.
- Propagate. The dashboard warms the renderer and purges cached pages. Best-effort — see How an update reaches a customer.
The repoint writes exactly this, and each field has a consequence for you:
| What central writes | Read from | What it changes for you |
|---|---|---|
manifest_url, current_version | The verified URL | Every new render resolves your keywords against the new version prefix |
manifest_cache | The manifest itself | The widget picker, per-widget summaries, slots, presets, fonts |
name, description, author.name, author.url | The manifest’s root fields | Your marketplace listing text — it only ever updates at publish |
status = approved | Fixed | A suspended plugin is un-suspended by publishing a fix |
submission_status = null | Fixed | Your submission is cleared; the stepper reads Published |
published_at, build_meta | Now, and the build hash + file list | Forensics only; no serving path reads build_meta |
Three things publishing does not do, and all three surprise people:
- It does not re-fetch anything later. Central serves its stored copy of your root manifest to every consumer. There is no manifest-refresh step you or anyone else has to trigger — the repoint is the refresh.
- It does not touch customer data. Widget settings live on the customer’s widget row, not in your plugin. Nothing is migrated, defaulted or rewritten on their behalf. See Settings and options.
- It does not delete the old version. The previous prefix stays on the CDN, byte for byte, which is what makes both cached pages and rollback safe.
Your own confirmation is the row in Dashboard ▸ Plugins ▸ My plugins: the badge flips to
live v{version} and a Published manifest link appears, pointing at the exact URL
central recorded. Nothing emails you — see the notification gap in
From your laptop to a customer page.
Version rules, in practice
(Both shapes.) One version number covers the entire plugin. A suite of twelve widgets has one version, ships as one review and one publish, and cannot release a widget on its own.
The three hard rules — three-part MAJOR.MINOR.PATCH, strictly greater than the published
version, and never re-publishable once it exists on the CDN — are stated with their exact
messages in
Packaging and publishing rules. Two practical
consequences are worth spelling out here:
- A version number is spent the moment it publishes. There is no re-publish. A one-line
CSS fix to
1.2.0is a submission of1.2.1, with a full review. - A version number is not spent by being rejected. The greater-than check compares
against the version that is live, never against your last submission. If
1.4.0came back aschanges_requested, resubmit the fix as1.4.0again.
Pick numbers that describe the config schema, because that is the only part of your plugin a customer’s stored data depends on: patch for fixes, minor for additive optional fields and new widgets, major when you break or remove a field. Nothing enforces this — advisory (unvalidated) — but see Evolving a plugin for what actually breaks.
Not supported yet. Pre-release versions such as
1.0.0-rc.1pass the browser audit, pass central, and publish — and then silently lose every immutability optimisation. Each detector (the proxy’s, the renderer’s, the SSR bundle cache’s) requires a bareX.Y.Zpath segment, so a pre-release prefix falls back to proxied asset URLs and short-TTL caching everywhere. Nothing warns anyone. Ship plainMAJOR.MINOR.PATCHversions. See Keywords, assets and URLs.
How an update reaches a customer
Customer pages are composed once and cached hard, so the platform does not wait for TTLs to expire. Publishing (and rollback, and every kill-switch change) pushes:
// Source: src/app/(protected)/plugins/cache-propagation.ts
central repoint ──▶ warm POST {renderer}/api/cache/warm-plugin { slug }
│ loads EVERY widget's SSR bundle for the slug; the
│ slug-keyed Redis entry it writes overwrites the
│ previous version's — warm and eviction are one call
│
└──▶ purge GET /plugins/{id}/feeds (feeds with the plugin ENABLED)
per feed: renderer Redis page keys + the edge KV page
cache, then one re-render of that feed's listings page
concurrency 4 · 30 s in-request · 150 s after the response
Neither leg can fail the publish. If the warm fails — a broken SSR bundle returns a 502 and stops the loop — the release still goes live and you find out on the first customer render. If the purge budget runs out, the feeds it did not reach fall back to their page TTLs.
What holds a copy of your old build
| Layer | Holds | What releases it |
|---|---|---|
| Browser, hashed asset URL | One immutable file | Nothing. A new version is a new filename. |
/p/ proxy pointer | Which plugin/version a logical filename maps to | 60 s TTL — see Keywords, assets and URLs |
| Renderer, evaluated SSR module | Your executed bundle, in process | A versioned URL never expires in process; a new version is a different URL |
| Renderer, Redis bundle text | Your bundle’s source | The publish warm overwrites the slug-keyed entry; a 30-day TTL is only a backstop |
| Renderer, compiled page | The customer’s rendered HTML | The per-feed purge, else 24 h |
| Edge KV, composed page + renderer payload | The customer’s served HTML | The per-feed purge, else the page TTL |
The page TTL is 24 h on property-detail, checkout and confirmation pages, and 300 s on listings and collection pages, with stale-while-revalidate on top: past the fresh window one request is served the old copy while a refresh runs behind it. So the honest bounds are seconds when the fan-out reaches a feed, and up to 24 h for a detail page on a feed it did not. Every number here is also indexed in Limits and error index.
The old guide claimed a “~5 minute worst case”. That was the legacy SSR-bundle TTL, which no longer applies to pipeline-published plugins at all. The real bound is the composed-page TTL above.
A stale page is stale consistently
On server-rendered pages the renderer writes direct content-addressed CDN URLs into the HTML, so a cached page keeps loading the exact bundles its HTML was rendered with — from the old prefix, which still exists. There is no window in which new markup meets an old bundle, or vice versa. That is the whole reason old prefixes are never overwritten.
It is also why deleting an old version prefix is the one operation that can break a live page. The dashboard refuses to delete the live version outright, and its bulk prune keeps the live version plus the newest previous one; anything older can still be referenced by a page cached within the last day.
What the customer does
Nothing. There is no per-feed version pinning: central holds one current-version pointer per plugin, and every feed, widget instance, SSR render and CSR embed follows it. Customers cannot stay on an old version, and cannot opt into a new one early.
The only visible change on their side is the config panel. A suite’s per-widget schemas
live at widgets/{slug}.manifest.json under the version prefix, so a new version is a new
URL and the dashboard picks up your new form on the next page load. (Suite.)
Rolling back
Rollback is the first-line fix for a bad release, and it is a single administrator action —
not a new review. On the admin plugin page, Versions on the CDN lists every
{env}/{slug}/{version}/ prefix R2 still holds (R2, not central, is the record of what was
ever published) with its file count, size, publish time and runtime.widgetCore stamp.
Any non-live row with a root manifest offers Roll back to this version.
Central then re-verifies the older build before trusting it — it re-fetches that manifest itself over https from a public address with no redirects, and re-applies the manifest validator, the id/version match, the frozen-shape gate and the widget-core floor (widget-core 0.10.0+). The refusals are indexed in Limits and error index; the ones you can actually cause are:
| You will hit | When |
|---|---|
ROLLBACK_MANIFEST_UNREACHABLE | The prefix was pruned from the CDN. Rollback needs the files to still be there. |
MANIFEST_WIDGET_CORE_MISSING / _TOO_OLD | The target predates the evergreen runtime floor. Old releases can age out of being rollback targets. |
MANIFEST_SHAPE_CHANGED | The target is the other manifest shape — see The two manifest shapes. |
ALREADY_LIVE | It is already the live version. |
What rollback changes, and what it deliberately leaves alone:
- Changes:
manifest_url,current_version,manifest_cache. Then the same warm and purge fan-out as a publish, so it lands in seconds. - Leaves alone:
status— rolling back does not un-suspend a suspended plugin, and cannot be used as a kill switch. Use suspension for that (Install, customers and kill switches). - Leaves alone: any in-flight submission. A fix you have already submitted keeps its place in review while the rollback protects customers.
- Leaves alone: customer settings, again. Which is where rollback can still hurt you: if the bad version added a widget or a required config field, rolling back makes those placements resolve against a manifest that no longer declares them, and they fail closed — see the resolution table in The two manifest shapes.
Rolling forward instead — submitting 1.4.1 — is the right move when the breakage is
small, the review queue is fast, or the bad version introduced a widget customers have
already placed. Rollback is right when the breakage is on a customer’s live page now.
Evolving a plugin without breaking customers
Four rules cover almost every release.
1. The manifest shape is frozen at your first publish. (Both shapes.) A live plugin can never move between the single-widget shape and the suite shape; the escape hatch is a new plugin slug, not a new version. Because a suite may contain one widget, starting there costs nothing — The two manifest shapes.
2. Deprecate a widget; never delete its slug. (Suite.) Setting deprecated: true on
a summary removes it from the picker while every existing placement keeps rendering
exactly as before. Deleting the entry instead strands every customer widget that stores
that keyword: the keyword stops resolving and the widget fails closed on their page. The
field’s full behaviour is in
Manifest: widget summary. Note this is not a kill
switch — a deprecated widget’s assets keep serving.
3. Treat your config schema as a stored data format. Nothing migrates settings, so what you do to a field decides what happens to the value a customer already saved:
| Schema change | What happens to existing widgets |
|---|---|
Add a field with .default() | Safe. The key is missing from storage, so parseWidgetConfig supplies the default at render. |
Change an existing .default() | Invisible to existing widgets. Their value was written at creation and stays. |
| Rename a field | The old value is stranded under the old key. Read the old key off raw props.options and fall back, for at least one release. |
| Remove a field | The value stays in storage and is dropped from your parsed config. Harmless. |
Tighten validation (.min(), a narrower enum, a type change) | Dangerous. A stored value that no longer parses makes parseWidgetConfig throw, which takes the widget down on a live page. Widen instead, or accept both shapes and normalise. |
Nothing validates stored settings at any layer — advisory (unvalidated) — so your own parse is the only gate, and it runs on the customer’s page. See Settings and options and Config schema and UI schema.
4. Adding is cheap; renaming is not. New widgets, new slots and new presets appear the moment you publish and affect nobody who has not used them. Slugs, slot names and config keys are the identifiers customers’ stored rows point at — change one and you have made a breaking release whatever the version number says.
Checking a release yourself
The dashboard already verified the manifest before recording it, so this is for answering “is what I think I shipped what is live?”.
# Source: src/app/(protected)/plugins/actions.ts — the same three facts the publish checks.
# Take the URL from the "Published manifest" link on your row in Plugins ▸ My plugins;
# the {env} segment (prod | dev | local) is never optional.
BASE=https://plugins.homerunner.io/prod/acme-weather/1.4.0
curl -s "$BASE/manifest.json?v=$(date +%s)" \
| jq '{id, version, core: .runtime.widgetCore, widgets: [.widgets[]?.slug]}'
curl -s "$BASE/dist/manifest.json" | jq '.files | keys'
If the manifest is right but a customer page still shows the old build, it is cache, not
publishing: append ?dev=1 to the page to bypass every plugin cache and render fresh, and
read the x-hr-render-* headers to see which layer answered. Both are covered in
Previewing SSR locally and
Troubleshooting. The URL layout itself, and what each object’s
cache header is, belong to
Keywords, assets and URLs.
Related
- Preflight and submit — getting to an approved submission.
- Install, customers and kill switches — suspension, per-widget kill switches, and what a customer can switch off.
- Packaging and publishing rules — version rules, the immutability refusal and both audits, verbatim.
- Limits and error index — every number and message on this page.
- From your laptop to a customer page — the whole pipeline in one diagram.