WP Manifestindependent plugin directory
manifest / updates / github-theme-updater

GitHub Theme Updater

WordPress plugin that updates a theme straight from a GitHub repository, private ones included, with backups, one-click rollback, protected paths and live progress.

by ms-m.pl · github.com/msiemieniukmorawski/github-theme-updater · website

0stars
0forks

Install

No release zip yet. The repository archive installs, but the folder name will carry the branch suffix and updates will not flow:

wp plugin install https://github.com/msiemieniukmorawski/github-theme-updater/archive/refs/heads/main.zip

Declares an update source (false), so updates arrive through the plugin's own updater.

Readme

GitHub Theme Updater

English · Polski

Updates a WordPress theme straight from a GitHub repository - private ones included - with backups, version rollback, and per-path protection against overwriting.

  • Version: 2.3.0
  • Author: ms-m.pl
  • Requires: WordPress 5.8+, PHP 7.4+
  • Text domain: github-theme-updater

Screenshots

Update Settings Backups
Update tab Settings tab Backups tab

Structure

github-theme-updater.php      bootstrap: constants, autoloader, hook registration
uninstall.php                 cleanup after the plugin is deleted for good
includes/
  class-plugin.php            builds the object graph and registers hooks
  class-lifecycle.php         activation, deactivation, migration from 1.x
  class-settings.php          settings schema and validation (a single option)
  class-token-storage.php     encrypts the token before it reaches the database
  class-repository.php        value object: owner/repo plus URL builders
  class-release.php           value object: a release, or a branch snapshot
  class-github-client.php     all HTTP to GitHub, caching, error mapping
  class-path-rules.php        protected path matching
  class-filesystem.php        recursive file operations through WP_Filesystem
  class-backup-manager.php    creating, restoring and rotating backups
  class-theme-installer.php   update sequence plus automatic rollback
  class-update-checker.php    scheduled version checks and the admin notice
  class-auto-updater.php      unattended updates: the daily schedule and webhook-queued runs
  class-notifier.php          e-mail reports of automatic runs
  class-webhook.php           GitHub webhook endpoint: signature check, filtering, queueing
  class-notices.php           messages carried across a redirect
  class-admin-page.php        menu and tabs
  class-admin-actions.php     form handling (nonce, capability, redirect)
views/                        tab templates
assets/                       admin styles and script
languages/                    .pot file for translations
tests/                        PHPUnit suite (runs without WordPress)
.github/workflows/ci.yml      lint, PHPCS, PHPStan and tests on every push
composer.json                 dev dependencies and the check scripts
phpcs.xml.dist                WordPress Coding Standards ruleset
phpstan.neon.dist             static analysis configuration
phpunit.xml.dist              test suite configuration

The dividing line: Github_Client is the only class that speaks HTTP, Filesystem is the only one that touches the disk, and Theme_Installer only decides the order of the steps. The rest of the plugin sees nothing but Release objects and WP_Errors.


Settings

Everything lives in a single gthu_settings option, which keeps a form submit atomic.

Key Default Description
repository '' owner/repo; normalised from any GitHub URL on save
token '' access token, encrypted with AES-256-CBC using a key derived from the WordPress salts
theme_slug '' name of the theme directory to overwrite
source release release (published releases) or branch (current state of a branch)
branch main used in branch mode only
asset_pattern '' glob for a ZIP attached to the release, e.g. theme-*.zip; empty means source code
include_prereleases false whether releases flagged as pre-release are offered
protected_paths languages, .env, acf-json paths an update must not touch
ignored_paths .git, node_modules paths never copied: not into backups, not from the archive, left alone on disk
create_backup true take a backup before every update
backup_limit 3 how many backups to keep
check_updates true scheduled checks and a dashboard notice
delete_data false whether to remove plugin data when the plugin is deleted
auto_update false install a newer release once a day without anyone clicking - see Automatic updates
auto_update_time 03:00 preferred hour of that run, in the site time zone
notify_emails '' comma separated addresses that receive a report after every automatic run
webhook_enabled false update the moment a release is published, through a GitHub webhook
webhook_secret '' shared secret of the webhook, generated by the plugin and encrypted like the token; webhook_secret_at records when

