Skip to content

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.json is at the root of the package and has "manifestVersion": 2.
  • id uses lower-case letters, digits and single hyphens, at most 64 characters, and never changes between versions.
  • version is a strict major.minor.patch version and matches the <Version> in your project file and the release tag.
  • type, entryPoint and sdkVersion are set, and capabilities agrees with the interfaces the entry class really implements.
  • Every key-shaped label, title and description (such as menu.reports) is defined in the en texts under locales.
  • Every route, page and slot follows the path, permission and slot rules in Write a plugin.
  • The optional icon is a package-relative .svg or .png file of at most 256 KiB.
  • The assembly name is your own prefix, such as MyCompany.MyGateway. Only official plugins use Innovayse.Hostpanel.Plugins.<Name>.
  • The package carries no copy of Hostpanel.Sdk.dll and 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).

bash
dotnet run --project backend/tools/Innovayse.Hostpanel.PluginSigner -- keygen my-key.key

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

bash
dotnet run --project backend/tools/Innovayse.Hostpanel.PluginSigner -- public-key my-key.key

Choose 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 signer

Never 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:

bash
dotnet run --project backend/tools/Innovayse.Hostpanel.PluginSigner -- sign ./stage/my-plugin my-key.key mycompany-2026

This 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.json and plugin.sig are at the root of the archive.
  • Name the archive <id>-<version>.zip, for example my-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_Store or a Thumbs.db.
  • Check the finished archive before you publish it: list it and confirm it holds plugin.json, plugin.sig and no Hostpanel.Sdk or framework assembly.
bash
(cd stage/my-plugin && zip -r -X ../../my-plugin-1.2.0.zip .)
unzip -l my-plugin-1.2.0.zip

Register your key and reserve an id ​

A registry needs to know who you are before it accepts a package. The steps, in order:

  1. Sign in to the registry and create a publisher account for yourself or your organisation.
  2. 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.
  3. 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.
  4. 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:

bash
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/versions

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

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 release with required reviewers and a deployment rule that allows only tags matching v*.
  • 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 into plugin.sig and 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 example https://registry.example.com) and the environment secret PLUGIN_REGISTRY_API_KEY. With no PLUGIN_REGISTRY_URL the 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.

yaml
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.txt

The 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.
Was this page helpful?

Released under the AGPL-3.0 License.