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
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.zipDeclares 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 |
|---|---|---|
![]() |
![]() |
![]() |
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
- Downloads the ZIP archive of the selected release.
- Unpacks it into a working directory under
wp-content/upgrade/. - Locates the theme inside the archive by looking for
style.css(GitHub names source archivesowner-repo-sha, so the directory name is unpredictable). - 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.
- Takes a backup of the current theme.
- Empties the theme directory, skipping protected and ignored paths.
- Copies the new files in, again skipping protected and ignored paths.
- 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_CRONis 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:
- 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_SECRETconstant inwp-config.phpcan replace the stored secret. - On GitHub: repository → Settings → Webhooks → Add webhook. Payload URL
https://example.com/wp-json/gthu/v1/release, content typeapplication/json, the secret from step 1, SSL verification on, events: "Let me select individual events" → Releases only, Active. - 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 withhash_equals()in the permission callback. Unsigned requests get 401, wrongly signed ones 403; both only leave a note on the Settings tab. - Only
releaseevents with thepublishedaction count.pinganswers with a pong; other events and actions return 202 and do nothing. - The
repository.full_namein the payload must match the configured repository (403 otherwise). Drafts and, unlessinclude_prereleasesis on, pre-releases are ignored. X-GitHub-Deliveryidentifiers 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
These releases are tags only. The author does not attach a packaged zip, so there are no download counts to report.


