LH GitHub Publisher
Publishes Peter's own LocalHero plugins to GitHub, and triages/contributes fixes to third-party repos (fork, propose, apply, PR). Network-activated on Multisite.
by Peter Shaw · github.com/lhero-org/lh-github-publisher · 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/lhero-org/lh-github-publisher/archive/refs/heads/main.zipPublishes Peter's own LocalHero plugins to GitHub, and triages/contributes fixes to third-party GitHub repos Peter doesn't own (fork → propose → apply → pull request).
This plugin replaces LH GitHub (lh-github, retired at v2.0). It is treated as a new plugin — fresh version numbering starting at 1.00 — though it carries forward the working publish logic from lh-github unchanged. Not to be confused with LH Git Updater (lh-git-updater), which pulls plugin updates from GitHub down onto the site. This plugin pushes/contributes in the other direction.
Network-activated on Multisite.
Installation
- Deactivate and delete the old
lh-githubplugin if present (this plugin supersedes it — running both will register duplicate admin menus). - Upload this plugin's zip via Network Admin → Plugins → Add New → Upload Plugin, or extract to
wp-content/plugins/lh-github-publisher/. - Network-activate.
- Go to Network Admin → Settings → LH GitHub Publisher and enter:
- A GitHub Personal Access Token with
reposcope (addworkflowscope too if you'll ever touch.github/workflows/*files via propose/apply). - Your default GitHub org/user (used for your own plugin repos).
- A GitHub Personal Access Token with
- Settings are encrypted at rest (AES-256-CBC, key derived from WP salts) in site options
lh_ghp_pat/lh_ghp_org.
Admin interface
Network Admin → Settings → LH GitHub Publisher (settings.php?page=lh-github-publisher)
- Settings form: PAT + default org.
- Plugin table: every installed plugin with a
README.md/readme.md, showing local version, GitHub version (1-hour cached, refreshable), sync status, repo link, last-push SHA/date, and a Publish/Update button. Publish creates the GitHub repo if missing; both force-push current disk state via git. Refuses to push if the local version number matches what's already on GitHub — bump the version first.
This admin UI only covers Peter's own repos. Upstream triage/contribution (issues, forks, PRs) is MCP-only by design — there's no admin UI for it.
Database
No custom tables. Two encrypted site options (lh_ghp_pat, lh_ghp_org) plus one site option holding pending/applied upstream-change proposals (lh_ghp_upstream_proposals, capped at the 20 most recent, oldest dropped first — this is a staging area, not an audit log). Transients cache the authenticated GitHub login and per-plugin GitHub version for an hour.
No cron jobs.
Hooks
lh-github-publisher_init_plugin(filter, default true) — return false to prevent the plugin from initialising.lh-github-publisher_return_plugin_namespace,lh-github-publisher_return_plugin_text_domain,lh-github-publisher_plugin_name,lh-github-publisher_plugin_version— filterable identity methods, standard across all LH plugins.- MCP:
wp_abilities_api_categories_init(registers thelh-github-publishercategory),wp_abilities_api_init(registers all abilities below).
MCP abilities
All abilities are gated to the install_plugins capability and are namespaced under lh-github-publisher/.
Own repos (disk canonical):
push-plugin— force-push an installed plugin to GitHub, creating the repo if needed. Refuses if the local version is already on GitHub.get-file-contents— read a file from any GitHub repo via the stored PAT.
Triage (GitHub canonical):
list-issues— list or full-text search issues on a repo. Always check before filing.create-issue-comment— comment on an existing issue or PR.create-issue— file a new issue.
Contribute (GitHub canonical, new territory for this plugin):
fork-repo— idempotent fork of an upstream repo to Peter's account or a given org. Safe to call repeatedly; recognises an existing fork by checkingparent.full_nameon the target repo rather than assuming.propose-upstream-change— ensures a fork exists, creates/reuses a branch on it, reads current file contents from that branch, and stores a diff of the proposed change as a pending proposal. Nothing is committed by this call.apply-upstream-change— commits an approved proposal to its fork branch. Requiresconfirmed:trueon every call — Peter's explicit decision (2026-07-10) was to confirm every commit rather than only gating at PR time, even though the fork branch is otherwise disposable/invisible upstream until a PR opens.create-pull-request— opens a PR from an applied proposal's fork branch against the upstream repo/base branch. Also requiresconfirmed:true, since this is the point a change becomes publicly visible to upstream maintainers.
Typical upstream-fix flow
list-issuesto confirm the bug and check for an existing fix PR.propose-upstream-changewith the target file(s) and full proposed contents — returns a diff.- Show the diff to Peter, get explicit approval.
apply-upstream-changewithconfirmed:true.- Confirm again, then
create-pull-requestwithconfirmed:true.
Known limitations (v1.02)
apply-upstream-changecommits each file in the proposal as a separate Contents-API commit (not one atomic multi-file commit via the Git Data tree API). Fine for the common single-file-fix case; a multi-file proposal will produce one commit per file on the fork branch.propose-upstream-changebranches from the fork's current state ofbase_branch. If the fork has drifted behind upstream, the base SHA used will be stale. There's no fork-sync step yet — worth adding if forks start going stale in practice.- The inline diff generator is a self-contained LCS implementation (no
xdiffdependency), guarded against pathologically large files (skips inline diff above ~4M line-pairs and says so).
Changelog
1.02 — 2026-07-10
- Fixed unescaped output in the admin notice renderer (now uses
printf()withesc_attr()/wp_kses_post()instead of raw string interpolation intoecho). - Switched all
wp_redirect()calls in the admin handlers towp_safe_redirect(). - Added a justified
phpcs:ignorefor the read-only$_GET['saved']display flag — it only toggles a notice, the actual save is nonce-verified separately inhandle_save_settings(). - Added
readme.txt(WP.org-standard plugin header format) alongside thisreadme.md. Plugin Check expectsreadme.txtindependently ofreadme.md's documentation role. - Fixed a widespread bug present since v1.00: em dashes and arrows were stored as literal
\u2014/\u2192text (6-7 raw characters) instead of real Unicode characters, across this file and 9 PHP files. PHP doesn't interpret\uXXXXescapes outside\u{XXXX}syntax in double-quoted strings, so every instance rendered literally. All replaced with real characters. - All
NonSingularStringLiteralDomainwarnings fromrun-plugin-checkare the accepted false positive fromself::return_plugin_text_domain()— documented per LH plugin conventions, no code change.
1.01 — 2026-07-10
- Fixed
LH_GHP_Github_Api::request()encoding an empty PHP array body as JSON[]instead of{}. This brokefork-repowhenever nofork_into_orgwas given — GitHub'sPOST .../forksendpoint (and others) reject[]for an empty object body with "Body should be a JSON object". Found via a live first run againstWordPress/mcp-adapter.
1.00 — 2026-07-10
- Initial release, superseding
lh-githubv2.0. Renamed for clarity againstlh-git-updater. - Ported
push-pluginandget-file-contentsunchanged in behaviour. - Added
list-issues,create-issue-comment,create-issuefor third-party issue triage. - Added
fork-repo,propose-upstream-change,apply-upstream-change,create-pull-requestfor upstream contribution, built to fixWordPress/mcp-adapter#182as the first real-world case. apply-upstream-changeandcreate-pull-requestboth require explicitconfirmed:trueon every call (Peter's decision, confirm-every-time model rather than confirm-only-at-PR).