How to enable WordPress to update your custom plugin hosted on GitHub

GitHub's Octocat standing on the trackpad of a MacBook.
To the Octocat mobile.

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:

  1. Rely on WordPress core as much as possible. Why reinvent the wheel for something WordPress can already do pretty well?
  2. Avoid creating a plugin to keep other plugins updated. What if that plugin got deactivated, deleted, or corrupted?
  3. 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:

array (
  1713761725 => 
  array (
    'wp_update_plugins' => 
    array (
      '40cd750bba9870f18aada2478b24840a' => 
      array (
        'schedule' => 'twicedaily',
        'args' =>
        array (
        ),
        'interval' => 43200,
      ),
    ),
  ),
  'version' => 2,
)

Excerpt of scheduled events in WordPress.

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:

(object) array(
   'last_checked' => 1714005541,
   'response' => 
  array (
    'github-updater-demo/github-updater-demo.php' => 
    (object) array(
       'id' => 'https://github.com/ryansechrest/github-updater-demo',
       'slug' => 'ryansechrest-github-updater-demo',
       'plugin' => 'github-updater-demo/github-updater-demo.php',
       'version' => '1.1.0',
       'url' => 'https://ryansechrest.github.io/github-updater-demo',
       'package' => 'https://api.github.com/repos/ryansechrest/github-updater-demo/zipball/master',
       'icons' => 
      array (
        '2x' => 'https://ryansechrest.github.io/github-updater-demo/icon-256x256.png',
        '1x' => 'https://ryansechrest.github.io/github-updater-demo/icon-128x128.png',
      ),
       'tested' => '6.5.2',
       'new_version' => '1.1.0',
    ),
  ),
   'translations' => 
  array (
  ),
   'no_update' => 
  array (
    'hello-dolly/hello.php' => 
    (object) array(
       'id' => 'w.org/plugins/hello-dolly',
       'slug' => 'hello-dolly',
       'plugin' => 'hello-dolly/hello.php',
       'new_version' => '1.7.2',
       'url' => 'https://wordpress.org/plugins/hello-dolly/',
       'package' => 'https://downloads.wordpress.org/plugin/hello-dolly.1.7.3.zip',
       'icons' => 
      array (
        '2x' => 'https://ps.w.org/hello-dolly/assets/icon-256x256.jpg?rev=2052855',
        '1x' => 'https://ps.w.org/hello-dolly/assets/icon-128x128.jpg?rev=2052855',
      ),
       'banners' => 
      array (
        '2x' => 'https://ps.w.org/hello-dolly/assets/banner-1544x500.jpg?rev=2645582',
        '1x' => 'https://ps.w.org/hello-dolly/assets/banner-772x250.jpg?rev=2052855',
      ),
       'banners_rtl' => 
      array (
      ),
       'requires' => '4.6',
    ),
  ),
   'checked' => 
  array (
    'github-updater-demo/github-updater-demo.php' => '1.0.0',
    'hello-dolly/hello.php' => '1.7.2',
  ),
)

List of plugins and whether they have a new version or not.

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:

WordPress update notice that a plugin has a new version 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:

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:

  1. admin_notices to display a notice in the WordPress admin should our custom plugin be missing required plugin header fields.
  2. 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.
  3. update_plugins_github.com to build a response for our custom plugin, resembling an officially hosted plugin, so that WordPress knows what to do.
  4. http_request_args to set an authorization header for GitHub's API should the custom plugin be hosted in a private GitHub repository.
  5. 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:

namespace RYSE\GitHubUpdaterDemo;

class GitHubUpdater
{

}

Create empty GitHubUpdater class.

Next, we're going to add all the properties we need to seamlessly pass data between different WordPress hooks:

namespace RYSE\GitHubUpdaterDemo;

class GitHubUpdater
{
    private string $file = '';

    private string $gitHubUrl = '';

