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

Preflight and submit

Your plugin passes three gates. You watch the first two happen; the third runs in a reviewer’s browser, days later, against a build you never see — and a failure there costs you a resubmission with a bumped version. So the work on this page is front-loading gate three into your own terminal before you zip anything.

GateRunsYou see it
Source-zip auditIn your browser, before a single byte is uploadedImmediately
Central’s submit APIAfter the bytes are in the bucketAs a coded error in the uploader
Built-zip auditIn the reviewer’s browser, at publish, on their build of your sourceNever

The three gates and every message they can emit are indexed in Limits and error index; the rules themselves are in Packaging and publishing rules. This page is the checklist, the zip, the upload, and what happens after.

The preflight checklist

Every item names the gate it satisfies. Items marked gate 3 are the expensive ones: they pass your submission, pass review, and fail at publish.

Every plugin — Both shapes

  • The build succeeds from a clean checkout of what you committed, not just from your working tree — git clone . /tmp/preflight && cd /tmp/preflight && npm ci && npm run build. That is exactly what the reviewer does, and it is the only way to catch a source file you forgot to commit. (npm run build clears dist/ itself, so a stale artefact cannot survive it.) (gate 3)
  • dist/manifest.json exists and lists a hashed twin for every asset your manifest declares, with that twin present on disk. A missing twin means the tree was edited after the build — rebuild, never patch it. (gate 3)
  • manifest.json is dirty in git — commit it. The build rewrites your tracked manifest twice: schema injection, then the runtime.widgetCore stamp. That stamp is the evergreen-runtime gate and must never be hand-written. (gate 3)
  • Nothing under dist/ is more than one directory deep. dist/{widget}/chunks/x.js is dropped silently at publish — no error, no warning, a 404 at runtime. Keep the SDK’s output layout. (gate 3, silent)
  • The version is bumped — three plain numeric parts, strictly greater than the version that is live, and no pre-release suffix. Publishing, versions and rollback explains what a version number costs you and why a rejected one is not spent. (gate 2)
  • Every media/ file your manifest declares is committed. Nothing generates media/ — not your build, not the reviewer’s. A declared icon, cover, screenshot or README that is missing from your source fails the publish. (gate 3)
  • The shape has not changed. If your plugin is already live, it must stay single-widget or stay a suite. This one is not checked at submit or at review — see The two manifest shapes. (publish only)
  • The zip carries no dist/, node_modules/ or .git/, and package-lock.json is committed so the reviewer’s install resolves what you tested. (gate 1; the lockfile is a warning)

Single-widget plugins only

  • widgetType, ssr.url and assets.js are present at the manifest root. (gate 1)
  • The root manifest.json carries a configSchema — that is your build:manifest step’s output, and without it the customer’s settings form is empty. (gate 3, warning only)

Suites only (widget-core 0.11.0+)

  • Every widgets[].manifest sub-manifest exists at the path you declared, is committed, and contains a non-empty configSchema. A declared-but-absent sub-manifest is a hard publish failure; a schemaless one is a silent empty form. (gate 3)
  • Widget slugs are unique, and none of them is dist, widgets or media. (gate 1)
  • Per-widget icon and readme files exist under media/. (gate 3)
  • Every presets[] entry names a declared layout and children that are either your own content widgets or bare system keywords. (gate 1)

By widget kind

  • A content widget with SSR — Both. ssr-entry.ts exists and re-exports the component, and npm run dev:ssr renders every scenario without window is not defined, with two renders of the same scenario byte-identical. Suite: a suite has no default widget, so name one — npm run dev:ssr -- --widget {slug} (widget-core 0.12.2+, create-hr-plugin 0.8.1+) — and run it once per server-rendering widget. Previewing SSR locally.
  • A CSR-only widget — Suite. The summary says exactly "ssr": false, there is no ssr-entry.ts for it, and expectedHeight is set so its empty server-rendered host reserves the right space. Verify it in the dev sandbox.
  • A layout — Suite. Walk the layout checklist in Layout widgets — an SSR bundle is mandatory, the slots zod field must exist, and the CSS must be class-namespaced because the shell renders as light DOM. npm run dev:ssr -- --widget {layout} previews the shell with every slot empty — enough to prove it evaluates and does not throw, which is the failure that 500s a whole customer page, but the composed page has no local preview (Previewing SSR locally).

Run the reviewer’s gate yourself

