Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

  1. Upload. The built zip’s publishable files are PUT one at a time into the immutable key prefix {env}/{slug}/{version}/, root manifest.json last. Until that last object exists, nothing is live and the same zip can be retried safely.
  2. Verify. The dashboard fetches the published manifest.json back from its public URL with a cache-buster and refuses to continue unless it serves and its id/version match the submission. “Published” means observed serving, not uploaded.
  3. Repoint. Central records the verified URL and manifest in one database transaction. This is the switch.
  4. 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 writesRead fromWhat it changes for you
manifest_url, current_versionThe verified URLEvery new render resolves your keywords against the new version prefix
manifest_cacheThe manifest itselfThe widget picker, per-widget summaries, slots, presets, fonts
name, description, author.name, author.urlThe manifest’s root fieldsYour marketplace listing text — it only ever updates at publish
status = approvedFixedA suspended plugin is un-suspended by publishing a fix
submission_status = nullFixedYour submission is cleared; the stepper reads Published
published_at, build_metaNow, and the build hash + file listForensics 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.0 is a submission of 1.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.0 came back as changes_requested, resubmit the fix as 1.4.0 again.

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.1 pass 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 bare X.Y.Z path segment, so a pre-release prefix falls back to proxied asset URLs and short-TTL caching everywhere. Nothing warns anyone. Ship plain MAJOR.MINOR.PATCH versions. 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

LayerHoldsWhat releases it
Browser, hashed asset URLOne immutable fileNothing. A new version is a new filename.
/p/ proxy pointerWhich plugin/version a logical filename maps to60 s TTL — see Keywords, assets and URLs
Renderer, evaluated SSR moduleYour executed bundle, in processA versioned URL never expires in process; a new version is a different URL
Renderer, Redis bundle textYour bundle’s sourceThe publish warm overwrites the slug-keyed entry; a 30-day TTL is only a backstop
Renderer, compiled pageThe customer’s rendered HTMLThe per-feed purge, else 24 h
Edge KV, composed page + renderer payloadThe customer’s served HTMLThe 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 hitWhen
ROLLBACK_MANIFEST_UNREACHABLEThe prefix was pruned from the CDN. Rollback needs the files to still be there.
MANIFEST_WIDGET_CORE_MISSING / _TOO_OLDThe target predates the evergreen runtime floor. Old releases can age out of being rollback targets.
MANIFEST_SHAPE_CHANGEDThe target is the other manifest shape — see The two manifest shapes.
ALREADY_LIVEIt 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 changeWhat 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 fieldThe 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 fieldThe 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.