From 33dfeb43b1dedaabbb43d6149031ed64818c761e Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Wed, 13 May 2026 20:49:49 +1000 Subject: [PATCH 1/6] Plugin Directory: Delay trunk-only imports on tag-releasing plugins to merge follow-up tag commit. Plugins that release from a tag commonly commit version-bumped files to /trunk first, then `svn cp trunk tags/X.Y` shortly after. Today's flow runs the import immediately on the trunk commit and publishes a trunk-fallback release; the follow-up tag commit then triggers a second import and re-publishes the same release from the tag. See r3530799 / r3530800 for an example. Delays trunk-only imports by 5 minutes when the plugin's current stable_tag is not 'trunk' (i.e. it currently releases from a tag, so a tag is likely incoming). Reinstates merge_plugin_data() and reshapes Plugin_Import::queue() so any pending future event is folded into the new request; the merged args determine the run time. The bulk re-index pull-forward from earlier in this PR and the new trunk-then-tag delay are now expressions of the same rule: "one pending event per plugin, run at the earlier of its existing schedule and the natural schedule of the merged args." Tag-touching changes, tag deletions, and plugins releasing from trunk run immediately (no behavior change). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jobs/class-plugin-import.php | 145 +++++++++++++++--- .../tests/Plugin_Import_Merge_Test.php | 137 +++++++++++++++++ 2 files changed, 263 insertions(+), 19 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Import_Merge_Test.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php index e2069a7e8e..f48c851ffa 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php @@ -3,6 +3,7 @@ use Exception; use WordPressdotorg\Plugin_Directory\CLI; +use WordPressdotorg\Plugin_Directory\Plugin_Directory; /** * Import plugin changes into WordPress. @@ -11,40 +12,146 @@ */ class Plugin_Import { + /** + * Queue an import job for a plugin, merging into any pending future event for the + * same plugin so a single import covers all the SVN changes seen so far. + * + * @param string $plugin_slug The plugin slug. + * @param array $plugin_data Data about the SVN change (tags_touched, revisions, etc). + */ public static function queue( $plugin_slug, $plugin_data ) { + $hook = "import_plugin:{$plugin_slug}"; + $new_args = array_merge( array( 'plugin' => $plugin_slug ), $plugin_data ); + /* - * If the next scheduled run is more than 5 minutes away (e.g. queued by a bulk - * batch re-index) and no import is currently running, pull it forward to the - * usual import time so a fresh commit isn't delayed behind the batch. The new - * commit-driven event is then queued 1hr later via the logic below. + * If there's already a future-scheduled import for this plugin and nothing + * is currently running, fold the new request into it. This handles two + * cases under one rule: + * + * - A bulk batch re-index queued an event far in the future; a fresh + * commit should pull that forward to the usual import time. + * - A plugin that releases from a tag commits to /trunk first and then + * `svn cp trunk tags/X.Y` a moment later (see queue_run_time()). The + * first commit gets delayed; the second merges into it so a single + * import publishes the release from the tag, not from a trunk + * fallback that the tag commit then has to overwrite. */ - $next_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'next' ); - if ( - $next_scheduled && - $next_scheduled > ( time() + 5 * MINUTE_IN_SECONDS ) && - ! Manager::is_event_running( "import_plugin:{$plugin_slug}" ) - ) { - Manager::reschedule_event( "import_plugin:{$plugin_slug}", time() + 5, $next_scheduled ); + $next_scheduled = Manager::get_scheduled_time( $hook, 'next' ); + if ( $next_scheduled && ! Manager::is_event_running( $hook ) ) { + $existing = Manager::get_scheduled_events( $hook, $next_scheduled ); + $existing_args = $existing[0]['args'][0] ?? array(); + $merged_args = self::merge_plugin_data( $existing_args, $new_args ); + + $updated = Manager::update_scheduled_event( + $hook, + $next_scheduled, + array( + 'nextrun' => min( $next_scheduled, self::queue_run_time( $plugin_slug, $merged_args ) ), + 'args' => array( $merged_args ), + ) + ); + + if ( $updated ) { + return; + } } - // To avoid a situation where two imports run concurrently, if one is already scheduled or in flight, run it 1hr later (We'll trigger it after the current one finishes). - $when_to_run = time() + 5; - $last_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'last' ); + $when_to_run = self::queue_run_time( $plugin_slug, $new_args ); + + // To avoid a situation where two imports run concurrently, if one is already scheduled or in flight, run it 1hr later (we'll trigger it after the current one finishes). + $last_scheduled = Manager::get_scheduled_time( $hook, 'last' ); if ( $last_scheduled ) { $when_to_run = $last_scheduled + HOUR_IN_SECONDS; - } elseif ( Manager::is_event_running( "import_plugin:{$plugin_slug}" ) ) { + } elseif ( Manager::is_event_running( $hook ) ) { $when_to_run = time() + HOUR_IN_SECONDS; } wp_schedule_single_event( $when_to_run, - "import_plugin:{$plugin_slug}", - array( - array_merge( array( 'plugin' => $plugin_slug ), $plugin_data ), - ) + $hook, + array( $new_args ) ); } + /** + * Decide when an import job should run, based on the SVN changes it covers. + * + * Plugins that release from tags typically commit the version bump to /trunk + * first and then `svn cp trunk tags/X.Y` shortly after. Running the import + * immediately on the trunk commit publishes a trunk-fallback release that the + * tag commit then has to overwrite as a second release. To collapse those + * into one import, defer trunk-only updates by 5 minutes when the plugin is + * currently releasing from a tag — that gives the follow-up tag commit time + * to merge into the same job (see queue()). + * + * Tag-touching changes, and changes on plugins releasing from trunk, run + * immediately. + * + * @param string $plugin_slug The plugin slug. + * @param array $args The args for the import job (post-merge where applicable). + * @return int Unix timestamp for when the event should run. + */ + protected static function queue_run_time( $plugin_slug, $args ) { + if ( self::is_trunk_only_update_on_tagged_plugin( $plugin_slug, $args ) ) { + return time() + 5 * MINUTE_IN_SECONDS; + } + + return time() + 5; + } + + /** + * Whether an import covers only /trunk on a plugin that currently releases from a tag. + * + * @param string $plugin_slug The plugin slug. + * @param array $args The args for the import job. + * @return bool + */ + protected static function is_trunk_only_update_on_tagged_plugin( $plugin_slug, $args ) { + $tags_touched = (array) ( $args['tags_touched'] ?? array() ); + $tags_deleted = (array) ( $args['tags_deleted'] ?? array() ); + + $trunk_only = $tags_touched && array( 'trunk' ) === array_values( array_unique( $tags_touched ) ) && ! $tags_deleted; + if ( ! $trunk_only ) { + return false; + } + + $plugin = Plugin_Directory::get_plugin_post( $plugin_slug ); + if ( ! $plugin ) { + return false; + } + + $current_stable_tag = get_post_meta( $plugin->ID, 'stable_tag', true ); + + return $current_stable_tag && 'trunk' !== $current_stable_tag; + } + + /** + * Merge two plugin_data payloads into a single import-job payload. + * + * Used when folding an already-scheduled future event into a newer request so + * neither set of changes is lost. + * + * @param array $existing The args from the currently-scheduled event. + * @param array $incoming The args from the new request. + * @return array Merged args ready to pass to the import job. + */ + protected static function merge_plugin_data( array $existing, array $incoming ) { + $merged = array_merge( $existing, $incoming ); + + foreach ( array( 'tags_touched', 'tags_deleted', 'revisions' ) as $key ) { + $existing_values = (array) ( $existing[ $key ] ?? array() ); + $incoming_values = (array) ( $incoming[ $key ] ?? array() ); + + $merged[ $key ] = array_values( array_unique( array_merge( $existing_values, $incoming_values ) ) ); + } + + foreach ( array( 'readme_touched', 'code_touched', 'assets_touched' ) as $key ) { + $merged[ $key ] = ! empty( $existing[ $key ] ) || ! empty( $incoming[ $key ] ); + } + + return $merged; + } + /** * The cron trigger for the import job. */ diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Import_Merge_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Import_Merge_Test.php new file mode 100644 index 0000000000..118421a7f4 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Import_Merge_Test.php @@ -0,0 +1,137 @@ +setAccessible( true ); + + return $method->invoke( null, $existing, $incoming ); + } + + /** + * Invoke the protected Plugin_Import::is_trunk_only_update_on_tagged_plugin() helper. + */ + private function is_trunk_only_on_tagged_plugin( string $plugin_slug, array $args ): bool { + $method = new ReflectionMethod( Plugin_Import::class, 'is_trunk_only_update_on_tagged_plugin' ); + $method->setAccessible( true ); + + return $method->invoke( null, $plugin_slug, $args ); + } + + public function test_merges_revisions_and_tags_without_duplicates() { + $existing = [ + 'plugin' => 'hello', + 'tags_touched' => [ 'trunk', '1.0' ], + 'tags_deleted' => [], + 'revisions' => [ 100, 101 ], + 'readme_touched' => true, + 'code_touched' => false, + 'assets_touched' => false, + ]; + $new = [ + 'plugin' => 'hello', + 'tags_touched' => [ 'trunk', '2.0' ], + 'tags_deleted' => [ '0.9' ], + 'revisions' => [ 101, 200 ], + 'readme_touched' => false, + 'code_touched' => true, + 'assets_touched' => false, + ]; + + $merged = $this->merge( $existing, $new ); + + $this->assertEqualsCanonicalizing( [ 'trunk', '1.0', '2.0' ], $merged['tags_touched'] ); + $this->assertEqualsCanonicalizing( [ '0.9' ], $merged['tags_deleted'] ); + $this->assertEqualsCanonicalizing( [ 100, 101, 200 ], $merged['revisions'] ); + } + + public function test_boolean_flags_are_ored() { + $existing = [ + 'readme_touched' => true, + 'code_touched' => false, + 'assets_touched' => false, + ]; + $new = [ + 'readme_touched' => false, + 'code_touched' => true, + 'assets_touched' => false, + ]; + + $merged = $this->merge( $existing, $new ); + + $this->assertTrue( $merged['readme_touched'] ); + $this->assertTrue( $merged['code_touched'] ); + $this->assertFalse( $merged['assets_touched'] ); + } + + public function test_missing_keys_in_existing_are_safe() { + $merged = $this->merge( + [ 'plugin' => 'hello' ], + [ + 'plugin' => 'hello', + 'tags_touched' => [ 'trunk' ], + 'revisions' => [ 42 ], + 'readme_touched' => true, + ] + ); + + $this->assertSame( [ 'trunk' ], $merged['tags_touched'] ); + $this->assertSame( [ 42 ], $merged['revisions'] ); + $this->assertSame( [], $merged['tags_deleted'] ); + $this->assertTrue( $merged['readme_touched'] ); + } + + /** + * If a tag is touched the change should never be classified as trunk-only, + * even if /trunk is also in the same commit. + */ + public function test_change_touching_a_tag_is_not_trunk_only() { + $this->assertFalse( $this->is_trunk_only_on_tagged_plugin( 'no-such-plugin', [ + 'tags_touched' => [ 'trunk', '1.2.3' ], + 'tags_deleted' => [], + ] ) ); + + $this->assertFalse( $this->is_trunk_only_on_tagged_plugin( 'no-such-plugin', [ + 'tags_touched' => [ '1.2.3' ], + 'tags_deleted' => [], + ] ) ); + } + + /** + * A change that deletes a tag isn't a candidate for the 5-minute delay either: + * the deletion needs to propagate to the directory immediately. + */ + public function test_change_deleting_a_tag_is_not_trunk_only() { + $this->assertFalse( $this->is_trunk_only_on_tagged_plugin( 'no-such-plugin', [ + 'tags_touched' => [ 'trunk' ], + 'tags_deleted' => [ '1.1.0' ], + ] ) ); + } + + /** + * Without a backing plugin post the helper falls back to "run immediately", + * so the import isn't held up just because the directory hasn't seen the + * plugin yet. + */ + public function test_unknown_plugin_does_not_delay() { + $this->assertFalse( $this->is_trunk_only_on_tagged_plugin( 'no-such-plugin', [ + 'tags_touched' => [ 'trunk' ], + 'tags_deleted' => [], + ] ) ); + } +} From 6a5fff07685acab8f125372bb284c0ae90289c5e Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 14 May 2026 12:28:34 +1000 Subject: [PATCH 2/6] Plugin Directory: Delay all trunk-only imports by 15 minutes regardless of tag history. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the delay only kicked in when the plugin's current stable_tag was already a versioned tag — the heuristic for "this plugin probably follows up with a tag commit." Replace that with a blanket 15-minute grace window on any trunk-only import; tag-touching changes (additions or deletions) still process immediately. Why: even plugins that have historically released from trunk can switch to tagging at any release, and the cost of a 15-minute delay on a trunk-only commit is small — the import is no-op at worst — while a missed tag follow-up means a duplicate release. Removes the post lookup and stable_tag check entirely. Also renames the helper to is_trunk_only_update() to reflect that it no longer considers the plugin's tagging history. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jobs/class-plugin-import.php | 60 +++++++------------ .../tests/Plugin_Import_Merge_Test.php | 41 ++++++++----- 2 files changed, 46 insertions(+), 55 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php index f48c851ffa..87ed7fdd53 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php @@ -3,7 +3,6 @@ use Exception; use WordPressdotorg\Plugin_Directory\CLI; -use WordPressdotorg\Plugin_Directory\Plugin_Directory; /** * Import plugin changes into WordPress. @@ -30,10 +29,10 @@ public static function queue( $plugin_slug, $plugin_data ) { * * - A bulk batch re-index queued an event far in the future; a fresh * commit should pull that forward to the usual import time. - * - A plugin that releases from a tag commits to /trunk first and then - * `svn cp trunk tags/X.Y` a moment later (see queue_run_time()). The - * first commit gets delayed; the second merges into it so a single - * import publishes the release from the tag, not from a trunk + * - The 15-minute trunk grace window (see queue_run_time()): an author + * commits to /trunk first and then `svn cp trunk tags/X.Y` a moment + * later. The first commit's event is delayed; the second merges into + * it so a single import publishes from the tag, not from a trunk * fallback that the tag commit then has to overwrite. */ $next_scheduled = Manager::get_scheduled_time( $hook, 'next' ); @@ -46,7 +45,7 @@ public static function queue( $plugin_slug, $plugin_data ) { $hook, $next_scheduled, array( - 'nextrun' => min( $next_scheduled, self::queue_run_time( $plugin_slug, $merged_args ) ), + 'nextrun' => min( $next_scheduled, self::queue_run_time( $merged_args ) ), 'args' => array( $merged_args ), ) ); @@ -56,7 +55,7 @@ public static function queue( $plugin_slug, $plugin_data ) { } } - $when_to_run = self::queue_run_time( $plugin_slug, $new_args ); + $when_to_run = self::queue_run_time( $new_args ); // To avoid a situation where two imports run concurrently, if one is already scheduled or in flight, run it 1hr later (we'll trigger it after the current one finishes). $last_scheduled = Manager::get_scheduled_time( $hook, 'last' ); @@ -76,53 +75,36 @@ public static function queue( $plugin_slug, $plugin_data ) { /** * Decide when an import job should run, based on the SVN changes it covers. * - * Plugins that release from tags typically commit the version bump to /trunk - * first and then `svn cp trunk tags/X.Y` shortly after. Running the import - * immediately on the trunk commit publishes a trunk-fallback release that the - * tag commit then has to overwrite as a second release. To collapse those - * into one import, defer trunk-only updates by 5 minutes when the plugin is - * currently releasing from a tag — that gives the follow-up tag commit time - * to merge into the same job (see queue()). + * Authors that release from a tag commonly commit the version bump to /trunk + * first and then `svn cp trunk tags/X.Y` a moment later. Running the import + * immediately on the trunk commit publishes a trunk-fallback release that + * the follow-up tag commit then re-publishes from the tag. To collapse the + * two into one import, all trunk-only updates are deferred by 15 minutes — + * that gives the follow-up tag commit time to merge into the same job (see + * queue()). Tag-touching changes (additions or deletions) run immediately. * - * Tag-touching changes, and changes on plugins releasing from trunk, run - * immediately. - * - * @param string $plugin_slug The plugin slug. - * @param array $args The args for the import job (post-merge where applicable). + * @param array $args The args for the import job (post-merge where applicable). * @return int Unix timestamp for when the event should run. */ - protected static function queue_run_time( $plugin_slug, $args ) { - if ( self::is_trunk_only_update_on_tagged_plugin( $plugin_slug, $args ) ) { - return time() + 5 * MINUTE_IN_SECONDS; + protected static function queue_run_time( $args ) { + if ( self::is_trunk_only_update( $args ) ) { + return time() + 15 * MINUTE_IN_SECONDS; } return time() + 5; } /** - * Whether an import covers only /trunk on a plugin that currently releases from a tag. + * Whether an import only covers /trunk (no tags added or removed). * - * @param string $plugin_slug The plugin slug. - * @param array $args The args for the import job. + * @param array $args The args for the import job. * @return bool */ - protected static function is_trunk_only_update_on_tagged_plugin( $plugin_slug, $args ) { + protected static function is_trunk_only_update( $args ) { $tags_touched = (array) ( $args['tags_touched'] ?? array() ); $tags_deleted = (array) ( $args['tags_deleted'] ?? array() ); - $trunk_only = $tags_touched && array( 'trunk' ) === array_values( array_unique( $tags_touched ) ) && ! $tags_deleted; - if ( ! $trunk_only ) { - return false; - } - - $plugin = Plugin_Directory::get_plugin_post( $plugin_slug ); - if ( ! $plugin ) { - return false; - } - - $current_stable_tag = get_post_meta( $plugin->ID, 'stable_tag', true ); - - return $current_stable_tag && 'trunk' !== $current_stable_tag; + return $tags_touched && array( 'trunk' ) === array_values( array_unique( $tags_touched ) ) && ! $tags_deleted; } /** diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Import_Merge_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Import_Merge_Test.php index 118421a7f4..2426c1a168 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Import_Merge_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Import_Merge_Test.php @@ -1,6 +1,6 @@ setAccessible( true ); - return $method->invoke( null, $plugin_slug, $args ); + return $method->invoke( null, $args ); } public function test_merges_revisions_and_tags_without_duplicates() { @@ -96,41 +96,50 @@ public function test_missing_keys_in_existing_are_safe() { $this->assertTrue( $merged['readme_touched'] ); } + /** + * A commit that touches only /trunk is the candidate for the grace window. + */ + public function test_trunk_only_commit_is_classified_as_trunk_only() { + $this->assertTrue( $this->is_trunk_only( [ + 'tags_touched' => [ 'trunk' ], + 'tags_deleted' => [], + ] ) ); + } + /** * If a tag is touched the change should never be classified as trunk-only, * even if /trunk is also in the same commit. */ public function test_change_touching_a_tag_is_not_trunk_only() { - $this->assertFalse( $this->is_trunk_only_on_tagged_plugin( 'no-such-plugin', [ + $this->assertFalse( $this->is_trunk_only( [ 'tags_touched' => [ 'trunk', '1.2.3' ], 'tags_deleted' => [], ] ) ); - $this->assertFalse( $this->is_trunk_only_on_tagged_plugin( 'no-such-plugin', [ + $this->assertFalse( $this->is_trunk_only( [ 'tags_touched' => [ '1.2.3' ], 'tags_deleted' => [], ] ) ); } /** - * A change that deletes a tag isn't a candidate for the 5-minute delay either: - * the deletion needs to propagate to the directory immediately. + * A change that deletes a tag isn't a candidate for the grace window + * either: the deletion needs to propagate to the directory immediately. */ public function test_change_deleting_a_tag_is_not_trunk_only() { - $this->assertFalse( $this->is_trunk_only_on_tagged_plugin( 'no-such-plugin', [ + $this->assertFalse( $this->is_trunk_only( [ 'tags_touched' => [ 'trunk' ], 'tags_deleted' => [ '1.1.0' ], ] ) ); } /** - * Without a backing plugin post the helper falls back to "run immediately", - * so the import isn't held up just because the directory hasn't seen the - * plugin yet. + * An empty set of touched tags (a defensive default) shouldn't be + * misclassified as a delayable trunk-only update. */ - public function test_unknown_plugin_does_not_delay() { - $this->assertFalse( $this->is_trunk_only_on_tagged_plugin( 'no-such-plugin', [ - 'tags_touched' => [ 'trunk' ], + public function test_empty_tags_touched_is_not_trunk_only() { + $this->assertFalse( $this->is_trunk_only( [ + 'tags_touched' => [], 'tags_deleted' => [], ] ) ); } From 242a9e18ff202ecf7212f76a34292d68b0a6c0d8 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 14 May 2026 12:30:52 +1000 Subject: [PATCH 3/6] Plugin Directory: Inline the import_plugin:{slug} hook name in queue(). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugin-directory/jobs/class-plugin-import.php | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php index 87ed7fdd53..2f0a60af8f 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php @@ -19,7 +19,6 @@ class Plugin_Import { * @param array $plugin_data Data about the SVN change (tags_touched, revisions, etc). */ public static function queue( $plugin_slug, $plugin_data ) { - $hook = "import_plugin:{$plugin_slug}"; $new_args = array_merge( array( 'plugin' => $plugin_slug ), $plugin_data ); /* @@ -35,14 +34,14 @@ public static function queue( $plugin_slug, $plugin_data ) { * it so a single import publishes from the tag, not from a trunk * fallback that the tag commit then has to overwrite. */ - $next_scheduled = Manager::get_scheduled_time( $hook, 'next' ); - if ( $next_scheduled && ! Manager::is_event_running( $hook ) ) { - $existing = Manager::get_scheduled_events( $hook, $next_scheduled ); + $next_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'next' ); + if ( $next_scheduled && ! Manager::is_event_running( "import_plugin:{$plugin_slug}" ) ) { + $existing = Manager::get_scheduled_events( "import_plugin:{$plugin_slug}", $next_scheduled ); $existing_args = $existing[0]['args'][0] ?? array(); $merged_args = self::merge_plugin_data( $existing_args, $new_args ); $updated = Manager::update_scheduled_event( - $hook, + "import_plugin:{$plugin_slug}", $next_scheduled, array( 'nextrun' => min( $next_scheduled, self::queue_run_time( $merged_args ) ), @@ -58,16 +57,16 @@ public static function queue( $plugin_slug, $plugin_data ) { $when_to_run = self::queue_run_time( $new_args ); // To avoid a situation where two imports run concurrently, if one is already scheduled or in flight, run it 1hr later (we'll trigger it after the current one finishes). - $last_scheduled = Manager::get_scheduled_time( $hook, 'last' ); + $last_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'last' ); if ( $last_scheduled ) { $when_to_run = $last_scheduled + HOUR_IN_SECONDS; - } elseif ( Manager::is_event_running( $hook ) ) { + } elseif ( Manager::is_event_running( "import_plugin:{$plugin_slug}" ) ) { $when_to_run = time() + HOUR_IN_SECONDS; } wp_schedule_single_event( $when_to_run, - $hook, + "import_plugin:{$plugin_slug}", array( $new_args ) ); } From ea92023a9adbd529d6bb475fa185a83ceb7cb663 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 14 May 2026 12:38:43 +1000 Subject: [PATCH 4/6] Plugin Directory: Skip the trunk grace window on a readme stable_tag flip to an existing tag. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a trunk-only commit just bumps Stable Tag in /trunk/readme.txt to a version that already exists in /tags/ (and differs from the directory's current stable_tag), there is no follow-up `svn cp trunk tags/X.Y` coming — the author is releasing by flipping the stable_tag pointer at an existing build. Process those immediately instead of waiting out the 15-minute grace window. Adds trunk_stable_tag_flip_to_existing_tag() which fetches /trunk/readme.txt via Readme_Parser and compares against the post's tagged_versions and stable_tag meta. Only invoked when the commit is trunk-only AND readme was touched, so the extra HTTP read does not fire on tag commits or pure-code trunk commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../jobs/class-plugin-import.php | 62 ++++++++++++++++--- 1 file changed, 53 insertions(+), 9 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php index 2f0a60af8f..6f578e305b 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-import.php @@ -3,6 +3,8 @@ use Exception; use WordPressdotorg\Plugin_Directory\CLI; +use WordPressdotorg\Plugin_Directory\Plugin_Directory; +use WordPressdotorg\Plugin_Directory\Readme\Parser as Readme_Parser; /** * Import plugin changes into WordPress. @@ -44,7 +46,7 @@ public static function queue( $plugin_slug, $plugin_data ) { "import_plugin:{$plugin_slug}", $next_scheduled, array( - 'nextrun' => min( $next_scheduled, self::queue_run_time( $merged_args ) ), + 'nextrun' => min( $next_scheduled, self::queue_run_time( $plugin_slug, $merged_args ) ), 'args' => array( $merged_args ), ) ); @@ -54,7 +56,7 @@ public static function queue( $plugin_slug, $plugin_data ) { } } - $when_to_run = self::queue_run_time( $new_args ); + $when_to_run = self::queue_run_time( $plugin_slug, $new_args ); // To avoid a situation where two imports run concurrently, if one is already scheduled or in flight, run it 1hr later (we'll trigger it after the current one finishes). $last_scheduled = Manager::get_scheduled_time( "import_plugin:{$plugin_slug}", 'last' ); @@ -78,19 +80,28 @@ public static function queue( $plugin_slug, $plugin_data ) { * first and then `svn cp trunk tags/X.Y` a moment later. Running the import * immediately on the trunk commit publishes a trunk-fallback release that * the follow-up tag commit then re-publishes from the tag. To collapse the - * two into one import, all trunk-only updates are deferred by 15 minutes — - * that gives the follow-up tag commit time to merge into the same job (see + * two into one import, trunk-only updates are deferred by 15 minutes — that + * gives the follow-up tag commit time to merge into the same job (see * queue()). Tag-touching changes (additions or deletions) run immediately. * - * @param array $args The args for the import job (post-merge where applicable). + * The grace window is bypassed when the only change is a `Stable Tag` flip + * in /trunk/readme.txt pointing at a tag that already exists in /tags/: + * there's no follow-up tag to wait for in that case. + * + * @param string $plugin_slug The plugin slug. + * @param array $args The args for the import job (post-merge where applicable). * @return int Unix timestamp for when the event should run. */ - protected static function queue_run_time( $args ) { - if ( self::is_trunk_only_update( $args ) ) { - return time() + 15 * MINUTE_IN_SECONDS; + protected static function queue_run_time( $plugin_slug, $args ) { + if ( ! self::is_trunk_only_update( $args ) ) { + return time() + 5; } - return time() + 5; + if ( ! empty( $args['readme_touched'] ) && self::trunk_stable_tag_flip_to_existing_tag( $plugin_slug ) ) { + return time() + 5; + } + + return time() + 15 * MINUTE_IN_SECONDS; } /** @@ -106,6 +117,39 @@ protected static function is_trunk_only_update( $args ) { return $tags_touched && array( 'trunk' ) === array_values( array_unique( $tags_touched ) ) && ! $tags_deleted; } + /** + * Whether /trunk/readme.txt's Stable Tag points to a tag that's already in + * /tags/ AND differs from the directory's current stable_tag. + * + * Indicates a release-by-readme-flip — the author isn't going to follow up + * with `svn cp trunk tags/X.Y` because the tag already exists. Used to skip + * the 15-minute trunk grace window in that case. + * + * @param string $plugin_slug The plugin slug. + * @return bool + */ + protected static function trunk_stable_tag_flip_to_existing_tag( $plugin_slug ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin_slug ); + if ( ! $plugin ) { + return false; + } + + $readme = new Readme_Parser( "https://plugins.svn.wordpress.org/{$plugin_slug}/trunk/readme.txt" ); + $new_stable_tag = $readme->stable_tag; + + if ( ! $new_stable_tag || 'trunk' === $new_stable_tag ) { + return false; + } + + if ( get_post_meta( $plugin->ID, 'stable_tag', true ) === $new_stable_tag ) { + return false; + } + + $tagged_versions = (array) get_post_meta( $plugin->ID, 'tagged_versions', true ); + + return in_array( $new_stable_tag, $tagged_versions, true ); + } + /** * Merge two plugin_data payloads into a single import-job payload. * From 89c4b89b56ec962b3c80331fe9acbd9e00372c60 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 14 May 2026 13:18:07 +1000 Subject: [PATCH 5/6] Plugin Directory: Only flag readme_touched for the readme at the root of /trunk or a tag. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drops the long-standing false-positive (called out in the comment) where any readme.txt or readme.md anywhere in the commit set readme_touched, including ones in subdirectories that are just bundled documentation. Now only the plugin's own readme — `/trunk/readme.{txt,md}` or `/tags/X.Y/readme.{txt,md}` — counts. Matters for the new trunk grace-window bypass in Plugin_Import::queue_run_time: a subdir readme tweak should not be misread as a stable_tag flip that warrants skipping the 15-minute wait. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/plugin-directory/cli/class-svn-watcher.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php index 3ce9c092fc..884e6adf85 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php @@ -155,8 +155,10 @@ protected function get_plugin_changes_between( $rev, $head_rev = 'HEAD' ) { } - // This will have false-positives for when a readme in a subdirectory is hit, but this is only for optimizations. - if ( in_array( strtolower( basename( $path ) ), array( 'readme.txt', 'readme.md' ) ) ) { + // Only count the readme at the top level of /trunk or /tags/X.Y as the plugin's readme — a readme.txt in a subdirectory is just bundled documentation. + $is_top_level_in_trunk = ( 'trunk' === $path_parts[1] && isset( $path_parts[2] ) && ! isset( $path_parts[3] ) ); + $is_top_level_in_tag = ( 'tags' === $path_parts[1] && isset( $path_parts[3] ) && ! isset( $path_parts[4] ) ); + if ( ( $is_top_level_in_trunk || $is_top_level_in_tag ) && in_array( strtolower( basename( $path ) ), array( 'readme.txt', 'readme.md' ) ) ) { $plugin['readme_touched'] = true; } if ( ! $plugin['code_touched'] && ( '/' === substr( $path, -1 ) || '.php' === substr( $path, -4 ) || '.js' === substr( $path, -3 ) ) ) { From 7b6c732ec5ed39a44681e21faffb74b3d2073a26 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 14 May 2026 13:20:32 +1000 Subject: [PATCH 6/6] Plugin Directory: Tidy the readme_touched check with a single regex. Folds the structural depth check (only /trunk/readme or /tags//readme) into one regex instead of three booleans plus a basename lookup. Drops the false-positive on readmes nested in subdirectories that the previous basename-only match had. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/plugin-directory/cli/class-svn-watcher.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php index 884e6adf85..730f58111f 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-svn-watcher.php @@ -155,10 +155,8 @@ protected function get_plugin_changes_between( $rev, $head_rev = 'HEAD' ) { } - // Only count the readme at the top level of /trunk or /tags/X.Y as the plugin's readme — a readme.txt in a subdirectory is just bundled documentation. - $is_top_level_in_trunk = ( 'trunk' === $path_parts[1] && isset( $path_parts[2] ) && ! isset( $path_parts[3] ) ); - $is_top_level_in_tag = ( 'tags' === $path_parts[1] && isset( $path_parts[3] ) && ! isset( $path_parts[4] ) ); - if ( ( $is_top_level_in_trunk || $is_top_level_in_tag ) && in_array( strtolower( basename( $path ) ), array( 'readme.txt', 'readme.md' ) ) ) { + // Only count the readme at the root of /trunk or a tag — a readme.txt in a subdirectory is just bundled documentation. + if ( preg_match( '!/(trunk|tags/[^/]+)/readme\.(txt|md)$!i', $path ) ) { $plugin['readme_touched'] = true; } if ( ! $plugin['code_touched'] && ( '/' === substr( $path, -1 ) || '.php' === substr( $path, -4 ) || '.js' === substr( $path, -3 ) ) ) {