Publishing a plugin
This page takes a finished plugin to a signed, versioned package that operators can install, and shows how to list it in a plugin registry. For writing the plugin itself, see Write a plugin.
A plugin registry is a service that lists signed plugin packages and lets an operator install one from the admin panel. Where this page needs an address, it uses registry.example.com; use the address of the registry you publish to.
Checklist before you release
Check each item. The host runs the same rules at install and at load, so a miss here becomes a refused package later.
plugin.jsonis at the root of the package and has"manifestVersion": 2.iduses lower-case letters, digits and single hyphens, at most 64 characters, and never changes between versions.versionis a strictmajor.minor.patchversion and matches the<Version>in your project file and the release tag.type,entryPointandsdkVersionare set, andcapabilitiesagrees with the interfaces the entry class really implements.- Every key-shaped label, title and description (such as
menu.reports) is defined in theentexts underlocales. - Every route, page and slot follows the path, permission and slot rules in Write a plugin.
- The optional
iconis a package-relative.svgor.pngfile of at most 256 KiB. - The assembly name is your own prefix, such as
MyCompany.MyGateway. Only official plugins useInnovayse.Hostpanel.Plugins.<Name>. - The package carries no copy of
Hostpanel.Sdk.dlland no assembly the host already provides. - The plugin builds against the SDK major the target host supports. A package built against an older SDK major is refused with
PLUGIN_SDK_ASSEMBLY_OUTDATED.
Generate a signing key
Production servers accept only signed packages. Signing uses an Ed25519 key pair and the PluginSigner tool from the Hostpanel repository (backend/tools/Innovayse.Hostpanel.PluginSigner).
dotnet run --project backend/tools/Innovayse.Hostpanel.PluginSigner -- keygen my-key.keyThe command writes the private key to my-key.key (the base64 of the 32-byte key) and prints the public key. The tool refuses a key file inside a git working tree, so keep it elsewhere. To print the public key again later:
dotnet run --project backend/tools/Innovayse.Hostpanel.PluginSigner -- public-key my-key.keyChoose a key id of lower-case letters, digits, dots, hyphens and underscores, starting with a letter or digit, at most 64 characters, for example mycompany-2026. The id is part of the signed text, so a signature cannot be shown under another id.
The private key is never committed, never put in configuration and never copied to a server. In a release pipeline it lives in a protected secret, as in the workflow template below. The key id is public and needs no protection.
Package layout
Stage a folder that holds only what the host needs, then sign that folder:
plugin.json at the root
MyCompany.MyGateway.dll your assembly
assets/icon.svg the file plugin.json names in icon (optional)
LICENSE
plugin.sig written by the signerNever copy the whole build output into the folder. It also holds the SDK and the framework libraries, and the host refuses a package that ships an assembly it provides.
Sign the folder
Sign after the last change to any file in the folder:
dotnet run --project backend/tools/Innovayse.Hostpanel.PluginSigner -- sign ./stage/my-plugin my-key.key mycompany-2026This writes plugin.sig at the root. The signature format and its verification live in the Hostpanel.Sdk.Packaging package (3.1.0), so the signer, the host and your own pipeline all use one implementation. The signature covers every file in the folder, subfolders included, by path and SHA-256 digest. Changing, adding or removing a file afterwards makes the package fail with PLUGIN_SIGNATURE_INVALID.
The signer refuses a path with a control character in its name and any file or folder that is a symbolic link or a junction.
Zip content rules
- Zip the contents of the folder, not the folder itself, so
plugin.jsonandplugin.sigare at the root of the archive. - Name the archive
<id>-<version>.zip, for examplemy-plugin-1.2.0.zip. - Keep the archive within the limits of the installing host and registry: a bounded entry count and a bounded uncompressed size. A package over the limit is refused with
PLUGIN_ARCHIVE_TOO_LARGE. - Every entry must stay inside the plugin folder. A name that climbs out with
..or starts at a root is refused. - Do not add a file after signing, including a stray
.DS_Storeor aThumbs.db. - Check the finished archive before you publish it: list it and confirm it holds
plugin.json,plugin.sigand noHostpanel.Sdkor framework assembly.
(cd stage/my-plugin && zip -r -X ../../my-plugin-1.2.0.zip .)
unzip -l my-plugin-1.2.0.zipRegister your key and reserve an id
A registry needs to know who you are before it accepts a package. The steps, in order:
- Sign in to the registry and create a publisher account for yourself or your organisation.
- Register your public key under that publisher, with the key id you pass to
sign. Only the public key is shared. A key id belongs to one publisher. - Reserve your plugin id. An id is owned by one publisher, and the registry refuses a package for an id that another publisher holds. Some ids are reserved for the registry operator.
- For GitHub release publishing, prove that you control the repository, as the registry instructs.
Operators who install your package straight from a ZIP, without a registry, add the same public key to PluginSigning:OperatorKeys on their server. Without it the install is refused with PLUGIN_SIGNATURE_KEY_UNKNOWN.
Upload to a registry
There are two ways to get a signed package into a registry. Use one or both.
Upload with an API key
Create an API key for your publisher in the registry. The upload is a multipart request to POST /v1/plugins/{id}/versions with the signed ZIP in the file part:
curl --fail-with-body \
--header "Authorization: Bearer $PLUGIN_REGISTRY_API_KEY" \
--form "file=@my-plugin-1.2.0.zip;type=application/zip" \
https://registry.example.com/v1/plugins/my-plugin/versionsThe registry runs the checks the host runs, which come from the Hostpanel.Sdk.Packaging package (3.1.0, versioned with Hostpanel.Sdk; the same package you can reference to run them locally): manifest rules, the id and its reservation, the SDK contract and assembly major, no host assemblies, archive limits, icon safety and the signature against a key registered to your publisher. A refusal comes back as an error status with the rule that failed. Treat the API key as a secret: keep it in the CI secret store, never in the repository.
The workflow template below ends with an optional step that makes this request for you.
Link GitHub releases
Instead of uploading, link the repository to the registry. When you publish a GitHub release that carries the signed ZIP as an asset, the registry pulls it and runs the same checks. The release workflow below already creates such a release. Name the asset <id>-<version>.zip and keep the release tag equal to v<version>.
The release workflow
The template is a GitHub Actions workflow in two jobs. The package job builds, tests and stages the folder and has no secrets. The sign job runs in a protected release environment that holds the signing key and waits for a required reviewer. It signs the folder, zips it, checks the archive for host assemblies, uploads the ZIP to the registry when one is configured and only then publishes the GitHub release, so a package the registry refuses leaves no release behind. The sign job never builds the plugin's own code; it builds only the pinned signing tool and signs the folder the package job staged. Third-party actions are pinned to a commit SHA.
Download plugin-release.yml. Save it as .github/workflows/release.yml and change the values marked CHANGE: PLUGIN_ID (the id from plugin.json), PROJECT and PROJECT_FILE (the project folder and the project file that holds <Version>), SOLUTION (the solution to build and test) and the dotnet-version of the package job (change it when your plugin targets another framework). It expects scripts/package.sh in your repository to stage the folder shown above.
One-time setup in the plugin repository:
- An environment named
releasewith required reviewers and a deployment rule that allows only tags matchingv*. - The environment secret
PLUGIN_SIGNING_KEY(the contents of the private key file). It is the only secret the signing needs. - The repository variable
PLUGIN_SIGNING_KEY_ID, your key id. It is a variable, not a secret, because the id is public by design: it is written intoplugin.sigand the release notes. - A repository variable
HOSTPANEL_SIGNER_REF: the full 40 character commit SHA of the Hostpanel repository whose signer builds the signature. The workflow refuses an empty or non-SHA value. - A tag protection rule for
v*. - To publish to a registry: the repository variable
PLUGIN_REGISTRY_URL(for examplehttps://registry.example.com) and the environment secretPLUGIN_REGISTRY_API_KEY. With noPLUGIN_REGISTRY_URLthe upload step is skipped.
To release, bump the version in plugin.json and the project file, merge to main, then push a tag v<version>. The workflow refuses a tag whose commit is not on main and a tag that disagrees with the manifest or the project.
name: Release
# Builds, tests, packs and signs a Hostpanel plugin when a version tag (vX.Y.Z) is pushed.
# Optionally posts the signed zip to a plugin registry.
#
# Copy this file to .github/workflows/release.yml in your plugin repository and change the values
# marked CHANGE below: PLUGIN_ID, PROJECT, PROJECT_FILE, SOLUTION and the dotnet-version of the
# package job. Read "Publishing a plugin" in the Hostpanel developer docs first.
#
# Two jobs, so the private key is never near code that builds the plugin:
#
# package builds, tests and stages the plugin folder. It has no secrets at all.
# sign runs in the protected "release" environment, which holds the signing key and waits for a required
# reviewer. It signs the staged folder, zips it, uploads the zip to the registry when one is
# configured and only then publishes the release, so a package the registry refuses leaves no release.
#
# The sign job never builds the plugin's own code. It builds only the pinned signing tool and signs the
# files the package job staged, so nothing from your repository runs next to the key.
#
# Setup the repository owner does once (nothing here creates it):
# - Environment "release": required reviewers, and a deployment rule that allows only tags matching v*.
# - Environment secret PLUGIN_SIGNING_KEY: the base64 private key the PluginSigner keygen command wrote. It is
# the only secret the signing needs.
# - Repository variable PLUGIN_SIGNING_KEY_ID: the id operators and the registry list your trusted key under.
# It is a variable, not a secret, because the id is public by design: it is written into plugin.sig and the
# release notes.
# - Repository variable HOSTPANEL_SIGNER_REF: the full 40 character commit SHA of the hostpanel repository whose
# PluginSigner tool signs the package. It is a variable with no default, and the sign job refuses an empty or
# non-SHA value before it checks anything out, so a release never signs with a tool nobody pinned.
# - A tag protection rule for v* so only maintainers can push a release tag.
# - Optional, to publish to a registry: the variable PLUGIN_REGISTRY_URL (for example
# https://registry.example.com) and the environment secret PLUGIN_REGISTRY_API_KEY. With no
# PLUGIN_REGISTRY_URL the publish step is skipped and the release stays on GitHub only.
#
# A tag is signed only when its commit is an ancestor of origin/main.
#
# Third-party actions are pinned to a commit SHA with the version in a comment.
on:
push:
tags:
- "v*"
permissions:
contents: read
env:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
# CHANGE: the plugin id from plugin.json (lower-case letters, digits and single hyphens).
PLUGIN_ID: my-plugin
# CHANGE: the plugin project folder and its project file, which holds the <Version> element.
PROJECT: src/MyCompany.MyPlugin
PROJECT_FILE: src/MyCompany.MyPlugin/MyCompany.MyPlugin.csproj
# CHANGE: the solution to restore, build and test.
SOLUTION: MyCompany.MyPlugin.sln
jobs:
package:
name: Build, test and stage
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
version: ${{ steps.version.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
# The whole history, so the next step can tell whether the tagged commit is on main.
fetch-depth: 0
persist-credentials: false
- name: Check the tagged commit is on main
# A tag can be pushed on any branch; only a commit that reached main may be signed.
run: |
set -eu
git fetch --no-tags origin main:refs/remotes/origin/main
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then
echo "The tagged commit $GITHUB_SHA is not an ancestor of origin/main." >&2
exit 1
fi
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
# CHANGE if your plugin targets another framework.
dotnet-version: "8.0.x"
- name: Check the tag against the manifest and the project
id: version
env:
TAG: ${{ github.ref_name }}
run: |
version="${TAG#v}"
manifest="$(jq -r '.version' plugin.json)"
project="$(sed -n 's:.*<Version>\(.*\)</Version>.*:\1:p' "$PROJECT_FILE" | head -n 1)"
if [ "$version" != "$manifest" ] || [ "$version" != "$project" ]; then
echo "Tag $TAG, plugin.json $manifest and the project $project must all be the same version." >&2
exit 1
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Restore
run: dotnet restore "$SOLUTION"
- name: Build
run: dotnet build "$SOLUTION" -c Release --no-restore
- name: Test
run: dotnet test "$SOLUTION" -c Release --no-build
- name: Stage the plugin folder
# scripts/package.sh copies only the plugin assembly, plugin.json, the icon and the licence into
# the folder it is given. It must not copy the whole build output, and it never signs.
run: sh scripts/package.sh "stage/$PLUGIN_ID"
- name: Keep the staged folder
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ${{ env.PLUGIN_ID }}-staged
path: stage/${{ env.PLUGIN_ID }}
if-no-files-found: error
retention-days: 1
sign:
name: Sign and publish
needs: package
runs-on: ubuntu-latest
timeout-minutes: 30
environment: release
permissions:
contents: write
steps:
- name: Refuse a signing tool that is not pinned to a full commit
# Runs before the tool is checked out: an empty or branch-like ref must never reach the checkout, which
# would take the default branch and build whatever it holds next to the key.
env:
SIGNER_REF: ${{ vars.HOSTPANEL_SIGNER_REF }}
run: |
set -eu
if ! printf '%s' "$SIGNER_REF" | grep -Eq '^[0-9a-f]{40}$'; then
echo "Set the HOSTPANEL_SIGNER_REF repository variable to a full 40 character commit SHA of the hostpanel repository." >&2
exit 1
fi
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
with:
dotnet-version: "9.0.x"
- name: Fetch the staged folder
uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
name: ${{ env.PLUGIN_ID }}-staged
path: stage/${{ env.PLUGIN_ID }}
- name: Check out the pinned signing tool
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
repository: innovayse/hostpanel
ref: ${{ vars.HOSTPANEL_SIGNER_REF }}
submodules: recursive
path: hostpanel
persist-credentials: false
- name: Build the signing tool
# Only the pinned signing tool is built in this job. The plugin's own code is built and tested in the
# package job, which has no secrets, and is never built here.
# No secret is in this step's environment: the tool is built before the key is ever written.
run: dotnet build hostpanel/backend/tools/Innovayse.Hostpanel.PluginSigner -c Release -p:InnovayseSso=false -o signer
- name: Sign the staged folder
env:
PLUGIN_SIGNING_KEY: ${{ secrets.PLUGIN_SIGNING_KEY }}
PLUGIN_SIGNING_KEY_ID: ${{ vars.PLUGIN_SIGNING_KEY_ID }}
run: |
set -eu
# The tool refuses a key inside a git working tree; the runner temp folder is outside the checkout.
key_file="$RUNNER_TEMP/plugin-signing.key"
umask 077
printf '%s\n' "$PLUGIN_SIGNING_KEY" > "$key_file"
dotnet signer/Innovayse.Hostpanel.PluginSigner.dll sign "stage/$PLUGIN_ID" "$key_file" "$PLUGIN_SIGNING_KEY_ID"
dotnet signer/Innovayse.Hostpanel.PluginSigner.dll public-key "$key_file" > "$RUNNER_TEMP/public-key.txt"
rm -f -- "$key_file"
- name: Remove the key file if signing failed
if: failure()
run: rm -f -- "$RUNNER_TEMP/plugin-signing.key"
- name: Zip the signed folder
env:
VERSION: ${{ needs.package.outputs.version }}
run: |
set -eu
test -f "stage/$PLUGIN_ID/plugin.sig"
mkdir -p release
archive="${PLUGIN_ID}-${VERSION}.zip"
# plugin.json must be at the root of the archive, so zip the folder contents.
(cd "stage/$PLUGIN_ID" && zip -r -X "$GITHUB_WORKSPACE/release/$archive" .)
if unzip -l "release/$archive" | grep -q -E 'Hostpanel\.Sdk|Innovayse\.SDK|Polly|Microsoft\.Extensions'; then
echo "The package carries an assembly the host provides." >&2
exit 1
fi
(cd release && sha256sum "$archive" > "$archive.sha256")
echo "ARCHIVE=$archive" >> "$GITHUB_ENV"
- name: Upload to the plugin registry
# Optional, and before the release is created: a package the registry refuses fails the job here, so no
# GitHub release is left behind. Runs only when the PLUGIN_REGISTRY_URL repository variable is set; leave it unset to
# keep the release on GitHub only. The registry checks the manifest, the id reservation and the
# signature against the key you registered, and answers with an error status when it refuses.
if: ${{ vars.PLUGIN_REGISTRY_URL != '' }}
env:
REGISTRY_URL: ${{ vars.PLUGIN_REGISTRY_URL }}
REGISTRY_API_KEY: ${{ secrets.PLUGIN_REGISTRY_API_KEY }}
run: |
set -eu
case "$REGISTRY_URL" in
https://*) ;;
*)
echo "PLUGIN_REGISTRY_URL must start with https://." >&2
exit 1
;;
esac
if [ -z "$REGISTRY_API_KEY" ]; then
echo "Set the PLUGIN_REGISTRY_API_KEY environment secret." >&2
exit 1
fi
# The key goes in a header file so it never shows in the process list or the log.
header_file="$RUNNER_TEMP/registry-header.txt"
umask 077
printf 'Authorization: Bearer %s\n' "$REGISTRY_API_KEY" > "$header_file"
curl --fail-with-body --silent --show-error --proto '=https' --tlsv1.2 \
--header "@$header_file" \
--form "file=@release/$ARCHIVE;type=application/zip" \
"${REGISTRY_URL%/}/v1/plugins/${PLUGIN_ID}/versions"
rm -f -- "$header_file"
- name: Remove the registry header file if the upload failed
if: failure()
run: rm -f -- "$RUNNER_TEMP/registry-header.txt"
- name: Publish the release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
VERSION: ${{ needs.package.outputs.version }}
PLUGIN_SIGNING_KEY_ID: ${{ vars.PLUGIN_SIGNING_KEY_ID }}
run: |
set -eu
{
echo "Signed plugin ${PLUGIN_ID} ${VERSION}."
echo
echo "Signing key id: ${PLUGIN_SIGNING_KEY_ID}"
echo "Public key (base64): $(cat "$RUNNER_TEMP/public-key.txt")"
echo
echo "Add the public key to PluginSigning:OperatorKeys on the server, then upload the zip under Plugins."
} > release-notes.txt
cat release-notes.txt >> "$GITHUB_STEP_SUMMARY"
gh release create "$TAG" "release/$ARCHIVE" "release/$ARCHIVE.sha256" \
--repo "$GITHUB_REPOSITORY" --title "$PLUGIN_ID $VERSION" --notes-file release-notes.txtThe upload step assumes a registry that takes an API key as a bearer token. If your registry names its header differently, change the one line that writes the header file.
Verification badge
A registry may show a verification badge beside a plugin. The badge is set by the registry after its own review, usually a manual one, and it is not something the package can claim. A signature proves who published the files and that they are unchanged. It does not prove the code is safe, and it does not stop an older signed version from being installed again.
To be reviewed, keep the source public or available to the reviewers, list the permissions and hosts the plugin uses, and keep the changelog current. Follow the registry's own review guidance for the rest.
After you publish
- Operators install from the registry or by uploading the ZIP under Plugins. An upgrade of a loaded plugin takes effect at the next restart.
- To withdraw a bad version, follow the registry's yank process and ship a new, higher version. To stop operators trusting a key, remove its registration and tell operators to remove it from
PluginSigning:OperatorKeys. - Never paste live credentials or the private key into an issue, a log or a screenshot.