Runtime state (gthu_state, autoload = false): installed_version, installed_at, installed_by, last_check, latest_version, last_error, plus the outcome of the last automatic run (auto_last_run, auto_last_trigger, auto_last_status, auto_last_message, auto_notified_error) and of the last webhook delivery (webhook_last_at, webhook_last_status, webhook_last_message).

Operation log (gthu_history, autoload = false): the last 5 updates and restores, failed ones included, each with the version, the previous version, the result, the number of copied files, the user and the duration. Shown at the bottom of the Update tab; the gthu_history_limit filter changes how many entries are kept.

Protected paths

One rule per line, relative to the theme root:

Rule Meaning
languages the whole directory and everything under it
.env a single file
assets/css/client.css a file in a subdirectory
*.log wildcard match at any depth
# text a comment, ignored

.. segments are stripped during normalisation, so a rule can never reach outside the theme directory.

Ignored paths

Same syntax, different purpose. A protected path is something the site owner wants to keep, and it still goes into every backup. An ignored path is something the plugin should never look at: it is left out of backups, never installed from the archive, and never deleted or overwritten on disk. The defaults are .git and node_modules - development leftovers that are not part of a theme but can hold tens of thousands of files and turn a backup into a multi-minute job. Add vendor if it is git-ignored in your repository; do not add it if the theme needs a committed vendor at runtime, because it would then be missing after the first install.


Token in wp-config.php

The recommended setup for production - the token never reaches the database and cannot be read back from the admin:

define( 'GTHU_GITHUB_TOKEN', 'ghp_...' );

The constant takes precedence over whatever is stored in the settings.


What an update does

  1. Downloads the ZIP archive of the selected release.
  2. Unpacks it into a working directory under wp-content/upgrade/.
  3. Locates the theme inside the archive by looking for style.css (GitHub names source archives owner-repo-sha, so the directory name is unpredictable).
  4. Checks that everything it is about to delete can be deleted: every directory has to be writable by the PHP user, and on Windows read-only files are made writable on the spot. An undeletable path stops the update here, before anything changes.
  5. Takes a backup of the current theme.
  6. Empties the theme directory, skipping protected and ignored paths.
  7. Copies the new files in, again skipping protected and ignored paths.
  8. Cleans up temporary files and records the new state.

Nothing in the theme directory is touched before step 6, so a failed download, a corrupt archive or a permissions problem cannot break a working site. If step 6 or 7 fails, the theme is restored automatically from the backup taken in step 5, and the result message says whether that restore worked.

Only one update may run at a time: it holds the gthu_install_lock option row, claimed with a single INSERT IGNORE so that two requests cannot both win. Rollbacks and manual backups take the same lock. Before anything is deleted the installer also compares the Theme Name and Text Domain headers of the archive's style.css with the theme on disk, and refuses to replace one theme with another (the gthu_theme_identity_matches filter can override that).

Watching it run

While an update runs, the Update tab lists every step above, marks the one in progress, shows a running file count for the backup and copy steps, and counts the elapsed time. The installer records its current step in the gthu_install_progress transient; the admin script sends the form with fetch() and polls the gthu_progress AJAX endpoint once a second until the request completes, then follows the redirect so the result notice appears as usual. Opening the Update tab while an update started elsewhere is still running picks the progress up and reloads the page when it finishes.

Without JavaScript the form submits normally and the result appears after the redirect, as before.


Automatic updates

Off by default. Both options live in the "Automatic updates" panel on the Settings tab, work in Releases mode only (a branch has no version number to compare) and run the exact sequence of the Update button - lock, backup, verification, copy, rollback on failure, operation log - followed by an e-mail report. The Run now button under the settings performs one such run immediately, e-mails included, so the chain can be tested while someone is watching.