    private string $gitHubPath = '';
    private string $gitHubOrg = '';
    private string $gitHubRepo = '';
    private string $gitHubBranch = 'main';
    private string $gitHubAccessToken = '';

    private string $pluginFile = '';
    private string $pluginDir = '';
    private string $pluginFilename = '';
    private string $pluginSlug = '';
    private string $pluginUrl = '';
    private string $pluginVersion = '';

    private string $testedWpVersion = '';
}

Add properties to pass data between 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:

/var/www/domains/example.org/wp-content/plugins/github-updater-demo/github-updater-demo.php

Example of absolute plugin file path.

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:

namespace RYSE\GitHubUpdaterDemo;

class GitHubUpdater
{
    private string $file = '';

    private string $gitHubUrl = '';

    private string $gitHubPath = '';
    private string $gitHubOrg = '';
    private string $gitHubRepo = '';
    private string $gitHubBranch = 'master';
    private string $gitHubAccessToken = '';

    private string $pluginFile = '';
    private string $pluginDir = '';
    private string $pluginFilename = '';
    private string $pluginSlug = '';
    private string $pluginUrl = '';
    private string $pluginVersion = '';

    private string $testedWpVersion = '';

    public function __construct(string $file)
    {
        $this->file = $file;

        $this->load();
    }
}

Add default constructor to load properties.

To instantiate GitHubUpdater, you would:

new GitHubUpdater(__FILE__);

Instantiate GitHubUpdater class.

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:

namespace RYSE\GitHubUpdaterDemo;

class GitHubUpdater
{
    private string $file = '';

    private string $gitHubUrl = '';

    private string $gitHubPath = '';
    private string $gitHubOrg = '';
    private string $gitHubRepo = '';
    private string $gitHubBranch = 'master';
    private string $gitHubAccessToken = '';

    private string $pluginFile = '';
    private string $pluginDir = '';
    private string $pluginFilename = '';
    private string $pluginSlug = '';
    private string $pluginUrl = '';
    private string $pluginVersion = '';

    private string $testedWpVersion = '';

    public function __construct(string $file)
    {
        $this->file = $file;

        $this->load();
    }

    private function load(): void
    {
        $pluginData = get_file_data(
            $this->file,
            [
                'PluginURI' => 'Plugin URI',
                'Version' => 'Version',
                'UpdateURI' => 'Update URI',
            ]
        );

        $pluginUri = $pluginData['PluginURI'] ?? '';
        $updateUri = $pluginData['UpdateURI'] ?? '';
        $version = $pluginData['Version'] ?? '';

        if (!$pluginUri || !$updateUri || !$version) {
            $this->addAdminNotice('Plugin <b>%s</b> is missing one or more required header fields: <b>Plugin URI</b>, <b>Version</b>, and/or <b>Update URI</b>.');
            return;
        };

        $this->gitHubUrl = $updateUri;
        $this->gitHubPath = trim(
            parse_url($updateUri, PHP_URL_PATH),
            '/'
        );
        list($this->gitHubOrg, $this->gitHubRepo) = explode(
            '/', $this->gitHubPath
        );
        $this->pluginFile = str_replace(
            WP_PLUGIN_DIR . '/', '', $this->file
        );
        list($this->pluginDir, $this->pluginFilename) = explode(
            '/', $this->pluginFile
        );
        $this->pluginSlug = sprintf(
            '%s-%s', $this->gitHubOrg, $this->gitHubRepo
        );
        $this->pluginUrl = $pluginUri;
        $this->pluginVersion = $version;
    }
}

Add load() method to populate properties.

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:

Admin notice showing an error after instantiating GitHubUpdater class.

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:

public function add(): void
{
    $this->updatePluginDetailsUrl();
    $this->checkPluginUpdates();
    $this->prepareHttpRequestArgs();
    $this->moveUpdatedPlugin();
}

Add add() method to register WordPress actions and filters.

Since we called addAdminNotice() up in load(), let's define it:

