Skip to content

Plugin Distribution & Packing

This guide is for plugin authors — anyone publishing a Checkstack plugin that operators install via the runtime Plugin Manager UI. It covers the required package.json shape, how to pack with the @checkstack/scripts plugin-pack CLI, single-package vs bundle mode, and release workflow patterns for npm, GitHub, and direct tarball delivery.

If you only want to consume a plugin as a platform operator, see Install a plugin for the Plugin Manager UI walkthrough.

Looking for the dev loop? This doc covers the distribution mechanics (packing, bundles, channels). For local development — add @checkstack/dev-server as a devDependency, wire "dev": "checkstack-dev" into your package.json scripts, and run bun run dev — see Developing Plugins in Isolation.

Distinction: monorepo-internal plugins (the ones living in core/ and plugins/ of this repo) are loaded automatically at boot via filesystem discovery. Everything below is about plugins that ship independently via npm / GitHub release / tarball upload — the mechanism the runtime Plugin Manager uses.

Every installable plugin is just an npm package whose package.json declares a checkstack block. The platform’s install pipeline validates this block (plus a few standard fields) on every install.

FieldSourceNotes
namestandardMust be the npm package name. Scoped names (@org/foo) are fine.
versionstandardA valid semver string. Used for compatibility checks.
descriptionstandardOne-line summary shown in the install confirmation modal.
authorstandardString ("Jane <jane@example.com>") or object ({ name, email?, url? }).
licensestandardAny SPDX identifier (or SEE LICENSE IN ...).
checkstack.typeCheckstackOne of "backend", "frontend", "common".
checkstack.pluginIdCheckstackStable runtime id (e.g. "healthcheck-http"). Must match pluginId in plugin-metadata.ts.
FieldNotes
homepageLinked from the install confirmation modal.
repositoryStandard repository form. Surfaced in the admin UI for the operator’s reference.
checkstack.bundleArray of sibling package names that install/uninstall atomically with this one. Set on the primary package only.
checkstack.usageInstructionsMarkdown string shown in the install confirmation modal. Use it to describe required env vars, config, integrations, etc.
checkstack.allowInstallScriptsDefault false. When true, the platform runs bun install without --ignore-scripts. Surfaces in the security warning. Use sparingly — it’s the loudest dial-up of trust requirements during install.

A minimal valid package.json:

{
"name": "@my-org/widget-backend",
"version": "1.2.3",
"description": "Widget tracker for Checkstack",
"author": "ACME Corp",
"license": "MIT",
"checkstack": {
"type": "backend",
"pluginId": "widget"
},
"scripts": {
"pack": "bunx @checkstack/scripts plugin-pack"
}
}

The pack script is the single supported entrypoint. Do not call bun pm pack directly — plugin-pack validates metadata before packing, catches issues at build time instead of install time.

Install once per machine via npx-style runner:

Terminal window
bunx @checkstack/scripts plugin-pack --help
Usage: checkstack-scripts plugin-pack [options]
Options:
--bundle Pack the primary plus every sibling declared in
package.json#checkstack.bundle into a single outer
tarball with a bundle.json manifest.
--out-dir <dir> Output directory (default: ./dist)
--validate-only Only validate metadata; do not pack.
--cwd <dir> Run as if invoked from <dir> (default: process.cwd())
--help, -h Show this message.
  1. Reads <cwd>/package.json and validates it against installPackageMetadataSchema — the same Zod schema the runtime install pipeline uses. Failures are reported with the specific field path so you know exactly what to fix.
  2. Runs bun run typecheck and bun run lint if those scripts exist. Mirrors what CI does — catches type/lint regressions before packing.
  3. Resolves any workspace:* dependency ranges to concrete versions read from sibling package.jsons. If you publish from a workspace, never ship workspace:* to npm — plugin-pack rewrites them at pack time and restores your source on disk afterward, so your dev tree is unchanged.
  4. Calls bun pm pack --destination <out-dir> to produce the tarball.
  5. (Bundle mode only) Wraps the per-package tarballs in an outer <name>-<version>-bundle.tgz with a bundle.json manifest.