Scheduled run

auto_update schedules the gthu_auto_update WP-Cron event daily at auto_update_time (site time zone). The run fetches the release list with the cache bypassed, compares the newest release with installed_version, and installs it only if it is newer. Nothing newer means nothing happens and no e-mail. A run that finds the install lock taken (an update or restore in progress) is skipped until the next day. The schedule is re-synchronised whenever the settings are saved, after each run (DST drift) and by the twice-daily check as a safety net.

The hour is the earliest the run can start, not a guarantee. WordPress has no clock of its own; WP-Cron only wakes up when a request runs PHP. The run is late when:

  • nobody visits the site at night - the task waits for the first request after the scheduled time;
  • a page cache or CDN serves visitors without touching PHP;
  • DISABLE_WP_CRON is set and the hosting's system cron ticks less often;
  • another operation holds the lock at that moment;
  • the site time zone or daylight saving changed (corrected on the next run or settings save);
  • the server is busy or a previous cron worker is still running - core runs one at a time.

For an exact hour, add define( 'DISABLE_WP_CRON', true ); to wp-config.php and have a system cron call wp-cron.php every few minutes:

*/5 * * * * curl -s https://example.com/wp-cron.php?doing_wp_cron > /dev/null 2>&1

E-mail reports

notify_emails takes one or more addresses (commas, semicolons or whitespace separate them; invalid entries are reported on save). Every automatic run that installs something, or fails trying, sends a plain text message with the previous and the new version, the trigger, the backup name, the number of files, the protected paths, a link to the plugin screen and the release notes written on GitHub. A failed GitHub check (expired token, renamed repository) is reported once per distinct error. Updates started by hand from the Update tab are not e-mailed. Send a test e-mail confirms the addresses and the site's mail setup.

GitHub webhook

webhook_enabled makes the update happen the moment a release is published. Setup:

  1. Tick the option and save. The plugin generates a 64-character secret, stores it encrypted (like the token) and shows it once; the payload URL appears under the checkbox. A GTHU_WEBHOOK_SECRET constant in wp-config.php can replace the stored secret.
  2. On GitHub: repository → Settings → Webhooks → Add webhook. Payload URL https://example.com/wp-json/gthu/v1/release, content type application/json, the secret from step 1, SSL verification on, events: "Let me select individual events" → Releases only, Active.
  3. GitHub sends a ping; "Recent Deliveries" should show 200 and the Settings tab "GitHub sent a ping".

The site must be reachable from the internet (a local development site needs a tunnel). On a delivery the plugin queues a gthu_webhook_update single event and pokes the cron runner, so the update runs within a minute or two; when GitHub's API has not caught up with the event yet, the run is retried after 3 and 6 minutes.

How the endpoint is protected:

  • The route is not registered at all while the feature is off or no secret exists - it answers 404 like any unknown URL.
  • Bodies over 256 KB are refused before being read.
  • Every request must carry X-Hub-Signature-256, an HMAC-SHA256 of the raw body under the secret, compared with hash_equals() in the permission callback. Unsigned requests get 401, wrongly signed ones 403; both only leave a note on the Settings tab.
  • Only release events with the published action count. ping answers with a pong; other events and actions return 202 and do nothing.
  • The repository.full_name in the payload must match the configured repository (403 otherwise). Drafts and, unless include_prereleases is on, pre-releases are ignored.
  • X-GitHub-Delivery identifiers are remembered for a week, so a redelivered or replayed request is a no-op.
  • The payload never decides what is installed. A verified delivery only queues a normal automatic run, which asks GitHub's API for the newest release with the site's own token and installs it only if it is newer than the installed version - after the same identity check, backup and rollback as any other update, under the same lock.

Extension points

Read the full README on GitHub →

Releases

TagPublished
v2.3.0 Sep 8, 2026
v2.2.0 Sep 7, 2026

These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.