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.
1. Generate a signing key
Section titled “1. Generate a signing key”On a machine that is not CI:
minisign -G -p publisher.pub -s publisher.keyGive 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.
2. Set up the repository
Section titled “2. Set up the repository”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:
scripts/build-index.mjs # index builder (no dependencies, deliberately).github/workflows/publish.yml # pack → sign → release → rebuild + sign indexCODEOWNERS # point it at your reviewersyanked.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.jsonpublisher.pub # committedyanked.json # versions to withdrawand CI publishes the generated index to a separate orphan registry branch:
index.jsonindex.json.minisigThat 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
publishenvironment with required reviewers, holdingMINISIGN_SECRET_KEY(base64 ofpublisher.key) andMINISIGN_PASSWORD. Gating the key behind an environment means a merge alone cannot produce a signature. CODEOWNERSrequiring a maintainer review onplugins/**. 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.
3. Point clients at it
Section titled “3. Point clients at it”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: truemuaz refuses http:// otherwise, and never follows an https:// → http://
redirect regardless.
4. Publish a plugin
Section titled “4. Publish a plugin”Contributors submit sources; CI does the rest. Locally, an author checks their work with the same code that runs at install time:
muaz plugins pack plugins/acme-tools # validates, writes acme-tools-1.0.0.zipmuaz plugins verify acme-tools-1.0.0.zip # hash, manifest, signaturePacking 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.
5. Withdraw a bad version
Section titled “5. Withdraw a bad version”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.
6. Rotate your signing key
Section titled “6. Rotate your signing key”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:
- Generate the new keypair offline, as in step 1.
- Add it to
trusted_keysalongside the old one and roll that config out. Both are accepted during the overlap, so nothing installed stops verifying. - Swap
MINISIGN_SECRET_KEYin thepublishenvironment and re-run the workflow. Everything published from now on is signed by the new key. - 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.
What clients enforce
Section titled “What clients enforce”Worth knowing, because it constrains how you operate the registry:
- The index must be signed when
require_signedis 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
serialit 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 theregistrybranch — a restore from backup, a force push, a delete-and-recreate — will lock clients out until they runmuaz plugins forget-registry. - A registry that has signed cannot go unsigned. Dropping the
.minisigis refused even if you later setrequire_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_atolder 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.