Per-package mode (default — what you publish to npm):

Terminal window
cd packages/widget-backend
bun run pack
# → dist/my-org-widget-backend-1.2.3.tgz

Bundle mode (what you attach to a GitHub release or upload via the Plugin Manager UI):

Terminal window
cd packages/widget-backend # the *primary* — the one with checkstack.bundle
bun run pack -- --bundle
# → dist/my-org-widget-backend-1.2.3-bundle.tgz

Bundle tarballs are never published to npm. npm always gets the per-package .tgzs individually.

A “plugin” the operator installs may consist of several npm packages — the classic example is a backend (-backend), a frontend (-frontend), and a shared types package (-common). The platform installs and uninstalls all of them atomically.

Pick one package as the primary (typically -backend) and add a checkstack.bundle array listing the sibling package names:

my-org-widget-backend/package.json
{
"name": "@my-org/widget-backend",
"version": "1.2.3",
"checkstack": {
"type": "backend",
"pluginId": "widget",
"bundle": [
"@my-org/widget-common",
"@my-org/widget-frontend"
]
}
}

Siblings should NOT carry checkstack.bundle — only the primary does. Siblings can still ship the same pluginId if they’re part of the same logical plugin.

All siblings in a bundle must share the same version at pack time. The compatibility checker resolves bundle-internal @checkstack/* dependencies against the bundle’s package set first, falling back to the platform’s loaded versions only when the dep isn’t part of the bundle. Mismatched sibling versions will fail the install with a clear message.

Use changesets or a release tool that bumps siblings in lockstep.

For npm distribution, publish each sibling separately as a normal npm package. The platform installs the primary by name; on previewInstall, the runtime resolves each sibling from checkstack.bundle against the same registry and pins to the primary’s exact version. The Plugin Manager UI shows the full list before the operator confirms.

Terminal window
# In CI, publish each sibling
cd packages/widget-common && bun publish --access public
cd packages/widget-backend && bun publish --access public
cd packages/widget-frontend && bun publish --access public

Distributing bundles via GitHub release or tarball upload

Section titled “Distributing bundles via GitHub release or tarball upload”

For GitHub or direct upload, use bundle mode to produce a single outer tarball that contains every sibling and a manifest:

Terminal window
bun run pack -- --bundle

The result has this layout:

my-org-widget-backend-1.2.3-bundle.tgz
├── bundle.json # manifest
└── packages/
├── my-org-widget-common-1.2.3.tgz
├── my-org-widget-backend-1.2.3.tgz
└── my-org-widget-frontend-1.2.3.tgz

bundle.json:

{
"bundleVersion": 1,
"primary": "@my-org/widget-backend",
"packages": [
{ "name": "@my-org/widget-backend", "version": "1.2.3",
"tarball": "packages/my-org-widget-backend-1.2.3.tgz" },
{ "name": "@my-org/widget-common", "version": "1.2.3",
"tarball": "packages/my-org-widget-common-1.2.3.tgz" },
{ "name": "@my-org/widget-frontend", "version": "1.2.3",
"tarball": "packages/my-org-widget-frontend-1.2.3.tgz" }
]
}

Attach this single tarball to your GitHub release; the platform unpacks all siblings on install.

Compatibility (no manual declaration needed)

Section titled “Compatibility (no manual declaration needed)”

You do not declare a “compatible Checkstack version” anywhere. The platform reads the semver ranges in your plugin’s dependencies block and checks each @checkstack/* entry with semver.satisfies against the loaded core packages.

{
"dependencies": {
"@checkstack/backend-api": "^1.0.0", // → must match what's loaded
"@checkstack/widget-common": "^1.2.0", // → resolved from bundle if present
"lodash": "^4.0.0" // → ignored (not @checkstack/*)
}
}

If the platform has @checkstack/backend-api@2.0.0 loaded but your plugin declared ^1.0.0, install fails with a BAD_REQUEST and the message "Plugin '...' requires @checkstack/backend-api@^1.0.0 but this platform has 2.0.0."

workspace:* ranges are explicitly rejected by the runtime — they’re a pack-time-only construct. The plugin-pack CLI resolves them automatically.

ChannelBest forPack mode
npm (public)Community plugins; broad discoverabilityper-package
npm (private)Org-internal plugins behind an npm registryper-package
GitHub releasePlugins not published to npm; signed artifacts--bundle
GitHub EnterpriseAir-gapped / private GHE deployments--bundle
Tarball upload (UI)Local dev; one-off testingper-package or --bundle

Publish per-package as you would any npm library. The platform’s npm installer hits the registry’s metadata endpoint (<registry>/<package>/<version>), downloads the dist.tarball URL, and stores the bytes in Postgres. Configurable per-source via PluginSource.registry so deployments behind a private registry (Verdaccio, JFrog, GitHub Packages, …) work without env-var twiddling.

Convention: each release tag has exactly one .tgz asset (the bundle tarball produced by plugin-pack --bundle). The platform fetches via the GitHub API, downloads the asset, validates, and stores.

For repos with multiple .tgz assets, the install source carries an optional assetName field — the Plugin Manager UI exposes this.

Three additional fields on the install source:

Source fieldMeaning
apiBaseUrlYour GHE API root, e.g. https://github.example.com/api/v3. Defaults to public github.com when omitted.
tokenEnvVarName of the env var on the platform that holds the PAT. Defaults to GITHUB_TOKEN.

Multiple GitHub instances in the same deployment? Set different tokenEnvVar values per source — e.g. GITHUB_TOKEN_PUBLIC, GITHUB_TOKEN_GHE.

Operators can drag-and-drop a .tgz directly. The platform:

  1. Stores the bytes in plugin_artifacts and returns an artifactId.
  2. Treats it the same as a GitHub-release source — peek the package.json or bundle.json, validate, install.

Useful for local development where you don’t want to push to a registry. The 50MB tarball cap applies to all sources.

Section titled “Recommended release workflow (GitHub Actions)”

A copy-paste starting point lives at docs/examples/plugin-release.yml. The short version:

name: Release Plugin
on:
push:
tags: ["v*.*.*"]
permissions:
contents: write # to upload release assets
id-token: write # for npm provenance
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- run: bun install --frozen-lockfile
- run: bunx @checkstack/scripts plugin-pack --bundle
- name: Attach bundle to release
uses: softprops/action-gh-release@v2
with:
files: dist/*-bundle.tgz
# Optional: publish per-package to npm in a matrix
# - run: cd packages/widget-backend && bun publish --access public
# env:
# NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}

For per-package npm publish, run bun publish --access public in each sibling directory. Don’t run bun publish on the bundle tarball — npm won’t accept it.

Lint your package.json against the install-time schema in your own CI, without pack:

Terminal window
bunx @checkstack/scripts plugin-pack --validate-only

Returns non-zero on any schema violation with a per-field error list. We run this in our own publish script before each bun publishscripts/publish-packages.ts is a working reference if you want to mirror the pattern.

Plugins run in-process with full platform access — same Bun event loop, same database connection, same secrets. The platform’s defenses are:

  1. Strong typed-confirmation modal — operator types the exact plugin name to install. No “click OK” muscle-memory bypass.
  2. bun install --ignore-scripts — postinstall scripts in the plugin and its transitive deps don’t execute. Opt out per-plugin via checkstack.allowInstallScripts: true; this surfaces in the security warning so the operator sees it.
  3. Source disclosure — the install modal shows source type (npm/github/tarball), the package name + version, the author, license, homepage, and any compatibility issues before commit.
  4. Bundle atomicity — partial installs aren’t possible; if any sibling fails validation, none commit.

The platform does not sandbox plugin code (process isolation, V8 contexts, etc. — see the design doc for why). Operators should only install plugins from trusted authors. Plugin authors should make their distribution channels easy to audit (signed tags, reproducible builds, public source).