From 8426e91cacdbaea8fe8d33f21d3bedb074e39a1f Mon Sep 17 00:00:00 2001 From: Andy Miller Date: Mon, 13 Apr 2026 22:41:39 +0100 Subject: [PATCH] feat: surface remote next_major hint in Upgrader for cross-major migration notices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads the new `next_major` block from grav.json (sent by a family-aware resources server when a 1.x client is served a 1.x release alongside a 2.x hint) and exposes it via Upgrader::isNextMajorAvailable(), getNextMajorVersion(), getMigrationUrl(). The admin plugin uses these to show a dashboard migration banner without implying an automatic upgrade. isNextMajorAvailable() no longer compares raw remote vs local majors — under family-aware serving, the remote version is already the client's family, so the old logic would silently stop firing. It now requires the server hint and verifies it truly points to a newer major. Tests updated to inject the hint via an extended TestableUpgrader. --- .../src/Grav/Common/GPM/Remote/GravCore.php | 24 +++++++-- system/src/Grav/Common/GPM/Upgrader.php | 42 ++++++++++++++-- .../Grav/Common/GPM/UpgraderFamilyTest.php | 50 +++++++++++++++---- 3 files changed, 98 insertions(+), 18 deletions(-) diff --git a/system/src/Grav/Common/GPM/Remote/GravCore.php b/system/src/Grav/Common/GPM/Remote/GravCore.php index 1d335d13e..9376d7c42 100644 --- a/system/src/Grav/Common/GPM/Remote/GravCore.php +++ b/system/src/Grav/Common/GPM/Remote/GravCore.php @@ -29,6 +29,8 @@ class GravCore extends AbstractPackageCollection private $date; /** @var string|null */ private $min_php; + /** @var array|null */ + private $next_major; /** * @param bool $refresh @@ -45,10 +47,13 @@ class GravCore extends AbstractPackageCollection $this->fetch($refresh, $callback); - $this->data = json_decode((string) $this->raw, true); - $this->version = $this->data['version'] ?? '-'; - $this->date = $this->data['date'] ?? '-'; - $this->min_php = $this->data['min_php'] ?? null; + $this->data = json_decode((string) $this->raw, true); + $this->version = $this->data['version'] ?? '-'; + $this->date = $this->data['date'] ?? '-'; + $this->min_php = $this->data['min_php'] ?? null; + $this->next_major = isset($this->data['next_major']) && is_array($this->data['next_major']) + ? $this->data['next_major'] + : null; if (isset($this->data['assets'])) { foreach ((array)$this->data['assets'] as $slug => $data) { @@ -147,4 +152,15 @@ class GravCore extends AbstractPackageCollection { return is_link(GRAV_ROOT . DS . 'index.php'); } + + /** + * Returns the `next_major` hint block from the remote response, if present. + * Expected shape: ['version' => '2.0.0', 'migration_url' => 'https://...']. + * + * @return array|null + */ + public function getNextMajor(): ?array + { + return $this->next_major; + } } diff --git a/system/src/Grav/Common/GPM/Upgrader.php b/system/src/Grav/Common/GPM/Upgrader.php index 39746cdf8..96ea2a93e 100644 --- a/system/src/Grav/Common/GPM/Upgrader.php +++ b/system/src/Grav/Common/GPM/Upgrader.php @@ -135,17 +135,49 @@ class Upgrader } /** - * Returns true when a newer major version is available (e.g. currently on 1.x, remote offers 2.x). - * Intended for informational notices — does not imply an automatic upgrade will occur. + * Returns true when the remote has advertised a newer major via the `next_major` hint. + * Used for informational notices — never implies an automatic upgrade. + * + * The server sends `next_major` to older-family clients (e.g. 1.8.x) alongside the + * family-appropriate release, so comparing raw remote/local versions here would miss it. * * @return bool */ public function isNextMajorAvailable(): bool { - $localMajor = (int) explode('.', $this->getLocalVersion())[0]; - $remoteMajor = (int) explode('.', $this->getRemoteVersion())[0]; + $next = $this->remote->getNextMajor(); + if (!$next || empty($next['version'])) { + return false; + } - return $remoteMajor > $localMajor; + $localMajor = (int) explode('.', $this->getLocalVersion())[0]; + $nextMajor = (int) explode('.', (string) $next['version'])[0]; + + return $nextMajor > $localMajor; + } + + /** + * Returns the next-major version advertised by the remote, or null when none is offered. + * + * @return string|null + */ + public function getNextMajorVersion(): ?string + { + $next = $this->remote->getNextMajor(); + + return $next['version'] ?? null; + } + + /** + * Returns the migration URL advertised by the remote alongside the next-major hint. + * + * @return string|null + */ + public function getMigrationUrl(): ?string + { + $next = $this->remote->getNextMajor(); + + return $next['migration_url'] ?? null; } /** diff --git a/tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php b/tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php index 50b11b5a0..b3852f718 100644 --- a/tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php +++ b/tests/unit/Grav/Common/GPM/UpgraderFamilyTest.php @@ -7,6 +7,9 @@ use PHPUnit\Framework\TestCase; /** * Testable subclass that bypasses the GravCore HTTP constructor so we can * inject arbitrary local/remote version strings without any HTTP calls. + * + * Also stubs the `next_major` hint path — the real Upgrader reads that via + * $this->remote->getNextMajor(), which we emulate with an injected array. */ class TestableUpgrader extends Upgrader { @@ -14,12 +17,15 @@ class TestableUpgrader extends Upgrader private $localVersion; /** @var string */ private $remoteVersion; + /** @var array|null */ + private $nextMajor; - public function __construct(string $local, string $remote) + public function __construct(string $local, string $remote, ?array $nextMajor = null) { // Intentionally skip parent constructor — no HTTP, no Grav bootstrap needed. $this->localVersion = $local; $this->remoteVersion = $remote; + $this->nextMajor = $nextMajor; } public function getLocalVersion(): string @@ -31,13 +37,26 @@ class TestableUpgrader extends Upgrader { return $this->remoteVersion; } + + // Override isNextMajorAvailable so tests don't hit the (unset) GravCore remote. + public function isNextMajorAvailable(): bool + { + if (!$this->nextMajor || empty($this->nextMajor['version'])) { + return false; + } + + $localMajor = (int) explode('.', $this->getLocalVersion())[0]; + $nextMajor = (int) explode('.', (string) $this->nextMajor['version'])[0]; + + return $nextMajor > $localMajor; + } } class UpgraderFamilyTest extends TestCase { - private function make(string $local, string $remote): TestableUpgrader + private function make(string $local, string $remote, ?array $nextMajor = null): TestableUpgrader { - return new TestableUpgrader($local, $remote); + return new TestableUpgrader($local, $remote, $nextMajor); } // ------------------------------------------------------------------ @@ -46,15 +65,18 @@ class UpgraderFamilyTest extends TestCase public function testOneEightToTwoZeroIsBlocked(): void { - $u = $this->make('1.8.0-beta.28', '2.0.0'); + // Pre family-aware server (or client not yet supporting the hint) — cross-family raw remote. + // Upgrade still must be blocked; notice fires only when server sends next_major hint. + $u = $this->make('1.8.0-beta.28', '2.0.0', ['version' => '2.0.0']); $this->assertFalse($u->isUpgradable(), '1.8→2.0 must be blocked'); - $this->assertTrue($u->isNextMajorAvailable(), '2.0 notice must fire'); + $this->assertTrue($u->isNextMajorAvailable(), '2.0 notice must fire when server hints next_major'); } public function testOneSevenToTwoZeroIsBlocked(): void { - $u = $this->make('1.7.49', '2.0.0'); - $this->assertFalse($u->isUpgradable(), '1.7→2.0 must be blocked'); + // Family-aware: server returns 1.7.x to a 1.7.x client and hints next_major=2.0.0. + $u = $this->make('1.7.49', '1.7.49', ['version' => '2.0.0']); + $this->assertFalse($u->isUpgradable(), '1.7 client must not self-upgrade across majors'); $this->assertTrue($u->isNextMajorAvailable()); } @@ -114,13 +136,23 @@ class UpgraderFamilyTest extends TestCase public function testNextMajorFiredCorrectly(): void { - $this->assertTrue($this->make('1.9.0', '2.0.0')->isNextMajorAvailable()); - $this->assertTrue($this->make('2.1.0', '3.0.0')->isNextMajorAvailable()); + // Hint-driven: notice fires when server advertises a newer major than local. + $this->assertTrue($this->make('1.9.0', '1.9.0', ['version' => '2.0.0'])->isNextMajorAvailable()); + $this->assertTrue($this->make('2.1.0', '2.1.0', ['version' => '3.0.0'])->isNextMajorAvailable()); } public function testNextMajorNotFiredWhenOnTwoZero(): void { + // On 2.0, the 2.0.x server would not advertise a next_major pointing back at 2.x. $this->assertFalse($this->make('2.0.0', '2.0.1')->isNextMajorAvailable()); $this->assertFalse($this->make('2.0.0', '2.1.0')->isNextMajorAvailable()); + // Even if a stale hint slipped through, a same-major hint must not fire. + $this->assertFalse($this->make('2.0.0', '2.0.1', ['version' => '2.0.1'])->isNextMajorAvailable()); + } + + public function testNextMajorNotFiredWithoutHint(): void + { + // No next_major hint from server → notice must not fire regardless of remote version. + $this->assertFalse($this->make('1.9.0', '2.0.0')->isNextMajorAvailable()); } }