blackbricksoftware / wp-artifact-updater
WordPress plugin updates from the release artifact your pipeline publishes (Bitbucket Downloads or GitHub release assets), for plugins distributed outside wordpress.org.
Package info
github.com/blackbricksoftware/wp-artifact-updater
pkg:composer/blackbricksoftware/wp-artifact-updater
Requires
- php: >=8.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
WordPress updates for plugins that don't live on wordpress.org.
Point it at the release artifact your build already publishes — a zip in Bitbucket Downloads or a GitHub release asset — and WordPress treats the plugin like any other: update notice, one-click update, and the per-plugin "Enable auto-updates" toggle all start working.
use BlackBrickSoftware\WpArtifactUpdater\V1\{Updater, BitbucketDownloads}; Updater::register([ 'plugin_file' => __FILE__, 'source' => new BitbucketDownloads('acme/my-plugin'), 'artifact' => '/^my-plugin-v?(\d+\.\d+\.\d+)\.zip$/', 'authorization' => static fn(): string => defined('MY_PLUGIN_UPDATE_AUTH') ? 'Bearer ' . MY_PLUGIN_UPDATE_AUTH : '', ]);
Plus one header in the plugin's main file:
* Update URI: https://bitbucket.org/acme/my-plugin
Why this exists
WordPress can only update what it can find on wordpress.org. The usual answer is
plugin-update-checker, and it's a good library — but its Bitbucket
integration installs the tag archive (bitbucket.org/…/get/<ref>.zip), which
is the repository tree. A composer-based plugin's tree has no vendor/, so the
"update" installs a plugin with no autoloader that fatals on activation. There
is no Downloads support to point it at instead, and its auth header is gated on
a /get/ URL prefix, so rewriting the download URL yields a 401.
This library installs what your pipeline built, which is the thing you actually tested.
What it deliberately does not do
- Force installation. No
auto_update_pluginfilter is registered, so updates are offered and each site opts in. For a plugin that can lock people out — authentication, access control — a bad release installing itself unattended across every site at once is the failure worth designing against. - Guess where your credential lives. You pass a callable. Read it from an environment variable, a constant, your own settings page; the library never looks.
- Downgrade. Only a strictly newer version is offered.
- Send your credential to storage hosts. Release hosts redirect to signed URLs on separate infrastructure; the redirect is resolved first so the signed URL — which carries its own auth — is what gets downloaded.
Install
composer require blackbricksoftware/wp-artifact-updater
Requires PHP 8.0+ and WordPress 4.6+ (5.8+ uses the better Update URI path
automatically). No dependencies.
Configuration
| Key | Required | Meaning |
|---|---|---|
plugin_file |
yes | Your plugin's main file, normally __FILE__. Basename, slug and installed version are read from it — don't repeat them. |
source |
yes | A Source: BitbucketDownloads or GitHubReleases. |
artifact |
yes | PCRE selecting the release file. Capture group 1 is the version, when the filename has one. |
authorization |
no | callable(): string returning an Authorization header value. '' means none. |
enabled |
no | callable(): bool. Defaults to "the source needs no credential, or one is configured". |
ttl |
no | Cache lifetime for the release lookup. Default 6 hours. |
authorization is a header value, not a username and password
Different hosts and token types want different schemes, and the caller knows which it has:
'authorization' => static fn(): string => 'Bearer ' . $token, // Bitbucket/GitHub token 'authorization' => static fn(): string => 'Basic ' . base64_encode("$user:$pass"), // Bitbucket app password 'authorization' => static fn(): string => '', // public repo, or updates off
enabled — the off switch
By default, a private source with no credential registers no hooks and makes no HTTP requests. That is what keeps the library out of the way on composer-managed installs, where the deploy owns the plugin version and a self-update would be reverted on the next deploy.
A public source has nothing to switch it off, so pass enabled yourself:
'enabled' => static fn(): bool => !defined('COMPOSER_MANAGED_SITE'),
Bitbucket
new BitbucketDownloads('workspace/repository') // private (default) new BitbucketDownloads('workspace/repository', false) // public
Reads the repository's Downloads list and picks the highest version matching
artifact — by version comparison, not list order, so 2.10.0 correctly beats
2.9.0.
Credentials. There is no Downloads-only scope; repository (read) is
the finest grant Bitbucket offers. Prefer a repository access token, which
is scoped to a single repository and isn't tied to a person's account (an app
password dies when that user is deactivated — a memorable way to discover your
update mechanism depended on someone's employment). A leaked per-repo token
reads one repository whose code the site already has on disk.
'authorization' => static fn(): string => 'Bearer ' . MY_PLUGIN_UPDATE_TOKEN,
GitHub
new GitHubReleases('owner/repository') // public (default) new GitHubReleases('owner/repository', true) // private
Reads the latest release and picks the matching asset. If artifact
captures a version, that wins; otherwise the version comes from tag_name, so a
workflow publishing a fixed filename works:
'artifact' => '/^my-plugin\.zip$/', // version comes from the tag
What your pipeline must produce
Three requirements, each of which fails silently if you get it wrong:
- A built artifact, not a source archive. Run
composer install --no-dev --optimize-autoloaderand includevendor/. A tag archive installs a plugin with no autoloader. - A single top-level directory named exactly like the plugin folder. Stage
into
dist/my-plugin/and zip that.zip -r my-plugin.zip .produces an archive with no wrapping folder, and WordPress then names the destination after the zip file — installing a second copy under a different folder name instead of upgrading. It looks like it worked. - A plugin header version that matches the release. WordPress reads the
installed version from the header. If the header says
1.0.0while you publish1.0.2, every site installs 1.0.2, still reports 1.0.0, and is offered the update again — forever. Bump the header in the commit you tag. (This is a real bug we shipped, not a hypothetical.)
A Bitbucket pipeline doing all three:
- composer install --no-dev --optimize-autoloader --no-interaction - mkdir -p dist/my-plugin - rsync -a --exclude='.git/' --exclude='dist/' ./ dist/my-plugin/ - (cd dist && zip -r "../my-plugin-${BITBUCKET_TAG}.zip" my-plugin)
Checking a credential
A bad credential fails silently — no release is found, which looks exactly like being up to date. So verify it rather than waiting to find out:
$result = Updater::verify(new BitbucketDownloads('acme/my-plugin'), $header); // ['ok' => false, 'status' => 401, 'message' => 'Bitbucket rejected the credential (401). …']
One cheap request, never throws, and status === 0 means the host could not be
reached rather than the credential being wrong. Worth calling when a credential
is saved, and from a "test this token" button — tokens get revoked long after
anyone remembers configuring them.
Bitbucket token form: paste a repository access token on its own; it is
sent as Bearer <token>. The x-token-auth:<token> form is for git over HTTPS
and the REST API rejects it with a 401 (verified against a live repository).
Forcing a re-check
Two caches sit between a published release and the Plugins screen: ours (6
hours) and WordPress's own update transient (it re-asks at most twice a day).
Clearing either one alone achieves nothing — ours gets refilled from a check
WordPress won't run yet, and WordPress's gets refilled from our stale answer.
forceRecheck() clears both:
$updater->forceRecheck(); // then wp_update_plugins() really asks the host
It drops our cached release, removes this plugin's entries from the update
transient, and resets last_checked so core's twice-a-day gate reopens. It
deliberately does not delete the whole update_plugins transient: that would
throw away what the site knows about every other plugin's updates, and keeping
our own entries around would let an "update available" outlive the credential
that found it.
Call it from a "check for updates now" control, and whenever the credential
changes — a no release found cached against the old credential otherwise makes
a good new one look broken too.
How it behaves
- Cached. The release lookup is cached in a site transient (6h by default; 15 minutes after a failure) so a bad minute at the host doesn't mean a request on every admin page load.
- Quiet on failure. An unreachable host or a bad credential means no update is offered. Sources never throw; an update check isn't worth a fatal.
Update URImatters. Besides routing the check, it stops wordpress.org pushing a same-slug plugin onto your sites. Set it even on WP 5.8+.- Version comparison ignores a leading
v.version_compare('v2.0.0', '2.0.0')is-1in PHP, so av-prefixed header would otherwise be offered the same version forever.
Namespacing
The namespace carries a major version — BlackBrickSoftware\WpArtifactUpdater\V1 — and
the layout mirrors it:
src/
V1/ Source.php Updater.php BitbucketDownloads.php GitHubReleases.php
PSR-4 maps BlackBrickSoftware\WpArtifactUpdater\ to src/, so a breaking change is a
new src/V2/ directory and nothing else: no autoload change, and V1 keeps
working for plugins that haven't moved.
Two plugins on one site can each ship their own copy of V1 in vendor/ and the
first one loaded wins — harmless, because it's the same code. Only an actual
breaking change needs a new major, and then the two coexist.
If a plugin ever vendors third-party dependencies into its zip, prefix them at build time with PHP-Scoper — that's the general fix for WordPress's one-global-namespace problem, and it's a separate concern from this library.
Testing an update without shipping one
Tag a throwaway patch release and watch it appear:
wp eval 'delete_site_transient("wpu_release_" . md5(plugin_basename(WP_PLUGIN_DIR . "/my-plugin/my-plugin.php") . "|bitbucket.org"));' wp eval 'wp_update_plugins();' wp plugin list --name=my-plugin --fields=name,version,update
Both lines are needed: the first clears our cache, and the second only does
anything once WordPress's own twelve-hour gate has passed — which is why a
plugin exposing forceRecheck() behind a button is worth the ten lines.
Licence
MIT.