WordPress GitHub plugin updates
Implement private-repo auto-releases and WordPress in-dashboard updates using this proven pattern. Adapt names to the target plugin; do not hard-code Provider Connect.
Goal
- Bump
Version:in the main plugin PHP file → push → GitHub Actions creates a release + lean zip. - Installed sites see the update via Dashboard → Updates / Plugins.
- Plugin directory name stays
{plugin-slug}/after update. dev(or any non-main) releases do not affect production sites followingmain.
Gather before coding
Ask if missing:
- GitHub
org/repo(private or public) - Main plugin file path and slug (folder name must match zip prefix)
- Whether Composer
vendor/is gitignored (assume yes → require release assets) - Production branch name (default
main)
Channel model (required)
| Push branch | Tag | GitHub release | Sites that see it |
|---|---|---|---|
main |
v{Version} |
stable | Branch setting = main |
other (e.g. dev) |
v{Version}-{branch} |
prerelease | Branch setting = that branch only |
WordPress sites following main must ignore prereleases. Non-main sites filter tags ending with -{branch}.
flowchart LR
bump["Bump Version header"] --> push["Push branch"]
push --> ci["GitHub Actions"]
ci --> zip["Lean zip + release"]
zip --> stable["main: stable vX.Y.Z"]
zip --> pre["other: prerelease vX.Y.Z-branch"]
stable --> prod["WP branch=main"]
pre --> staging["WP branch=dev"]
Implementation checklist
Copy and track:
- [ ] Lean zip packager (allowlist; root folder = plugin slug)
- [ ] GitHub Actions: version bump → tag → release + attach zip
- [ ] Composer dep: yahnis-elsts/plugin-update-checker
- [ ] updater.php: auth, release assets required, channel filters
- [ ] Force release strategy for non-main (PUC defaults to branch zip)
- [ ] Admin settings: repo, token, branch <select>, Test connection
- [ ] Update URI header → real GitHub repo URL
- [ ] Plugin header: clear short Description (+ Requires at least / Requires PHP as needed)
- [ ] WordPress-standard readme.txt (Description / Installation / FAQ / Changelog)
- [ ] Plugin icons under assets/ (icon.svg and/or icon-128x128.png, icon-256x256.png)
- [ ] Plugin banners under assets/ (banner-772x250.png, banner-1544x500.png)
- [ ] Updater: inject local icons (puc_pre_inject_update), banners + readme (puc_pre_inject_info)
- [ ] Zip allowlist includes assets/ and readme.txt
- [ ] Keep Stable tag in readme.txt in sync with Version header on release
- [ ] README: ship flow, channels, token scopes, constants
- [ ] .gitignore: vendor/, node_modules/, dist/
1. Lean zip
- Allowlist runtime files only (main PHP,
readme.txt,assets/icons/banners, CSS/JS,includes/,templates/,vendor/, needed JSON). Never shipnode_modules,.git,scripts/(unless required at runtime), tests, or CI files. - Zip top-level folder must be
{plugin-slug}/so updates replace the same directory. - Run
composer install --no-devbefore zipping whenvendor/is not in git. - Name artifact
{plugin-slug}-{version}.zip.
2. Auto-release workflow
Trigger on push when the main plugin file changes (path filter).
- Parse
Version:from the plugin header. - Tag:
v{version}onmain;v{version}-{sanitized-branch}otherwise. - Skip if tag already exists.
- Build zip; create release with
softprops/action-gh-release. prerelease: ${{ steps.meta.outputs.prerelease == 'true' }}— string"false"is truthy in JS; compare explicitly.- Attach only the expected zip path.
Template: reference.md.
3. WordPress updater
Use Plugin Update Checker v5+.
Config precedence (highest first):
wp-config.phpconstants- Admin options
- Filters
Required behavior:
- Authenticate with PAT for private repos (
repoclassic, or fine-grained Contents + Metadata read). enableReleaseAssets('/^{slug}-.+\.zip$/i', REQUIRE_RELEASE_ASSETS)— never fall back to source zip ifvendor/is missing from git.- Branch
main: skip prereleases (PUC default). - Other branch:
RELEASE_FILTER_ALL+ callback: tag ends with-{branch}andprereleaseis true. - Critical: PUC only uses release strategy for
main/master. For other branches, filterpuc_vcs_update_detection_strategies-{slug}to onlylatest_release(no branch archive).
Settings UI:
- Repository
org/repo - Access token (password; blank keep existing)
- Branch
<select>populated after successful GitHub connection (list/repos/.../branches) - Test connection AJAX: repo metadata, branch list, latest channel version, zip asset present?, installed version
- Constants lock corresponding fields when defined
Patterns: reference.md.
4. Plugin header + docs
* Update URI: https://github.com/{org}/{repo}
Improve the short header Description: (Plugins list blurb). Optional headers: Requires at least, Requires PHP.
README must document: bump Version → push → workflow; channel rules; Settings vs constants; token scopes; folder name {slug}.
5. Plugin details UI (icons, banners, readme)
Give admins a proper View details experience for private-repo plugins.
Why local injection
Private GitHub repos break the default “pretty” Plugins UI:
- WordPress / PUC may try to load icon/banner URLs from GitHub; those URLs won’t load in the browser without public access.
- The short
Description:header alone is too thin for View details. - PUC can fetch remote
readme.txt, but local packaging + injection is more reliable for private repos and for sites that already have the zip installed.
Pattern: ship assets + readme.txt inside the lean zip, then inject them via PUC filters using plugins_url() from the installed plugin folder.
Files to ship
| Path | Purpose |
|---|---|
readme.txt |
WordPress.org-format readme; powers Description / Installation / FAQ / Changelog tabs |
assets/icon.svg (optional) |
SVG icon |
assets/icon-128x128.png |
1x icon |
assets/icon-256x256.png |
2x icon |
assets/banner-772x250.png |
Low-res banner |
assets/banner-1544x500.png |
High-res banner |
readme.txt requirements
- First line:
=== {Plugin Display Name} === - Metadata block: Contributors, Requires at least, Tested up to, Requires PHP, Stable tag, License
- One-line short description (≤150 chars preferred; parser truncates)
- Sections:
== Description ==,== Installation ==,== Frequently Asked Questions ==,== Changelog ==, optional== Upgrade Notice == - Keep Stable tag aligned with the plugin
Version:on release
PUC parses this with PucReadmeParser (bundled with Plugin Update Checker).
Updater injection (required for private repos)
After buildUpdateChecker + auth, register local asset filters. Do not rely on GitHub-hosted image URLs.
// Icons → update notices / Plugins list
add_filter('puc_pre_inject_update-{plugin-slug}', function ($update) use ($icons) {
if (is_object($update)) {
$update->icons = $icons; // keys: svg, 1x, 2x → plugins_url(...) values
}
return $update;
});
// Banners + readme sections → View details modal
add_filter('puc_pre_inject_info-{plugin-slug}', function ($info) use ($banners, $readme) {
if (!is_object($info)) {
return $info;
}
if ($banners) {
$info->banners = $banners; // keys: low, high
}
if (!empty($readme['sections'])) {
$info->sections = array_merge(
is_array($info->sections ?? null) ? $info->sections : [],
$readme['sections']
);
}
// Optionally fill short_description / requires / tested / requires_php if empty
return $info;
});
Implementation notes:
- Resolve files with
is_readable()+plugins_url($relative, $plugin_file)so only present files are attached. - Parse local
readme.txtwithnew \PucReadmeParser()when the class exists. - Prefer packaged readme sections over thin remote metadata (
array_mergeso local keys win). - These filters only run when the updater is bootstrapped (repo + token configured). That matches when PUC’s View details hook is active.
Full patterns: reference.md.
Verify (details UI)
- Deploy/sync plugin with assets +
readme.txtpresent. - Configure GitHub updater (repo + token) so PUC is active.
- Plugins → View details shows banner, Description/Installation/FAQ from readme.
- Plugins list / update UI shows icon when available.
- Release zip contains
assets/andreadme.txtunder{plugin-slug}/.
Anti-patterns
- Manual “draft a release” as the primary ship path
- Downloading GitHub source zip when Composer deps are required
- Same tag for
mainanddev(collides; always suffix non-main) - Letting non-main use PUC branch download (breaks installs without
vendor/) - Changing zip root folder name across releases
- Pointing icon/banner URLs at private GitHub raw/blob links (admin browser can’t auth)
- Shipping only a long header
Description:and skippingreadme.txt - Forgetting
readme.txt/assets/in the zip allowlist (works in local clone, breaks after update) - Letting
Stable tagdrift fromVersion:
Verify
- Push Version bump on
main→ stable release + zip asset. - Push Version bump on
dev→ prereleasevX.Y.Z-devonly. - Site branch=
mainTest connection → sees stable only. - Site branch=
dev→ sees*-devprerelease only. - Update installs into
wp-content/plugins/{slug}/(name unchanged). - View details shows local banner + readme sections; Plugins list shows icon.
Sharing this skill with a team
See sharing.md.