Publishing your plugin to camp

The developer's path from a discovered listing to verified releases, and what each step gets you. Steady state is deliberately minimal: publishing a release means pushing a git tag (RFC §12).

Where you probably start: your plugin is already listed

The index was seeded by scanning public sources (RFC §4.4). If your plugin is on GitHub or GitLab under its frankenstyle name with a GPL-family license, it likely already has a Tier 0 (discovered) listing: name, description, license, and a link to your repository. Nothing is hosted; it's a search result and an invitation. If you want it gone instead, removal is no-questions-asked: use the removal request form (also linked from your plugin's page).

Plugin missing entirely? Two common reasons: the repository was created after the last discovery sweep, or GitHub flags it as a fork, which discovery skips (that hides legitimate plugins whose canonical home began as a fork of an abandoned upstream, common after a maintainer takeover). Either way, use the seed request form and the registry will seed the listing directly; no hand-written index entry is needed.

Naming a new plugin? Search camp-registry.org for the component name first; with the moodle.org directory gone, the registry is the ecosystem's de facto availability check. Component names are a flat namespace, so two plugins cannot share one. If the name you want is already listed by a different project, don't publish under it: open an issue and the collision is resolved per NAMESPACE.md.

Step 1 — Claim the listing (Tier 0 → Tier 1)

Claiming is one pull request against your entry in the index (plugins/<type>/<component>.yml in camp-index) that adds what only an author can declare. Fastest path: your plugin's page on the website has an "edit your entry directly" link that opens the file in GitHub's editor — GitHub forks and turns your edit into a PR automatically. You're adding:

  • yourself under maintainers;
  • a security-contact — preferably your repository's private vulnerability-reporting URL (GitHub: Security → Advisories → Report a vulnerability; GitLab has the equivalent), else an email you actually read (RFC §5.1);
  • your disclosure labels (RFC §4.7): fully-free, freemium, paid-service, external-account, and/or the promotional donation-supported / commercial-support-available. Labels inform, they never disqualify — the requirement is that administrators know what they're installing;
  • tier: 1.

Leave releases exactly as it is ([]): the ledger is never hand-edited. Release records carry verified tags, commits and hashes, and they are generated by the release pipeline (Step 3) — a hand-written entry fails validation, and the claim itself never includes releases.

