Files
unsupervised-scheduler/docs/features/plugin-self-update.md
T
KydoimosandClaude Opus 5 e522789104
CI / Coding Standards (pull_request) Failing after 28s
CI / Tests (PHP 8.5) (pull_request) Failing after 27s
CI / No Debug Code (pull_request) Successful in 3s
CI / Tests (PHP 8.1) (pull_request) Failing after 39s
CI / Tests (PHP 8.3) (pull_request) Failing after 1m7s
CI / Tests (PHP 8.2) (pull_request) Failing after 1m8s
CI / Static Analysis (pull_request) Successful in 1m17s
CI / Build Plugin Zip (pull_request) Skipped
Fix five findings from a security assessment of the plugin
The assessment looked for three things: whether students can reach each
other's bookings, whether payment settings can be dodged, and whether the
plugin opens a way into the rest of the install. The student-isolation and
payment paths held up. These are what did not.

- The front-end login form told WordPress not to work out whether the site
  was secure, so on HTTPS every student's session cookie was issued without
  the Secure flag. wp_signon() only derives it from is_ssl() when the second
  argument is left at its default; an explicit false reads like "no
  preference" and is not.

- The update check took whatever download URL the release API returned and
  handed it to core, which unpacks it over the installed plugin. The package
  must now be https on git.unsupervised.ca exactly, compared on the parsed
  host so a lookalike name cannot pass.

- Uninstalling dropped 2 of 14 tables and left the Stripe secret and webhook
  signing key in wp_options. Removal is now a choice made in advance on
  Access -> Plugin removal: records are kept unless the owner opts in (with a
  typed confirmation), while credentials and the borrowed core registration
  settings go every time.

- Open registration switches on the site-wide users_can_register and makes
  Student the default role, arming any other signup form on the site to mint
  students who could book and be billed immediately. The pending state is now
  decided once, on user_register, rather than by whichever form created the
  account.

- Cancel and withdraw answered "not yours" differently from "does not exist",
  which let a signed-in student enumerate the studio's bookings. Both now
  give the same 404.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-05 11:31:12 -03:00

4.5 KiB

Feature: Plugin Self-Update from Gitea Releases

Overview

WordPress sites running this plugin receive updates directly from the Gitea repository's releases — no wordpress.org listing and no manual zip uploads. Publishing a release is the whole deploy: bump the version, merge to main, tag vX.Y.Z in Gitea. Every site sees the update on its next check and can install it with one click, or unattended if the site admin enables auto-updates for the plugin.

How It Works

Release side (.gitea/workflows/release.yml)

Pushing a v* tag (including tags created through Gitea's "New Release" UI) triggers the release workflow, which:

  1. Fails if the tag does not match the Version: plugin header — a mismatch would make sites see a phantom update forever, or never see a real one.
  2. Runs the test suite.
  3. Builds the distributable zip via composer build (bin/build-zip.sh): a single top-level unsupervised-schedular/ folder with a production (no-dev) Composer autoloader.
  4. Creates the release for the tag (or reuses one created via the UI) and attaches the zip as a release asset. Versions containing a hyphen (e.g. 1.2.3-rc.1) are flagged as pre-releases.

The attached asset — not Gitea's auto-generated source archive — is the update package. Source archives have the wrong top-level folder name and no vendor/ directory, so WordPress could not install them.

Site side (src/Update/UpdateChecker.php)

The plugin header declares:

Update URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler

Since WP 5.8 that header both blocks wordpress.org from ever serving an update for a same-slug plugin and makes core fire the update_plugins_git.unsupervised.ca filter during update checks. UpdateChecker (registered in Plugin::boot()) answers that filter:

  1. Fetches GET /api/v1/repos/Unsupervised/unsupervised-scheduler/releases/latest (anonymous — the repo is public). The /latest endpoint excludes drafts and pre-releases, so -rc builds are never offered to sites.

  2. Caches the result (including failures) in the us_schedular_latest_release transient for 6 hours.

  3. Strips the leading v from the tag and compares against USC_VERSION with version_compare; PHP orders 1.0.0-rc.2 < 1.0.0 correctly.

  4. When newer, returns the release's first .zip asset whose download URL is https on git.unsupervised.ca itself as the update package. Core takes over from there: Plugins-screen notice, one-click update, and WP-Cron auto-updates if enabled.

    The host check is not ceremony. Whatever this returns is downloaded and unpacked over the installed plugin, so the URL is executable code by another name — and it arrives in a JSON body. An answer that is not really the release server's (a hijacked hostname, a tampered response, a repo host handing out a package hosted somewhere else) would otherwise install arbitrary code on every site running the plugin, silently for anyone with auto-updates on. The host must match exactly: evil-git.unsupervised.ca, git.unsupervised.ca.evil.test and cdn.git.unsupervised.ca are all refused, and so is plain http. An asset that fails the check is skipped and the scan continues, so one bad asset does not hide a good one.

  5. When not newer — the site is current, or the lookup failed — returns a no_update payload (installed version, empty package). This keeps the plugin in core's update_plugins transient so core's update-supported flag stays set and the Enable auto-updates toggle shows on the Plugins screen. Without it, an off-directory plugin is absent from the transient between releases and the toggle never appears.

Any API failure, malformed response, or asset-less release degrades to "no update available" (the no_update payload) — never an error surfaced to the site, and never a lost auto-update toggle during a Gitea blip.

Cutting a Release

  1. Bump the version in unsupervised-schedular.php (both the Version: header and the USC_VERSION constant) and merge to main.
  2. Tag the merge commit vX.Y.Z — via Gitea's New Release UI or git tag vX.Y.Z && git push origin vX.Y.Z.
  3. The release workflow attaches the zip; sites pick the update up on their next check (twice daily via cron, or immediately from Dashboard → Updates → Check again).

Classes

Class Responsibility
Update\UpdateChecker Answers core's update_plugins_{hostname} filter from the Gitea releases API

Tests

  • tests/Unit/Update/UpdateCheckerTest.php