Using Github To Self-Host Updates For WordPress Plugins

I recently converted my paid WordPress plugin WP Freighter into a free plugin. Have I mentioned before that I really dislike license keys? As a free plugin, there is no more need for license keys or Easy Digital Downloads. This greatly simplifies the plugin. Without EDD one thing I needed to figure out is how future plugin updates were going to be delivered.

One option would be to publish to the official plugin directory: That lets handle the distribution of plugin updates. While makes sense for most folks I prefer more control and handle distribution myself.

Misha Rudrastyh wrote a fantastic post “How to Configure Self-Hosted Updates for Your Private Plugins“. It’s an amazing lightweight solution and no extra PHP libraries are needed. Just a few filters to direct plugin updates from your own unique location. For WP Freighter, I ended up removing 886 lines of EDD integration code and added only 144 lines to support the new method. I call that a huge win.

Creating a plugin manifest with reliance on GitHub.

I’m not going to redo Misha’s guide but rather give an overview of how WP Freighter is doing plugin updates directly from Github. The first part is the plugin manifest which you can see below and I’m serving from here: Within Github I create a tagged release of the WordPress plugin and manually attach an installable zip. I then configure the download_url to that downloadable zip.

Creating a new release on Github.
	"name" : "WP Freighter",
	"slug" : "wp-freighter",
	"author" : "<a href=''>Austin Ginder</a>",
	"author_profile" : "",
	"donate_link" : "",
	"version" : "1.1",
	"download_url" : "",
	"requires" : "5.6",
	"tested" : "6.1",
	"requires_php" : "5.6",
	"added" : "2020-09-10 02:10:00",
	"last_updated" : "2022-10-14 02:10:00",
	"homepage" : "",
	"sections" : {
		"description" : "Efficiently run many WordPress sites from a single WordPress installation.",
		"installation" : "Click the activate button and that's it.",
		"changelog" : "<h4>v1.1 released on December 29, 2022</h4><ul><li>Feature: Free for everyone. Removed EDD intergration. Automatic updates integrated with Github release.</li><li>Improvement: Add settings link to plugin page.</li></ul><h4>v1.0.2 released on August 21, 2021</h4><ul><li>Fixed: Bad logic where configurations weren’t always regenerated after sites changed.</li><li>Fixed: Various PHP warnings.</li></ul><h4>v1.0.1 released on September 18, 2020</h4><ul><li>Feature: Will automatically install default theme when creating new sites if needed.</li><li>Improvement: Shows overlay loader while new sites are installing.</li><li>Improvement: Added fields for domain or label on the new site dialog.</li><li>Improvement: Compatibility for alternative wp-config.php location.</li><li>Improvement: Force HTTPS in urls.</li><li>Fixed: Inconsistent response of sites array.</li></ul><h4>v1.0.0 released on September 9, 2020</h4><ul><li>Initial release of WP Freighter. Ability to add or remove stacked sites with database prefix <code>stacked_#_</code>.</li><li>Feature: Clone existing site to new database prefix</li><li>Feature: Add new empty site to new database prefix</li><li>Feature: Domain mapping off or on</li><li>Feature: Files shared or dedicated</li></ul>"
	"banners" : {
		"low" : "",
		"high" : ""

Adapting updater class for WP Freighter.

WP Freighter is using PHP namespaces so I moved the upgrader logic into a class inside my project. There are only three things that are unique to my project $this->version, $this->cache_key and the URL inside of wp_remote_get. If you’re looking to implement this for your custom plugins then be sure to change those lines of code for your own needs.


namespace WPFreighter;

class Updater {

    public $plugin_slug;
    public $version;
    public $cache_key;
    public $cache_allowed;

    public function __construct() {

        if ( defined( 'WP_FREIGHTER_DEV_MODE' ) ) {
            add_filter('https_ssl_verify', '__return_false');
            add_filter('https_local_ssl_verify', '__return_false');
            add_filter('http_request_host_is_external', '__return_true');

        $this->plugin_slug   = dirname ( plugin_basename( __DIR__ ) );
        $this->version       = '1.1';
        $this->cache_key     = 'wpfreighter_updater';
        $this->cache_allowed = false;

        add_filter( 'plugins_api', [ $this, 'info' ], 20, 3 );
        add_filter( 'site_transient_update_plugins', [ $this, 'update' ] );
        add_action( 'upgrader_process_complete', [ $this, 'purge' ], 10, 2 );


    public function request(){

        $remote = get_transient( $this->cache_key );

        if( false === $remote || ! $this->cache_allowed ) {

            $remote = wp_remote_get( '', [
                    'timeout' => 10,
                    'headers' => [
                        'Accept' => 'application/json'

            if ( is_wp_error( $remote ) || 200 !== wp_remote_retrieve_response_code( $remote ) || empty( wp_remote_retrieve_body( $remote ) ) ) {
                return false;

            set_transient( $this->cache_key, $remote, DAY_IN_SECONDS );


        $remote = json_decode( wp_remote_retrieve_body( $remote ) );

        return $remote;


    function info( $response, $action, $args ) {

        // do nothing if you're not getting plugin information right now
        if ( 'plugin_information' !== $action ) {
            return $response;

        // do nothing if it is not our plugin
        if ( empty( $args->slug ) || $this->plugin_slug !== $args->slug ) {
            return $response;

        // get updates
        $remote = $this->request();

        if ( ! $remote ) {
            return $response;

        $response = new \stdClass();

        $response->name           = $remote->name;
        $response->slug           = $remote->slug;
        $response->version        = $remote->version;
        $response->tested         = $remote->tested;
        $response->requires       = $remote->requires;
        $response->author         = $remote->author;
        $response->author_profile = $remote->author_profile;
        $response->donate_link    = $remote->donate_link;
        $response->homepage       = $remote->homepage;
        $response->download_link  = $remote->download_url;
        $response->trunk          = $remote->download_url;
        $response->requires_php   = $remote->requires_php;
        $response->last_updated   = $remote->last_updated;

        $response->sections = [
            'description'  => $remote->sections->description,
            'installation' => $remote->sections->installation,
            'changelog'    => $remote->sections->changelog

        if ( ! empty( $remote->banners ) ) {
            $response->banners = [
                'low'  => $remote->banners->low,
                'high' => $remote->banners->high

        return $response;


    public function update( $transient ) {

        if ( empty($transient->checked ) ) {
            return $transient;

        $remote = $this->request();

        if ( $remote && version_compare( $this->version, $remote->version, '<' ) && version_compare( $remote->requires, get_bloginfo( 'version' ), '<=' ) && version_compare( $remote->requires_php, PHP_VERSION, '<' ) ) {
            $response              = new \stdClass();
            $response->slug        = $this->plugin_slug;
            $response->plugin      = "{$this->plugin_slug}/{$this->plugin_slug}.php";
            $response->new_version = $remote->version;
            $response->tested      = $remote->tested;
            $response->package     = $remote->download_url;

            $transient->response[ $response->plugin ] = $response;


        return $transient;


    public function purge( $upgrader, $options ) {

        if ( $this->cache_allowed && 'update' === $options['action'] && 'plugin' === $options[ 'type' ] ) {
            // just clean the cache when new plugin version is installed
            delete_transient( $this->cache_key );



This code will check the plugin manifest once per day. If the version in the manifest has changed then WordPress will display the new update as available as part of the WordPress updates. This code also handles filling the details within the plugin listings as populated by the plugin manifest.

Steps to produce a new plugin release.

Here are the steps required when releasing a new version:

  • Bump the version line in the main plugin, for my example: /wp-freighter/wp-freighter.php
  • Bump the version line in the updater class, for my example: /wp-freighter/app/updater.php
  • Create a .zip of the new plugin and add it to Github as a new release
  • Update the self-host plugin manifest with the new version and link to the new zip on Github

That’s pretty much it. This solution works great for self-hosting free plugins and could easily be extended for paid plugins. With a paid plugin you most likely would want to introduce some sort of protection so that only paid customers can access the download link. Where you serve the paid plugin isn’t important. My example shows it’s served from Github but for protection, you might consider storing it on a private B2 Bucket behind a PHP script which checks for a unique token per each paying customer.