Skip to content

Guide: Run an internal plugin registry

A muaz plugin registry is two static files: an index.json listing available plugins, and an index.json.minisig signature next to it. Anything that serves files over HTTPS can host one — S3, nginx, GitHub Pages, an internal artifact server.

Running your own matters for more than convenience. A registry that declares its own trusted_keys is trusted only for its own plugins: your signing key lets you publish to your team, and gives you no standing anywhere else. That is the property that makes an internal registry safe to add alongside the public one.

On a machine that is not CI:

Terminal window
minisign -G -p publisher.pub -s publisher.key

Give it a real passphrase. Whoever holds publisher.key can publish plugins your team installs as verified, so treat it like a code-signing key: offline backup, no copies on laptops, and a second key generated now and kept in reserve for rotation.

publisher.pub is public. Its second line is the key you distribute.

The public registry, shigar-dev/plugins-muaz, is the reference implementation — the same workflow and index builder muaz’s own plugins are published with. Copy these four files into a new repository:

Terminal window
scripts/build-index.mjs # index builder (no dependencies, deliberately)
.github/workflows/publish.yml # pack → sign → release → rebuild + sign index
CODEOWNERS # point it at your reviewers
yanked.json # start it as []

Then replace publisher.pub with yours and drop the plugins.

Your default branch holds the reviewed sources:

plugins/<name>/manifest.yaml # + agents/ skills/ pipelines/ …
scripts/build-index.mjs # previous index + dist/ → new index.json
publisher.pub # committed
yanked.json # versions to withdraw

and CI publishes the generated index to a separate orphan registry branch:

index.json
index.json.minisig

That split is the whole reason the setup needs no bypass anywhere: CI never writes to the branch people contribute to, so that branch is free to require approvals and code-owner review without deadlocking a publish.

Three things to configure:

  • A publish environment with required reviewers, holding MINISIGN_SECRET_KEY (base64 of publisher.key) and MINISIGN_PASSWORD. Gating the key behind an environment means a merge alone cannot produce a signature.
  • CODEOWNERS requiring a maintainer review on plugins/**. This is the control that covers safety; signing only covers provenance.
  • Branch protection on both branches: a pull request plus whatever review your maintainer count supports on the source branch, and block-force-push + block-deletion (but no PR requirement) on registry. Do not grant a GitHub Actions bypass on either — it would let any workflow on any branch write to a protected branch.

In ~/.muaz/config.yaml:

plugin_registries:
- name: acme-internal
index_url: "https://plugins.acme.internal/index.json"
trusted_keys: ["RW<the key line from publisher.pub>"]
require_signed: true
- name: muaz
index_url: "https://raw.githubusercontent.com/shigar-dev/plugins-muaz/registry/index.json"

Registries are consulted in order and the first hit wins, so listing yours first means an internal plugin shadows a public one of the same name rather than the other way round.

A registry with no trusted_keys inherits muaz’s built-in publisher keys — the right default for the public registry and the wrong one for yours, so always set trusted_keys on an internal entry.

Only if the host is plain HTTP on a private network:

allow_plaintext: true

muaz refuses http:// otherwise, and never follows an https://http:// redirect regardless.

Contributors submit sources; CI does the rest. Locally, an author checks their work with the same code that runs at install time:

Terminal window
muaz plugins pack plugins/acme-tools # validates, writes acme-tools-1.0.0.zip
muaz plugins verify acme-tools-1.0.0.zip # hash, manifest, signature

Packing is reproducible — the same sources always produce the same bytes — so the sha256 an author sees is the one CI publishes and the one every client checks.

On merge, the workflow packs and signs every plugin, creates a release for any version that is new, rebuilds the index, signs it, and pushes both to the registry branch.

It re-packs everything on every run on purpose: that is what keeps checking that the current toolchain still reproduces every artifact you have already published, rather than only the one you just touched.

Add it to yanked.json and merge:

["acme-tools@1.2.0"]

Do not delete the release. Pinned installs (name@version) and every recorded checksum depend on those bytes staying reachable; deleting them breaks honest users while doing nothing to the people who already have the plugin. The flag is what carries the message: muaz skips a yanked version when resolving the newest, refuses it when pinned, hides it from search, and warns anyone still running it.

Removing the line un-yanks it.

Rotating is cheap for a self-hosted registry, because your trust anchors live in each client’s config.yaml rather than compiled into the binary. Do it on a schedule, and immediately if the key is ever exposed:

  1. Generate the new keypair offline, as in step 1.
  2. Add it to trusted_keys alongside the old one and roll that config out. Both are accepted during the overlap, so nothing installed stops verifying.
  3. Swap MINISIGN_SECRET_KEY in the publish environment and re-run the workflow. Everything published from now on is signed by the new key.
  4. Once every client has the new config, drop the old key from trusted_keys.

The overlap in steps 2–4 is the whole point: a client that verifies against only one key at a time will reject either the old archives or the new ones during the switch. If the old key was compromised rather than retired, shorten the overlap to as long as the rollout takes, and re-run the workflow so the index is re-signed under the new key — a leaked key can still sign an old index, and the client’s serial check is what makes replaying one useless.

Worth knowing, because it constrains how you operate the registry:

  • The index must be signed when require_signed is set, and the signature is checked over the raw index bytes before anything parses them.
  • The serial only goes up. Each client records the highest serial it has accepted from your registry and refuses a lower one, so an old index cannot be replayed to hide a security update. The index builder increments it for you; rewriting the registry branch — a restore from backup, a force push, a delete-and-recreate — will lock clients out until they run muaz plugins forget-registry.
  • A registry that has signed cannot go unsigned. Dropping the .minisig is refused even if you later set require_signed: false.
  • Signed plugins stay signed. Once a client installs a plugin verified, an unsigned build of it is refused forever, with no override.
  • published_at older than 30 days raises a staleness warning. If your registry legitimately publishes rarely, re-running the workflow re-stamps it.

See Security for what a signature does and does not prove, and Plugins for the index format.