Central registry for semrel plugins — a Go-based REST API that stores, validates, and serves plugin metadata.
- semrel — the core release tool
- semrel-plugins — official plugin catalog
- semrel-docs — documentation site
The registry is the canonical source for published semrel plugins.
- Plugin authors publish versioned GitHub Releases in their own repositories.
- A
repository_dispatchwebhook notifies the registry (POST /api/v1/webhooks/release). - The registry validates metadata, stores plugin and version records, and updates
plugins.json. - Consumers fetch the index via
GET /plugins.jsonor browse individual plugins via the REST API. - The
semrelCLI respectsSEMREL_REGISTRY_URLto discover plugins from a custom registry.
For update-aware clients, each plugin's versions array is the source for version checks; clients are expected to select the highest stable release (prerelease: false) as the default update target.
Supported plugin categories in the registry are currently provider, analyzer, generator, condition, hook, updater, plus parity-foundation categories packager and publisher.
See the registry API docs for the full endpoint reference.
See the contributing guide for contribution rules and review expectations.
api/- Go web service skeleton for the upcoming dynamic registry backendadmin/- Nginx-served SPA (admin UI) that proxies/schemas/and/api/to the API containerapi/handlers/schemas/- embedded core and first-party plugin configuration schemas served at/schemas/; the scheduled sync fetches them from the semrel core and plugin repositoriesschemas/plugin-metadata.json- metadata schema used to validate the generated registry indexdocs/- contributor, API, and publishing documentation.github/workflows/- automation for validation, synchronization, and web deploymentplugins.json- generated registry index served via GitHub Pages
Version entries may optionally declare compatibility.semrelCore as a
space-separated semver range (for example >=0.25.0 <1.0.0). Missing metadata
remains valid for backward compatibility with existing plugins and clients.
The simplest way to run the registry locally is with the file storage backend — no Postgres required.
cp .env.example .env # set JWT_SECRET and ADMIN_TOKEN
docker compose -f docker-compose.file.yml up -dThe registry stores all plugin data as JSON files in a named Docker volume (registry_data).
This is ideal for self-hosting with small to medium plugin catalogues.
Choose PostgreSQL when you need full-text search, concurrent writes, or plan to host more than ~10 000 plugins.
The admin/ directory contains an nginx-served SPA that acts as the public entry point for registry.semrel.io. It proxies:
/schemas/→ API container (serves embedded JSON schemas)/auth/→ API container (GitHub OAuth entry point and callback)/api/→ API container (REST endpoints)- Everything else → SPA (
index.html)
The admin login and authenticated plugin-management screens surface Terms, Privacy, and Imprint links. Destructive plugin and version removals are intentionally gated in the SPA with typed confirmations before the existing authenticated delete endpoints are called.
cd admin
npm install
npm run test
npm run builddocker build -f admin/Dockerfile -t semrel-registry-admin .| Environment variable | Default | Description |
|---|---|---|
API_URL |
http://api:8080 |
Origin URL of the Go API as reachable from the admin container. |
The admin container listens on port 8080, not 80. It runs as an unprivileged user (uid 101), which cannot bind a privileged port. Publish it with
-p 80:8080(or point your ingress at 8080).
The default works only when the admin and API containers share a network where the API has the DNS name api. For a separate deployment, set API_URL to a reachable internal or public API origin (without a path) and either attach both services to a shared network or provide working DNS and routing. For example, if the API service is named registry, set API_URL=http://registry:8080. A permanently incorrect hostname continues to return 502; there is no fallback backend.
The image uses the official nginx entrypoint's local resolver discovery and resolves the API hostname at request time. This lets nginx start before the API DNS record exists and recover after it appears. Only API_URL and the discovered resolver list are substituted into the template; nginx request variables remain intact. The image health check verifies that nginx can serve the SPA, not that the API backend is ready.
cd web
npm install
npm run devThe Astro site runs on http://localhost:3000, builds static files into web/dist, and mirrors the repository root plugins.json into web/public/plugins.json during install/build.
The API refuses to start in ENVIRONMENT=prod unless it can authenticate
callers properly. Each check exists because failing it silently downgrades
authentication to something forgeable:
| Requirement | Why |
|---|---|
JWT_SECRET set, ≥ 32 characters |
The development fallback is published in this repository; with it, anyone can mint an admin session. |
WEBHOOK_SECRET set, ≥ 32 characters |
Without it POST /api/v1/webhooks/release accepts unauthenticated calls that can trigger an organisation-wide sync. |
ADMIN_TOKEN unused |
A static, non-expiring, identity-less admin credential. It is ignored in production even if set. |
ALLOWED_ORIGINS without * |
Credentialed endpoints would otherwise be readable cross-origin by any site. |
Generate secrets with openssl rand -base64 48. See .env.example for the
complete list, including session-cookie, trusted-proxy and rate-limit settings.
The OAuth callback sets an HttpOnly, SameSite=Lax session cookie rather than
returning the token in the redirect URL. Browser clients therefore send no
Authorization header; API clients (the semrel CLI, CI jobs) continue to use
Authorization: Bearer <token>. Signing out revokes the session server-side, and
deleting an account requires an interactive GitHub sign-in within the last five
minutes.
Plugin repositories authenticate release notifications by signing the request body:
X-Hub-Signature-256: sha256=<hmac-sha256 of the raw body, keyed with WEBHOOK_SECRET>
The older X-Webhook-Secret: <secret> header still works and is compared in
constant time, but it transmits the secret on every call and is deprecated.
Deleting a version breaks every build that pins it. Yanking is the safe retraction:
curl -X PUT https://registry.semrel.io/api/v1/plugins/@semrel/provider-github/versions/42/yank \
-H 'Content-Type: application/json' \
--cookie 'semrel_session=…' \
-d '{"reason":"The linux-amd64 binary was built from the wrong commit."}'A yanked version:
- stays resolvable, so
semrel plugin install name@1.2.3keeps working; - is never returned as
latestVersionand never chosen as an update target; - carries
"yanked": trueand"yankedReason"inplugins.json, so clients can warn the people already using it.
DELETE on the same path lifts the yank. Publishers may yank their own
plugins' versions; admins may yank any.
GET /feed.atom— the 50 most recent releases across the registry.GET /plugins.jsoncarries a strongETagandCache-Control. Clients that sendIf-None-Matchget a304instead of the whole catalogue.POST /api/v1/plugins/:id/versions/:version/downloadscounts one download per client per version per hour, so the figure reflects adoption rather than how often a CI pipeline ran.
A submitter may give an address on the submission form. If they do — and
SMTP_HOST and SMTP_FROM are configured — the registry emails them once,
when their plugin is approved or rejected, with the reviewer's reason.
The address is deliberately not derived from the GitHub profile and is not part
of the plugin record: it lives in a separate column (or, on the file backend, a
separate 0600 file), is returned by no endpoint, and is erased when the
account is deleted. Without SMTP configured the outcome is still recorded and
shown on the author's plugin list; only the delivery is skipped.
Forcing the author field to the submitter's login records who submitted a
plugin; it proves nothing about whether they control it. Submitting therefore
requires showing control of the repository, checked in increasing order of
effort and using only public GitHub endpoints:
- the repository is on the submitter's own account;
- the submitter is a public member of the owning organisation;
- the default branch carries a
.semrel-registry-claimfile containing the submitter's login on a line of its own.
The claim file is the fallback that always works — a private org membership, a collaborator who is not a member, a repository owned by a bot account — and needs only the write access a maintainer already has. Admins are exempt, since they import first-party plugins on the organisation's behalf.
POST /api/v1/plugins/verify-ownership runs the same check on demand, which is
what the submission form calls before asking for the rest of the details.
A checksum proves an artifact's bytes were not altered in transit; it proves nothing about who produced them. A publisher whose token has been stolen can compute a perfectly correct checksum for a malicious binary, and the registry would serve it happily.
When a version is published, the registry looks up GitHub's artifact attestation for its artifact digest, in the background, and records:
- whether an attestation exists at all for these exact bytes;
- which repository and workflow the attestation says built them;
- whether that repository matches the one the plugin claims to come from.
A repository mismatch is the result that matters most — it means the artifact
was built somewhere other than where the plugin says it comes from — so it is
recorded as verified: false with an issue, not silently dropped. Provenance
is included in a version's API representation and in plugins.json once a
lookup has actually been attempted; a version that predates this feature, or
whose repository publishes no attestations, simply omits it.
POST /api/v1/plugins/:id/versions/:version/reverify-provenance re-runs the
check on demand — the automatic lookup on publish can run before GitHub has
finished generating the attestation, so a manual recheck shortly after often
succeeds where the first one didn't. Publishers may reverify their own
plugins' versions; admins may reverify any.
This verifies the attestation's subject — that one exists for this digest and names the expected repository — not the Sigstore signature bundle itself, which needs the full transparency-log client. What it rules out is the case that matters most in a registry: an artifact whose bytes no build in the claimed repository ever produced.
A checksum and a build attestation both describe an artifact as published;
neither says anything about vulnerabilities discovered in it afterwards.
GitHub already collects those, as
Security Advisories
published against a plugin's own repository, so the registry imports them —
a consumer sees known vulnerabilities without leaving the registry, and
semrel plugin audit has something to check installed versions against.
Advisories are re-imported whenever a plugin is created, submitted, or synced from GitHub, and can be refreshed on demand:
curl -X POST https://registry.semrel.io/api/v1/plugins/analyzer-conventional/advisories/refresh \
--cookie 'semrel_session=…'Each advisory carries a vulnerableRange in the registry's own semver range
syntax. A range that cannot be parsed, or was never recorded, is treated as
affecting every version — the conservative direction for a security check. A
withdrawn GitHub advisory (a false positive, retracted) is imported but never
reported as affecting anything.
curl -X POST https://registry.semrel.io/api/v1/audit \
-H 'Content-Type: application/json' \
-d '{"plugins":[{"ref":"analyzer-conventional","version":"1.1.0"}]}'returns the subset of the given plugin@version pairs that have at least one
affecting advisory, each with the advisories that apply. Unknown plugin refs
are silently skipped rather than erroring, since this endpoint is meant to be
called with a whole lockfile at once.
The registry already accepts an inbound webhook — a plugin repository telling
the registry about a new release. This is the other direction: a registry
consumer, not necessarily the plugin's own publisher, can ask to be notified
about a plugin they depend on instead of polling plugins.json on a timer.
curl -X POST https://registry.semrel.io/api/v1/webhooks/subscriptions \
-H 'Content-Type: application/json' \
--cookie 'semrel_session=…' \
-d '{
"url": "https://example.com/hooks/semrel",
"pluginRef": "analyzer-conventional",
"events": ["version.published", "advisory.published"]
}'The response's secret is shown once and never again; it signs every
delivery the same way the registry's own inbound release webhook is signed:
X-Hub-Signature-256: sha256=<hmac-sha256 of the raw body, keyed with the subscription's secret>
Available events: version.published, version.yanked, version.unyanked,
advisory.published (only for advisories not seen on a previous import, so a
periodic refresh does not re-notify about the same advisory every time it
runs). A subscription's URL must be https:// and must not resolve to a
loopback, private, or link-local address — a webhook URL is arbitrary, unlike
an artifact URL, so it cannot be allowlisted by host and is checked by address
class instead, both when the subscription is created and again immediately
before every delivery. A subscription disables itself automatically after 10
consecutive delivery failures, so a dead endpoint is not retried forever.
GET /api/v1/webhooks/subscriptions lists the caller's own subscriptions
(never including the secret); DELETE /api/v1/webhooks/subscriptions/:id
removes one. Publishers may manage their own subscriptions; admins may remove
any.