Skip to content

Latest commit

 

History

History
237 lines (166 loc) · 14.3 KB

File metadata and controls

237 lines (166 loc) · 14.3 KB
title Plugin CI/CD

Continuous Integration for Plugins

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.

Quick Start

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@master

Push to GitHub — your plugin is now tested on Linux (x64 + arm64), macOS, Windows, and armv7 (Cerbo GX).

Manual Trigger with Custom Settings

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.

What Gets Tested

Platforms

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)

Validation Checks

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.jsonsignalk-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 (setProviderStatussetPluginStatus, setProviderErrorsetPluginError)
  • Internal server properties (app.server, app.deltaCache, app.pluginsMap)
  • Route registration anti-patterns (direct app.get() instead of registerWithRouter())
  • File storage anti-patterns (writing to __dirname or process.cwd() instead of app.getDataDirPath())
  • Security anti-patterns (accessing app.securityStrategy or isDummy() — plugin routes are already protected by the server)
  • Node built-in module version mismatches (node:sqlite requires engines.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

Configuration

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

Formatting and coverage

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.

package.json

The CI validates the same fields described in the publishing guide. The most important for CI:

  • keywords must include signalk-node-server-plugin
  • main or exports must point to your entry file
  • engines.node should declare the minimum Node.js version (required if you use node:sqlite or other version-specific built-in modules)

Plugins without a test script still get all validation checks — tests are skipped with a notice.

armv7 / Cerbo GX Testing

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.

Limitations

  • 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

Integration Tests

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.

Provider API Verification

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.

Self-Hosted Runner for Real Hardware

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 test

Crashing the server after start()

The 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.

See also