The WP Manifest updater
Dashboard → Updates from your own forge. One JSON file, one release zip, a short class — no WordPress.org required.
Most plugin authors think Dashboard → Updates only works if you are on WordPress.org. It does not. A manifest.json on your default branch, a release zip on the forge, and a short updater class are enough for WordPress to offer updates from your source — the same screen, no middleman, no license server.
This is a convention, not a required library. Copy the pattern, keep the “WP Manifest updater” branding if you like, change what you need. Used today by Disembark, Minn Admin, CaptainCore Manager, WP Freighter, WP Shipyard, and a handful of other listed plugins. The 2023 walkthrough is the origin story; this page is the current guideline.
The three things that matter
- Publish a
manifest.jsonon the default branch (mainormaster). Point the updater at the raw URL. Sites check that file, not the release API. - Attach an installable plugin zip to each release. Name it like the plugin folder (
your-plugin.zip). Source tarballs from “Download ZIP” break installs and do not count as downloads here. - Cache the check. Honour a transient (an hour is plenty; a day is fine). Flush it after a successful update. Do not hit the forge on every admin page load.
Everything else on this page is optional hardening or niceties.
Ask an AI agent
If you already work with Cursor, Claude, Copilot, or similar, you do not have to wire this by hand. Paste one of these into the agent with your plugin repo open. Point it at a real reference implementation and let it adapt the files.
Baseline — same shape as Disembark
Wire up plugin updates for this plugin the same way Austin Ginder’s Disembark plugin does.
Reference: https://github.com/DisembarkHost/disembark
- app/Updater.php
- manifest.json on the default branch
- release zip attached to GitHub Releases (not the auto-generated source archive)
Requirements:
1. Add a root manifest.json with name, slug, version, download_url, requires, tested, requires_php, homepage, and a short sections.description.
2. Add a small Updater class that filters plugins_api and site_transient_update_plugins, caches the remote manifest in a transient, falls back to the local manifest.json if GitHub is unreachable, and purges the cache after a successful update.
3. Point the remote check at the raw manifest.json on this repo’s default branch.
4. Document the release steps: bump Version, build an installable {slug}.zip, attach it to the GitHub release, update manifest.json version + download_url, push the default branch.
5. Do not add Composer packages or Plugin Update Checker unless I ask. Keep it a short class I own.
With translations — same shape as Minn Admin
Set up this plugin to update from GitHub, including translations, like Minn Admin. Reference: https://github.com/austinginder/minn-admin - includes/class-minn-admin-updater.php - manifest.json (including translations[] and sha256) - language packs as separate release assets - docs/i18n-roadmap.md for the delivery model Requirements: 1. Manifest-based plugin updates from the raw manifest.json on the default branch, with a cached transient and local fallback. 2. Allowlist package URLs (https, this repo’s /releases/download/ path) and require sha256 verification on download. 3. Offer language packs through $transient->translations from manifest.translations[], only for locales the site actually uses. 4. Each language-pack zip should include .po, .mo, and .l10n.php (plus JS JSON if we enqueue scripts), installed to wp-content/languages/plugins/. 5. Stamp sha256 for the plugin zip and each pack into manifest.json as part of the release process; do not trust the copy of the manifest bundled inside the plugin zip for verification. 6. Keep the implementation in-repo (no new SaaS). Match Minn’s behaviour, adapted to this plugin’s slug and namespace.
Swap the reference URLs if you prefer CaptainCore Manager or WP Shipyard as the template. The agent should read those files and rename the class, slug, and cache key for your plugin — not invent a parallel update system.
What goes in manifest.json
Minimum fields the updater needs:
{
"name": "Your Plugin",
"slug": "your-plugin",
"author": "<a href=\"https://example.com\">You</a>",
"version": "1.2.0",
"download_url": "https://github.com/you/your-plugin/releases/download/v1.2.0/your-plugin.zip",
"requires": "6.0",
"tested": "6.9",
"requires_php": "7.4",
"homepage": "https://example.com",
"sections": {
"description": "One or two sentences for the plugin details modal."
}
}
Useful extras:
sha256— hex digest of the release zip. Lets the updater refuse a tampered package.author_profile,donate_link,banners, richersections(installation,changelog) — fill the “View details” modal the same way WordPress.org would.added/last_updated— timestamps sites and directories can display.translations— optional language packs (see below).
Keep version and download_url in lockstep with the release you just cut. Stamp sha256 after the zip exists; the copy of manifest.json inside the zip cannot honestly contain that zip’s own hash, so verification always reads the remote file on the default branch.
Where the updater looks
Pin a single HTTPS URL to the raw file on the default branch, for example:
https://raw.githubusercontent.com/you/your-plugin/main/manifest.json
That is the source of truth for “is there a newer version?” The release asset is only the package. Serving the same JSON from your marketing site also works (WP Freighter started that way); the default-branch file is simpler and is what WP Manifest’s crawler already understands as a manifest updater.
Ship a copy of manifest.json inside the plugin too. When GitHub is unreachable, fall back to the local file so the details modal and version display still work. Never use the bundled copy as the authority for sha256 verification.
Caching
WordPress reads site_transient_update_plugins often — admin screens, cron, the admin-bar nag. Without a cache, every one of those becomes a live request to your forge.
- Store the decoded remote manifest in a dedicated transient (
yourplugin_updater). - TTL: one hour if you want releases to surface promptly; one day if traffic is the worry.
- Set
cache_allowed = trueand actually honour it. A false flag that always refetches defeats the point. - On
upgrader_process_completefor your plugin, delete the transient so the next check sees the new local version. - On a failed remote fetch, keep the previous transient (or the local fallback) rather than thrashing.
The release zip
- Build a zip whose root folder is the plugin slug (
your-plugin/your-plugin.php). - Attach it as a release asset, not only as the forge’s auto-generated source archive.
- Point
download_urlat that asset URL (…/releases/download/v1.2.0/your-plugin.zip). - Prefer HTTPS hosts you control the path of (your repo’s
/releases/download/prefix). Allowlist that host and path in the updater before handing the URL to WordPress.
GitHub’s “Source code (zip)” links use different folder names and inflate download stats that are not plugin installs. WP Manifest only counts .zip release assets toward downloads and the active-site estimate.
What the PHP side does
Three hooks are enough:
plugins_api— when WordPress asks forplugin_informationabout your slug, return name, version, sections, anddownload_linkfrom the manifest.site_transient_update_plugins— if the remoteversionis newer than the installed constant, put a response object in$transient->responsewithpackageset todownload_url.upgrader_process_complete— purge your cache.
That is the whole “library”: a short class you can paste and rename. Reference implementations live in Disembark (app/Updater.php), Minn Admin (includes/class-minn-admin-updater.php), and CaptainCore Manager.
Hardening worth stealing
From the newer plugins (Minn Admin, CaptainCore Manager):
- Allowlist package URLs before download: https only, known hosts, path anchored under your
owner/repo/releases/download/. - Require
sha256in the remote manifest and verify withhash_equalsonupgrader_pre_download. Missing hash = refuse, do not skip. - Scope any SSL relaxation to the manifest URL, and only under a truthy
*_DEV_MODEon a non-production site. Never disable verification for the package download. - Keep the plugin header
Version:and the updater’s version constant in sync (or read one from the other).
Translations (optional, but native)
You do not need translate.wordpress.org to offer language packs. Put them on the same release and list them in the manifest:
"translations": [
{
"language": "de_DE",
"version": "1.2.0",
"updated": "2026-09-11 12:00:00",
"package": "https://github.com/you/your-plugin/releases/download/v1.2.0/your-plugin-de_DE.zip",
"sha256": "…"
}
]
Then, in the same site_transient_update_plugins filter, append matching entries to $transient->translations. WordPress installs them into wp-content/languages/plugins/ and refreshes them after a plugin update on its own.
Tips that saved real pain on Minn Admin:
- Ship
.po+.mo+.l10n.php(and a JS JSON file if you have a script handle). Without the.po, core thinks nothing is installed and re-offers the pack forever. - Prefer language packs over bundling every locale in the plugin zip: smaller downloads, packs survive reinstalls, and Updates → Translations just works.
- Version per language when you can: bump a pack’s
versiononly when that catalog’s content changed, so a Spanish-only fix does not force every locale to re-download. - Offer packs only for locales the site actually uses (site locale + user locales), not every language ever installed on the server.
- Verify language-pack zips with the same host allowlist and
sha256gate as the plugin zip.
Release checklist
- Bump
Version:in the main plugin file (and any mirrored constant). - Build
your-plugin.zipwith the correct root folder. - Create the forge release (
v1.2.0) and attach the zip (plus any language-pack zips). - Compute
sha256of the zip(s). - Update
manifest.jsonon the default branch:version,download_url,last_updated,sha256, andtranslationsif you ship packs. - Commit and push the branch. Sites pick it up on the next cached check.
Automate steps 4–5 in release scripts when the project grows; Minn Admin’s bin/release-manifest.js is one example of stamping hashes and translation entries without hand-editing JSON.
How this shows up on WP Manifest
- Plugins whose code follows this pattern are counted under self-updates on the Usage page (the
manifest.jsonupdater bucket). - A root
manifest.jsonwithdownload_url/sectionsis also one of the hunt signals that helps a new repository get found. - Release zips feed download counts and the active-site estimate described on About.
You do not need to register anything with WP Manifest for updates to work. The directory indexes what you already ship; the updater talks only to your forge.
Related options
- Plugin Update Checker — battle-tested library; many listed plugins use it.
- Git Updater — site-side plugin that reads Git hosts; headers alone are not enough unless Git Updater is installed.
Update URI:header — tells WordPress not to look on WordPress.org for a same-named slug. Worth setting even when you self-host.
The Manifest updater is for authors who want Dashboard → Updates without a middleman and without a large dependency: one JSON file, one zip, a short class.
Examples
- Disembark — classic pattern; local fallback ·
manifest.json - Minn Admin —
sha256, allowlists, language packs ·manifest.json - CaptainCore Manager — same hardening as Minn ·
manifest.json - WP Freighter — original GitHub-based flow ·
manifest.json - WP Shipyard — fresh minimal example ·
manifest.json
Questions or a listing that should credit a Manifest updater and does not: feedback@wpmanifest.xyz.