Merge pull request 'Auto-update the plugin from tagged Gitea releases' (#66) from feature/plugin-self-update into main
CI / Tests (PHP 8.1) (push) Successful in 41s
CI / PHPStan (push) Successful in 2m51s
CI / Tests (PHP 8.2) (push) Successful in 43s
CI / No Debug Code (push) Successful in 2s
CI / Tests (PHP 8.3) (push) Successful in 2m35s
CI / Coding Standards (push) Successful in 3m59s
CI / Build Plugin Zip (push) Successful in 2m43s

Reviewed-on: #66
This commit was merged in pull request #66.
This commit is contained in:
2026-07-18 14:22:05 +00:00
7 changed files with 479 additions and 0 deletions
+79
View File
@@ -0,0 +1,79 @@
name: Release
# Fires when a v* tag is pushed — including tags created through Gitea's
# "New Release" UI. Builds the distributable plugin zip and attaches it to
# the release for that tag (creating the release if only a bare tag was
# pushed). The attached zip is what UpdateChecker serves to WordPress
# sites as the update package.
on:
push:
tags:
- 'v*'
jobs:
release:
name: Build and Publish Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer:v2
# A tag that disagrees with the plugin header would make sites see a
# phantom update forever (or never see a real one), so fail fast.
- name: Verify tag matches plugin version
id: meta
run: |
tag_version="${GITHUB_REF_NAME#v}"
header_version="$(sed -nE 's/^[[:space:]]*\*?[[:space:]]*Version:[[:space:]]*([^[:space:]]+).*/\1/p' unsupervised-schedular.php | head -1)"
if [ "$tag_version" != "$header_version" ]; then
echo "Tag ${GITHUB_REF_NAME} does not match plugin header Version: ${header_version}" >&2
exit 1
fi
echo "version=${header_version}" >> "$GITHUB_OUTPUT"
- name: Install dependencies
run: composer install --prefer-dist --no-progress --no-interaction
- name: Run tests
run: composer test
- name: Build plugin zip
run: composer build
- name: Publish release with zip asset
env:
TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
api="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
version="${{ steps.meta.outputs.version }}"
zip="dist/unsupervised-schedular-${version}.zip"
# Pre-release versions (1.2.3-rc.1) are flagged so Gitea's
# /releases/latest endpoint — and therefore the update checker —
# skips them.
prerelease=false
case "$version" in *-*) prerelease=true ;; esac
# Reuse the release if the tag was created via Gitea's release UI.
release_id="$(curl -sS -H "Authorization: token ${TOKEN}" \
"${api}/releases/tags/${GITHUB_REF_NAME}" | jq -r '.id // empty' || true)"
if [ -z "$release_id" ]; then
release_id="$(curl -fsS -X POST "${api}/releases" \
-H "Authorization: token ${TOKEN}" \
-H 'Content-Type: application/json' \
-d "{\"tag_name\":\"${GITHUB_REF_NAME}\",\"name\":\"${GITHUB_REF_NAME}\",\"prerelease\":${prerelease}}" \
| jq -r '.id')"
fi
echo "Attaching ${zip} to release ${release_id}"
curl -fsS -X POST \
"${api}/releases/${release_id}/assets?name=unsupervised-schedular-${version}.zip" \
-H "Authorization: token ${TOKEN}" \
-F "attachment=@${zip}" > /dev/null
+73
View File
@@ -0,0 +1,73 @@
# 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 as the update
package. Core takes over from there: Plugins-screen notice, one-click
update, and WP-Cron auto-updates if enabled.
Any API failure, malformed response, or asset-less release degrades to
"no update available" — never an error surfaced to the site.
## 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`
+2
View File
@@ -29,6 +29,7 @@ use Unsupervised\Schedular\Policy\PolicyVersionRepository;
use Unsupervised\Schedular\Registration\AnswerRepository;
use Unsupervised\Schedular\Registration\QuestionRepository;
use Unsupervised\Schedular\Registration\RegistrationGate;
use Unsupervised\Schedular\Update\UpdateChecker;
class Plugin {
@@ -74,6 +75,7 @@ class Plugin {
$registrationPage = new RegistrationPage( $invites, $policies, $policyVersions, $acceptances, $settings, $registrationMailer );
$groupClassPage = new GroupClassPage();
( new UpdateChecker() )->register();
( new RoleManager() )->register();
( new RegistrationLoginGate() )->register();
( new EmailConfirmationHandler( $settings, $registrationMailer ) )->register();
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Update;
use Unsupervised\Schedular\Val;
/**
* Serves plugin updates from the Gitea repository's releases.
*
* Core reads the plugin's `Update URI` header and, during every update
* check, fires the `update_plugins_{hostname}` filter for that host. This
* class answers the filter by fetching the latest published release from
* the Gitea API and returning its zip asset when it is newer than the
* installed version. Everything downstream — the Plugins-screen notice,
* one-click updates, and opt-in auto-updates — is handled by core.
*
* Drafts and releases marked "pre-release" in Gitea are never offered:
* the `/releases/latest` endpoint excludes both.
*/
class UpdateChecker {
public const HOSTNAME = 'git.unsupervised.ca';
public const REPO_URL = 'https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler';
public const API_URL = 'https://git.unsupervised.ca/api/v1/repos/Unsupervised/unsupervised-scheduler/releases/latest';
public const TRANSIENT = 'us_schedular_latest_release';
/**
* How long a release lookup (including a failed one) is cached. Core
* runs update checks on admin page loads as well as twice-daily cron,
* so the cache keeps the plugin from hammering the Gitea API.
*/
private const CACHE_TTL = 6 * 3600;
public function register(): void {
add_filter( 'update_plugins_' . self::HOSTNAME, [ $this, 'provideUpdate' ], 10, 3 );
}
/**
* `update_plugins_{hostname}` filter callback. Returns the incoming
* value untouched unless a newer release with a zip asset exists, in
* which case it returns the update array core expects.
*/
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
return $update;
}
$release = $this->latestRelease();
if ( '' === $release['version'] || '' === $release['package'] ) {
return $update;
}
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
return $update;
}
return [
'slug' => 'unsupervised-schedular',
'version' => $release['version'],
'url' => self::REPO_URL,
'package' => $release['package'],
];
}
/**
* The latest published release, from the transient cache when fresh.
*
* @return array{version: string, package: string} Empty strings when no
* usable release exists.
*/
private function latestRelease(): array {
$cached = get_transient( self::TRANSIENT );
if ( is_array( $cached ) ) {
return [
'version' => Val::string( $cached['version'] ?? '' ),
'package' => Val::string( $cached['package'] ?? '' ),
];
}
$release = $this->fetchLatestRelease();
set_transient( self::TRANSIENT, $release, self::CACHE_TTL );
return $release;
}
/**
* Ask the Gitea API for the latest published release's version and zip asset.
*
* @return array{version: string, package: string}
*/
private function fetchLatestRelease(): array {
$none = [
'version' => '',
'package' => '',
];
$response = wp_remote_get(
self::API_URL,
[
'timeout' => 10,
'headers' => [ 'Accept' => 'application/json' ],
]
);
if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) {
return $none;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $body ) ) {
return $none;
}
// Release tags are named v1.2.3; the plugin header carries the bare version.
$version = preg_replace( '/^v/i', '', Val::string( $body['tag_name'] ?? '' ) ) ?? '';
// The release workflow attaches the built plugin zip (top-level
// unsupervised-schedular/ folder, production autoloader) as an asset.
// Gitea's auto-generated source archives are not usable packages.
$package = '';
$assets = $body['assets'] ?? null;
if ( is_array( $assets ) ) {
foreach ( $assets as $asset ) {
if ( ! is_array( $asset ) ) {
continue;
}
if ( str_ends_with( strtolower( Val::string( $asset['name'] ?? '' ) ), '.zip' ) ) {
$package = Val::string( $asset['browser_download_url'] ?? '' );
break;
}
}
}
if ( '' === $version || '' === $package ) {
return $none;
}
return [
'version' => $version,
'package' => $package,
];
}
}
+177
View File
@@ -0,0 +1,177 @@
<?php
declare(strict_types=1);
namespace Unsupervised\Schedular\Tests\Unit\Update;
use Brain\Monkey\Filters;
use Brain\Monkey\Functions;
use Unsupervised\Schedular\Tests\Unit\TestCase;
use Unsupervised\Schedular\Update\UpdateChecker;
class UpdateCheckerTest extends TestCase
{
private const PLUGIN_FILE = 'unsupervised-schedular/unsupervised-schedular.php';
private const PACKAGE_URL = 'https://git.unsupervised.ca/attachments/abc123';
/** Stub a successful Gitea API response with the given decoded body. */
private function stubApiResponse(int $code, mixed $body): void
{
Functions\when('wp_remote_get')->justReturn(['response' => ['code' => $code]]);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('wp_remote_retrieve_response_code')->justReturn($code);
Functions\when('wp_remote_retrieve_body')->justReturn(json_encode($body));
}
private function release(string $tag, array $assets): array
{
return ['tag_name' => $tag, 'assets' => $assets];
}
private function zipAsset(string $name = 'unsupervised-schedular-9.9.9.zip'): array
{
return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL];
}
public function testRegisterHooksHostnameFilter(): void
{
Filters\expectAdded('update_plugins_git.unsupervised.ca')->once();
(new UpdateChecker())->register();
}
public function testOffersUpdateWhenReleaseIsNewer(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
// USC_VERSION is 1.0.0 in the test bootstrap.
$this->stubApiResponse(200, $this->release('v9.9.9', [$this->zipAsset()]));
Functions\expect('set_transient')->once()->with(
UpdateChecker::TRANSIENT,
['version' => '9.9.9', 'package' => self::PACKAGE_URL],
\Mockery::type('int')
);
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertSame(
[
'slug' => 'unsupervised-schedular',
'version' => '9.9.9',
'url' => UpdateChecker::REPO_URL,
'package' => self::PACKAGE_URL,
],
$result
);
}
public function testReturnsUpdateUnchangedForOtherPlugins(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\expect('wp_remote_get')->never();
$result = (new UpdateChecker())->provideUpdate(false, [], 'other-plugin/other-plugin.php');
self::assertFalse($result);
}
public function testNoUpdateWhenReleaseIsNotNewer(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
$this->stubApiResponse(200, $this->release('v1.0.0', [$this->zipAsset('unsupervised-schedular-1.0.0.zip')]));
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
}
public function testUsesCachedReleaseWithoutHittingApi(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(['version' => '2.0.0', 'package' => self::PACKAGE_URL]);
Functions\expect('wp_remote_get')->never();
Functions\expect('set_transient')->never();
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertIsArray($result);
self::assertSame('2.0.0', $result['version']);
}
public function testApiFailureIsCachedAndReturnsUpdateUnchanged(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('wp_remote_get')->justReturn('irrelevant');
Functions\when('is_wp_error')->justReturn(true);
// A failed lookup is cached too, so a down Gitea is not re-polled
// on every admin page load.
Functions\expect('set_transient')->once()->with(
UpdateChecker::TRANSIENT,
['version' => '', 'package' => ''],
\Mockery::type('int')
);
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
}
public function testNon200ResponseReturnsUpdateUnchanged(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
$this->stubApiResponse(404, ['message' => 'Not Found']);
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
}
public function testPicksFirstZipAssetAndSkipsOthers(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
$this->stubApiResponse(200, $this->release('v9.9.9', [
['name' => 'release-notes.pdf', 'browser_download_url' => 'https://example.test/notes.pdf'],
$this->zipAsset(),
]));
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertIsArray($result);
self::assertSame(self::PACKAGE_URL, $result['package']);
}
public function testReleaseWithoutZipAssetOffersNoUpdate(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
$this->stubApiResponse(200, $this->release('v9.9.9', [
['name' => 'release-notes.pdf', 'browser_download_url' => 'https://example.test/notes.pdf'],
]));
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
}
public function testMalformedApiBodyOffersNoUpdate(): void
{
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false);
Functions\when('set_transient')->justReturn(true);
Functions\when('wp_remote_get')->justReturn(['response' => ['code' => 200]]);
Functions\when('is_wp_error')->justReturn(false);
Functions\when('wp_remote_retrieve_response_code')->justReturn(200);
Functions\when('wp_remote_retrieve_body')->justReturn('not json');
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result);
}
}
+1
View File
@@ -16,3 +16,4 @@ remove_role('us_instructor');
remove_role('us_student');
delete_option('us_schedular_version');
delete_transient('us_schedular_latest_release');
+1
View File
@@ -9,6 +9,7 @@
* Author: Unsupervised
* License: GPL-2.0-or-later
* Text Domain: unsupervised-schedular
* Update URI: https://git.unsupervised.ca/Unsupervised/unsupervised-scheduler
*/
declare(strict_types=1);