private function addAdminNotice(string $message): void
{
    add_action('admin_notices', function () use ($message) {
        $pluginFile = str_replace(
            WP_PLUGIN_DIR . '/', '', $this->file
        );
        echo '<div class="notice notice-error">';
        echo '<p>' . sprintf($message, $pluginFile) . '</p>';
        echo '</div>';
    });
}

Add admin_notices action to print notice in WordPress admin dashboard.

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:

Modal when you click on a plugin detail link of a custom plugin.

You'd get this error if you were to click on [View details] or [View version 1.1.0 details] on the Plugins page:

Custom plugin with a pending update on Plugins page.

And if you were to click on [View version 1.1.0 details] on the Dashboard > Updates page:

Custom plugin with a pending update on 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 this 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:

private function updatePluginDetailsUrl(): void
{
    add_filter(
        'admin_url',
        [$this, '_updatePluginDetailsUrl'],
        10,
        4
    );
}

public function _updatePluginDetailsUrl(string $url, string $path): string
{
    $query = 'plugin=' . $this->pluginSlug;

    if (!str_contains($path, $query)) return $url;

    return sprintf(
        '%s?TB_iframe=true&width=600&height=550',
        $this->pluginUrl
    );
}

Add admin_url filter to update URL to plugin details.

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:

private function checkPluginUpdates(): void
{
    add_filter(
        'update_plugins_github.com',
        [$this, '_checkPluginUpdates'],
        10,
        3
    );
}

public function _checkPluginUpdates(
    array|false $update, array $data, string $file
): array|false
{
    $updateUri = $data['UpdateURI'] ?? '';

    $gitHubPath = trim(
        parse_url($updateUri, PHP_URL_PATH),
        '/'
    );

    if ($gitHubPath !== $this->gitHubPath) return false;

    $fileContents = $this->getRemotePluginFileContents();

    preg_match_all(
        '/\s+\*\s+Version:\s+(\d+(\.\d+){0,2})/',
        $fileContents,
        $matches
    );

    $newVersion = $matches[1][0] ?? '';

    if (!$newVersion) return false;

    return [
        'id' => $this->gitHubUrl,
        'slug' => $this->pluginSlug,
        'plugin' => $this->pluginFile,
        'version' => $newVersion,
        'url' => $this->pluginUrl,
        'package' => $this->getRemotePluginZipFile(),
        'icons' => [
            '2x' => $this->pluginUrl . '/icon-256x256.png',
            '1x' => $this->pluginUrl . '/icon-128x128.png',
        ],
        'tested' => $this->testedWpVersion,
    ];
}

Add update_plugins_github.com to build custom plugin response for WordPress.

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:

  1. icon-256x256.png
  2. 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:


private function getRemotePluginFileContents(): string
{
    return $this->gitHubAccessToken
        ? $this->getPrivateRemotePluginFileContents()
        : $this->getPublicRemotePluginFileContents();
}

private function getPublicRemotePluginFileContents(): string
{
    $remoteFile = $this->getPublicRemotePluginFile($this->pluginFilename);

    return wp_remote_retrieve_body(
        wp_remote_get($remoteFile)
    );
}

private function getPublicRemotePluginFile(string $filename): string
{
    return sprintf(
        'https://raw.githubusercontent.com/%s/%s/%s',
        $this->gitHubPath,
        $this->gitHubBranch,
        $filename
    );
}

private function getPrivateRemotePluginFileContents(): string
{
    $remoteFile = $this->getPrivateRemotePluginFile($this->pluginFilename);

    return wp_remote_retrieve_body(
        wp_remote_get(
            $remoteFile,
            [
                'headers' => [
                    'Authorization' => 'Bearer ' . $this->gitHubAccessToken,
                    'Accept' => 'application/vnd.github.raw+json',
                ]
            ]
        )
    );
}

private function getPrivateRemotePluginFile(string $filename): string
{
    // Generate URL to private remote plugin file.
    return sprintf(
        'https://api.github.com/repos/%s/contents/%s?ref=%s',
        $this->gitHubPath,
        $filename,
        $this->gitHubBranch
    );
}

