Skip to content

feat(wp-premium-update): PHP-version-gated premium plugin updater (0.0.2.2) - #40

Open
khoipro wants to merge 1 commit into
mainfrom
feat/premium-plugin-update
Open

khoipro wants to merge 1 commit into
mainfrom
feat/premium-plugin-update

Conversation

@khoipro

@khoipro khoipro commented Oct 26, 2026

Copy link
Copy Markdown
Member

Adds wp-premium-update.sh — an updater for premium (licensed) plugins that refuses to install a package the site's PHP cannot run.

Why

Premium plugins aren't on WordPress.org, so they're installed from version-pinned zips in the private store codetot-web/premium-plugins. Those packages declare PHP floors the web PHP often doesn't meet, and installing them anyway white-screens the site. Two signatures, both real:

PHP Fatal error: Uncaught RuntimeException: Composer detected issues in your
platform: Your Composer dependencies require a PHP version ">= 8.1.0".
  (thrown by vendor/composer/platform_check.php at autoload time)
PHP Parse error: syntax error, unexpected '|'      <- PHP 8.0+ union types

The incident this is built around: perfmatters 2.6.7 declares Requires PHP: 8.1 and auto-deactivated itself on five PHP 7.4 sites, killing their custom /quantri/ login URL. The plugin stays installed but inactive — nothing looks broken until someone needs the feature.

The gate

For every (site, package) pair:

  1. Package floor = the higher of the Requires PHP: header and the version pinned in vendor/composer/platform_check.php. Composer throws at autoload, before any plugin code runs, so it wins when stricter.

  2. Site PHP = the version actually serving the site, detected per stack:

    • RunCloud nginx → /etc/php<XY>rc/fpm.d/<app>.conf
    • RunCloud OLS → /etc/lsws-rc/conf.d/<app>.d/handler.conf → lsphpNN
    • litesoup/Apache → vhost SetHandler proxy:unix:/run/php/…-php<X.Y>-fpm.sock, matched by vhost filename → ServerName/ServerAlias → DocumentRoot (webapp dichvu-website lives in dichvu.website.conf, so name matching alone misses real sites — found and fixed during testing)

    It never uses php -v: the CLI binary is routinely a different major than the web PHP, and trusting it is the original incident.

  3. Site PHP < floor → BLOCKED. Nothing is written, the required version is named. No automatic override — --allow-php-gate-bypass exists only for staging ahead of a planned PHP upgrade.

  4. Lint gate. The package's top-level PHP files are linted with the site's own php binary before anything is installed, so a package using newer syntax than it advertises is caught pre-install rather than by a 500.

  5. PHP undetectable → site blocked, never guessed. A wrong PHP assumption is exactly what takes a site down.

Apply behaviour

Dry-run by default. --apply: keeps <slug>.bak-<ts>, moves the old folder aside and extracts fresh (never extracts on top — that leaves files from the previous version behind), chowns to the site owner (root:root from a root extract is unreadable by the PHP worker), re-reads the on-disk version, re-activates a previously-active plugin and verifies it stayed active (an auto-deactivate means it fatalled → rollback), HTTP-checks siteurl and auto-rolls-back on non-200/301/302, flushes WP + LiteSpeed caches, commits per plugin. Exit 2 = something failed and was rolled back.

Three latent bugs fixed along the way

  • Swallowed git add failure. When .git/objects/<xx> is root-owned (a root git fetch leaves it that way), git add fails with insufficient permission for adding an object to repository database — and a bare git diff --cached --quiet then reports no diff, indistinguishable from "nothing to commit". The plugin landed on disk with no commit and the per-plugin revert safety net silently did not exist. Any output from git add is now a failure, printed with the exact chown.
  • version_cmp compared only 3 components — Gravity Forms 3.1.3.4 vs 3.1.3.10 compared equal, so a real update would have been skipped. Now compares 4.
  • zip_entry_php false negatives under pipefail — unzip -p … | grep -q fails when grep exits early and unzip takes SIGPIPE, so packages whose entry file isn't first alphabetically (Gravity Forms' gravityforms.php) read as having no version header. Body is now captured before matching.