Ownership is verified mechanically where it is publicly legible: a claim whose author owns the source repository, or is a public member of the owning organization, merges automatically once validation passes (camp-index#210); anything less legible, an admin checks by hand. Schema validation enforces the fields above at Tier 1+, so CI tells you if anything is missing.

Tier 1 means: you're accountable, the security pipeline can reach you, and your listing is yours. It does not make the plugin installable yet — that takes a verified release.

One licensing note: contributions to the index (your claim PR included) are accepted under CC0, and the assembled database is published under ODbL with advisories under CC BY 4.0; see LICENSE-DATA and CONTRIBUTING.md in camp-index. Your plugin's own code and listing content stay under your plugin's license, always.

Claiming as an organization

If your organization maintains many listed plugins and the maintainer and security-contact details are identical across them, you don't need a PR per plugin — or any PR at all. Publish a repository named camp-claim in your organization with a camp-claim.yml at its root:

maintainers:
- github: your-account          # the accounts that may publish releases
security-contact: https://github.com/your-org/some-repo/security
labels: [fully-free]            # default for every plugin
overrides:                      # optional, per-component exceptions
  local_example:
    labels: [external-account, paid-service]
exclude: [local_notours]        # optional, components not to claim

Then file an Organization claim request issue on camp-index. The registry runs a sweep that claims every listed entry whose source repository belongs to your organization, writing exactly what individual claim PRs write. Control of the organization's repositories is the authorization — the same trust root release publishing uses. Entries someone already claimed individually are never overwritten; they're reported back on the request for reconciliation.

That first request is the only one you'll file. Afterwards the registry watches your manifest: edit it (new maintainer, changed contact, label fix) and the change reaches all your claimed entries within a day. The same applies to plugins of yours that get listed later — they're claimed automatically on the next sweep. The watch only ever updates entries your manifest claimed; removing an entry from your claim (or un-claiming) always involves a human, so file an issue for that.

Nothing in this step requires a local toolchain: everything the pipeline runs happens on your host's CI servers, so your own operating system (Windows included) never matters. Local Python is an optional convenience, not a requirement. There is also nothing to create or guard: no fork, no access token, no secrets.

1. Add .camp/listing.yml to your plugin repository. Two ways:

  • By hand (no tools needed): copy the annotated example into .camp/listing.yml and edit the obvious fields.
  • With the camp tool (any OS with Python; pip is Python's installer, not a Linux thing): pip install "git+https://github.com/camp-registry/camp-tools", then camp scaffold /path/to/your-plugin-repo, which pre-fills the manifest from your version.php and writes .gitattributes export-ignore defaults that keep .github/, dotfiles and other dev clutter out of your distribution ZIPs.

Screenshots are raster only (png/jpg/webp, no SVG), each entry a repo-relative path with an optional caption; the registry re-encodes every image at release time and your plugin page shows the first as the lead image. From Tier 1 up this manifest in your repository is your listing content (RFC §4.1): you update it with ordinary commits, and it is pinned at each release.

2. Add the release workflow. On GitHub, the recommended setup is a six-line caller of the maintained reusable workflow — create .github/workflows/camp-release.yml in your plugin repo:

name: Publish release to camp
on:
  push:
    tags: ["v*"]
  workflow_dispatch: {}
permissions:
  contents: read
  id-token: write
jobs:
  camp:
    uses: camp-registry/camp-workflows/.github/workflows/release.yml@v1

Fixes and tooling updates then reach you automatically — nothing to re-copy, ever. Organizations whose plugins already inherit a shared workflow can add that same uses: job to the shared workflow instead, covering every plugin with no per-repository changes.

Prefer a self-contained copy? The original template templates/author-release.yml remains supported — copy it to the same path. On gitlab.com, copy templates/author-release-gitlab.yml into your .gitlab-ci.yml instead; GitLab-hosted plugins need no GitHub account at all. No values to edit any way you choose: the component name and supported-Moodle range are read from your version.php at the tag. The overrides (workflow inputs on the reusable workflow, env values on the templates) exist for the rare plugin whose version.php can't say what it means.

How publishing is authorized: when your workflow runs at a tag, your CI host hands it a short-lived signed statement of identity (an OIDC token: "this job is running in repository X, at tag Y"). The camp publish service verifies that statement and checks one fact recorded when you claimed your listing: that your repository is the entry's listed source, by permanent repository id. Then the service, not your workflow, opens the release PR. The id-token: write permission in the workflow grants exactly the ability to request that statement; it writes nothing to your repository. No credential exists anywhere in this flow, so there is nothing to leak, rotate, or expire. If you rename your repository or transfer it to a new owner, publishing pauses with a pointer to update your listing (a reviewed change), then resumes.

Prefer holding a token yourself, or hosting somewhere without CI identity tokens? The original personal-access-token flow remains a supported alternative, unchanged: templates/author-release-pat.yml, plus a fork of camp-index and a classic public_repo-scoped token as CAMP_INDEX_TOKEN in your repo secrets (fine-grained tokens cannot be scoped to the camp-registry organization). It is also the fallback if the publish service is ever unavailable. Both flows end at the same place: a release PR that camp CI independently rebuilds and verifies from the public tag, then merges automatically as a registry act (camp-index#66, #210).

Optional, and only if you installed the camp tool: preview what registry CI will say about you.

camp lint-labels /path/to/your-plugin-repo   # disclosure-label heuristics
camp audit /path/to/your-plugin-repo         # basic security lint

Both are warn-only, in your repo and in registry CI (DESIGN.md D10): findings are evidence for humans, not gates.

Step 3 — Release (Tier 1 → Tier 2, then every release after)

git tag v1.2.3
git push --tags

That's the whole ceremony, on GitHub and GitLab alike. Your workflow builds the canonical ZIP with the same code registry CI verifies against and computes the release record (version from $plugin->release, tagged commit, SHA-256, supported branches derived from version.php, release timestamp), and a PR appending it to your entry's ledger opens on the index: the publish service opens it in the tokenless flow, your own workflow does in the PAT flow. Either way the PR names you as the author of the release commit.

Registry CI then independently: clones your repository at the tag, rebuilds the ZIP deterministically, confirms the hashes match, runs the standard static checks (moodle-plugin-ci subset, RFC §4.2) and malware scan, and verifies the ledger is append-only. Green means merged — minutes, not a review queue. Your first merged release flips the listing to Tier 2: source-verified: the website page gains its install panel, the Composer metadata picks you up, and sites can install your plugin — clearly labelled "source-verified, not human-reviewed".

Three rules worth internalizing:

  • Releases are immutable (RFC §4.2). Fix mistakes by tagging a new version. Never move or re-push a tag — the recorded commit no longer matches, verification fails, and you get alerted.
  • The artifact is exactly your public source. camp never modifies your code; if the ZIP contains something you didn't want shipped, the fix is .gitattributes export-ignore, not registry-side trimming.
  • The tagged tree is the complete installable plugin. If your release has a build step, commit its outputs at the tag: compiled amd/build/ modules (the usual Moodle convention), compiled CSS, and any libraries your code requires — a committed vendor/ directory is fine and common. A packaging script that fetches a dependency while building the ZIP — especially from an unpinned branch head — produces an artifact nobody can reproduce or audit, the registry included: what's inside depends on the day it was packed. Git submodules don't count as committed (archives exclude their contents); commit the files themselves. Anything your plugin needs that isn't in the tag isn't in the artifact.

Badges

Once claimed (Tier 1+), your plugin has a registry badge at https://camp-registry.org/badge/<component>.svg — self-hosted, updated whenever your tier changes:

[![camp](https://camp-registry.org/badge/mod_example.svg)](https://camp-registry.org/plugin/mod_example.html)

Prefer shields.io's styling? The same data is served in their endpoint schema at …/badge/<component>.json for use with img.shields.io/endpoint?url=….

Verified plugins also get a code-check badge at …/badge/<component>-checks.svg — the registry's prechecker result for your newest release (errors | warnings), recomputed on every release. The full per-version breakdown (top rules, affected files) is public at https://camp-registry.org/checks/<component>.json and shown as chips on your plugin page.

You can also display third-party badges on your camp plugin page by declaring endpoint-schema URLs in .camp/listing.yml (allowlisted hosts only — currently MDL Shield security grades; propose additions by PR to camp-tools):

badges:
  - endpoint: https://mdlshield.com/api/badge/mod_example
    link: https://mdlshield.com/plugins/mod_example

The registry fetches the data at publish time and renders the chip itself, so visitors never load third-party images (RFC §4.6).

Claim a utility listing

Utility listings (the tools under /utility/) are curated by the registry until their maintainer claims them. To claim yours, open utilities/<slug>.yml in the index with the GitHub web editor, add yourself under maintainers: (your GitHub handle) and a claimed: line with today's date, and submit the pull request from the account that controls the repository the listing points to — that authorship is the proof, exactly as with plugin claims. A claimed listing shows you as its maintainer, and the descriptive fields (summary, homepage, install methods, labels) become yours to keep current by PR. There are no releases to publish: utilities are pointers, and the release shown on your page is read from your own distribution channel automatically.

Afterwards — all optional

  • Listing updates: edit .camp/listing.yml, commit; ingested and pinned at your next release.
  • Your index entry is machine-maintained. The registry's automation rewrites plugins/<type>/<component>.yml (metrics refresh, release appends), so YAML # comments there won't survive the next update. Notes belong in your PR description, an issue, or your own .camp/listing.yml — which is yours alone: the registry pins its bytes and never rewrites it.
  • Backfilling older releases: the ledger accepts releases in any order — a version older than your latest lists just the same. Run your release workflow at the existing tag (gh workflow run camp-release.yml --ref v1.1.0; if your copy of the template predates the workflow_dispatch trigger, add it) — never re-tag. Worth doing for the newest release of each Moodle branch sites still run; every tag you ever pushed is history, not coverage. Your plugin page sorts this out automatically: it features the newest release per branch (the same set the install picker offers) and folds everything older behind a disclosure — so deeper backfill is welcome, just invisible by default. If a backfilled version had a known vulnerability, publish the advisory with it (RFC §5.3) so the version table warns instead of silently serving it. One honest limit: pinning happens at publication — the ledger proves the artifact matches your tag from that day forward, not that the tag never moved in the years before. In the tokenless flow, releases published while an earlier one is still awaiting merge simply append to the same open PR (one rolling release PR per plugin), so backfilling several versions in a row just works. In the PAT flow, release one version at a time: two release PRs open at once edit the same entry lines, so the second conflicts; if that happens, re-run the workflow at the affected tag and its PR updates in place.
  • Security reports arrive at your declared contact; the coordinated disclosure process (RFC §5) handles embargo, advisory publication, and automatically warning every affected site.
  • Tier 3 (human-reviewed): once the review board exists, request promotion — two independent reviewers sign it (RFC §4.4).
  • Dual-listing elsewhere (Packagist, the Marketplace, a partner store) needs no permission and no ceremony — camp imposes no exclusivity (RFC §3).
  • Withdrawing a version: nothing is ever deleted — that's the trust model — but any version can be made uninstallable: publish an advisory with revoke: true naming the affected versions (RFC §5.3). Revoked versions drop out of the Composer metadata and the artifact tree, and show struck through on your page with the advisory as the public reason. A withdrawn version name is retired for good: revocation follows the version name, so ship the replacement as a new version rather than republishing the old one. The reason doesn't have to be a security hole (a data-loss bug qualifies); it does have to be stated. To stop supporting an old Moodle branch, no ceremony at all: just stop releasing for it — the install panel offers the newest release per branch, so support ends by omission.
  • Finished with a plugin? Archive the repository on GitHub/GitLab and you're done: the registry observes the archived flag and your plugin page shows the "Archived upstream" health phrase automatically (health is observed, never self-declared, which is what makes it trustworthy; there is no status value to set). Published releases stay installable and advisory-covered, and claiming an archived plugin works exactly like claiming any other, which is worth doing so a human stays reachable for it. If you want the listing gone instead, the removal request form applies as always.
  • Leaving: one final PR setting status: moved plus moved-to: where you now publish (RFC §6.3). Your published versions stay archived, installable, and advisory-covered; your component name stays yours if you return.
This page is rendered from AUTHORS.md in camp-docs — the canonical source. Edits there appear here at the next publish.