Show the Enable auto-updates toggle for the self-updater
CI / Tests (PHP 8.1) (pull_request) Successful in 47s
CI / Tests (PHP 8.2) (pull_request) Successful in 47s
CI / No Debug Code (pull_request) Successful in 3s
CI / Coding Standards (pull_request) Successful in 3m7s
CI / Tests (PHP 8.3) (pull_request) Successful in 2m38s
CI / PHPStan (pull_request) Successful in 2m50s
CI / Build Plugin Zip (pull_request) Skipped

WordPress only renders the "Enable auto-updates" toggle for a plugin that
appears in the update_plugins transient's response or no_update list, which
is what sets core's update-supported flag. UpdateChecker only populated the
response side (when a newer release existed), so between releases the plugin
was absent from the transient and the toggle never showed.

provideUpdate() now returns a no_update payload (installed version, empty
package) whenever no newer release is offered — including when the release
lookup fails — so the plugin stays in the transient and the toggle appears.
The response path (one-click and unattended updates) is unchanged.

Bumps to 1.1.1 so the fix ships to installed sites via the self-updater.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
2026-07-24 08:46:31 -03:00
co-authored by Claude Opus 4.8
parent 37ec8a3315
commit 51dd032668
4 changed files with 68 additions and 22 deletions
+3
View File
@@ -13,6 +13,9 @@ each change under the current top section as you work.
## [1.1.1] ## [1.1.1]
### Fixed
- The **Enable auto-updates** toggle now appears for the plugin on the Plugins screen. The self-updater now reports the plugin to WordPress even when it is already current, so core marks it update-supported and shows the toggle; previously the toggle was hidden between releases.
## [1.1.0] ## [1.1.0]
### Added ### Added
+8 -1
View File
@@ -50,9 +50,16 @@ update for a same-slug plugin and makes core fire the
4. When newer, returns the release's first `.zip` asset as the update 4. When newer, returns the release's first `.zip` asset as the update
package. Core takes over from there: Plugins-screen notice, one-click package. Core takes over from there: Plugins-screen notice, one-click
update, and WP-Cron auto-updates if enabled. update, and WP-Cron auto-updates if enabled.
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 Any API failure, malformed response, or asset-less release degrades to
"no update available" — never an error surfaced to the site. "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 ## Cutting a Release
1. Bump the version in `unsupervised-schedular.php` (both the `Version:` 1. Bump the version in `unsupervised-schedular.php` (both the `Version:`
+25 -11
View File
@@ -75,9 +75,18 @@ class UpdateChecker {
} }
/** /**
* `update_plugins_{hostname}` filter callback. Returns the incoming * `update_plugins_{hostname}` filter callback.
* value untouched unless a newer release with a zip asset exists, in *
* which case it returns the update array core expects. * For a newer release with a zip asset, returns the update array core
* files under the transient's `response` list (the update offer).
* Otherwise — the plugin is current, or the release lookup failed — it
* returns a payload with the installed version and no package, which core
* files under `no_update`. That `no_update` entry is what sets core's
* `update-supported` flag and makes the "Enable auto-updates" toggle
* appear on the Plugins screen; without it, an off-directory plugin is
* absent from the transient between releases and the toggle never shows.
*
* The incoming value is only passed through untouched for other plugins.
*/ */
public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed { public function provideUpdate( mixed $update, mixed $plugin_data, mixed $plugin_file ): mixed {
if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) { if ( plugin_basename( USC_PLUGIN_FILE ) !== $plugin_file ) {
@@ -86,14 +95,8 @@ class UpdateChecker {
$release = $this->latestRelease(); $release = $this->latestRelease();
if ( '' === $release['version'] || '' === $release['package'] ) { if ( '' !== $release['version'] && '' !== $release['package']
return $update; && version_compare( $release['version'], USC_VERSION, '>' ) ) {
}
if ( version_compare( $release['version'], USC_VERSION, '<=' ) ) {
return $update;
}
return [ return [
'slug' => 'unsupervised-schedular', 'slug' => 'unsupervised-schedular',
'version' => $release['version'], 'version' => $release['version'],
@@ -102,6 +105,17 @@ class UpdateChecker {
]; ];
} }
// No newer release: answer with a `no_update` payload so core keeps
// the plugin in the update transient and shows the auto-update toggle.
// The empty package leaves core nothing to auto-install, as intended.
return [
'slug' => 'unsupervised-schedular',
'version' => USC_VERSION,
'url' => self::REPO_URL,
'package' => '',
];
}
/** /**
* The latest published release, from the transient cache when fresh. * The latest published release, from the transient cache when fresh.
* *
+32 -10
View File
@@ -32,6 +32,22 @@ class UpdateCheckerTest extends TestCase
return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL]; return ['name' => $name, 'browser_download_url' => self::PACKAGE_URL];
} }
/**
* The payload provideUpdate() returns when no newer release is offered.
* Core files this under the transient's `no_update` list, which is what
* makes the "Enable auto-updates" toggle appear. USC_VERSION is 1.0.0 in
* the test bootstrap.
*/
private function noUpdatePayload(): array
{
return [
'slug' => 'unsupervised-schedular',
'version' => '1.0.0',
'url' => UpdateChecker::REPO_URL,
'package' => '',
];
}
public function testRegisterHooksHostnameFilter(): void public function testRegisterHooksHostnameFilter(): void
{ {
Filters\expectAdded('update_plugins_git.unsupervised.ca')->once(); Filters\expectAdded('update_plugins_git.unsupervised.ca')->once();
@@ -109,7 +125,7 @@ class UpdateCheckerTest extends TestCase
self::assertFalse($result); self::assertFalse($result);
} }
public function testNoUpdateWhenReleaseIsNotNewer(): void public function testNoUpdatePayloadWhenReleaseIsNotNewer(): void
{ {
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false); Functions\when('get_transient')->justReturn(false);
@@ -118,7 +134,9 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result); // Current version → core files this under `no_update` so the
// auto-update toggle stays visible; no package to install.
self::assertSame($this->noUpdatePayload(), $result);
} }
public function testUsesCachedReleaseWithoutHittingApi(): void public function testUsesCachedReleaseWithoutHittingApi(): void
@@ -134,7 +152,7 @@ class UpdateCheckerTest extends TestCase
self::assertSame('2.0.0', $result['version']); self::assertSame('2.0.0', $result['version']);
} }
public function testApiFailureIsCachedAndReturnsUpdateUnchanged(): void public function testApiFailureIsCachedAndStillReportsUpdateSupport(): void
{ {
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false); Functions\when('get_transient')->justReturn(false);
@@ -150,10 +168,12 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result); // Even with the lookup failed we still return the `no_update` payload,
// so the auto-update toggle does not flicker away during a Gitea blip.
self::assertSame($this->noUpdatePayload(), $result);
} }
public function testNon200ResponseReturnsUpdateUnchanged(): void public function testNon200ResponseReturnsNoUpdatePayload(): void
{ {
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false); Functions\when('get_transient')->justReturn(false);
@@ -162,7 +182,7 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result); self::assertSame($this->noUpdatePayload(), $result);
} }
public function testPicksFirstZipAssetAndSkipsOthers(): void public function testPicksFirstZipAssetAndSkipsOthers(): void
@@ -181,7 +201,7 @@ class UpdateCheckerTest extends TestCase
self::assertSame(self::PACKAGE_URL, $result['package']); self::assertSame(self::PACKAGE_URL, $result['package']);
} }
public function testReleaseWithoutZipAssetOffersNoUpdate(): void public function testReleaseWithoutZipAssetOffersNoUpdatePayload(): void
{ {
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false); Functions\when('get_transient')->justReturn(false);
@@ -192,10 +212,12 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result); // No installable package means no update to offer, but we still keep
// the plugin in `no_update` so the toggle shows.
self::assertSame($this->noUpdatePayload(), $result);
} }
public function testMalformedApiBodyOffersNoUpdate(): void public function testMalformedApiBodyOffersNoUpdatePayload(): void
{ {
Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE); Functions\when('plugin_basename')->justReturn(self::PLUGIN_FILE);
Functions\when('get_transient')->justReturn(false); Functions\when('get_transient')->justReturn(false);
@@ -207,6 +229,6 @@ class UpdateCheckerTest extends TestCase
$result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE); $result = (new UpdateChecker())->provideUpdate(false, [], self::PLUGIN_FILE);
self::assertFalse($result); self::assertSame($this->noUpdatePayload(), $result);
} }
} }