From 7d519731ef9c647ad0c8d3090eecebac728a2d2b Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 6 Nov 2026 06:48:05 -0600 Subject: [PATCH 1/3] Plugin Directory: Warn about invalid Requires plugin headers on import The importer prefers the Requires at least and Requires PHP headers from the main plugin file over the readme, but only checks that they look roughly like a version. A plugin can ship `Requires at least: 7.4` and the directory stores it without a word, while WordPress refuses to activate the plugin. The readme fields can simply be ignored when they're invalid; the plugin headers can't, because WordPress enforces them. So the import now warns the plugin's committers when a header isn't a valid version, is newer than trunk, or disagrees with the readme. Fixes #7781. Co-Authored-By: Claude Opus 5.5 --- .../plugin-directory/cli/class-import.php | 51 ++++++++ .../readme/class-validator.php | 30 +++++ .../Import_Requires_Header_Warnings_Test.php | 114 ++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Import_Requires_Header_Warnings_Test.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php index 1abbec62b1..e761b46316 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php @@ -196,6 +196,8 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk ]; } + $this->warnings = array_merge( $this->warnings, self::requires_header_warnings( $headers, $readme ) ); + /* * Check to see if the plugin is using the `Update URI` header. * @@ -1496,6 +1498,55 @@ public static function version_matches_tag( $version, $tag ) { return version_compare( $normalized_tag, $normalized_version, '==' ); } + /** + * Check the plugin's Requires at least and Requires PHP headers. + * + * WordPress enforces these headers from the main plugin file, so unlike the readme fields + * they can't be ignored when invalid. Warn when a header isn't a valid version, is higher + * than the readme allows, or disagrees with the readme. + * + * @param object $headers The plugin headers, with `RequiresWP` and `RequiresPHP` properties. + * @param object $readme The parsed readme, with `requires` and `requires_php` properties. + * @return array Import warnings, keyed by warning code. + */ + public static function requires_header_warnings( $headers, $readme ) { + $warnings = []; + + // `6.0` and `6.0.0` are the same requirement. + $normalize = static function ( $v ) { + return preg_replace( '/(\.0)+$/', '', $v ); + }; + + $stable_branch = defined( 'WP_CORE_STABLE_BRANCH' ) ? WP_CORE_STABLE_BRANCH : $GLOBALS['wp_version']; + + $requires = $headers->RequiresWP ?? ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Plugin header key. + if ( $requires ) { + // Same rules the readme parser applies to its Requires at least field. + if ( ! preg_match( '!^\d+\.\d(\.\d+)?$!', $requires ) || (float) $requires > (float) $stable_branch + 0.1 ) { + $warnings['plugin_requires_header_invalid'] = $requires; + } elseif ( ! empty( $readme->requires ) && $normalize( $requires ) !== $normalize( $readme->requires ) ) { + $warnings['requires_header_mismatch'] = [ + 'plugin' => $requires, + 'readme' => $readme->requires, + ]; + } + } + + $requires_php = $headers->RequiresPHP ?? ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Plugin header key. + if ( $requires_php ) { + if ( ! preg_match( '!^\d+(\.\d+){1,2}$!', $requires_php ) ) { + $warnings['plugin_requires_php_header_invalid'] = $requires_php; + } elseif ( ! empty( $readme->requires_php ) && $normalize( $requires_php ) !== $normalize( $readme->requires_php ) ) { + $warnings['requires_php_header_mismatch'] = [ + 'plugin' => $requires_php, + 'readme' => $readme->requires_php, + ]; + } + } + + return $warnings; + } + /** * Add support for additional plugin headers prior to WordPress supporting it. * diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-validator.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-validator.php index 70d73c5fd6..04ffe23865 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-validator.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-validator.php @@ -335,6 +335,36 @@ public function translate_code_to_message( $error_code, $data = false ) { __( 'The %s field is missing. It should be defined here, or in your main plugin file.', 'wporg-plugins' ), 'Requires PHP' ); + case 'plugin_requires_header_invalid': + $latest_wordpress_version = defined( 'WP_CORE_STABLE_BRANCH' ) ? WP_CORE_STABLE_BRANCH : '6.5'; + + return sprintf( + /* translators: 1: plugin header tag; 2: the header value; 3: Example version 5.0. 4: Example version 4.9. */ + __( 'The %1$s header in your main plugin file (%2$s) is not a valid WordPress version. WordPress checks this header before activating your plugin, so it should only contain a WordPress version such as %3$s or %4$s.', 'wporg-plugins' ), + 'Requires at least', + '' . esc_html( $data ) . '', + '' . number_format( $latest_wordpress_version, 1 ) . '', + '' . number_format( $latest_wordpress_version - 0.1, 1 ) . '' + ); + case 'plugin_requires_php_header_invalid': + global $required_php_version; // WP wp-includes/version.php. + return sprintf( + /* translators: 1: plugin header tag; 2: the header value; 3: Example version 7.0. 4: Example version 8.2. */ + __( 'The %1$s header in your main plugin file (%2$s) is not a valid PHP version. WordPress checks this header before activating your plugin, so it should only contain a PHP version such as %3$s or %4$s.', 'wporg-plugins' ), + 'Requires PHP', + '' . esc_html( $data ) . '', + '' . esc_html( $required_php_version ?? '7.0' ) . '', + '8.2' + ); + case 'requires_header_mismatch': + case 'requires_php_header_mismatch': + return sprintf( + /* translators: 1: plugin header tag; 2: the value in the main plugin file; 3: the value in the readme. */ + __( 'The %1$s header in your main plugin file (%2$s) does not match the one in your readme (%3$s). The value from your main plugin file is used, so update your readme to match.', 'wporg-plugins' ), + '' . ( 'requires_header_mismatch' === $error_code ? 'Requires at least' : 'Requires PHP' ) . '', + '' . esc_html( is_array( $data ) ? ( $data['plugin'] ?? '' ) : '' ) . '', + '' . esc_html( is_array( $data ) ? ( $data['readme'] ?? '' ) : '' ) . '' + ); case 'stable_tag_invalid': return sprintf( /* translators: 1: 'Stable tag', 2: /trunk/ SVN directory */ diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Import_Requires_Header_Warnings_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Import_Requires_Header_Warnings_Test.php new file mode 100644 index 0000000000..c3b1f77c16 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Import_Requires_Header_Warnings_Test.php @@ -0,0 +1,114 @@ +assertSame( $expected, Import::requires_header_warnings( (object) $headers, (object) $readme ) ); + } + + /** + * Supplies plugin headers, readme fields, and the warnings they should produce. + * + * @return array> + */ + public static function headers_provider(): array { + return array( + 'matching, with trailing .0' => array( + array( + 'RequiresWP' => '6.0', + 'RequiresPHP' => '7.4', + ), + array( + 'requires' => '6.0.0', + 'requires_php' => '7.4', + ), + array(), + ), + 'no readme fields to compare' => array( + array( + 'RequiresWP' => '6.0', + 'RequiresPHP' => '7.4', + ), + array( + 'requires' => '', + 'requires_php' => '', + ), + array(), + ), + 'header newer than trunk' => array( + array( + 'RequiresWP' => '99.0', + 'RequiresPHP' => '', + ), + array( + 'requires' => '6.0', + 'requires_php' => '', + ), + array( 'plugin_requires_header_invalid' => '99.0' ), + ), + 'headers not versions' => array( + array( + 'RequiresWP' => '6.0-beta', + 'RequiresPHP' => '8', + ), + array( + 'requires' => '', + 'requires_php' => '', + ), + array( + 'plugin_requires_header_invalid' => '6.0-beta', + 'plugin_requires_php_header_invalid' => '8', + ), + ), + 'headers differ from readme' => array( + array( + 'RequiresWP' => '6.2', + 'RequiresPHP' => '8.0', + ), + array( + 'requires' => '6.0', + 'requires_php' => '7.4', + ), + array( + 'requires_header_mismatch' => array( + 'plugin' => '6.2', + 'readme' => '6.0', + ), + 'requires_php_header_mismatch' => array( + 'plugin' => '8.0', + 'readme' => '7.4', + ), + ), + ), + ); + } +} From 90e37c33eb50fe2bfccbedef268dc132bb00d7e2 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Fri, 6 Nov 2026 06:56:05 -0600 Subject: [PATCH 2/3] Plugin Directory: Round the trunk version in the Requires header check `6.8 + 0.1` is 6.8999999999999995 as a float, so on some stable branches a Requires at least header naming trunk was reported as not a valid version. Co-Authored-By: Claude Opus 5.5 --- .../wp-content/plugins/plugin-directory/cli/class-import.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php index e761b46316..70234d289f 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php @@ -1522,7 +1522,7 @@ public static function requires_header_warnings( $headers, $readme ) { $requires = $headers->RequiresWP ?? ''; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Plugin header key. if ( $requires ) { // Same rules the readme parser applies to its Requires at least field. - if ( ! preg_match( '!^\d+\.\d(\.\d+)?$!', $requires ) || (float) $requires > (float) $stable_branch + 0.1 ) { + if ( ! preg_match( '!^\d+\.\d(\.\d+)?$!', $requires ) || (float) $requires > round( (float) $stable_branch + 0.1, 1 ) ) { $warnings['plugin_requires_header_invalid'] = $requires; } elseif ( ! empty( $readme->requires ) && $normalize( $requires ) !== $normalize( $readme->requires ) ) { $warnings['requires_header_mismatch'] = [ From 1ee863811eebc5e1df2ac5ff4178104efa23435b Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Tue, 10 Nov 2026 15:02:18 -0600 Subject: [PATCH 3/3] Plugin Directory: Round the trunk version in the readme Requires at least check `6.8 + 0.1` is 6.8999999999999995 as a float, so the readme parser discarded a Requires at least value naming trunk on some stable branches, flagged it as ignored, and hid any mismatch with the plugin header. Co-Authored-By: Claude Opus 5.5 --- .../wp-content/plugins/plugin-directory/readme/class-parser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-parser.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-parser.php index 3fd4b782fe..84b88b7e4d 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-parser.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/readme/class-parser.php @@ -867,7 +867,7 @@ protected function sanitize_requires_version( $version ) { // x.y or x.y.z ! preg_match( '!^\d+\.\d(\.\d+)?$!', $version ) || // Allow plugins to mark themselves as requireing Stable+0.1 (trunk/master) but not higher - defined( 'WP_CORE_STABLE_BRANCH' ) && ( (float)$version > (float)WP_CORE_STABLE_BRANCH+0.1 ) + ( defined( 'WP_CORE_STABLE_BRANCH' ) && (float) $version > round( (float) WP_CORE_STABLE_BRANCH + 0.1, 1 ) ) ) { $this->warnings['requires_header_ignored'] = true; // Ignore the readme value.