Not supported yet. You cannot run the built-zip audit, and its warnings are never forwarded to you. Unless a reviewer copies them into review notes, you will not learn that your plugin shipped with an empty settings form.

This script reproduces the gate-3 checks that depend only on your build. Run it from the project root after npm run build. No FAIL: line means clean.

# Reproduces the publish gates in src/lib/plugin-zip.ts (auditBuildZip) that you can check
# locally: declared assets, hashed twins, the HRPlugins marker, base-schema extension and
# declared media. Verified against hr-plugins/pdp-suite 1.3.3, which passes silently.
node -e '
const fs = require("fs");
const m = JSON.parse(fs.readFileSync("manifest.json", "utf8"));
const twins = JSON.parse(fs.readFileSync("dist/manifest.json", "utf8")).files;
const say = (...a) => console.log("FAIL:", ...a);
const media = [m.icon, m.cover, m.readme, ...(m.screenshots ?? [])];
for (const w of m.widgets ?? [{ slug: m.id, ...m }]) {
  media.push(w.icon, w.readme);
  for (const p of [w.assets?.js, w.assets?.css, w.ssr && w.ssr.url].filter(Boolean)) {
    const key = p.replace(/^.*?dist\//, "");
    if (!fs.existsSync(p)) say("declared asset not built:", p);
    else if (!twins[key]) say("no hashed twin for", key);
    else if (!fs.existsSync("dist/" + twins[key])) say("twin missing:", twins[key]);
  }
  if (w.assets?.js && fs.existsSync(w.assets.js) &&
      !fs.readFileSync(w.assets.js, "utf8").includes("HRPlugins"))
    say("never registers on window.HRPlugins:", w.assets.js);
  const sub = w.manifest ? JSON.parse(fs.readFileSync(w.manifest, "utf8")) : m;
  const props = sub.configSchema?.properties ?? {};
  if (!props.colorScheme || !props.spacing)
    say("configSchema does not extend widgetSchema:", w.manifest ?? "manifest.json");
}
for (const ref of media)
  if (typeof ref === "string" && ref.startsWith("media/") && !fs.existsSync(ref))
    say("declared media missing:", ref);
console.log("preflight complete —", m.id + "@" + m.version, "widgetCore", m.runtime?.widgetCore);
'

It does not cover the size thresholds. Check those by eye against the built-zip limits in Packaging and publishing rules — the one worth acting on is the SSR bundle, past which the renderer stops caching your bundle and every cold server render pays the load cost.

Zip it

Zip the source, not the build. Two forms both pass the audit; the first is better.

# Verified: auditPluginZip (src/lib/plugin-zip.ts) returns ok with 0 errors and 0 warnings
# for both commands, run against hr-plugins/pdp-suite 1.3.3.

# Preferred — archives exactly what is committed, at HEAD.
git archive --format=zip -o ../my-plugin-1.0.0-source.zip HEAD

# Or, from the project directory, if you are not using git.
zip -qr ../my-plugin-1.0.0-source.zip . \
  -x 'node_modules/*' 'dist/*' '.git/*' '*/.DS_Store'

git archive is the better default for one reason: it can only include committed files, so it enforces the rule that bites people after approval. If media/ or widgets/ are gitignored, they vanish from the zip and you find out now instead of at publish. The trade-off is the same property in reverse — uncommitted local fixes are silently left out, so commit first and archive second.

zip -r honours no ignore file. It will happily package .env.local, and a human reads your source. Delete your secrets or use git archive.

Two shapes of archive are accepted: files at the zip root, or everything inside a single wrapper folder (what macOS Finder’s Compress produces). __MACOSX/ entries and .DS_Store files are ignored. Zip64 archives are rejected outright, so use zip -r, Finder, or your platform’s archiver rather than a tool that forces zip64.

Upload it

Dashboard ▸ PluginsMy pluginsUpload plugin. Drag the zip onto the drop zone or click to choose one.

Nothing is transmitted while the audit runs. On failure you get a list of exactly what is wrong and no upload happens. On success you get a green summary card — plugin name, version, slug, and either the widget list or the widgetType, with any warnings in amber underneath.

Read the widget list. It is parsed from the manifest.json inside the zip you just selected, so it is the cheapest check that you zipped the tree you think you did: a widget you added but do not see there is a widget the archived manifest does not declare.

Then the optional notes box, then Submit for review. Use the notes: they are the only channel you have to the reviewer, and review notes are the only channel back.

Checked in your browser, before anything transmits

The archive’s structure and entry rules, its size, entry count and compression ratio, the presence and parseability of manifest.json and package.json, and the shape of the manifest — required root fields, the id slug pattern, the version semver pattern, every widgets[] summary and every preset.

Two of the uploader’s own pre-checks fire before the audit even reads the archive: Please choose a .zip file. and The zip is over the 20 MB limit.

Then, still before the bytes move, the dashboard mints a presigned upload URL and makes a best-effort ownership check — so a slug that belongs to someone else can stop you here rather than after the transfer.

Checked after the bytes are in the bucket

Your browser cannot know these; only central can. Each is a coded 4xx surfaced in the uploader, with the exact messages in Packaging and publishing rules.

CodeWhat it means for you
SLUG_TAKENAnother author owns this manifest.id. Pick a different slug.
SLUG_RESERVEDThe slug exists with no owner — a legacy admin-registered row. Ask HomeRunner to hand it over.
VERSION_NOT_GREATERNot strictly greater than your live version. The browser only checks the version’s shape, never its ordering.
INVALID_VERSIONCentral demands exactly three numeric parts, so 1.0 and 1 are refused here even though a looser parser accepts them.
INVALID_MANIFESTA server-side manifest rule the browser does not apply — length caps on id, description and author, plus the ceiling on how many widgets one plugin may declare. (the browser checks the version length first, so you should not reach Central with that one.)
VALIDATION_ERRORThe submission envelope itself: a bad checksum, a size outside the allowed range, or notes over the length cap.
INVALID_ZIP_KEYThe upload key does not sit under your slug’s folder. The dashboard mints that key, so you should never see this.

Before the metadata call, the dashboard checks the bucket itself: the object must be there, its size must be the one you submitted, and the SHA-256 the bucket stored from your signed upload must match. A mismatch is refused with both values in the message. If storage reports no checksum at all, the submission is refused rather than registered on trust — the presigned URL is not remembered between the two calls, so the bucket’s stored checksum is the only thing tying the hash you register to the bytes you uploaded.

If the bucket’s copy does not match, or Central refuses the registration, the object you just uploaded is deleted again. Two cases deliberately delete nothing: if the dashboard cannot reach storage — or storage will not say what it holds — it registers nothing and removes nothing, because it will not destroy an object it could not inspect; and a key that was not minted for you is refused before Central is told anything. In both cases the bytes you already uploaded stay in the bucket, but you cannot reach them: retrying means choosing the zip again, which mints a fresh key and uploads from scratch, and the preserved object is cleaned up on our side. Only the object your own upload created is ever deleted — the upload key names the account it was minted for.

After you upload

Your plugin row now shows a pending badge and a timeline. A reviewer downloads your zip, verifies its checksum against the one your browser recorded, and reads your source.

The three outcomes

OutcomeNotesWhat it means for you
approvedoptionalNothing is live yet. Your submission is waiting for a publish, and public-versus-private visibility was fixed in the approve dialog.
changes_requestedrequiredThe notes appear on your row, followed by “— fix and re-submit the zip above.”
rejectedrequiredSame, and your uploaded zip is deleted from the bucket.

The five timelines you can be in — pending, changes_requested, rejected, approved and published — are rendered in From your laptop to a customer page, along with who is blocked in each.

What the reviewer is looking at

For a first submission, your source. For an update, the dashboard also diffs the live manifest against yours and shows: config option keys added or removed (base keys excluded), a changed description, a changed widgetType, widgets added or removed, and — per widget present in both versions — changes to category, slot names, pages, SSR on/off, deprecated and declared fonts, plus icon and cover changes.

Per-widget configSchema diffs are not in that list. Sub-manifests only exist as built artifacts, so they are diffed later, at publish, against the live version’s copies on the CDN. An option key you quietly removed is reviewed after the human decision, not before it.

Nobody will tell you

Not supported yet. Nothing notifies you of a review outcome, or of a publish. The only channel is opening Dashboard ▸ Plugins ▸ My plugins and reading the badge, the timeline and the review notes yourself — so poll it, and do not build a release process that waits for a message. The gap is described in full in From your laptop to a customer page.

Re-submitting

Uploading a new zip for the same slug replaces whatever is in flight, from any state — including approved-but-not-yet-published. The status returns to pending and the reviewer, notes and timestamp are cleared.

Your published version is never touched by this. It keeps serving throughout review, rejection and resubmission; only a publish or a rollback changes what customers load. So if an approved submission has not shipped yet and you spot a bug, just submit the fix.