mirror of
https://github.com/getgrav/grav.git
synced 2026-05-09 02:37:29 +02:00
Add compatibility: blueprint support for major version upgrade gating
Adds a new `compatibility:` field to plugin/theme blueprints.yaml that allows
authors to declare which Grav versions they've tested on:
compatibility:
grav: ['1.7', '1.8']
api: ['1.0']
When absent, compatibility is inferred from dependencies (grav >=1.8 means
1.8-only, otherwise assumes 1.7). This gates major upgrades (1.7→1.8) by
blocking if enabled plugins aren't marked as 1.8-compatible.
Core changes:
- Install.php: detectIncompatiblePackages() in preflight checks
- SafeUpgradeService: same detection for admin pre-download checks
- SelfupgradeCommand: interactive handling (disable/continue/abort)
- GPM Local/Package: computed compatibility property from blueprints
- CLI badges: IndexCommand, InfoCommand, UpdateCommand show 1.7/1.8 badges
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -39,6 +39,7 @@ class Package extends BasePackage
|
||||
$this->data->set('description_html', $html_description);
|
||||
$this->data->set('description_plain', strip_tags((string) $html_description));
|
||||
$this->data->set('symlink', is_link(USER_DIR . $package_type . DS . $this->__get('slug')));
|
||||
$this->data->set('compatibility', $this->resolveCompatibility($data));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -48,4 +49,55 @@ class Package extends BasePackage
|
||||
{
|
||||
return (bool)$this->settings['enabled'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the compatibility metadata for this package.
|
||||
*
|
||||
* Reads explicit `compatibility.grav` / `compatibility.api` from the blueprint.
|
||||
* When absent, infers Grav compatibility from the `dependencies` array.
|
||||
*
|
||||
* @param Data $data Blueprint data
|
||||
* @return array{grav: string[], api: string[]}
|
||||
*/
|
||||
protected function resolveCompatibility(Data $data): array
|
||||
{
|
||||
$raw = $data->get('compatibility');
|
||||
|
||||
if (is_array($raw) && isset($raw['grav']) && is_array($raw['grav'])) {
|
||||
return [
|
||||
'grav' => array_map('strval', $raw['grav']),
|
||||
'api' => isset($raw['api']) && is_array($raw['api']) ? array_map('strval', $raw['api']) : [],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->inferCompatibility($data->get('dependencies') ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer Grav compatibility from the dependencies array.
|
||||
*
|
||||
* @param array $dependencies
|
||||
* @return array{grav: string[], api: string[]}
|
||||
*/
|
||||
protected function inferCompatibility(array $dependencies): array
|
||||
{
|
||||
foreach ($dependencies as $dep) {
|
||||
if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') {
|
||||
continue;
|
||||
}
|
||||
$version = $dep['version'] ?? '';
|
||||
|
||||
if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version_compare($m[1], '1.8', '>=')) {
|
||||
return ['grav' => ['1.8'], 'api' => []];
|
||||
}
|
||||
|
||||
return ['grav' => ['1.7'], 'api' => []];
|
||||
}
|
||||
|
||||
return ['grav' => ['1.7'], 'api' => []];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,10 +184,19 @@ class SafeUpgradeService
|
||||
$warnings[] = 'Potential Monolog logger API incompatibilities detected.';
|
||||
}
|
||||
|
||||
$incompatible = ['blocking' => [], 'warnings' => [], 'target' => ''];
|
||||
if ($isMajorMinorUpgrade && $targetVersion !== null) {
|
||||
$incompatible = $this->detectIncompatiblePackages($targetVersion);
|
||||
if (!empty($incompatible['blocking'])) {
|
||||
$warnings[] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $incompatible['target'] . '.';
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'plugins_pending' => $pending,
|
||||
'psr_log_conflicts' => $psrLogConflicts,
|
||||
'monolog_conflicts' => $monologConflicts,
|
||||
'incompatible_packages' => $incompatible,
|
||||
'warnings' => $warnings,
|
||||
'is_major_minor_upgrade' => $isMajorMinorUpgrade,
|
||||
];
|
||||
@@ -836,6 +845,174 @@ class SafeUpgradeService
|
||||
return $conflicts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installed plugins/themes not compatible with the target Grav version.
|
||||
*
|
||||
* @param string $targetVersion Target Grav version (e.g. '1.8.0')
|
||||
* @return array{blocking: array, warnings: array, target: string}
|
||||
*/
|
||||
protected function detectIncompatiblePackages(string $targetVersion): array
|
||||
{
|
||||
$parts = explode('.', $targetVersion);
|
||||
$targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7');
|
||||
|
||||
$blocking = [];
|
||||
$warnings = [];
|
||||
|
||||
$pluginDirs = glob($this->rootPath . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($pluginDirs as $dir) {
|
||||
$slug = basename($dir);
|
||||
$compat = $this->readBlueprintCompatibility($dir);
|
||||
|
||||
if (in_array($targetMajorMinor, $compat['grav'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->readBlueprintVersion($dir) ?? 'unknown';
|
||||
$enabled = $this->isPluginEnabled($slug);
|
||||
|
||||
$entry = [
|
||||
'type' => 'plugin',
|
||||
'version' => $version,
|
||||
'compatibility' => $compat,
|
||||
'enabled' => $enabled,
|
||||
];
|
||||
|
||||
if ($enabled) {
|
||||
$blocking[$slug] = $entry;
|
||||
} else {
|
||||
$warnings[$slug] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
$themeDirs = glob($this->rootPath . '/user/themes/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($themeDirs as $dir) {
|
||||
$slug = basename($dir);
|
||||
$compat = $this->readBlueprintCompatibility($dir);
|
||||
|
||||
if (in_array($targetMajorMinor, $compat['grav'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->readBlueprintVersion($dir) ?? 'unknown';
|
||||
$active = $this->isThemeEnabled($slug);
|
||||
|
||||
$entry = [
|
||||
'type' => 'theme',
|
||||
'version' => $version,
|
||||
'compatibility' => $compat,
|
||||
'enabled' => $active,
|
||||
];
|
||||
|
||||
if ($active) {
|
||||
$blocking[$slug] = $entry;
|
||||
} else {
|
||||
$warnings[$slug] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'blocking' => $blocking,
|
||||
'warnings' => $warnings,
|
||||
'target' => $targetMajorMinor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the compatible Grav versions from a package's blueprints.yaml.
|
||||
*
|
||||
* @param string $dir Package directory
|
||||
* @return array{grav: string[], api: string[]}
|
||||
*/
|
||||
protected function readBlueprintCompatibility(string $dir): array
|
||||
{
|
||||
$file = $dir . '/blueprints.yaml';
|
||||
if (!is_file($file)) {
|
||||
return ['grav' => [], 'api' => []];
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = @file_get_contents($file);
|
||||
if ($contents === false) {
|
||||
return ['grav' => [], 'api' => []];
|
||||
}
|
||||
$data = Yaml::parse($contents);
|
||||
if (!is_array($data)) {
|
||||
return ['grav' => [], 'api' => []];
|
||||
}
|
||||
|
||||
if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) {
|
||||
return [
|
||||
'grav' => array_map('strval', $data['compatibility']['grav']),
|
||||
'api' => isset($data['compatibility']['api']) && is_array($data['compatibility']['api'])
|
||||
? array_map('strval', $data['compatibility']['api'])
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->inferCompatibleVersions($data['dependencies'] ?? []);
|
||||
} catch (\Throwable $e) {
|
||||
return ['grav' => [], 'api' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer compatible Grav versions from a package's dependency list.
|
||||
*
|
||||
* @param array $dependencies
|
||||
* @return array{grav: string[], api: string[]}
|
||||
*/
|
||||
protected function inferCompatibleVersions(array $dependencies): array
|
||||
{
|
||||
foreach ($dependencies as $dep) {
|
||||
if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') {
|
||||
continue;
|
||||
}
|
||||
$version = $dep['version'] ?? '';
|
||||
|
||||
if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version_compare($m[1], '1.8', '>=')) {
|
||||
return ['grav' => ['1.8'], 'api' => []];
|
||||
}
|
||||
|
||||
return ['grav' => ['1.7'], 'api' => []];
|
||||
}
|
||||
|
||||
return ['grav' => ['1.7'], 'api' => []];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the version string from a package's blueprints.yaml.
|
||||
*
|
||||
* @param string $dir Package directory
|
||||
* @return string|null
|
||||
*/
|
||||
protected function readBlueprintVersion(string $dir): ?string
|
||||
{
|
||||
$file = $dir . '/blueprints.yaml';
|
||||
if (!is_file($file)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = @file_get_contents($file);
|
||||
if ($contents === false) {
|
||||
return null;
|
||||
}
|
||||
$data = Yaml::parse($contents);
|
||||
if (is_array($data) && isset($data['version'])) {
|
||||
return (string)$data['version'];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure directories flagged for ignoring get hydrated from the current installation.
|
||||
*
|
||||
|
||||
@@ -141,7 +141,7 @@ class IndexCommand extends GpmCommand
|
||||
if (!empty($packages)) {
|
||||
$io->section('Packages table');
|
||||
$table = new Table($io);
|
||||
$table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Installed', 'Enabled']);
|
||||
$table->setHeaders(['Count', 'Name', 'Slug', 'Version', 'Compat', 'Installed', 'Enabled']);
|
||||
|
||||
$index = 0;
|
||||
foreach ($packages as $slug => $package) {
|
||||
@@ -150,6 +150,7 @@ class IndexCommand extends GpmCommand
|
||||
'Name' => '<cyan>' . Utils::truncate($package->name, 20, false, ' ', '...') . '</cyan> ',
|
||||
'Slug' => $slug,
|
||||
'Version'=> $this->version($package),
|
||||
'Compat' => $this->compatBadges($package),
|
||||
'Installed' => $this->installed($package),
|
||||
'Enabled' => $this->enabled($package),
|
||||
];
|
||||
@@ -231,6 +232,38 @@ class IndexCommand extends GpmCommand
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Package $package
|
||||
* @return string
|
||||
*/
|
||||
private function compatBadges(Package $package): string
|
||||
{
|
||||
$type = ucfirst(preg_replace('/s$/', '', $package->package_type));
|
||||
$method = 'is' . $type . 'Installed';
|
||||
$installed = $this->gpm->{$method}($package->slug);
|
||||
|
||||
if ($installed) {
|
||||
$local = $this->gpm->{'getInstalled' . $type}($package->slug);
|
||||
$compat = $local->compatibility ?? null;
|
||||
} else {
|
||||
$compat = $package->compatibility ?? null;
|
||||
}
|
||||
|
||||
if (!is_array($compat) || empty($compat['grav'])) {
|
||||
return '<blue>1.7</blue>';
|
||||
}
|
||||
|
||||
$badges = [];
|
||||
if (in_array('1.7', $compat['grav'], true)) {
|
||||
$badges[] = '<blue>1.7</blue>';
|
||||
}
|
||||
if (in_array('1.8', $compat['grav'], true)) {
|
||||
$badges[] = '<green>1.8</green>';
|
||||
}
|
||||
|
||||
return implode(' ', $badges);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Packages $data
|
||||
* @return Packages
|
||||
|
||||
@@ -131,9 +131,36 @@ class InfoCommand extends GpmCommand
|
||||
}
|
||||
}
|
||||
|
||||
// Display compatibility badges
|
||||
$type = rtrim((string) $foundPackage->package_type, 's');
|
||||
$updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug);
|
||||
$installed = $this->gpm->{'is' . $type . 'Installed'}($foundPackage->slug);
|
||||
if ($installed) {
|
||||
$local = $this->gpm->{'getInstalled' . $type}($foundPackage->slug);
|
||||
$compat = $local->compatibility ?? null;
|
||||
} else {
|
||||
$compat = $foundPackage->compatibility ?? null;
|
||||
}
|
||||
|
||||
$compatStr = '';
|
||||
if (is_array($compat) && !empty($compat['grav'])) {
|
||||
$badges = [];
|
||||
if (in_array('1.7', $compat['grav'], true)) {
|
||||
$badges[] = '<blue>1.7</blue>';
|
||||
}
|
||||
if (in_array('1.8', $compat['grav'], true)) {
|
||||
$badges[] = '<green>1.8</green>';
|
||||
}
|
||||
$compatStr = implode(' ', $badges);
|
||||
} else {
|
||||
$compatStr = '<blue>1.7</blue>';
|
||||
}
|
||||
$io->writeln('<green>' . str_pad('Compatibility', 12) . ':</green> ' . $compatStr);
|
||||
|
||||
if (is_array($compat) && !empty($compat['api'])) {
|
||||
$io->writeln('<green>' . str_pad('API', 12) . ':</green> ' . implode(', ', $compat['api']));
|
||||
}
|
||||
|
||||
$updatable = $this->gpm->{'is' . $type . 'Updatable'}($foundPackage->slug);
|
||||
|
||||
// display current version if installed and different
|
||||
if ($installed && $updatable) {
|
||||
|
||||
@@ -396,6 +396,9 @@ class SelfupgradeCommand extends GpmCommand
|
||||
$conflicts = $preflight['psr_log_conflicts'] ?? [];
|
||||
$monologConflicts = $preflight['monolog_conflicts'] ?? [];
|
||||
$warnings = $preflight['warnings'] ?? [];
|
||||
$incompatible = $preflight['incompatible_packages'] ?? [];
|
||||
$incompatibleBlocking = $incompatible['blocking'] ?? [];
|
||||
$incompatibleTarget = $incompatible['target'] ?? '';
|
||||
$isMajorMinorUpgrade = $preflight['is_major_minor_upgrade'] ?? null;
|
||||
if ($isMajorMinorUpgrade === null && $this->upgrader) {
|
||||
$local = $this->upgrader->getLocalVersion();
|
||||
@@ -420,17 +423,22 @@ class SelfupgradeCommand extends GpmCommand
|
||||
}
|
||||
}
|
||||
|
||||
if ($blocking && empty($pending)) {
|
||||
// Filter out the incompatible-packages blocker (handled separately below)
|
||||
$filteredBlocking = array_filter($blocking, static function ($reason) {
|
||||
return !stripos($reason, 'not been marked as compatible');
|
||||
});
|
||||
|
||||
if ($filteredBlocking && empty($pending)) {
|
||||
$io->newLine();
|
||||
$io->writeln('<red>Upgrade blocked:</red>');
|
||||
foreach ($blocking as $reason) {
|
||||
foreach ($filteredBlocking as $reason) {
|
||||
$io->writeln(' - ' . $reason);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (empty($pending) && empty($conflicts) && empty($monologConflicts)) {
|
||||
if (empty($pending) && empty($conflicts) && empty($monologConflicts) && empty($incompatibleBlocking)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -467,6 +475,58 @@ class SelfupgradeCommand extends GpmCommand
|
||||
$io->writeln(' › Proceeding despite pending plugin/theme updates.');
|
||||
}
|
||||
|
||||
// Handle incompatible packages
|
||||
if ($incompatibleBlocking) {
|
||||
$io->newLine();
|
||||
$io->writeln('<yellow>The following enabled plugins/themes are not marked as compatible with Grav ' . $incompatibleTarget . ':</yellow>');
|
||||
foreach ($incompatibleBlocking as $slug => $info) {
|
||||
$type = $info['type'] ?? 'plugin';
|
||||
$ver = $info['version'] ?? 'unknown';
|
||||
$gravCompat = implode(', ', $info['compatibility']['grav'] ?? ['?']);
|
||||
$io->writeln(sprintf(' - %s (%s v%s) — compatible with: %s', $slug, $type, $ver, $gravCompat));
|
||||
}
|
||||
$io->writeln(' › Plugins/themes must be marked as compatible with Grav ' . $incompatibleTarget . ' before upgrading.');
|
||||
$io->writeln(' Either update the plugins, or disable them to proceed.');
|
||||
|
||||
$choice = $this->all_yes ? 'abort' : $io->choice(
|
||||
'How would you like to proceed?',
|
||||
['disable', 'continue', 'abort'],
|
||||
'abort'
|
||||
);
|
||||
|
||||
if ($choice === 'abort') {
|
||||
$io->writeln('Aborting self-upgrade. Update or disable incompatible plugins first.');
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** @var \Grav\Common\Recovery\RecoveryManager $recovery */
|
||||
$recovery = Grav::instance()['recovery'];
|
||||
|
||||
if ($choice === 'disable') {
|
||||
foreach (array_keys($incompatibleBlocking) as $slug) {
|
||||
$recovery->disablePlugin($slug, ['message' => 'Disabled before upgrade — not marked as compatible with Grav ' . $incompatibleTarget]);
|
||||
$io->writeln(sprintf(' - Disabled %s.', $slug));
|
||||
}
|
||||
$io->writeln('Continuing with incompatible plugins disabled.');
|
||||
} else {
|
||||
Install::allowIncompatibleOverride(true);
|
||||
$io->writeln(' › Proceeding despite incompatible plugins/themes.');
|
||||
}
|
||||
}
|
||||
|
||||
// Show incompatible warnings (disabled packages) — informational only
|
||||
$incompatibleWarnings = $incompatible['warnings'] ?? [];
|
||||
if ($incompatibleWarnings) {
|
||||
$io->newLine();
|
||||
$io->writeln('<cyan>Disabled plugins/themes not yet compatible with Grav ' . $incompatibleTarget . ' (will not block upgrade):</cyan>');
|
||||
foreach ($incompatibleWarnings as $slug => $info) {
|
||||
$type = $info['type'] ?? 'plugin';
|
||||
$ver = $info['version'] ?? 'unknown';
|
||||
$io->writeln(sprintf(' - %s (%s v%s)', $slug, $type, $ver));
|
||||
}
|
||||
}
|
||||
|
||||
$handled = $this->handleConflicts(
|
||||
$conflicts,
|
||||
static function (SymfonyStyle $io, array $conflicts): void {
|
||||
|
||||
@@ -211,13 +211,29 @@ class UpdateCommand extends GpmCommand
|
||||
$package->available = $package->version;
|
||||
}
|
||||
|
||||
// Build compatibility badges
|
||||
$compat = $package->compatibility ?? null;
|
||||
$compatStr = '';
|
||||
if (is_array($compat) && !empty($compat['grav'])) {
|
||||
$badges = [];
|
||||
if (in_array('1.7', $compat['grav'], true)) {
|
||||
$badges[] = '<blue>1.7</blue>';
|
||||
}
|
||||
if (in_array('1.8', $compat['grav'], true)) {
|
||||
$badges[] = '<green>1.8</green>';
|
||||
}
|
||||
$compatStr = ' ' . implode(' ', $badges);
|
||||
}
|
||||
|
||||
$io->writeln(
|
||||
// index
|
||||
str_pad((string)$index++, 2, '0', STR_PAD_LEFT) . '. ' .
|
||||
// name
|
||||
'<cyan>' . str_pad((string) $package->name, 15) . '</cyan> ' .
|
||||
// version
|
||||
"[v<magenta>{$package->version}</magenta> -> v<green>{$package->available}</green>]"
|
||||
"[v<magenta>{$package->version}</magenta> -> v<green>{$package->available}</green>]" .
|
||||
// compat badges
|
||||
$compatStr
|
||||
);
|
||||
$slugs[] = $slug;
|
||||
}
|
||||
|
||||
@@ -165,6 +165,8 @@ final class Install
|
||||
private static $forceSafeUpgrade = null;
|
||||
/** @var bool */
|
||||
private static $allowPendingOverride = false;
|
||||
/** @var bool */
|
||||
private static $allowIncompatibleOverride = false;
|
||||
/** @var int|null */
|
||||
private static $snapshotLimit = null;
|
||||
/** @var callable|null */
|
||||
@@ -208,6 +210,15 @@ final class Install
|
||||
}
|
||||
}
|
||||
|
||||
public static function allowIncompatibleOverride(?bool $state = true): void
|
||||
{
|
||||
if ($state === null) {
|
||||
self::$allowIncompatibleOverride = false;
|
||||
} else {
|
||||
self::$allowIncompatibleOverride = (bool)$state;
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureLocation(): void
|
||||
{
|
||||
if (null === $this->location) {
|
||||
@@ -954,6 +965,7 @@ ERR;
|
||||
'psr_log_conflicts' => [],
|
||||
'monolog_conflicts' => [],
|
||||
'plugins_pending' => [],
|
||||
'incompatible_packages' => [],
|
||||
'is_major_minor_upgrade' => $this->isMajorMinorUpgrade($targetVersion),
|
||||
'blocking' => [],
|
||||
];
|
||||
@@ -970,6 +982,15 @@ ERR;
|
||||
}
|
||||
}
|
||||
|
||||
if ($report['is_major_minor_upgrade']) {
|
||||
$report['incompatible_packages'] = $this->detectIncompatiblePackages($targetVersion);
|
||||
|
||||
if (!empty($report['incompatible_packages']['blocking']) && !self::$allowIncompatibleOverride) {
|
||||
$target = $report['incompatible_packages']['target'];
|
||||
$report['blocking'][] = 'Some enabled plugins/themes have not been marked as compatible with Grav ' . $target . '. Disable them before continuing.';
|
||||
}
|
||||
}
|
||||
|
||||
$elapsed = microtime(true) - $start;
|
||||
$this->relayProgress('initializing', sprintf('Preflight checks complete in %.3fs.', $elapsed), null);
|
||||
|
||||
@@ -1295,6 +1316,148 @@ ERR;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect installed plugins/themes not compatible with the target Grav version.
|
||||
*
|
||||
* @param string $targetVersion Target Grav version (e.g. '1.8.0')
|
||||
* @return array{blocking: array, warnings: array, target: string}
|
||||
*/
|
||||
private function detectIncompatiblePackages(string $targetVersion): array
|
||||
{
|
||||
$parts = explode('.', $targetVersion);
|
||||
$targetMajorMinor = ($parts[0] ?? '1') . '.' . ($parts[1] ?? '7');
|
||||
|
||||
$blocking = [];
|
||||
$warnings = [];
|
||||
$scanRoot = GRAV_ROOT ?: getcwd();
|
||||
|
||||
// Scan plugins
|
||||
$pluginDirs = glob($scanRoot . '/user/plugins/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($pluginDirs as $dir) {
|
||||
$slug = basename($dir);
|
||||
$compat = $this->readBlueprintCompatibility($dir);
|
||||
|
||||
if (in_array($targetMajorMinor, $compat['grav'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->readBlueprintVersion($dir) ?? 'unknown';
|
||||
$enabled = $this->isPluginEnabled($slug);
|
||||
|
||||
$entry = [
|
||||
'type' => 'plugin',
|
||||
'version' => $version,
|
||||
'compatibility' => $compat,
|
||||
'enabled' => $enabled,
|
||||
];
|
||||
|
||||
if ($enabled) {
|
||||
$blocking[$slug] = $entry;
|
||||
} else {
|
||||
$warnings[$slug] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
// Scan themes
|
||||
$themeDirs = glob($scanRoot . '/user/themes/*', GLOB_ONLYDIR) ?: [];
|
||||
foreach ($themeDirs as $dir) {
|
||||
$slug = basename($dir);
|
||||
$compat = $this->readBlueprintCompatibility($dir);
|
||||
|
||||
if (in_array($targetMajorMinor, $compat['grav'], true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$version = $this->readBlueprintVersion($dir) ?? 'unknown';
|
||||
$active = $this->isThemeEnabled($slug);
|
||||
|
||||
$entry = [
|
||||
'type' => 'theme',
|
||||
'version' => $version,
|
||||
'compatibility' => $compat,
|
||||
'enabled' => $active,
|
||||
];
|
||||
|
||||
if ($active) {
|
||||
$blocking[$slug] = $entry;
|
||||
} else {
|
||||
$warnings[$slug] = $entry;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'blocking' => $blocking,
|
||||
'warnings' => $warnings,
|
||||
'target' => $targetMajorMinor,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the compatible Grav versions from a package's blueprints.yaml.
|
||||
*
|
||||
* @param string $dir Package directory
|
||||
* @return array{grav: string[], api: string[]}
|
||||
*/
|
||||
private function readBlueprintCompatibility(string $dir): array
|
||||
{
|
||||
$file = $dir . '/blueprints.yaml';
|
||||
if (!is_file($file)) {
|
||||
return ['grav' => [], 'api' => []];
|
||||
}
|
||||
|
||||
try {
|
||||
$contents = @file_get_contents($file);
|
||||
if ($contents === false) {
|
||||
return ['grav' => [], 'api' => []];
|
||||
}
|
||||
$data = Yaml::parse($contents);
|
||||
if (!is_array($data)) {
|
||||
return ['grav' => [], 'api' => []];
|
||||
}
|
||||
|
||||
if (isset($data['compatibility']['grav']) && is_array($data['compatibility']['grav'])) {
|
||||
return [
|
||||
'grav' => array_map('strval', $data['compatibility']['grav']),
|
||||
'api' => isset($data['compatibility']['api']) && is_array($data['compatibility']['api'])
|
||||
? array_map('strval', $data['compatibility']['api'])
|
||||
: [],
|
||||
];
|
||||
}
|
||||
|
||||
return $this->inferCompatibleVersions($data['dependencies'] ?? []);
|
||||
} catch (Throwable $e) {
|
||||
return ['grav' => [], 'api' => []];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Infer compatible Grav versions from a package's dependency list.
|
||||
*
|
||||
* @param array $dependencies
|
||||
* @return array{grav: string[], api: string[]}
|
||||
*/
|
||||
private function inferCompatibleVersions(array $dependencies): array
|
||||
{
|
||||
foreach ($dependencies as $dep) {
|
||||
if (!is_array($dep) || ($dep['name'] ?? '') !== 'grav') {
|
||||
continue;
|
||||
}
|
||||
$version = $dep['version'] ?? '';
|
||||
|
||||
if (!preg_match('/(\d+\.\d+(?:\.\d+)?)/', $version, $m)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (version_compare($m[1], '1.8', '>=')) {
|
||||
return ['grav' => ['1.8'], 'api' => []];
|
||||
}
|
||||
|
||||
return ['grav' => ['1.7'], 'api' => []];
|
||||
}
|
||||
|
||||
return ['grav' => ['1.7'], 'api' => []];
|
||||
}
|
||||
|
||||
private function isGpmPackagePublished($package): bool
|
||||
{
|
||||
if (is_object($package) && method_exists($package, 'getData')) {
|
||||
|
||||
@@ -124,6 +124,111 @@ class SelfupgradeCommandTest extends \PHPUnit\Framework\TestCase
|
||||
self::assertStringContainsString('Proceeding with potential psr/log incompatibilities still active.', $output);
|
||||
}
|
||||
|
||||
public function testHandlePreflightReportBlocksOnIncompatiblePackages(): void
|
||||
{
|
||||
$command = new TestSelfupgradeCommand();
|
||||
[$style] = $this->injectIo($command);
|
||||
$this->setAllYes($command, true);
|
||||
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'monolog_conflicts' => [],
|
||||
'warnings' => [],
|
||||
'is_major_minor_upgrade' => true,
|
||||
'blocking' => [],
|
||||
'incompatible_packages' => [
|
||||
'blocking' => [
|
||||
'old-plugin' => [
|
||||
'type' => 'plugin',
|
||||
'version' => '1.0.0',
|
||||
'compatibility' => ['grav' => ['1.7'], 'api' => []],
|
||||
'enabled' => true,
|
||||
],
|
||||
],
|
||||
'warnings' => [],
|
||||
'target' => '1.8',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertFalse($result);
|
||||
$output = implode("\n", $style->messages);
|
||||
self::assertStringContainsString('old-plugin', $output);
|
||||
self::assertStringContainsString('not marked as compatible', $output);
|
||||
}
|
||||
|
||||
public function testHandlePreflightReportDisablesIncompatibleWhenRequested(): void
|
||||
{
|
||||
$gravFactory = Fixtures::get('grav');
|
||||
$grav = $gravFactory();
|
||||
$stub = new class {
|
||||
public $disabled = [];
|
||||
public function disablePlugin(string $slug, array $context = []): void
|
||||
{
|
||||
$this->disabled[] = $slug;
|
||||
}
|
||||
};
|
||||
$grav['recovery'] = $stub;
|
||||
|
||||
$command = new TestSelfupgradeCommand();
|
||||
[$style] = $this->injectIo($command, ['disable']);
|
||||
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'monolog_conflicts' => [],
|
||||
'warnings' => [],
|
||||
'is_major_minor_upgrade' => true,
|
||||
'blocking' => [],
|
||||
'incompatible_packages' => [
|
||||
'blocking' => [
|
||||
'old-plugin' => [
|
||||
'type' => 'plugin',
|
||||
'version' => '1.0.0',
|
||||
'compatibility' => ['grav' => ['1.7'], 'api' => []],
|
||||
'enabled' => true,
|
||||
],
|
||||
],
|
||||
'warnings' => [],
|
||||
'target' => '1.8',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertTrue($result);
|
||||
self::assertSame(['old-plugin'], $stub->disabled);
|
||||
}
|
||||
|
||||
public function testHandlePreflightReportContinuesWithIncompatibleOverride(): void
|
||||
{
|
||||
$command = new TestSelfupgradeCommand();
|
||||
[$style] = $this->injectIo($command, ['continue']);
|
||||
|
||||
$result = $command->runHandle([
|
||||
'plugins_pending' => [],
|
||||
'psr_log_conflicts' => [],
|
||||
'monolog_conflicts' => [],
|
||||
'warnings' => [],
|
||||
'is_major_minor_upgrade' => true,
|
||||
'blocking' => [],
|
||||
'incompatible_packages' => [
|
||||
'blocking' => [
|
||||
'old-plugin' => [
|
||||
'type' => 'plugin',
|
||||
'version' => '1.0.0',
|
||||
'compatibility' => ['grav' => ['1.7'], 'api' => []],
|
||||
'enabled' => true,
|
||||
],
|
||||
],
|
||||
'warnings' => [],
|
||||
'target' => '1.8',
|
||||
],
|
||||
]);
|
||||
|
||||
self::assertTrue($result);
|
||||
$output = implode("\n", $style->messages);
|
||||
self::assertStringContainsString('Proceeding despite incompatible', $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param TestSelfupgradeCommand $command
|
||||
* @param array<int, mixed> $responses
|
||||
|
||||
191
tests/unit/Grav/Installer/InstallCompatibilityTest.php
Normal file
191
tests/unit/Grav/Installer/InstallCompatibilityTest.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
use Grav\Common\Filesystem\Folder;
|
||||
use Grav\Installer\Install;
|
||||
|
||||
class InstallCompatibilityTest extends \PHPUnit\Framework\TestCase
|
||||
{
|
||||
private string $tmpDir;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->tmpDir = sys_get_temp_dir() . '/grav-compat-test-' . uniqid();
|
||||
mkdir($this->tmpDir . '/user/plugins', 0755, true);
|
||||
mkdir($this->tmpDir . '/user/themes', 0755, true);
|
||||
mkdir($this->tmpDir . '/user/config/plugins', 0755, true);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (is_dir($this->tmpDir)) {
|
||||
Folder::delete($this->tmpDir);
|
||||
}
|
||||
}
|
||||
|
||||
public function testReadBlueprintCompatibilityExplicit(): void
|
||||
{
|
||||
$dir = $this->createPlugin('test-plugin', [
|
||||
'name' => 'Test Plugin',
|
||||
'version' => '1.0.0',
|
||||
'compatibility' => [
|
||||
'grav' => ['1.7', '1.8'],
|
||||
'api' => ['1.0'],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->callMethod('readBlueprintCompatibility', [$dir]);
|
||||
self::assertSame(['1.7', '1.8'], $result['grav']);
|
||||
self::assertSame(['1.0'], $result['api']);
|
||||
}
|
||||
|
||||
public function testReadBlueprintCompatibilityInferredFrom18Dep(): void
|
||||
{
|
||||
$dir = $this->createPlugin('test-18', [
|
||||
'name' => 'Test 1.8',
|
||||
'version' => '2.0.0',
|
||||
'dependencies' => [
|
||||
['name' => 'grav', 'version' => '>=1.8.0'],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->callMethod('readBlueprintCompatibility', [$dir]);
|
||||
self::assertSame(['1.8'], $result['grav']);
|
||||
self::assertSame([], $result['api']);
|
||||
}
|
||||
|
||||
public function testReadBlueprintCompatibilityInferredFrom17Dep(): void
|
||||
{
|
||||
$dir = $this->createPlugin('test-17', [
|
||||
'name' => 'Test 1.7',
|
||||
'version' => '1.0.0',
|
||||
'dependencies' => [
|
||||
['name' => 'grav', 'version' => '>=1.7.0'],
|
||||
],
|
||||
]);
|
||||
|
||||
$result = $this->callMethod('readBlueprintCompatibility', [$dir]);
|
||||
self::assertSame(['1.7'], $result['grav']);
|
||||
}
|
||||
|
||||
public function testReadBlueprintCompatibilityNoDependency(): void
|
||||
{
|
||||
$dir = $this->createPlugin('test-none', [
|
||||
'name' => 'Test None',
|
||||
'version' => '1.0.0',
|
||||
]);
|
||||
|
||||
$result = $this->callMethod('readBlueprintCompatibility', [$dir]);
|
||||
self::assertSame(['1.7'], $result['grav']);
|
||||
}
|
||||
|
||||
public function testReadBlueprintCompatibilityMissingFile(): void
|
||||
{
|
||||
$dir = $this->tmpDir . '/user/plugins/no-blueprint';
|
||||
mkdir($dir, 0755, true);
|
||||
|
||||
$result = $this->callMethod('readBlueprintCompatibility', [$dir]);
|
||||
self::assertSame([], $result['grav']);
|
||||
}
|
||||
|
||||
public function testInferCompatibleVersionsVariousConstraints(): void
|
||||
{
|
||||
$install = Install::instance();
|
||||
|
||||
// >=1.8.0
|
||||
$result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '>=1.8.0']]]);
|
||||
self::assertSame(['1.8'], $result['grav']);
|
||||
|
||||
// ~1.8
|
||||
$result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '~1.8']]]);
|
||||
self::assertSame(['1.8'], $result['grav']);
|
||||
|
||||
// >=1.7.0,<2.0
|
||||
$result = $this->callMethod('inferCompatibleVersions', [[['name' => 'grav', 'version' => '>=1.7.0,<2.0']]]);
|
||||
self::assertSame(['1.7'], $result['grav']);
|
||||
|
||||
// No grav dep
|
||||
$result = $this->callMethod('inferCompatibleVersions', [[['name' => 'form', 'version' => '>=6.0']]]);
|
||||
self::assertSame(['1.7'], $result['grav']);
|
||||
|
||||
// Empty
|
||||
$result = $this->callMethod('inferCompatibleVersions', [[]]);
|
||||
self::assertSame(['1.7'], $result['grav']);
|
||||
}
|
||||
|
||||
public function testDetectIncompatiblePackagesBlocksEnabled(): void
|
||||
{
|
||||
$this->createPlugin('incompatible-enabled', [
|
||||
'name' => 'Incompatible Enabled',
|
||||
'version' => '1.0.0',
|
||||
'compatibility' => ['grav' => ['1.7']],
|
||||
]);
|
||||
// Plugin enabled by default (no config file = enabled)
|
||||
|
||||
$result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']);
|
||||
self::assertArrayHasKey('incompatible-enabled', $result['blocking']);
|
||||
self::assertSame('1.8', $result['target']);
|
||||
}
|
||||
|
||||
public function testDetectIncompatiblePackagesWarnsDisabled(): void
|
||||
{
|
||||
$this->createPlugin('incompatible-disabled', [
|
||||
'name' => 'Incompatible Disabled',
|
||||
'version' => '1.0.0',
|
||||
'compatibility' => ['grav' => ['1.7']],
|
||||
]);
|
||||
// Disable it
|
||||
file_put_contents(
|
||||
$this->tmpDir . '/user/config/plugins/incompatible-disabled.yaml',
|
||||
"enabled: false\n"
|
||||
);
|
||||
|
||||
$result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']);
|
||||
self::assertArrayHasKey('incompatible-disabled', $result['warnings']);
|
||||
self::assertEmpty($result['blocking']);
|
||||
}
|
||||
|
||||
public function testDetectIncompatiblePackagesPassesCompatible(): void
|
||||
{
|
||||
$this->createPlugin('compatible-plugin', [
|
||||
'name' => 'Compatible Plugin',
|
||||
'version' => '2.0.0',
|
||||
'compatibility' => ['grav' => ['1.7', '1.8']],
|
||||
]);
|
||||
|
||||
$result = $this->callMethod('detectIncompatiblePackages', ['1.8.0']);
|
||||
self::assertArrayNotHasKey('compatible-plugin', $result['blocking']);
|
||||
self::assertArrayNotHasKey('compatible-plugin', $result['warnings']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a plugin directory with a blueprints.yaml file.
|
||||
*/
|
||||
private function createPlugin(string $slug, array $blueprint): string
|
||||
{
|
||||
$dir = $this->tmpDir . '/user/plugins/' . $slug;
|
||||
mkdir($dir, 0755, true);
|
||||
|
||||
$yaml = \Symfony\Component\Yaml\Yaml::dump($blueprint, 4);
|
||||
file_put_contents($dir . '/blueprints.yaml', $yaml);
|
||||
|
||||
return $dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a private/protected method on the Install singleton via reflection.
|
||||
* We temporarily override GRAV_ROOT for the detection methods.
|
||||
*/
|
||||
private function callMethod(string $method, array $args): mixed
|
||||
{
|
||||
$install = Install::instance();
|
||||
$ref = new \ReflectionMethod($install, $method);
|
||||
$ref->setAccessible(true);
|
||||
|
||||
// For methods that use GRAV_ROOT, we need to define it if not set
|
||||
if (!defined('GRAV_ROOT')) {
|
||||
define('GRAV_ROOT', $this->tmpDir);
|
||||
}
|
||||
|
||||
return $ref->invoke($install, ...$args);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user