Test plan

  • bash -n clean; shellcheck -S warning clean.
  • Unit-tested version_cmp (4-part versions), required_php (header + composer floor), zip_entry_php, and lint_zip against the real store packages.
  • Dry-run, live: sg10 (Apache/litesoup, 4 sites) and jp1 — correct PHP per site, correct plan, 0 blocked.
  • Block path proven live: synthetic Requires PHP: 9.9 package against a PHP 8.2 site on sg10 → ⊘ BLOCKED site PHP 8.2 < package requires 9.9, nothing written.
  • Apply path proven live: msn.chuyen.dev wpml-string-translation 3.5.2 → 5.1.0 — plugin stayed active, HTTP 200, ownership litesoup:litesoup, backup created. Re-tested after a manual revert; the git-add failure surfaced with the actionable message instead of being swallowed.
  • Deployed to all 27 fleet servers; store zips rsync'd to /root/premium-plugins/ (those boxes clone over HTTPS with no credentials and the store is private, so a git submodule can't be pulled there).

Bookkeeping

VERSION 0.0.2.0 → 0.0.2.2 (0.0.2.1 was already in CHANGELOG but never bumped in VERSION), CHANGELOG [v0.0.2.2], README feature bullet + full section, docs/premium-plugin-update.md runbook.

Reviewer note

plugins/ in this repo is currently just a .gitkeep. The zips are deployed by rsync to /root/premium-plugins/, which the script auto-detects; committing them into the repo would make every clone 47 MB heavier and duplicate the private store.

…0.2.2)

Premium (licensed) plugins cannot be updated via WordPress.org, so they are
installed from version-pinned zips in the private store
codetot-web/premium-plugins. Those packages often declare a PHP floor the
site's WEB php does not meet, and installing them anyway is a documented way
to white-screen a site:

  PHP Fatal error: Uncaught RuntimeException: Composer detected issues in
  your platform: Your Composer dependencies require a PHP version >= 8.1.0
  (thrown by vendor/composer/platform_check.php at autoload time)

  PHP Parse error: syntax error, unexpected '|'   (PHP 8.0+ union types)

Real incident: perfmatters 2.6.7 requires PHP 8.1 and auto-deactivated itself
on five PHP 7.4 sites, killing their custom /quantri/ login URL.

This script makes the PHP requirement a HARD GATE instead of a warning. Per
(site, package):

  1. floor = max(Requires PHP header, vendor/composer/platform_check.php) --
     composer throws before any plugin code runs, so it wins when stricter;
  2. site PHP is detected from the stack actually serving the site:
     RunCloud nginx fpm.d, RunCloud OLS handler.conf, or litesoup/Apache
     proxy:fcgi socket, matched by vhost name -> ServerName/ServerAlias ->
     DocumentRoot (webapp dichvu-website lives in dichvu.website.conf, so
     name matching alone misses real sites);
     never php -v -- the CLI binary is routinely a different major;
  3. site PHP < floor  => BLOCKED, nothing written, required version reported.
     No automatic override; --allow-php-gate-bypass is for staging ahead of a
     planned PHP upgrade;
  4. package's top-level PHP files are linted with the SITE's own php binary
     before install, catching packages that use newer syntax than advertised;
  5. PHP undetectable => site blocked, never guessed.

Apply (dry-run by default): keeps <slug>.bak-<ts>, moves the old folder aside
and extracts fresh (never on top), chowns to the site owner, re-reads the
on-disk version, re-activates a previously-active plugin and VERIFIES it is
still active (auto-deactivate => rollback), checks HTTP and auto-rolls-back on
non-200/301/302, flushes WP + LiteSpeed caches, commits per plugin.
Exit code 2 = something failed and was rolled back.

Also fixes three latent bugs found while testing:

- swallowed 'git add' failure: when .git/objects/<xx> is root-owned (a root
  git fetch leaves it that way) the add fails and a bare
  'git diff --cached --quiet' then reports NO diff -- indistinguishable from
  'nothing to commit'. The update landed on disk with no commit and the
  per-plugin revert safety net silently did not exist. Any output from git add
  is now treated as a failure, printed with the exact chown to run.
- version_cmp compared only 3 components, so Gravity Forms 3.1.3.4 vs 3.1.3.10
  compared EQUAL and a real update would have been skipped. Now compares 4.
- zip_entry_php had false negatives under set -o pipefail:
  'unzip -p | grep -q' fails when grep exits early and unzip takes SIGPIPE, so
  packages whose entry file is not first alphabetically (Gravity Forms'
  gravityforms.php) read as having no version header.

Verified live: dry-run on sg10 (Apache/litesoup, 4 sites) and jp1; block path
proven with a synthetic Requires-PHP-9.9 package on a PHP 8.2 site; apply path
proven on msn.chuyen.dev (wpml-string-translation 3.5.2 -> 5.1.0, plugin stayed
active, HTTP 200, ownership litesoup:litesoup).

Bookkeeping: VERSION 0.0.2.0 -> 0.0.2.2, CHANGELOG [v0.0.2.2], README feature
bullet + full section, docs/premium-plugin-update.md runbook.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant