# GitHub Auto-Updates for WordPress Plugins Complete guide for implementing automatic updates for WordPress plugins hosted outside the WordPress.org repository. --- ## Table of Contents 1. [Overview](#overview) 2. [How WordPress Updates Work](#how-wordpress-updates-work) 3. [Solution 1: Plugin Update Checker (Recommended)](#solution-1-plugin-update-checker-recommended) 4. [Solution 2: Git Updater](#solution-2-git-updater) 5. [Solution 3: Custom Update Server](#solution-3-custom-update-server) 6. [Commercial Solutions](#commercial-solutions) 7. [Security Best Practices](#security-best-practices) 8. [Comparison Matrix](#comparison-matrix) 9. [Common Pitfalls](#common-pitfalls) 10. [Resources](#resources) --- ## Overview WordPress plugins don't need to be hosted on WordPress.org to provide automatic updates. Several well-established solutions enable auto-updates from GitHub, GitLab, BitBucket, or custom servers. ### Why Auto-Updates Matter - **Security**: Users get critical security patches automatically - **Features**: Seamless delivery of new features and improvements - **Support**: Reduces support burden from outdated installations - **Professional**: Provides polished user experience ### Solutions Available | Solution | Best For | Complexity | Cost | |----------|----------|------------|------| | Plugin Update Checker | Most use cases | Low | Free | | Git Updater | Multi-platform Git hosting | Low | Free | | Custom Update Server | Enterprise/custom licensing | High | Hosting costs | | Freemius | Commercial plugins | Low | 15% or $99-599/yr | --- ## How WordPress Updates Work ### Core Update Mechanism WordPress uses a **transient-based system** to check for plugin updates: ``` 1. WordPress checks transients (every 12 hours) 2. Queries WordPress.org API for available updates 3. Stores results in `update_plugins` transient 4. Displays "Update available" notifications 5. Downloads and installs when user clicks "Update" ``` ### Key Hooks for Custom Updates ```php // Intercept BEFORE WordPress checks (saves to transient) add_filter('pre_set_site_transient_update_plugins', 'my_check_for_updates'); // Filter update data (returns without saving) add_filter('site_transient_update_plugins', 'my_push_update'); // Modify plugin information for "View details" modal add_filter('plugins_api', 'my_plugin_info', 20, 3); // Post-installation cleanup add_action('upgrader_post_install', 'my_post_install', 10, 3); ``` ### Update Flow for Custom Plugins ``` 1. WordPress checks transients ↓ 2. Your filter hook intercepts ↓ 3. You query GitHub/custom server ↓ 4. Inject update data into transient ↓ 5. WordPress shows "Update available" ↓ 6. User clicks update ↓ 7. WordPress downloads from your URL ↓ 8. Installs and activates ``` --- ## Solution 1: Plugin Update Checker (Recommended) **GitHub**: https://github.com/YahnisElsts/plugin-update-checker **Stars**: 2.2k+ | **License**: MIT | **Status**: Actively maintained (v5.x) ### What It Does Lightweight library that enables automatic updates from GitHub, GitLab, BitBucket, or custom JSON servers. No WordPress.org submission required. ### Pros - ✅ **Minimal setup**: ~5 lines of code - ✅ **Multiple platforms**: GitHub, GitLab, BitBucket, custom JSON - ✅ **Active development**: Regular updates since 2011 - ✅ **Private repos**: Supports authentication tokens - ✅ **Well documented**: Extensive examples and guides - ✅ **No dependencies**: Self-contained library - ✅ **Flexible versioning**: Supports releases, tags, or branches - ✅ **Standards compliant**: Uses WordPress.org `readme.txt` format ### Cons - ❌ No built-in license management (requires custom implementation) - ❌ No package signing (relies on HTTPS/token security) - ❌ Requires bundling library with plugin (~100KB) ### Installation **Option A: Git Submodule** (Recommended) ```bash cd your-plugin/ git submodule add https://github.com/YahnisElsts/plugin-update-checker.git ``` **Option B: Composer** ```bash composer require yahnis-elsts/plugin-update-checker ``` **Option C: Manual** ```bash cd your-plugin/ git clone https://github.com/YahnisElsts/plugin-update-checker.git # Or download and extract ZIP ``` ### Basic Implementation ```php setBranch('main'); ``` ### Private Repository Support ```php // For private GitHub repos, add authentication $myUpdateChecker->setAuthentication('ghp_YourGitHubPersonalAccessToken'); // Better: Use WordPress constant (define in wp-config.php) if (defined('MY_PLUGIN_GITHUB_TOKEN')) { $myUpdateChecker->setAuthentication(MY_PLUGIN_GITHUB_TOKEN); } ``` ### Release Strategies #### Strategy 1: GitHub Releases (Recommended) **Best for**: Production plugins with formal releases ```bash # 1. Update version in plugin header # my-plugin.php: Version: 1.0.1 # 2. Commit and tag git add my-plugin.php git commit -m "Bump version to 1.0.1" git tag 1.0.1 git push origin main git push origin 1.0.1 # 3. Create GitHub Release # - Go to GitHub → Releases → Create Release # - Select tag: 1.0.1 # - Upload pre-built plugin ZIP (optional but recommended) ``` **Enable release assets**: ```php // Download ZIP from releases instead of source code $myUpdateChecker->getVcsApi()->enableReleaseAssets(); ``` **Benefits**: - ✅ Professional release notes - ✅ Pre-built ZIP (can include compiled assets) - ✅ Changelog visible to users - ✅ Can exclude dev files (.git, tests, etc.) #### Strategy 2: Git Tags **Best for**: Simple plugins, rapid iteration ```bash # Just tag the commit git tag 1.0.1 git push origin 1.0.1 ``` **No additional code needed** - library auto-detects highest version tag. **Benefits**: - ✅ Simple workflow - ✅ No manual release creation **Drawbacks**: - ❌ Downloads entire repo (includes .git, tests, etc.) - ❌ No release notes visible to users #### Strategy 3: Branch-Based **Best for**: Beta testing, staging environments ```php // Point to specific branch $myUpdateChecker->setBranch('stable'); // Or beta branch $myUpdateChecker->setBranch('beta'); ``` **Update version in plugin header on that branch**: ```php /** * Version: 1.1.0-beta */ ``` **Benefits**: - ✅ Easy beta testing - ✅ Separate stable/development channels **Drawbacks**: - ❌ No version history - ❌ Downloads full repo ### Complete Example with Best Practices ```php

