diff --git a/features/plugin-list-wporg-status.feature b/features/plugin-list-wporg-status.feature
index 1da8360f..ff21ea03 100644
--- a/features/plugin-list-wporg-status.feature
+++ b/features/plugin-list-wporg-status.feature
@@ -111,6 +111,72 @@ Feature: Check the status of plugins on WordPress.org
| no-longer-in-directory | closed | 2017-11-13 |
| never-wporg | | |
+ @require-wp-5.2
+ Scenario: The wp.org last updated date for an active plugin does not depend on a second, rate-limited request
+ Given a WP install
+ And I run `wp plugin install wordpress-importer --version=0.5 --force`
+ And that HTTP requests to https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Blocale%5D=en_US&request%5Bslug%5D=wordpress-importer will respond with:
+ """
+ HTTP/1.1 200
+ Content-Type: application/json
+
+ {
+ "name": "WordPress Importer",
+ "slug": "wordpress-importer",
+ "last_updated": "2025-09-26 9:07pm GMT"
+ }
+ """
+ # plugins.trac.wordpress.org is known to rate-limit this scrape (HTTP 429), with no
+ # pubDate in the response body. wporg_last_updated must still resolve correctly for an
+ # active plugin, because the date is meant to come from the plugin-info API response
+ # above, not from a second request to trac.
+ And that HTTP requests to https://plugins.trac.wordpress.org/log/wordpress-importer/?limit=1&mode=stop_on_copy&format=rss will respond with:
+ """
+ HTTP/1.1 429
+ Content-Type: text/html
+
+
429 Too Many Requests
+ """
+
+ When I run `wp plugin list --fields=name,wporg_status,wporg_last_updated`
+ Then STDOUT should be a table containing rows:
+ | name | wporg_status | wporg_last_updated |
+ | wordpress-importer | active | 2025-09-26 |
+
+ @require-wp-5.2
+ Scenario: The wp.org last updated date falls back to the trac log when the plugin-info API omits it
+ Given a WP install
+ And I run `wp plugin install wordpress-importer --version=0.5 --force`
+ And that HTTP requests to https://api.wordpress.org/plugins/info/1.2/?action=plugin_information&request%5Blocale%5D=en_US&request%5Bslug%5D=wordpress-importer will respond with:
+ """
+ HTTP/1.1 200
+ Content-Type: application/json
+
+ {
+ "name": "WordPress Importer",
+ "slug": "wordpress-importer"
+ }
+ """
+ And that HTTP requests to https://plugins.trac.wordpress.org/log/wordpress-importer/?limit=1&mode=stop_on_copy&format=rss will respond with:
+ """
+ HTTP/1.1 200
+ Content-Type: application/rss+xml;charset=utf-8
+
+
+
+
+ -
+ Fri, 26 Sep 2025 21:07:26 GMT
+
+
+
+ """
+
+ When I run `wp plugin list --fields=name,wporg_status,wporg_last_updated`
+ Then STDOUT should be a table containing rows:
+ | name | wporg_status | wporg_last_updated |
+ | wordpress-importer | active | 2025-09-26 |
+
@less-than-wp-5.3
Scenario: The wp.org last updated date is still rendered on WordPress < 5.3
Given a WP install
diff --git a/src/Plugin_Command.php b/src/Plugin_Command.php
index 00505984..c2e3877f 100644
--- a/src/Plugin_Command.php
+++ b/src/Plugin_Command.php
@@ -1128,6 +1128,17 @@ protected function get_wporg_data( $plugin_name ) {
if ( ! $this->check_wporg['last_updated'] ) {
return $data; // The plugin is active on .org, but we don't need the date.
}
+ // The plugins API already reports when the plugin was last updated, so use
+ // that instead of also scraping the trac log, which is rate-limited and
+ // otherwise unnecessary here. If the value can't be parsed, fall through
+ // to the trac log below rather than defaulting to today's date.
+ if ( ! empty( $plugin_data['last_updated'] ) && is_string( $plugin_data['last_updated'] ) ) {
+ $pub_date = strtotime( $plugin_data['last_updated'] );
+ if ( false !== $pub_date ) {
+ $data['last_updated'] = $this->format_wporg_last_updated( $pub_date );
+ return $data;
+ }
+ }
}
// Just because the plugin is not in the api, does not mean it was never on .org.
}
@@ -1152,18 +1163,9 @@ protected function get_wporg_data( $plugin_name ) {
if ( false !== $xml ) {
$xml_pub_date = $xml->xpath( '//pubDate' );
if ( $xml_pub_date ) {
- $pub_date = strtotime( $xml_pub_date[0] ) ?: null;
-
- if ( function_exists( 'wp_date' ) ) {
- $data['last_updated'] = wp_date( 'Y-m-d', $pub_date );
- } else {
- // wp_date() is WordPress 5.3+. get_date_from_gmt() renders in the site
- // timezone the same way, without date_i18n()'s pre-5.3 contract of
- // expecting a timestamp that already has the offset added to it.
- $data['last_updated'] = get_date_from_gmt(
- gmdate( 'Y-m-d H:i:s', $pub_date ?? time() ),
- 'Y-m-d'
- );
+ $pub_date = strtotime( $xml_pub_date[0] );
+ if ( false !== $pub_date ) {
+ $data['last_updated'] = $this->format_wporg_last_updated( $pub_date );
}
}
}
@@ -1172,6 +1174,27 @@ protected function get_wporg_data( $plugin_name ) {
return $data;
}
+ /**
+ * Formats a wp.org publish date as a `Y-m-d` string in the site's configured timezone.
+ *
+ * @param int $pub_date Unix timestamp.
+ *
+ * @return string|false
+ */
+ private function format_wporg_last_updated( $pub_date ) {
+ if ( function_exists( 'wp_date' ) ) {
+ return wp_date( 'Y-m-d', $pub_date );
+ }
+
+ // wp_date() is WordPress 5.3+. get_date_from_gmt() renders in the site
+ // timezone the same way, without date_i18n()'s pre-5.3 contract of
+ // expecting a timestamp that already has the offset added to it.
+ return get_date_from_gmt(
+ gmdate( 'Y-m-d H:i:s', $pub_date ),
+ 'Y-m-d'
+ );
+ }
+
protected function filter_item_list( $items, $args ) {
$basenames = wp_list_pluck( $this->fetcher->get_many( $args ), 'file' );
return Utils\pick_fields( $items, $basenames );