private function getRemotePluginZipFile(): string
{
    return $this->gitHubAccessToken
        ? $this->getPrivateRemotePluginZipFile()
        : $this->getPublicRemotePluginZipFile();
}

private function getPublicRemotePluginZipFile(): string
{
    return sprintf(
        'https://github.com/%s/archive/refs/heads/%s.zip',
        $this->gitHubPath,
        $this->gitHubBranch
    );
}

private function getPrivateRemotePluginZipFile(): string
{
    return sprintf(
        'https://api.github.com/repos/%s/zipball/%s',
        $this->gitHubPath,
        $this->gitHubBranch
    );
}

Various methods to assemble GitHub URLs.

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:

private function prepareHttpRequestArgs(): void
{
    add_filter(
        'http_request_args',
        [$this, '_prepareHttpRequestArgs'],
        10,
        2
    );
}

public function _prepareHttpRequestArgs(array $args, string $url): array
{
    if ($url !== $this->getPrivateRemotePluginZipFile()) return $args;

    $args['headers']['Authorization'] = 'Bearer ' . $this->gitHubAccessToken;
    $args['headers']['Accept'] = 'application/vnd.github+json';

    return $args;
}

Add http_request_args filter to inject Authorization header for GET requests to GitHub.

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:

private function moveUpdatedPlugin(): void
{
    add_filter(
        'upgrader_install_package_result',
        [$this, '_moveUpdatedPlugin']
    );
}

public function _moveUpdatedPlugin(array $result): array
{
    $newPluginPath = $result['destination'] ?? '';

    if (!$newPluginPath) return $result;

    $pluginRootPath = $result['local_destination'] ?? WP_PLUGIN_DIR;

    $oldPluginPath = $pluginRootPath . '/' . $this->pluginDir;

    move_dir($newPluginPath, $oldPluginPath);

    $result['destination'] = $oldPluginPath;
    $result['destination_name'] = $this->pluginDir;
    $result['remote_destination'] = $oldPluginPath;

    return $result;
}

Add upgrader_install_package_result filter to move new plugin where old plugin 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:

public function setBranch(string $branch): self
{
    $this->gitHubBranch = $branch;

    return $this;
}

public function setAccessToken(string $accessToken): self
{
    $this->gitHubAccessToken = $accessToken;

    return $this;
}

public function setTestedWpVersion(string $version): self
{
    $this->testedWpVersion = $version;

    return $this;
}

Add set methods to configure GitHubUpdater further.

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.

  1. Click on the Generate new token button.
  2. Enter a Token name to help you identify it later.
  3. Set the Expiration date (the longest is a year).
  4. Under Repository access select Only select repositories, and pick your plugin repository, e.g. github-updater-demo.
  5. Then under Permissions and Repository permissions, only give Contents Read-only access.
  6. Click the Generate token button.
  7. 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:

$updater = new GitHubUpdater(__FILE__);

# String
$update->setAccessToken('github_pat_XXXXXXXXXX');

# Constant
$update->setAccessToken(GITHUB_ACCESS_TOKEN);

# Option
$update->setAccessToken(get_option('github_access_token'));

Demonstrate setting access token via string, constant, and option.

If it's a constant, you'd set that in wp-config.php:

define('GITHUB_ACCESS_TOKEN', 'github_pat_XXXXXXXXXX');

Define GITHUB_ACCESS_TOKEN constant 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:

$updater = new GitHubUpdater(__FILE__);

# Optional. Defaults to 'master'.
$updater->setBranch('master');

# Optional. Only needed for private repositories.
$updater->setAccessToken('...');

# Optional. Set this to the highest version of WordPress you've tested your plugin on.
$updater->setTestedWpVersion('6.5.2');

# Required. Without this, nothing happens. Zilch.
$updater->add();

Instantiate GitHubUpdater, demonstrate set methods, and add() it to WordPress.

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.