| title | Plugin CI/CD |
|---|
Signal K provides a reusable GitHub Actions workflow that tests your plugin across all platforms where Signal K server runs. Even plugins without a test suite benefit — the workflow validates your plugin's structure, entry point, configuration schema, lifecycle, and API usage.
Create .github/workflows/signalk-ci.yml in your plugin repository:
name: SignalK Plugin CI
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
test:
uses: SignalK/signalk-server/.github/workflows/plugin-ci.yml@masterPush to GitHub — your plugin is now tested on Linux (x64 + arm64), macOS, Windows, and armv7 (Cerbo GX).
Add workflow_dispatch to get a "Run workflow" button in the GitHub Actions UI where you can override Node versions, toggle armv7/Cerbo GX testing, enable integration tests, and more — without editing your workflow file.
Because workflow_call and workflow_dispatch inputs are separate namespaces in GitHub Actions, the workflow needs two jobs: one for automatic runs (push/PR) with hardcoded defaults, and one for manual runs that passes through your form inputs.
See examples/plugin-caller-example.yml for the full workflow with manual trigger support.
| Platform | Architecture | Node versions | Notes |
|---|---|---|---|
| Linux | x64 | 22, 24 | GitHub-hosted runner |
| Linux | arm64 | 22, 24 | GitHub-hosted runner — Raspberry Pi 4/5 |
| macOS | arm64 | 22, 24 | GitHub-hosted runner |
| Windows | x64 | 22, 24 | GitHub-hosted runner |
| Linux | armv7 (Cerbo GX) | 20 | QEMU emulation — matches Venus OS 3.70 (Node 20) |
The desktop jobs (Linux, Linux arm64, macOS, Windows) run these checks, even if your plugin has no test suite. The list below is a summary for readers — the authoritative source for what the CI actually validates is the workflow itself: .github/workflows/plugin-ci.yml.
package.json — signalk-node-server-plugin keyword, main or exports field, engines.node declaration
Entry point — After build, verifies the plugin exports a constructor function
plugin.schema() — Calls schema() and checks it returns a JSON-serializable schema-like object without crashing (not fully validated against the JSON Schema meta-schema)
Lifecycle — Runs start() → stop() → start() (restart) with an empty configuration. Validates delta messages emitted during startup and checks that registerDeltaInputHandler handlers forward deltas correctly.
Async crashes — Watches for roughly 1.5 seconds after the lifecycle calls return and fails the job if the plugin fails asynchronously in that window — an unhandled 'error' event (which ends the server process) or a floating promise that rejects late. A failure that takes longer to surface will not be caught, so a clean run is not proof there is none. See Crashing the server after start() below.
API usage — Scans source files for:
- Deprecated APIs (
setProviderStatus→setPluginStatus,setProviderError→setPluginError) - Internal server properties (
app.server,app.deltaCache,app.pluginsMap) - Route registration anti-patterns (direct
app.get()instead ofregisterWithRouter()) - File storage anti-patterns (writing to
__dirnameorprocess.cwd()instead ofapp.getDataDirPath()) - Security anti-patterns (accessing
app.securityStrategyorisDummy()— plugin routes are already protected by the server) - Node built-in module version mismatches (
node:sqliterequiresengines.node >= 22.5.0)
npm pack — Verifies all files referenced by main/exports are included in the published package
App Store compatibility — Installs the plugin with --ignore-scripts (as the App Store does) and checks for native addon dependencies
Stray files — Warns when build and test steps leave untracked files
Override defaults by passing inputs to the shared workflow:
jobs:
test:
uses: SignalK/signalk-server/.github/workflows/plugin-ci.yml@master
with:
test-command: 'npm run test:ci'
build-command: 'npm run build:plugin'
enable-armv7: false
enable-signalk-integration: true
node-versions: '["22"]'| Input | Default | Description |
|---|---|---|
test-command |
npm test |
Command to run your test suite |
build-command |
npm run build --if-present |
Build command |
format-check-command |
(empty) | Blocking format check (e.g. npm run prettier:check, npx biome check .); skipped when empty |
coverage-command |
(empty) | Runs tests with coverage (e.g. npm run coverage); replaces the standard test run and writes output to the step summary |
node-versions |
["22", "24"] |
Node versions for desktop platforms |
enable-armv7 |
true |
Test on armv7 (Cerbo GX) via QEMU |
enable-signalk-integration |
false |
Start SignalK server for integration tests |
signalk-server-versions |
["latest"] |
JSON array of signalk-server versions; the integration job fans out over each |
Both are tool-agnostic command strings — the workflow doesn't care whether you use Prettier/Biome or c8/nyc/jest --coverage. Leave either empty to opt out.
with:
format-check-command: 'npm run prettier:check'
coverage-command: 'npm run coverage'format-check-command runs after lint and blocks the job if it fails (unlike npm run lint --if-present, which is advisory). coverage-command replaces the standard Run tests step — its stdout is captured and appended to the GitHub Actions step summary so you can see coverage output without digging through logs.
The CI validates the same fields described in the publishing guide. The most important for CI:
keywordsmust includesignalk-node-server-pluginmainorexportsmust point to your entry fileengines.nodeshould declare the minimum Node.js version (required if you usenode:sqliteor other version-specific built-in modules)
Plugins without a test script still get all validation checks — tests are skipped with a notice.
The Cerbo GX runs an Allwinner dual-core Cortex-A7 (ARMv7, 32-bit) with Venus OS. The CI emulates this environment using QEMU with a node:20-bookworm-slim Docker image plus python3, make, and g++ — matching Venus OS 3.70 which ships Node 20 and has build tools available via opkg.
The armv7 job runs install, build, and tests — it does not repeat the full validation suite (that's covered by the desktop jobs). The armv7 Node version is fixed to match the Cerbo GX and is not user-configurable. Expect armv7 jobs to take 3-5x longer than native x64. armv7 failures are advisory and non-blocking.
- Native addons compile for armv7 inside the container (slow but works — pre-built binaries rarely exist for ARM32)
- Hardware peripherals (GPIO, CAN bus, serial) are not emulated — use a self-hosted runner for those
Enable enable-signalk-integration: true to run your plugin against a real Signal K server.
The job installs a Signal K server, packs and installs your plugin, auto-enables it, and starts the server with sample NMEA 0183 + NMEA 2000 data so the plugin has a realistic data environment (navigation, wind, depth, temperature, battery, and more). It then verifies the plugin loaded, checks provider API registrations, and runs npm run test:integration if defined. Your tests receive SIGNALK_URL=http://localhost:3000 to connect to the running server.
The authoritative sequence of steps lives in the workflow itself: .github/workflows/plugin-ci.yml.
Pass signalk-server-versions as a JSON array to fan the integration job out over multiple server versions — useful for catching regressions across the baconjs 1 → 3 transition (server 2.23.x vs 2.24.0+) and similar cross-generation breakage:
with:
enable-signalk-integration: true
signalk-server-versions: '["2.23.0", "latest"]'The integration job runs the full Cartesian product of node-versions × signalk-server-versions. The default ["22", "24"] × ["latest"] is 2 jobs; ["22", "24"] × ["2.23.0", "latest"] is 4. To keep the matrix small, shrink either dimension — integration coverage often only needs a single Node version (node-versions: '["22"]') even when the desktop jobs exercise several.
If your plugin registers as a provider for one of the server's provider APIs, the integration test verifies the registration actually works by calling the corresponding endpoint:
| Provider API | Registration method | Endpoint checked |
|---|---|---|
| History API v2 | app.registerHistoryApiProvider() |
/signalk/v2/api/history/values must not return 501 |
This catches a common class of bugs where a plugin calls a registration method but the endpoint still returns "no provider configured" — for example due to an API mismatch between the plugin and the server version being tested.
For testing against actual hardware (GPIO, CAN bus, serial ports), add a self-hosted runner on a Cerbo GX or Raspberry Pi:
test-cerbo-hardware:
name: Cerbo GX (real hardware)
runs-on: [self-hosted, cerbo-gx]
steps:
- uses: actions/checkout@v6
- run: npm ci
- run: npm testThe server does not sandbox plugins. Everything runs in one Node process, so
an error your plugin fails to handle does not disable just that plugin — it
terminates the whole server. Under a process supervisor (Restart=always,
Docker's restart: unless-stopped) the server then restart-loops: it comes up,
runs for a few seconds, dies again, and keeps going indefinitely.
The dangerous case is the one that escapes try/catch. Wrapping the body of
start() is not enough, because two common failures arrive after start()
has already returned successfully:
Unhandled 'error' events. In Node, 'error' is special: an EventEmitter
that emits it with no listener attached throws, and there is nothing up the
stack to catch it. Sockets, streams, D-Bus connections, serial ports and MQTT
clients all report failures this way.
Attach an 'error' listener to every emitter your plugin holds, at the point
you create it, and report the failure through app.setPluginError(). A
listener that only handles the success event ('data', 'message',
'connect') leaves the failure path unguarded.
A library opening the connection for you does not remove the obligation — if it hands back an emitter and attaches no listener itself, the responsibility is still yours.
Floating promises. An async call started but never awaited (and with no
.catch()) becomes an unhandled rejection. Node terminates the process for
these by default, but the server installs an unhandledRejection handler: it
logs the rejection and, when the originating plugin can be identified from the
stack, records a plugin error. So a floating rejection usually degrades your
plugin rather than killing the server — the failure is silent from the user's
point of view, and the work you meant to do never happened.
Either await the call inside start(), so its own error handling applies, or —
when the work must continue in the background — attach a rejection handler when
you kick it off. What fails the check is a call whose rejection nothing
observes.
The general rule: a plugin should report failures through
app.setPluginError() and keep the process alive. Hardware that is absent, a
socket that is missing, a remote service that is down — these are normal
conditions on a boat, not reasons to take navigation data offline.
CI installs uncaughtException and unhandledRejection handlers around the
lifecycle check and fails the job if either fires, including when
start(), stop() and restart all report success.
The check is an error rather than a warning because of the first case. The
server's own uncaughtException handler logs the error and flags the plugin,
but it cannot stop an unhandled 'error' event: Node throws on those
synchronously, and the throw ends the process regardless of what the handler
logged. One faulty emitter therefore takes the whole server down, and the
resulting crash-loop is difficult for users to trace back to its cause.
- Releases and Changelogs — once CI passes, automate the release cut and publish step.