Keep Minecraft server plugins up to date, three ways, depending on who you are.
| You... | Use | Guide |
|---|---|---|
| build your own plugin and want it to self-update | shade the library | Adopting the library |
| run a server and just want your installed plugins updated | the companion plugin | Companion plugin |
| have a jar you can't or won't rebuild | the browser tool | Web tool |
Each guide stands alone and assumes no prior knowledge. Follow only yours. New to a term? See the glossary. Not sure where your plugin's updates live? See finding your update source.
Everything below is the developer reference for the library (Path 1). Server owners and jar-only users should follow their guide above instead.
A small, dependency-free update-checker library for Paper and Spigot plugins. Shade it in, point it at where you publish releases, and your plugin gains:
- Multi-source update checking: Modrinth, GitHub Releases, Hangar, Jenkins CI, or any self-hosted JSON manifest, with ordered fallbacks.
- Clickable admin notifications: MiniMessage console + in-game notices on login, permission-gated, fully re-brandable.
- Version intelligence: semver-ish comparison, pre-release awareness
(
1.2.0-beta1 < 1.2.0), and distribution tracks for projects that ship parallel builds (e.g.1.7.3and1.7.3-mc26). - Rate-limit citizenship: identifying User-Agent (required by Modrinth), persisted check state, jittered intervals.
- Folia support: automatic scheduler detection, or plug in your own adapter.
- Verified one-command installs: downloads are checksum-verified
(sha512 > sha256 > sha1), the running jar is backed up, and the new jar is
staged into the server's
plugins/update/folder to apply on the next restart. One-command rollback to the latest backup.
- Paper (or a fork) 1.20.5+, or Spigot 1.20.5+, Java 21+.
- No runtime dependencies. On Paper, notices render as rich clickable MiniMessage; on Spigot (no Adventure) they automatically fall back to plain text with the download URL spelled out. Everything else works identically.
Via JitPack:
repositories {
maven("https://jitpack.io")
}
dependencies {
implementation("com.github.ESMP-FUN.PluginPulse:pluginpulse-core:v0.9.0")
}Shade and relocate it (Gradle Shadow shown):
tasks.shadowJar {
relocate("io.github.darkstarworks.pluginpulse", "my.plugin.libs.pluginpulse")
}1. Drop a pluginpulse.yml into src/main/resources/:
# Fill in whichever sources apply: the first one is primary, the rest are
# fallbacks. You need at least one.
modrinth: my-project-slug # Modrinth project slug (optional)
github: me/my-plugin # GitHub "owner/repo" for Releases (optional)
hangar: my-project # Hangar project slug (optional)
# jenkins: https://ci.example.org/job/MyPlugin/ # Jenkins job URL (optional)
# jenkins-artifact: "Paper" # optional regex: which jar when a build ships several
permission: myplugin.admin # who sees notices / can run /myplugin update
command-root: /myplugin # enables clickable buttons; self-registered if free
user-agent-contact: you@example.com # required by Modrinth's API rules
mode: notify # off | check-only | notify | download | auto-stage
check-interval-hours: 6
# hold-new-updates: false # true = ignore a release until it has been publicly
# hold-new-updates-hours: 18 # available for this long, so a broken release that
# # gets hotfixed hours later is never installed
# match-server-version: true # default true: when the plugin publishes separate jars
# # for 1.21 and 26.x, take the one listed for this
# # server's Minecraft version (Modrinth and Hangar)
# track: mc26 # optional: only take versions ending in "-<track>"
# self-register-command: true # default true; see below2. Three lines in your plugin:
@Override public void onEnable() { PluginPulse.bootstrap(this); }
@Override public void onDisable() { PluginPulse.shutdown(this); }
// in your command executor, when the first arg is "update":
// PluginPulse.handleUpdateCommand(this, sender, Arrays.copyOfRange(args, 1, args.length));Self-registered command. When command-root is set, PluginPulse registers a
matching command (/myplugin update ...) straight into the server's command map
if (and only if) that name is not already taken. So:
- If your plugin already declares
command-rootin itsplugin.yml, that command is left untouched and you delegateupdatetoPluginPulse.handleUpdateCommand(...)as shown above. - If it doesn't (injected jars, or a one-file adopter who'd rather not write a
command class), you get a working
/myplugin updatefor free, no descriptor entry, no executor.
Registration is fail-soft across Spigot/Paper/Folia and never throws into your
lifecycle; set self-register-command: false to opt out.
That's it. Server owners can override mode, check-interval-hours,
hold-new-updates and hold-new-updates-hours from an update: section in your
plugin's own config.yml without you doing anything.
hold-new-updates makes the updater ignore a release until it has been publicly
available for hold-new-updates-hours (default 18). It exists for the case where
a publisher ships a broken release and hotfixes it a few hours later: a server
that waits never installs the broken one, instead of picking it up and then
sitting on it until the next check.
The clock is the publisher's own release timestamp (Modrinth, GitHub, Hangar and
Jenkins all report one), falling back to the first time this server saw the
version, which can only lengthen the wait, never shorten it. The hold suppresses
notices, downloads and auto-staging alike; an admin running update download by
hand still gets the release immediately, and update status explains what is
being held and for how much longer.
For custom sources (self-hosted manifests), message overrides, a supplied scheduler, or the hot-reload engine, use the builder directly:
public final class MyPlugin extends JavaPlugin {
private Updater updater;
@Override
public void onEnable() {
updater = Updater.builder(this)
.source(new ModrinthSource("myplugin"))
.fallbackSource(new GitHubReleasesSource("me/myplugin"))
.mode(UpdateMode.NOTIFY)
.checkInterval(Duration.ofHours(6))
.permission("myplugin.admin")
.userAgentContact("you@example.com") // required by Modrinth's API rules
.build();
updater.start();
}
@Override
public void onDisable() {
if (updater != null) updater.shutdown();
}
}Delegate your /myplugin update ... subcommand to the bundled handler:
UpdateSubcommand updateCmd = new UpdateSubcommand(updater);
// in your command executor:
case "update" -> updateCmd.handle(sender, Arrays.copyOfRange(args, 1, args.length));which provides check, download/install, apply (hot reload, when
enabled), ignore <v>, unignore <v>, restore (stage the latest backup
for rollback), and status.
| Mode | Behavior |
|---|---|
CHECK_ONLY |
silent checks; results via API only |
NOTIFY |
+ console & in-game notices (default) |
DOWNLOAD |
+ admins may update download to stage a verified jar for the next restart |
AUTO_STAGE |
+ new releases are downloaded, verified and staged automatically |
Staging writes the new jar into the server's update folder under the running
jar's exact filename (required by Bukkit's swap-on-restart mechanism), after
backing up the current jar to plugins/<plugin>/pluginpulse/backups/
(retention configurable via .backupRetention(n)). Downloads without a
published checksum are refused unless you opt out with .requireHash(false).
A pending-update.json marker tracks whether a staged update actually applied
on the next boot; if it didn't, the plugin warns instead of re-staging forever.
| Source | Constructor | Hashes | Notes |
|---|---|---|---|
| Modrinth | new ModrinthSource("slug") |
sha1 + sha512 | 300 req/min limit; optional loader/game-version filters |
| GitHub Releases | new GitHubReleasesSource("owner/repo") |
digest field or .sha256 sidecar asset |
60 req/hr unauthenticated, keep intervals long; optional token |
| Hangar | new HangarSource("slug") |
sha256 | platform selectable (PAPER/VELOCITY/WATERFALL) |
| Jenkins | new JenkinsSource("https://ci.example.org/job/X/") |
none: download modes need require-hash: false |
last successful build; optional artifact-name filter |
| Custom JSON | new CustomJsonSource(url, headers) |
any | self-hosted manifest; headers carry auth (e.g. licence keys) |
{
"version": "1.0.4",
"changelog": "Fixed ...",
"download": "https://example.com/dl/plugin-1.0.4.jar",
"filename": "plugin-1.0.4.jar",
"sha256": "...",
"size": 123456,
"restart-required": true,
"page": "https://example.com/plugin",
"tracks": { "mc26": { "version": "1.0.4-mc26", "download": "...", "sha256": "..." } }
}If you publish parallel builds for different Minecraft generations (tags like
v1.7.3 and v1.7.3-mc26), set .track("mc26") on the build that should
follow the suffixed releases. Version comparison ignores the suffix; source
selection uses it to pick the right release/asset/manifest entry.
All notices are MiniMessage templates with <prefix>, <current>, <latest>,
<page>, <cmdroot> placeholders:
.prefix("<gradient:gold:yellow>[MyPlugin]</gradient>")
.message(UpdateNotifier.KEY_PLAYER, "<prefix> <latest> is out! <click:open_url:'<page>'>[Get it]</click>")The separate pluginpulse-hotreload artifact can apply a staged update
without a restart: it unloads the running plugin (disable, unregister
listeners/tasks/services/channels/commands, remove plugin-manager bookkeeping,
close the classloader, which also releases the Windows jar lock), swaps the
jar, and loads + enables the new version. If the new version fails to load it
rolls back to the automatic backup.
implementation("com.github.ESMP-FUN.PluginPulse:pluginpulse-hotreload:v0.9.0").reloadEngine(HotReloadEngine.create()) // on the Updater builderAdmins then get update apply (after update download) in addition to the
restart path.
Hard limits, by design:
- Refused on Folia: regionized schedulers can't be torn down safely.
- Refused while other enabled plugins depend on yours (hard or soft).
- Your plugin must shut down cleanly: static state, thread pools, coroutine dispatchers and objects other plugins captured from the old instance are your responsibility. Repeated reloads can leak metaspace.
- Works on Paper's legacy and 1.20.5+ plugin-manager internals; on unknown future layouts it refuses with a clear message instead of corrupting state.
Restart-install remains the recommended default; treat hot reload as a convenience for small, self-contained updates.
Source-available: free to view, study, and run; no commercial use or redistribution. See LICENSE for the full terms.