How to enable WordPress to update your custom plugin hosted on GitHub
Most of my WordPress plugins are exclusive to a specific site, but every now and then I'll create a utility plugin that gets installed on multiple sites. Then, whenever I update that plugin, I have to remember which sites have it installed, and manually push that update to them.
Ever since WordPress rolled out automatic updates for plugins and themes, I've had that feature enabled, so I thought: what would it take for WordPress to keep my plugins updated from a source such as GitHub?
Now, this isn't a new idea, and while there are a couple of different solutions out there, I had a few opinions of how this should work:
- Rely on WordPress core as much as possible. Why reinvent the wheel for something WordPress can already do pretty well?
- Avoid creating a plugin to keep other plugins updated. What if that plugin got deactivated, deleted, or corrupted?
- Keep the configuration simple. Most everything WordPress needs can be defined within plugin header fields, so let's use them.
In this post, we're going to look at how WordPress keeps plugins from wordpress.org updated, and using this information, we're going to create a single PHP class that enables WordPress to update our own custom plugin using GitHub as the source repository.
How WordPress keeps plugins updated
When something happens on a regular basis in WordPress, there's a good chance it's a scheduled event. WordPress keeps track of these events in its cron
option, which can be found in the wp_options
table:
You'll notice that there's a hook called wp_update_plugins
, which runs twice a day, and maps directly to a WordPress function called wp_update_plugins()
.
wp_update_plugins()
retrieves a list of plugins on your WordPress site, sends those to api.wordpress.org
via an API call, gets meta data about the latest version of each plugin, and stores that in a transient called update_plugins
:
Plugins that have updates are stored in the response
array, whereas plugins that are up to date are stored in the no_update
array. It's important to note that wp_update_plugins()
doesn't perform any updates– it just checks for them.
If you look at the plugin above that has a pending update, you'll see that there's a package
value with a path to a ZIP file. This allows WordPress to download the latest version of that plugin.
If a plugin is marked in the transient above as having an update, WordPress will display a notice to the user that an update is available:
Clicking that link corresponds to an action in wp-admin/update.php
, which creates a Plugin_Upgrader
instance. That class has a run()
method through its parent WP_Upgrader
class which then:
- Downloads package (ZIP file) via
download_package()
- Unzips package into a temporary directory via
unpack_package()
- Replaces plugin with temporary directory via
install_package()
The specified plugin was now successfully updated.
Let's use what we learned to leverage WordPress to update our custom plugins hosted on GitHub.
Leverage WordPress to update custom plugins
At a high level, to implement the functionality described in the introduction, we're going to use a total of five WordPress hooks:
- admin_notices to display a notice in the WordPress admin should our custom plugin be missing required plugin header fields.
- admin_url to replace the built-in plugin detail modal links, which usually reference a plugin on wordpress.org, with a destination of our own.
- update_plugins_github.com to build a response for our custom plugin, resembling an officially hosted plugin, so that WordPress knows what to do.
- http_request_args to set an authorization header for GitHub's API should the custom plugin be hosted in a private GitHub repository.
- upgrader_install_package_result to change the destination of the updated plugin so that it matches the old plugin.
Let's roll up our sleeves and get started.
First, we're going to create a new class for all of our logic that enables WordPress to keep our custom plugins updated:
Next, we're going to add all the properties we need to seamlessly pass data between different WordPress hooks:
Only $file
will need to be provided to GitHubUpdater
; every other property can be loaded from that. $file
must be the absolute path of the root plugin file that contains the plugin header.
For example:
If you are in your root plugin file, e.g. github-updater-demo.php
, and this is where you instantiate GitHubUpdater
, you can just use __FILE__
to pass that to the constructor.
Speaking of, let's add the constructor now:
To instantiate GitHubUpdater
, you would:
As previously mentioned, $file
is enough for us to load the remaining properties that we'd like to access, so let's define and flesh out the load()
method that we're calling in the constructor:
We use get_file_data()
to read the required plugin header fields: Plugin URI
, Update URI
, and Version
. If any one of them are missing, we're going to display a notice in the WordPress admin:
In order to prevent my code blocks in this blog post from growing by continuously repeating things we already covered, I'll now only focus on the new methods. They will sequentially follow whatever we've already covered.
Let's start with a method called add()
, which will tie everything together. Think of it as the blueprint for our logic in the class:
Since we called addAdminNotice()
up in load()
, let's define it:
The next two methods, updatePluginDetailsUrl()
and _updatePluginDetailsUrl()
are responsible for ensuring the plugin detail links, which open in a modal, don't lead us to a Plugin not found
error:
You'd get this error if you were to click on [View details] or [View version 1.1.0 details] on the Plugins page:
And if you were to click on [View version 1.1.0 details] on the Dashboard > Updates page:
We'll fix this by using the admin_url
filter in WordPress.
I'd like to point out that all of the methods in this class are set to a private
visibility, with the exception of:
- Methods called by WordPress hooks, which must be
public
. - Methods intended to
set
properties from outside the class.
Another pattern I follow is to create a private
and public
method pair when hooking into WordPress. The private
method, e.g. updatePluginDetailsUrl()
, registers the WordPress hook, whereas the corresponding public
method, e.g. _updatePluginDetailsUrl()
, performs the work.
I follow this naming convention because it immediately tells me that these methods belongs together, and that the one with the underscore is not directly called within the class, but rather, invoked by WordPress via a hook.
Speaking of, let's define both of those methods now:
As you can imagine, this hook filters many URLs in WordPress, so we need to look for something in the URL that let's us know that this URL is the plugin detail URL of our plugin. We'll use the $pluginSlug
for that, which is comprised of the GitHub account and repository name, e.g. ryansechrest-github-updater-demo
.
We then swap out the URL for whatever was defined as the Plugin URI
in the plugin header. In my case, it's a simple changelog page hosted in the same GitHub repository as the plugin, and then served via GitHub pages.
Note that GitHub pages can only be enabled for public repositories, so if your repository is private, you could simply create a public repository for the changelog.
The next part is meaty. It contains some of the core functionality that makes everything work. Let's take a look:
We're using the update_plugins_github.com
filter in WordPress. Note that github.com
is actually a dynamic value, meaning you can filter by any hostname set in your plugin's Update URI
header.
So, if the hostname of your Update URI
were gitlab.com
, you could filter those using update_plugins_gitlab.com
. This allows you to integrate any repository system. You could be building a GitLabUpdater
!
When WordPress checks for plugin updates, it makes an API request to api.wordpress.org
to get pertinent information about the plugin. Well, that won't work for our custom plugin, because it's not hosted on wordpress.org, so we have to emulate the behavior.
Instead of consulting the WordPress API, we're going to look at our latest plugin header on GitHub. We do this using $this->getRemotePluginFileContents()
, which gets the file contents of github-updater-demo.php
from the master
branch on github.com
.
Your main
(or master
) branch should always be on the latest, stable version, which means we read the root plugin file, extract the version from it, and let WordPress compare what you have installed with what's available on GitHub. If GitHub has a higher version, WordPress will mark the plugin as having an update.
We form a return
that sets all the array keys WordPress might expect (this is the emulating part). The most important ones here are version
(your latest version) and package
, which is the URL to the ZIP file containing the latest version of your plugin on GitHub. You'll later see that getRemotePluginZipFile()
will return a different URL based on whether your repository is public or private.
For the icons
, which are used on the Dashboard > Updates page (if your plugin has an update), I just keep it simple and assume you'll upload two icons in the root of whatever you set as your Plugin URI
. Down the road, I might make that more flexible so that you can use a set
method to define a different path.
For now, just upload two icons:
icon-256x256.png
icon-128x128.png
Next, let's define these "file" methods we references up above. I bet you're dying to unveil the mystery that lies within:
We're checking whether there is a $gitHubAccessToken
, and if there isn't, we return a public GitHub URL, but if there is, we provide an API-based URL (which requires authentication).
We use WordPress' wp_remote_get()
and wp_remote_retrieve_body()
to make a GET
request and extract the file body.
If you remember back, we set package
in our plugin response to a ZIP file, and now we know that this ZIP file could either be a URL to a public or private repository. If WordPress were to attempt to download the private ZIP file, it would fail, because it's not authorized.
To fix this, we're going to intercept the GET
request to our ZIP file and inject the necessary authorization header. We do that with the http_request_args
filter:
The next problem we need to solve is this: The ZIP file from GitHub will contain the repository and branch name, e.g. github-updater-demo-master
, which likely is not what our installed plugin is called, e.g. github-updater-demo
.
This wouldn't be a big deal, except that when WordPress deletes the old one and moves in the new one, WordPress will deactivate the plugin because the slug
has changed: github-updater-demo
-> github-updater-demo-master
.
That's because WordPress stores the active plugins in an option called active_plugins
and uses the slug as the identifier.
If we wanted, we could just manually activate the plugin this one time, and from here on out, this wouldn't be an issue, but that sounds... finicky.
To solve this, we're going to tell WordPress to move the new plugin, whatever the directory may be called, exactly to where the old one was:
And that's basically it... the entire functionality of GitHubUpdater
.
We have a few additional public
methods to allow you to set
properties that GitHubUpdater
can't derive in any other way:
If your production branch is main
, you can ignore setBranch()
, but if you use a different branch as your production branch, you may specify it using this method.
If your GitHub repo is public, you are a-ok, but if it's private, call setAccessToken()
and pass in your fine-grained personal access token.
- Click on the Generate new token button.
- Enter a Token name to help you identify it later.
- Set the Expiration date (the longest is a year).
- Under Repository access select Only select repositories, and pick your plugin repository, e.g.
github-updater-demo
. - Then under Permissions and Repository permissions, only give Contents Read-only access.
- Click the Generate token button.
- Copy the token to somewhere– you'll only see it once.
For testing, you could pass the access token directly into setAccessToken()
as a string, however, it would be better to either define a constant in wp-config.php
and then pass the constant, or save the token as an option in the database and then pass it in from there.
Let's review all three options:
If it's a constant, you'd set that in wp-config.php
:
And if it's an option, you may want to add a field to a settings page, such as Settings > General, or a custom page, where you can supply the token in an input field, save it as an option in the database, and retrieve it later to pass into setAccessToken()
.
Once your GitHubUpdater
class is complete, put it somewhere in your plugin directory, require it in your root plugin file, and instantiate it as follows:
If you're intrigued by all of this, didn't code along, and want to see it in action, head on over to my demo plugin on GitHub. It implements this very GitHubUpdater
we just created (and likely includes some bug fixes).
Either clone that plugin into your plugins
directory, or download the ZIP file and extract it. The demo plugin doesn't do anything except demonstrate how GitHubUpdater
works using a public GitHub repository.
To then test the actual update functionality, activate the plugin and edit the root plugin file's Version
header to a lower version.
For example, if the latest version on GitHub is 1.1.0
, set your local version to 1.0.0
. This will make WordPress think you're behind, and the next time it checks for updates, it'll show you the update notice.
If all goes well, fingers crossed, you can click [update now] in the GitHub Updater Demo plugin's update notice and it will update.
If you have questions or issues, leave a comment below, send me a message, or open a GitHub issue on the GitHub Updater Demo plugin.
Featured image by Roman Synkevych.