Making-of: Shipping and Auto-Updating Your Own WordPress Plugins with GitHub Actions
How a WordPress plugin outside the .org directory gets the one-click update experience: GitHub Actions builds the ZIP and checksum, a static JSON manifest on gh-pages replaces the rate-limited GitHub API, and a lean updater class hooks safely into WordPress' update system.
by Jean Pierre Kolb ·
If you write a WordPress plugin that doesn't live in the official wordpress.org directory — because it's too niche, stays internal, or simply doesn't belong there — you lose one thing you only miss once it's gone: the one-click update experience in the backend. No "Version 1.3.0 available", no blue update button, no automatic install. Instead: download the ZIP, upload via FTP, delete the old folder, hope for the best.
It doesn't have to be that way. WordPress' update system is extensible — you can build a plugin so it pulls its updates from its own source and still feels exactly like a directory plugin. This article is the making-of such a setup: a GitHub Actions pipeline automatically builds the ZIP, a checksum and a JSON manifest on every version tag; the manifest sits statically on GitHub Pages (gh-pages); and a compact updater class inside the plugin reads it and hooks safely into WordPress' update system.
The trick is in the combination: because the manifest is a static file on gh-pages and not the GitHub API, thousands of WordPress installs can hit the same source without ever running into a rate limit. I'm deliberately not naming the plugin here — the mechanics are what's interesting, and they transfer to any plugin of your own. Throughout the code examples, the placeholder slug jpkcom-example stands in.
The three building blocks and the data flow
Before the code, the big picture. There are three parts that come together through exactly one action: a Git tag.
- The release pipeline (GitHub Actions,
release.yml). Runs as soon as a tag likev1.3.0is pushed. It builds the plugin ZIP, computes its SHA256 checksum, attaches both to a GitHub release, and generates a JSON manifest from theREADME.md. - The manifest on gh-pages. A static
plugin_jpkcom-example.json, deployed to thegh-pagesbranch and served through GitHub's CDN. It's the "source of truth" for which version is current, where the ZIP lives, and what checksum it must have. - The updater class inside the plugin (PHP). It queries the manifest, compares the version against the installed one, tells WordPress about an update when needed — and verifies the checksum before installing.
The data flow is a one-way street from the tag all the way into the wp-admin update screen:
git tag v1.3.0 && git push --tags
│
▼
GitHub Actions (release.yml)
├── builds jpkcom-example.zip
├── computes SHA256
├── uploads ZIP + .sha256 to the GitHub release
└── generates plugin_jpkcom-example.json ──► deploy to gh-pages
│
▼
https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json
│
(static fetch, no API token, no rate limit)
│
▼
updater class in plugin ──► wp-admin: "Version 1.3.0 available"Each part has exactly one job and a clear interface: the pipeline produces the manifest, the manifest describes the release, the class consumes the manifest. You can understand and test each part in isolation — and that's exactly what we'll go through, one at a time.
Part 1 — GitHub Actions: from Git tag to release
The pipeline triggers on version tags only. That's half the battle for a clean release: not every commit fires something off, only the deliberate act of "this is now version X".
name: Build WordPress Plugin ZIP, Generate Manifest & Deploy
on:
push:
tags:
- 'v*'
permissions:
contents: write # needed to write release assets and the gh-pages branchpermissions: contents: write is deliberately minimal: the built-in GITHUB_TOKEN gets exactly the write access it needs for the release and gh-pages — nothing more. No personal access token, no secret to manage and rotate.
Deriving variables from the tag
The first job step pulls all the key facts from context rather than duplicating them somewhere. The slug is the repo name, the version is the tag without the leading v, and the download URL already points at the release asset that's about to be created:
echo "PLUGIN_SLUG=$(basename $GITHUB_REPOSITORY)" >> $GITHUB_ENV
TAG_NAME=${GITHUB_REF##*/} # e.g. "v1.3.0"
TAG_NAME=${TAG_NAME#v} # -> "1.3.0"
echo "TAG_NAME=$TAG_NAME" >> $GITHUB_ENV
echo "ZIP_FILE=$(basename $GITHUB_REPOSITORY).zip" >> $GITHUB_ENV
echo "DOWNLOAD_URL=https://github.com/${GITHUB_REPOSITORY}/releases/download/${GITHUB_REF##*/}/$(basename $GITHUB_REPOSITORY).zip" >> $GITHUB_ENVThe crucial idea: there is no hardcoded version number anywhere in the workflow. The tag is the single source. Push v1.3.0 and you get a 1.3.0 release with a matching ZIP name and matching download URL — without touching a single file.
Building the ZIP — with the right exclusions
A plugin ZIP should contain only what belongs on the target install. All the development scaffolding — .git, the workflow definition, build scripts, doc generators — has to go, or it bloats the package and leaks internals. rsync with an exclude list into a cleanly named staging directory is the robust tool for that:
STAGING_DIR="${{ env.PLUGIN_SLUG }}"
mkdir -p "$STAGING_DIR"
rsync -a \
--exclude='.git' --exclude='.gitignore' --exclude='.github' \
--exclude='.DS_Store' --exclude='*.py' \
--exclude='CLAUDE.md' --exclude='.claude' \
--exclude='gh-pages-*' --exclude='docs' --exclude='phpdoc*' \
. "$STAGING_DIR/"
zip -r "${{ env.ZIP_FILE }}" "$STAGING_DIR"
rm -rf "$STAGING_DIR"The important bit is the detour through $STAGING_DIR: it makes the ZIP contain a single top-level folder named after the plugin slug — exactly the structure WordPress expects when unpacking. A ZIP with its files flat in the root would drop the plugin in the wrong place on install.
Checksum: the security foundation
Right after the ZIP comes the step that later carries the entire security chain — the SHA256 checksum. It's computed, written to an environment variable (for the manifest) and stored as a separate .sha256 file (for manual verification):
SHA256_HASH=$(sha256sum "${{ env.ZIP_FILE }}" | awk '{print $1}')
echo "SHA256_HASH=$SHA256_HASH" >> $GITHUB_ENV
echo "$SHA256_HASH ${{ env.ZIP_FILE }}" > ${{ env.ZIP_FILE }}.sha256This number is the fingerprint of exactly the ZIP the pipeline just built. It travels into the manifest next — and the updater class will refuse to install anything that doesn't carry this fingerprint. More on that later.
Uploading to the release
Uploading is handled by an established action. generate_release_notes: true lets GitHub assemble the release notes automatically from the commits since the last tag:
- name: Upload ZIP and checksum to GitHub release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ github.ref_name }}
generate_release_notes: true
files: |
${{ env.ZIP_FILE }}
${{ env.ZIP_FILE }}.sha256
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}From here the ZIP is publicly retrievable at the DOWNLOAD_URL built above — through GitHub's release download infrastructure, not through the API. That distinction is the heart of the rate-limit topic further down.
Generating the manifest
The last substantive step builds the JSON file WordPress will read later. I do this in a Python step (shell: python), because assembling nested JSON structures in Bash gets unreadable fast. Simplified, the core looks like this:
import os, json
plugin = {
"name": meta.get("PluginName", ""),
"slug": os.environ["PLUGIN_SLUG"],
"version": meta.get("Version", os.environ["TAG_NAME"]),
"download_url": os.environ["DOWNLOAD_URL"],
"checksum_sha256": os.environ.get("SHA256_HASH", ""), # <- the fingerprint
"requires": meta.get("Requiresatleast", "6.8"),
"tested": meta.get("Testedupto", "6.9"),
"requires_php": meta.get("RequiresPHP", "8.3"),
"last_updated": os.environ["DATE"],
"sections": { # HTML for the "Details" modal
"description": load_section_html("description"),
"installation": load_section_html("installation"),
"changelog": load_section_html("changelog"),
"faq": load_section_html("faq"),
},
}
with open(f"gh-pages-json/plugin_{os.environ['PLUGIN_SLUG']}.json", "w", encoding="utf-8") as f:
json.dump(plugin, f, indent=2, ensure_ascii=False)The meta values and the sections come from the README.md: an earlier step uses pandoc and awk to pull out the ## Description, ## Installation, ## Changelog and ## FAQ sections and render them to HTML — exactly what WordPress shows in a plugin's "View details" modal. That keeps the README.md the single source for the description: maintain it once, current everywhere (GitHub, manifest, WP backend).
Deploying to gh-pages
Finally, the manifest (plus the rendered HTML snippets and the banners/icons) lands on the gh-pages branch:
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: gh-pages-deploy
publish_branch: gh-pages
keep_files: falsekeep_files: false makes the gh-pages branch fully rewritten on every release — so no stale files accumulate. Once the branch is published as GitHub Pages, the manifest sits at a stable, static URL:
https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.jsonThat completes Part 1: a single git push --tags is enough, and a few minutes later there's a release ZIP with a checksum and an updated manifest. No manual step in between.
Part 2 — The manifest: the contract between pipeline and plugin
The manifest is deliberately just a plain JSON file. It's the contract: the pipeline commits to filling it in correctly, the plugin relies on reading it correctly. Excerpt:
{
"name": "JPKCom Example",
"slug": "jpkcom-example",
"version": "1.3.0",
"download_url": "https://github.com/JPKCom/jpkcom-example/releases/download/v1.3.0/jpkcom-example.zip",
"checksum_sha256": "9f2c…a1b7",
"requires": "6.8",
"tested": "6.9",
"requires_php": "8.3",
"last_updated": "2026-07-06",
"sections": {
"description": "<p>…</p>",
"changelog": "<h4>1.3.0</h4><ul><li>…</li></ul>"
}
}Only a handful of fields carry the actual update logic:
| Field | What WordPress needs it for |
|---|---|
version | Compared against the installed version — only if newer is there an update |
download_url | Where the ZIP is pulled from (the release asset) |
checksum_sha256 | The fingerprint checked before installation |
requires / tested | Compatibility display in the backend (WordPress version) |
requires_php | Blocks the update on too-old PHP versions |
sections | HTML for the "View details" modal (description, changelog, FAQ) |
That this is a static document is not a compromise, it's the actual trick. There's no server generating it dynamically, no database, no PHP process on the other end. It's a file on a CDN. That makes it fast, resilient and — the crux — free of rate limits.
Part 3 — The updater class: hooking cleanly into WordPress' update system
Now the consumer side. WordPress periodically checks whether updates exist for installed plugins, and provides filters you can hook into for that. The class (here JPKComGitPluginUpdater) does two things in the constructor: it validates the environment and the manifest URL, and it registers its hooks.
public function __construct( string $plugin_file, string $current_version, string $manifest_url ) {
global $wp_version;
// Environment gate: don't even become active below PHP 8.3 / WP 6.8
if ( version_compare( PHP_VERSION, '8.3', '<' )
|| version_compare( $wp_version, '6.8', '<' ) ) {
return;
}
// Security: sanitize and validate the manifest URL before anything happens
$manifest_url = esc_url_raw( $manifest_url );
if ( ! wp_http_validate_url( $manifest_url ) ) {
return; // invalid URL -> abort initialization
}
$this->plugin_file = $plugin_file;
$this->plugin_slug = dirname( plugin_basename( $plugin_file ) );
$this->current_version = $current_version;
$this->manifest_url = $manifest_url;
$this->cache_key = 'jpk_git_update_' . md5( $this->plugin_slug );
add_filter( 'plugins_api', [ $this, 'plugin_info' ], 20, 3 );
add_filter( 'site_transient_update_plugins', [ $this, 'check_update' ] );
add_action( 'upgrader_process_complete', [ $this, 'clear_cache' ], 10, 2 );
add_filter( 'upgrader_pre_download', [ $this, 'verify_download_checksum' ], 10, 3 );
}The four hooks map to four clear jobs:
site_transient_update_plugins→check_update(): tells WordPress an update is available.plugins_api→plugin_info(): fills the "View details" modal from the manifest.upgrader_pre_download→verify_download_checksum(): checks the checksum before installing.upgrader_process_complete→clear_cache(): throws away the cache after a successful update.
The class is instantiated when the plugin loads — with its own version and the gh-pages manifest URL:
new \JPKComHideLoginGitUpdate\JPKComGitPluginUpdater(
plugin_file: __FILE__,
current_version: JPKCOM_EXAMPLE_VERSION,
manifest_url: 'https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.json'
);Fetching the manifest — with cache and race protection
The heart is get_remote_manifest(). It does three things at once: it caches the result for 24 hours, it uses a lock to prevent several concurrent requests from fetching the same manifest in parallel, and it fetches it SSRF-safely.
private function get_remote_manifest(): ?object {
$remote = get_transient( $this->cache_key );
if ( false === $remote ) {
// Race protection: is another fetch already running? Then skip this one.
$lock_key = $this->cache_key . '_lock';
if ( get_transient( $lock_key ) ) {
return null;
}
set_transient( $lock_key, true, 30 ); // lock for 30 seconds
$response = wp_safe_remote_get( $this->manifest_url, [
'timeout' => 15,
'headers' => [ 'Accept' => 'application/json' ],
] );
delete_transient( $lock_key );
if ( is_wp_error( $response )
|| wp_remote_retrieve_response_code( $response ) !== 200 ) {
return null; // in WP_DEBUG mode this also logs
}
$remote = json_decode( wp_remote_retrieve_body( $response ) );
if ( json_last_error() !== JSON_ERROR_NONE ) {
return null;
}
set_transient( $this->cache_key, $remote, DAY_IN_SECONDS );
}
return is_object( $remote ) ? $remote : null;
}Three details that make the difference between "works" and "works under load":
wp_safe_remote_get()instead ofwp_remote_get(). The "safe" variant blocks requests to internal/private IP ranges — protection against SSRF, should the manifest URL ever be manipulated.- The lock. On a busy site, several requests can find the cache empty at the same time. Without a lock they'd all fetch the manifest in parallel. The 30-second lock ensures exactly one goes and the others simply skip this cycle.
- The 24-hour cache (
DAY_IN_SECONDS). Each install queries the manifest at most once a day. That's both friendly to the source and more than fresh enough for plugin updates.
Reporting the update
check_update() is almost unspectacular after that — and that's a good thing. Check the cache is populated, compare the version, and on "newer" build an update object:
public function check_update( mixed $transient ): object {
if ( empty( $transient->checked ) ) {
return $transient;
}
$remote = $this->get_remote_manifest();
if ( ! $remote || empty( $remote->version ) ) {
return $transient;
}
if ( version_compare( $this->current_version, $remote->version, '<' ) ) {
$download_url = $remote->download_url ?? '';
if ( ! empty( $download_url ) && ! wp_http_validate_url( $download_url ) ) {
return $transient; // invalid download URL -> offer no update
}
$update = new \stdClass();
$update->slug = $this->plugin_slug;
$update->new_version = sanitize_text_field( $remote->version );
$update->package = esc_url_raw( $download_url ); // WP pulls the ZIP from here
$update->plugin = plugin_basename( $this->plugin_file );
$transient->response[ plugin_basename( $this->plugin_file ) ] = $update;
}
return $transient;
}Once this $transient->response object is set, WordPress shows the familiar "Version 1.3.0 available" with an update button in the backend — indistinguishable from a directory plugin. In parallel, plugin_info() (not shown here) fills the "View details" modal from the manifest's sections.
Security: why a URL alone is never enough
A self-hosted update system is a powerful thing — you're telling WordPress: "install whatever lives at this URL." If that URL, the manifest, or the connection to it were compromised, you'd be installing arbitrary code on every connected site. That's why the whole concept stands or falls on one rule: a download URL alone is no proof of trust. Only the checksum makes it credible.
The checksum gate
The most important hook is upgrader_pre_download. It fires before WordPress installs the package, and is allowed to abort the process with a WP_Error. That's exactly where the SHA256 stored in the manifest is checked against the ZIP that was actually downloaded:
public function verify_download_checksum( $reply, string $package, \WP_Upgrader $upgrader ) {
// Does this download even concern our plugin?
$remote = $this->get_remote_manifest();
// (matched against manifest->download_url or the slug – trimmed here)
if ( ! $remote || empty( $remote->checksum_sha256 ) ) {
return $reply; // no checksum in manifest: don't block (backward compatibility)
}
// Download the package temporarily and compute the hash
$temp_file = download_url( $package );
if ( is_wp_error( $temp_file ) ) {
return new \WP_Error( 'download_failed', $temp_file->get_error_message() );
}
$calculated_hash = hash_file( 'sha256', $temp_file );
@unlink( $temp_file );
// Timing-safe comparison
$expected_hash = strtolower( trim( $remote->checksum_sha256 ) );
if ( ! is_string( $calculated_hash ) || ! hash_equals( $expected_hash, $calculated_hash ) ) {
return new \WP_Error(
'checksum_mismatch',
__( 'Security check failed: checksums do not match.', 'jpkcom-example' )
);
}
return $reply; // checksum matches -> allow installation
}The effect: even if an attacker managed to bend the download URL in the manifest toward a tampered ZIP, they'd additionally have to store its SHA256 in the same manifest — and even then the question remains how they'd have altered the manifest at all. If the hash doesn't match exactly, the install aborts with a clear error message. Nothing gets installed.
Two subtleties matter here:
hash_equals()instead of===. The comparison runs timing-safely, so no conclusions about the expected hash can be drawn from how long the comparison takes. For checksums this is more principle than acute threat — but it's the right habit.- The hash is normalized (
strtolower,trim) before comparison, so that harmless whitespace or a case difference in the manifest doesn't get wrongly flagged as tampering.
Defense in depth
The checksum is the last line of defense, but not the only one. Across the whole class runs the principle of distrusting every value read from the manifest until it's validated and sanitized:
- URLs are consistently checked via
wp_http_validate_url()and sanitized withesc_url_raw()— the manifest URL itself, every download URL, every banner and icon URL. - Text fields from the manifest (
version,author,tested…) run throughsanitize_text_field()before output, HTML sections throughwp_kses_post(). The manifest must never write unfiltered HTML into the backend. wp_safe_remote_get()prevents the fetch from being redirected to internal network addresses (SSRF).- The environment gate in the constructor ensures the class doesn't even become active on outdated PHP/WP versions, rather than failing there unpredictably.
None of these measures is spectacular on its own. Together they form a chain in which every link assumes the others might fail — and that's exactly the point.
Rate-limit independence: why gh-pages instead of the GitHub API
Now to the point that sets this setup apart from many ready-made "GitHub updater" libraries. The obvious way to determine a repo's latest release is the GitHub REST API — e.g. api.github.com/repos/OWNER/REPO/releases/latest. Many update-checker libraries do exactly that. And that's precisely where the problem is.
The GitHub API limits unauthenticated requests to 60 per hour per IP address. That sounds like a lot, but it isn't:
- On shared hosting, dozens or hundreds of WordPress installs often share the same outbound IP. They'd all draw on the same quota of 60.
- Once the limit is exhausted, the API responds with
403— the update check fails, and silently: the site thinks there's no update. - The obvious "fix" of embedding an API token isn't one: a personal token in shipped plugin code would be a real security leak.
The way out is to bypass the API entirely. GitHub Pages is not an API endpoint but static hosting via a CDN. A fetch of
https://jpkcom.github.io/jpkcom-example/plugin_jpkcom-example.jsonis, to GitHub, the same as fetching any static web page: no token, no auth context, no API rate limit. The same holds for the ZIP download from the release: github.com/…/releases/download/… goes through the download infrastructure, not through api.github.com. So both steps of the update process — reading the manifest and pulling the ZIP — never touch the rate-limited API.
The contrast at a glance:
| GitHub API path | gh-pages manifest (this setup) | |
|---|---|---|
| Endpoint | api.github.com/... | static file on a CDN |
| Rate limit (without token) | 60 requests/h per IP | effectively none for normal traffic |
| Behavior on shared hosting | shared IP quota → exhausted early | every site gets its answer |
| Token needed for relief | yes (and a risk in the plugin) | no |
| Failure behavior | 403, update check fails silently | static file, as available as the CDN |
And the 24-hour transient plus the lock from the updater class pile on even more: even if thousands of sites use the same manifest, each queries it at most once a day — and per site, only one request actually goes out when several are concurrent. So the setup is built from the ground up to scale arbitrarily without ever hitting a limit.
What's deliberately missing — and the limits
An honest making-of also names what the setup does not do:
- No automatic rollback. If an update fails or a version is buggy, there's no built-in revert switch. The checksum prevents installing tampered packages, not a faultily published one. Discipline when tagging remains the actual safeguard.
- Checksum, not signature. SHA256 proves the ZIP is unchanged from what the manifest describes — it doesn't cryptographically prove who created it. The next stage would be a real signature (e.g. via Sigstore /
cosignor GPG) that also makes provenance provable. For a self-controlled repo with protectedcontents: write, the checksum is a pragmatic, well-justifiable starting point. - Trust in GitHub. Hosting your release and manifest on GitHub means trusting GitHub's access control. That's the same basis of trust as hosting the code itself — but you should make it consciously (2FA, protected branches, minimal token scopes).
- The manifest is public. gh-pages is public. For a commercial plugin with license checks, this route wouldn't be suitable as-is — then you do need a dynamic endpoint that verifies licenses. For freely available plugins that just aren't listed in the directory, public is exactly right.
These omissions are decisions, not oversights: the setup solves one problem — convenient, rate-limit-free, checksum-secured auto-updates for self-hosted plugins — and it solves it completely. Anything beyond that would be a different project.
FAQ
Do I have to touch code for every new release?
No. The entire flow hangs on a single Git tag. git tag v1.3.1 && git push --tags — the rest (ZIP, checksum, release, manifest, deploy) is handled by the pipeline. You maintain the version number in the plugin header and the README.md anyway; they're the source the tag and manifest feed from.
Does this work for themes instead of plugins?
Yes, the principle is identical. WordPress has the theme counterparts site_transient_update_themes and themes_api. The pipeline and manifest stay practically the same; you just swap the hooks in the updater class for the theme variants.
What happens if gh-pages is unreachable?
Then get_remote_manifest() returns null, and check_update() simply reports no update — the site keeps running normally, it just doesn't learn about a new version this cycle. On the next fetch (at latest once the 24-hour cache expires) it tries again. So an outage of the update source never takes the site down.
Is the SHA256 check really necessary if everything runs over HTTPS?
HTTPS secures the transport — it guarantees nobody alters the ZIP in transit. But it says nothing about whether the file is the right one at the origin. The checksum closes exactly that gap: it binds the installed ZIP to precisely the fingerprint the pipeline computed at build time. Both layers complement each other; neither replaces the other.
Why the README.md as the source for the manifest?
Because it's maintained anyway and comes close to WordPress conventions (description, installation, changelog, FAQ as ## sections). Maintaining description text twice — once for the repo, once for the backend — is a classic source of stale information. A single source prevents that.
Further reading
The thinking behind this article — build a tool yourself and expose how it really works — also runs through the making-of of my SEO and GEO tool. If you want to go deeper on the security and automation angle, Configuring Claude Code is the natural counterpart on hooks, permissions and hardened automation. And the fastest way to understand the setup is to rebuild it on a small plugin of your own: one tag, one release.yml, one manifest — and the update button appears.