feat: surface remote next_major hint in Upgrader for cross-major migration notices

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.
This commit is contained in:
Andy Miller
2026-04-13 22:41:39 +01:00
parent e83b12d8c4
commit 8426e91cac
3 changed files with 98 additions and 18 deletions

View File

@@ -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;
}
}

View File

@@ -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;
}
/**

View File

@@ -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());
}
}