'; echo 'My Plugin: Update checker library not found. '; echo 'Automatic updates disabled.'; echo '

'; }); return; } require $updater_path; $updateChecker = YahnisElsts\PluginUpdateChecker\v5\PucFactory::buildUpdateChecker( 'https://github.com/yourusername/my-plugin/', MY_PLUGIN_FILE, 'my-plugin' ); // Set branch $updateChecker->setBranch('main'); // Use GitHub Releases $updateChecker->getVcsApi()->enableReleaseAssets(); // Private repo authentication (optional) if (defined('MY_PLUGIN_GITHUB_TOKEN')) { $updateChecker->setAuthentication(MY_PLUGIN_GITHUB_TOKEN); } // Add custom authentication from settings (optional) $license_key = get_option('my_plugin_license_key'); if (!empty($license_key)) { // Use license key as token or validate separately $updateChecker->setAuthentication($license_key); } } // Rest of plugin code... ``` ### Creating Update-Ready ZIP Files **Important**: ZIP must contain plugin folder inside it! ``` my-plugin-1.0.1.zip └── my-plugin/ ← Plugin folder MUST be inside ├── my-plugin.php ├── readme.txt ├── plugin-update-checker/ └── ... ``` **Build script example**: ```bash #!/bin/bash # build-release.sh VERSION="1.0.1" PLUGIN_SLUG="my-plugin" # Create temp directory mkdir -p build # Export git repository (excludes .git, .gitignore, etc.) git archive HEAD --prefix="${PLUGIN_SLUG}/" --format=zip -o "build/${PLUGIN_SLUG}-${VERSION}.zip" echo "Built: build/${PLUGIN_SLUG}-${VERSION}.zip" ``` **With exclusions**: ```bash # .gitattributes .git export-ignore .gitignore export-ignore .gitattributes export-ignore tests/ export-ignore node_modules/ export-ignore src/ export-ignore .github/ export-ignore ``` ### readme.txt Format Plugin Update Checker reads `readme.txt` for changelog and upgrade notices: ``` === My Plugin === Contributors: yourusername Tags: feature, awesome Requires at least: 5.9 Tested up to: 6.4 Stable tag: 1.0.1 License: GPLv2 or later Short description here. == Description == Long description here. == Changelog == = 1.0.1 = * Fixed bug with user permissions * Added new feature X = 1.0.0 = * Initial release == Upgrade Notice == = 1.0.1 = Critical security fix. Update immediately. ``` --- ## Solution 2: Git Updater **GitHub**: https://github.com/afragen/git-updater **Stars**: 3.3k+ | **License**: MIT | **Status**: Actively maintained ### What It Does A **WordPress plugin** (not library) that enables updates for **all** GitHub/GitLab/Bitbucket/Gitea-hosted plugins and themes on a site. ### Pros - ✅ **User-installable**: Site owners install once, works for all compatible plugins - ✅ **Multi-platform**: GitHub, GitLab, Bitbucket, Gitea - ✅ **No coding required**: Just add headers to plugin - ✅ **Language pack support**: Automatic translations - ✅ **REST API**: Programmatic control ### Cons - ❌ **Dependency**: Users must install Git Updater plugin first - ❌ **Less control**: Developer doesn't control update logic - ❌ **PHP 8.0+ required**: May limit compatibility ### Implementation **Step 1: Add headers to your plugin** ```php Installation
  1. Upload plugin
