The post-0.4.1 plugin audit found five real bugs across the built-in kernel plugins (a UI surface that never showed up, a non-functional
export_endpoint, a sidebar that leaked command-palette items, a broken route on the ConfigPresets palette entry, and antelemetry.export.errorre-emit that could feedback-loop the export on a non-2xx response). They ship here together with the deploy-queue management surface (global queue page + cancel/remove + per-service position) that 0.4.1 tagged without, and the first end-to-end test against the typed SDK surface. Release pipeline is hardened so this version can never ship under a non-semver tag.
Plugin Audit & Fixes
- NotificationsDispatcherPlugin ships a
command:palettemenuItem now — the plugin listened ondeployment.status_changed/service.health_changed/backup.completedand emittednotification.queued, but had nomenuItemsentry at all, so the only path to its config was the hidden/settings?section=pluginsURL. The new entry points to/settings?section=notifications. - TelemetryStreamerPlugin actually POSTs to
export_endpointnow — the configSchema exposed the field and the description said records were pushed, but the init handler only re-emittedtelemetry.recordedas a pass-through and the endpoint was silently ignored. Wires a realfetch()call with HMAC-SHA256 signing (X-NineDeploy-Signature: sha256=<hex>) and per-request AbortSignal timeout; failures land ontelemetry.export.errorcustom events so the audit pipeline picks them up. - TelemetryStreamerPlugin wildcard filter drops
telemetry.recorded(recursion guard) andtelemetry.export.error(a non-2xx response re-emitted itself astelemetry.recorded, re-fetched, re-failed, and OOMed the test process — the export now short-circuits before the loop can build). Also dropsplugin.*/config.*so aplugin.registeredtick never surfaces as user-facing audit data. - Layout sidebar no longer leaks
command:paletteitems into the Extensions group. Every built-in plugin that registered anymenuItemsended up in the rail-mounted Extensions group regardless of slot —Build Cache,Webhook Out,Domain Presets,Sticky IPshowed up in BOTH the Cmd+K palette AND the sidebar. Filtermenus.datatom.slot === 'sidebar:secondary'so only Cloudflare Tunnels (the only built-in plugin in the registry that uses that slot) lands in the rail. - ConfigPresetsPlugin menuItem repointed from
/settings/presets(no such route) to/settings?section=config, where the panel renders thepreset.list/preset.<id>.valuesrows the plugin owns. - plugin-sdk MenuSlot union extended with
database:tabs— the slot was in the kernel's runtime type but missing from the SDK type, so an external plugin declaring a database-tab menuItem would compile against the SDK and then have the kernel reject the row at runtime. SDK now mirrors the kernel exactly.
Deploy Queue Management
- Global queue page at
/deploys— every in-flight (queued / building / deploying) deploy across every service the caller can see, with one-click cancel + remove, per-service position chip on queued rows (#3 of 5next to the timestamp), 3s auto-refresh, and aDeployQueueBadgein the top bar that hides when the queue is empty and pulses while a row is live. Member sessions see only the rows on services they can admin. - Multiple queued deploys per service (50-row cap) —
the old dedup short-circuited on ANY queued/building
match, so a
services deployclick during a long build silently dropped. Split into in-flight (still wins) + per-service queued (cap 50, returns the actual row). - Cancel + remove routes on
services.deploys— queued deploys stop immediately, in-flight ones stop at the next pipeline step boundary with the previous version still serving. Remove refuses in-flight (cancel first) and refuses therunningrow (it carries the digest a rollback re-deploys). - CLI:
ninedeploy deploys queue— same data the web panel's /deploys page renders, with per-service 1-based queue position (queued rows only; in-flight rows get a dash so the column reads cleanly) and a by-status k/v block at the bottom (queued / building / deploying) so the operator can confirm the empty state at a glance. - MCP:
list_queue+remove_deploytools register the matchingrequiredScopes(read for queue, write for remove) so aread-only token can list the queue but cannot delete a deploy — same gate the SDK + HTTP layer enforce. - Longest-match sidebar routing —
Layout.findGroupnow picks the longest prefix match, not the first one. Plugin-contributed menu items register routes like/settings/extensions/<plugin-id>; the static System group also owns/settings, so a first-match lookup routed every plugin click into System and the user landed in the wrong panel.
Integration Coverage
- SDK ↔ server queue end-to-end test — wires the real
SDK client to the real Fastify route via Fastify's
app.inject()(no port binding, no real network). A customfetchtranslates the SDK's standardAuthorization: Bearer <token>header to the in-process test app'sx-test-userheader. Pins the contract that the SDK schema and the route JSON agree — a renamed response key, a removed field, a 404 that became a 400, an SDK shape that no longer matches the route JSON will all surface here first.
Installer & Release
- Strict-semver tag validation in the release
workflow. The trigger
on: push: tags: ['v*']accepted any v-prefixed ref —v0.4.0-foo,v0.4.0+build.1, even a strayv0.4.0with trailing whitespace. install.sh and selfUpdate.ts only resolve^v\d+\.\d+\.\d+$, so pushing a different shape would build a real image no install path can ever reach. A new first step fails fast on anything that does not match strict semver. - Multi-arch release build — the ci.yml
publish-imagejob was already multi-arch (linux/amd64 + linux/arm64), but the release tag pipeline (which is whatinstall.sh --dockeractually pulls) was silently amd64-only. Every ARM host (Raspberry Pi, AWS Graviton, Apple-Silicon-as-target) hit a "no matching manifest" error ondocker pull. Mirrors the daily edge build's architecture coverage. - Marketplace catalog shape smoke test — every entry
in
MARKETPLACE_CATALOGis walked and asserted against the shape the loader depends on (id / name / version / menuItem id+slot+label+route+route-format / configSchema key+label+isSecret). A typo in a new catalog entry would have shipped as a row the panel could show but not install; the test makes that fail in CI.