", "changelog": "

1.0.1

" }, "banners": { "low": "https://example.com/banner-772x250.png", "high": "https://example.com/banner-1544x500.png" }, "icons": { "1x": "https://example.com/icon-128x128.png", "2x": "https://example.com/icon-256x256.png" } } ``` #### Plugin Code ```php checked)) { return $transient; } $plugin_slug = plugin_basename(__FILE__); // Check cache first (12 hours) $remote_data = get_transient('my_plugin_update_cache'); if (false === $remote_data) { $remote = wp_remote_get(MY_PLUGIN_UPDATE_URL, [ 'timeout' => 10, 'headers' => [ 'Accept' => 'application/json' ] ]); if (is_wp_error($remote) || 200 !== wp_remote_retrieve_response_code($remote)) { return $transient; } $remote_data = json_decode(wp_remote_retrieve_body($remote)); if (!$remote_data || !isset($remote_data->version)) { return $transient; } // Cache for 12 hours set_transient('my_plugin_update_cache', $remote_data, 12 * HOUR_IN_SECONDS); } // Compare versions if (version_compare(MY_PLUGIN_VERSION, $remote_data->version, '<')) { $obj = new stdClass(); $obj->slug = $remote_data->slug; $obj->plugin = $plugin_slug; $obj->new_version = $remote_data->version; $obj->url = $remote_data->homepage; $obj->package = $remote_data->download_url; $obj->tested = $remote_data->tested; $obj->requires = $remote_data->requires; $obj->requires_php = $remote_data->requires_php; $transient->response[$plugin_slug] = $obj; } else { // Mark as up-to-date $obj = new stdClass(); $obj->slug = $remote_data->slug; $obj->plugin = $plugin_slug; $obj->new_version = $remote_data->version; $transient->no_update[$plugin_slug] = $obj; } return $transient; } /** * Plugin information for "View details" modal */ add_filter('plugins_api', 'my_plugin_info', 20, 3); function my_plugin_info($res, $action, $args) { // Do nothing if not getting plugin information if ('plugin_information' !== $action) { return $res; } // Do nothing if it's not our plugin if (plugin_basename(__DIR__) !== $args->slug) { return $res; } // Try to get cached data $remote_data = get_transient('my_plugin_update_cache'); if (false === $remote_data) { $remote = wp_remote_get(MY_PLUGIN_UPDATE_URL, [ 'timeout' => 10, 'headers' => ['Accept' => 'application/json'] ]); if (is_wp_error($remote) || 200 !== wp_remote_retrieve_response_code($remote)) { return $res; } $remote_data = json_decode(wp_remote_retrieve_body($remote)); if (!$remote_data) { return $res; } } $res = new stdClass(); $res->name = $remote_data->name; $res->slug = $remote_data->slug; $res->version = $remote_data->version; $res->tested = $remote_data->tested; $res->requires = $remote_data->requires; $res->requires_php = $remote_data->requires_php; $res->author = $remote_data->author; $res->homepage = $remote_data->homepage; $res->download_link = $remote_data->download_url; $res->sections = (array)$remote_data->sections; if (isset($remote_data->banners)) { $res->banners = (array)$remote_data->banners; } if (isset($remote_data->icons)) { $res->icons = (array)$remote_data->icons; } if (isset($remote_data->last_updated)) { $res->last_updated = $remote_data->last_updated; } return $res; } /** * Clear cache when plugin is updated */ add_action('upgrader_process_complete', 'my_plugin_clear_update_cache', 10, 2); function my_plugin_clear_update_cache($upgrader_object, $options) { if ('update' === $options['action'] && 'plugin' === $options['type']) { delete_transient('my_plugin_update_cache'); } } ``` ### With License Validation ```php // Add license key field in settings function my_plugin_check_for_updates($transient) { $license_key = get_option('my_plugin_license_key'); if (empty($license_key)) { return $transient; // No license = no updates } $remote = wp_remote_post(MY_PLUGIN_UPDATE_URL, [ 'body' => [ 'license' => $license_key, 'domain' => home_url() ] ]); // Verify license is valid before offering update $remote_data = json_decode(wp_remote_retrieve_body($remote)); if (!isset($remote_data->license_valid) || !$remote_data->license_valid) { return $transient; // Invalid license = no updates } // Proceed with update check... } ``` ### When to Use - ✅ Need full control over update logic - ✅ Want to integrate license verification - ✅ Building commercial plugin with custom licensing - ✅ Need analytics/tracking on updates - ✅ Want to host on own infrastructure --- ## Commercial Solutions ### Freemius **Website**: https://freemius.com **Pricing**: Free (15% revenue share) or $99-599/year #### Features - ✅ Complete platform: Licensing, payments, updates, analytics - ✅ WordPress SDK: Drop-in library - ✅ Automatic updates with license integration - ✅ In-dashboard checkout: ~12% conversion boost - ✅ Secure repository: Amazon S3-backed - ✅ Staged rollouts: Beta testing, gradual releases #### Implementation ```php // Include Freemius SDK require_once dirname(__FILE__) . '/freemius/start.php'; $my_plugin_fs = fs_dynamic_init([ 'id' => '123', 'slug' => 'my-plugin', 'public_key' => 'pk_xxx', 'is_premium' => true, 'has_paid_plans' => true, 'menu' => [ 'slug' => 'my-plugin', ], ]); ``` Updates are fully automatic - Freemius handles everything. #### When to Use - ✅ Selling premium plugins commercially - ✅ Want all-in-one solution (licensing + updates + payments) - ✅ Need subscription management - ✅ Okay with revenue share --- ### Easy Digital Downloads + Software Licensing **Website**: https://easydigitaldownloads.com **Pricing**: $328/year (Recurring Payments + Software Licensing extensions) #### Features - ✅ Self-hosted: Run on your own WordPress site - ✅ Full control: Own the data - ✅ No revenue share: Fixed annual cost - ✅ Mature ecosystem: 10+ years #### Limitations - ❌ More setup: Requires custom development for update integration - ❌ No in-dashboard checkout - ❌ Extension costs add up #### When to Use - ✅ Already using WordPress for sales - ✅ Want complete control over infrastructure - ✅ Have development resources - ❌ Don't want to build update logic (use Freemius instead) --- ## Security Best Practices ### 1. Always Use HTTPS ```php // ✅ Good $remote = wp_remote_get('https://example.com/updates.json', [ 'sslverify' => true // Explicitly verify SSL ]); // ❌ Bad $remote = wp_remote_get('http://example.com/updates.json'); ``` **Why**: HTTPS prevents man-in-the-middle attacks. ### 2. Implement Token Authentication **For private repos**: ```php $updateChecker->setAuthentication(defined('MY_PLUGIN_TOKEN') ? MY_PLUGIN_TOKEN : ''); ``` **For custom servers**: ```php $remote = wp_remote_get($update_url, [ 'headers' => [ 'Authorization' => 'Bearer ' . get_option('my_plugin_license_key') ] ]); ``` **Never hardcode tokens** - use constants or encrypted options. ### 3. Validate License Keys ```php function my_plugin_check_for_updates($transient) { $license = get_option('my_plugin_license'); // Validate license before offering updates $validation = wp_remote_post('https://example.com/api/validate', [ 'body' => [ 'license' => $license, 'domain' => home_url() ] ]); $license_data = json_decode(wp_remote_retrieve_body($validation)); if (!$license_data->valid) { return $transient; // No updates for invalid licenses } // Proceed... } ``` ### 4. Use Checksums **Server** (info.json): ```json { "download_url": "https://example.com/plugin.zip", "checksum": "sha256:abc123def456...", "checksum_algorithm": "sha256" } ``` **Plugin**: ```php add_filter('upgrader_pre_install', 'my_plugin_verify_checksum', 10, 2); function my_plugin_verify_checksum($true, $hook_extra) { $package = $hook_extra['package']; // Get expected checksum $expected = get_transient('my_plugin_expected_checksum'); if (!$expected) { return $true; // Allow if no checksum available } // Download and verify $downloaded = download_url($package); if (is_wp_error($downloaded)) { return $downloaded; } $actual = hash_file('sha256', $downloaded); if (!hash_equals($expected, $actual)) { @unlink($downloaded); return new WP_Error('checksum_mismatch', 'Update file corrupted or tampered'); } return $true; } ``` ### 5. Implement Package Signing (Advanced) **Server-side** (sign ZIP): ```php $zip_contents = file_get_contents('plugin.zip'); $private_key = openssl_pkey_get_private(file_get_contents('private.pem')); openssl_sign($zip_contents, $signature, $private_key, OPENSSL_ALGO_SHA256); $info = [ 'download_url' => 'https://example.com/plugin.zip', 'signature' => base64_encode($signature) ]; file_put_contents('info.json', json_encode($info)); ``` **Plugin-side** (verify): ```php $remote_data = json_decode(wp_remote_retrieve_body($remote)); $zip = file_get_contents($remote_data->download_url); $signature = base64_decode($remote_data->signature); // Embed public key in plugin $public_key = <<get_error_message() )); return $transient; } // Continue... } ``` ### 8. Secure Token Storage ```php // ❌ Bad: Hardcoded $token = 'ghp_abc123'; // ✅ Good: WordPress constant (wp-config.php) define('MY_PLUGIN_GITHUB_TOKEN', 'ghp_xxx'); $token = MY_PLUGIN_GITHUB_TOKEN; // ✅ Better: Encrypted option function my_plugin_get_token() { $encrypted = get_option('my_plugin_token_encrypted'); return openssl_decrypt($encrypted, 'AES-256-CBC', wp_salt('auth')); } ``` --- ## Comparison Matrix | Feature | Plugin Update Checker | Git Updater | Custom Server | Freemius | |---------|----------------------|-------------|---------------|----------| | **Setup Complexity** | ⭐⭐⭐⭐⭐ Low | ⭐⭐⭐⭐ Low | ⭐⭐ High | ⭐⭐⭐⭐⭐ Low | | **Cost** | Free | Free | Hosting costs | 15% or $99-599/yr | | **GitHub Support** | ✅ Yes | ✅ Yes | ✅ (DIY) | ❌ No | | **Private Repos** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | | **License Management** | ❌ DIY | ❌ No | ✅ DIY | ✅ Built-in | | **Package Signing** | ❌ No | ❌ No | ✅ DIY | ✅ Built-in | | **HTTPS Required** | ✅ Yes | ✅ Yes | ✅ Yes | ✅ Yes | | **Dependencies** | Library (bundled) | Plugin (separate) | None | SDK (bundled) | | **Control Level** | Medium | Low | High | Medium | | **Maintenance** | Low | Low | High | None | | **Scalability** | High (GitHub CDN) | High | Varies | High (S3) | | **In-Dashboard Checkout** | ❌ No | ❌ No | ✅ DIY | ✅ Yes | | **Analytics** | ❌ No | ❌ No | ✅ DIY | ✅ Built-in | | **Best For** | Open source, freemium | Multi-platform | Custom licensing | Commercial SaaS | --- ## Common Pitfalls ### 1. Incorrect ZIP Structure ❌ **Wrong**: ``` plugin.zip ├── plugin.php ├── readme.txt └── assets/ ``` ✅ **Correct**: ``` plugin.zip └── my-plugin/ ← Plugin folder MUST be inside ├── plugin.php ├── readme.txt └── assets/ ``` **Fix**: Always include plugin folder in ZIP. ### 2. Missing HTTPS ```php // ❌ Bad $url = 'http://example.com/updates.json'; // ✅ Good $url = 'https://example.com/updates.json'; ``` **Fix**: Always use HTTPS for update URLs. ### 3. Not Caching Requests ```php // ❌ Bad: Checks every page load $remote = wp_remote_get($update_url); // ✅ Good: Cache for 12 hours $cached = get_transient('my_plugin_update_data'); if (false === $cached) { $remote = wp_remote_get($update_url); $cached = wp_remote_retrieve_body($remote); set_transient('my_plugin_update_data', $cached, 12 * HOUR_IN_SECONDS); } ``` **Fix**: Always cache remote requests. ### 4. Hardcoded Tokens ```php // ❌ Bad $updateChecker->setAuthentication('ghp_abc123'); // ✅ Good if (defined('MY_PLUGIN_TOKEN')) { $updateChecker->setAuthentication(MY_PLUGIN_TOKEN); } ``` **Fix**: Use constants or encrypted options. ### 5. No Error Handling ```php // ❌ Bad $remote = wp_remote_get($url); $data = json_decode(wp_remote_retrieve_body($remote)); // ✅ Good $remote = wp_remote_get($url); if (is_wp_error($remote)) { error_log('Update check failed: ' . $remote->get_error_message()); return $transient; } if (200 !== wp_remote_retrieve_response_code($remote)) { error_log('Update check returned: ' . wp_remote_retrieve_response_code($remote)); return $transient; } $data = json_decode(wp_remote_retrieve_body($remote)); if (!$data || !isset($data->version)) { error_log('Invalid update data received'); return $transient; } ``` **Fix**: Always validate responses. ### 6. Version Comparison Issues ```php // ❌ Bad: String comparison if (MY_PLUGIN_VERSION < $remote_data->version) { // Fails: "1.10.0" < "1.2.0" = false (string comparison) } // ✅ Good: Semantic version comparison if (version_compare(MY_PLUGIN_VERSION, $remote_data->version, '<')) { // Correctly: "1.10.0" < "1.2.0" = false (semantic comparison) } ``` **Fix**: Use `version_compare()`. ### 7. Forgetting to Flush Cache ```php // After updating plugin, clear cached update data add_action('upgrader_process_complete', 'my_plugin_clear_cache'); function my_plugin_clear_cache() { delete_transient('my_plugin_update_cache'); } ``` **Fix**: Clear transients after updates. --- ## Resources ### Official Documentation - **Plugin Update Checker**: https://github.com/YahnisElsts/plugin-update-checker - **Git Updater**: https://github.com/afragen/git-updater/wiki - **WordPress Plugin API**: https://developer.wordpress.org/plugins/ - **Transients API**: https://developer.wordpress.org/apis/transients/ ### Tutorials - **Self-Hosted Updates**: https://rudrastyh.com/wordpress/self-hosted-plugin-update.html - **GitHub Updates**: https://code.tutsplus.com/tutorials/distributing-your-plugins-in-github-with-automatic-updates--wp-34817 - **Serverless Update Server**: https://macarthur.me/posts/serverless-wordpress-plugin-update-server/ ### Security - **WordPress Security**: https://developer.wordpress.org/plugins/wordpress-org/plugin-security/ - **Checksum Verification**: https://developer.wordpress.org/cli/commands/plugin/verify-checksums/ - **Package Signing Proposal**: https://core.trac.wordpress.org/ticket/39309 ### Commercial Platforms - **Freemius**: https://freemius.com - **EDD Software Licensing**: https://easydigitaldownloads.com/downloads/software-licensing/ --- **Recommended**: For most developers, use **Plugin Update Checker** with GitHub. It provides the best balance of simplicity, features, and security. **Next Steps**: 1. Choose your update strategy 2. See `examples/github-updater.php` for complete working example 3. Implement in your plugin 4. Test thoroughly before releasing --- **Last Updated**: 2025-11-06 **Version**: 1.0.0