diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
index c438c8e50b..fba93c07d9 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
@@ -221,11 +221,21 @@ public static function update_single_plugin( $plugin_slug ) {
* @return string The served version, or '' when the plugin isn't in `update_source`.
*/
public static function get_served_version( $plugin_slug ) {
+ return (string) ( self::get_served_release( $plugin_slug )->version ?? '' );
+ }
+
+ /**
+ * The release currently served from `update_source`.
+ *
+ * @param string $plugin_slug The plugin slug.
+ * @return object|null Row with `version` and `stable_tag`, or null when the plugin isn't in `update_source`.
+ */
+ public static function get_served_release( $plugin_slug ) {
global $wpdb;
- return (string) $wpdb->get_var(
+ return $wpdb->get_row(
$wpdb->prepare(
- "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
+ "SELECT version, stable_tag FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
$plugin_slug
)
);
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php
index a0b58c457d..68274af6ec 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-plugin-scan-gandalf.php
@@ -7,6 +7,7 @@
namespace WordPressdotorg\Plugin_Directory\Jobs;
+use WordPressdotorg\Plugin_Directory\Plugin_Directory;
use WordPressdotorg\Plugin_Directory\Template;
use WP_Error;
use WP_Http;
@@ -74,6 +75,19 @@ public static function dispatch_from_import_context( $plugin, $import_context )
$previous_version = get_post_meta( $plugin->ID, 'last_version', true ) ?: null;
$previous_zip_url = null;
+ // A blocked or cooling-down release never reaches `update_source`, so diffing against the served release keeps a re-tagged payload from becoming its own baseline.
+ $served = API_Update_Updater::get_served_release( $plugin->post_name );
+ if ( $served && substr( $version, 0, 128 ) !== $served->version ) {
+ $previous_release_ref = $served->stable_tag ?: null;
+ $previous_version = $served->version ?: null;
+ }
+
+ // A blocked release must not be the baseline either way — dropping it makes the scanner run a full scan.
+ if ( $previous_release_ref && API_Update_Updater::is_release_blocked( Plugin_Directory::get_release( $plugin, $previous_release_ref ) ) ) {
+ $previous_release_ref = null;
+ $previous_version = null;
+ }
+
if ( $previous_release_ref && $previous_release_ref !== $release_ref && 'trunk' !== $previous_release_ref ) {
$previous_zip_url = Template::download_link( $plugin, $previous_release_ref );
}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Baseline_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Baseline_Test.php
new file mode 100644
index 0000000000..8e6cd296cc
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Gandalf_Scan_Baseline_Test.php
@@ -0,0 +1,259 @@
+ 'baseline-test-' . ( ++self::$plugin_count ),
+ 'post_title' => 'Scan Baseline Test Plugin',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $this->assertInstanceOf( \WP_Post::class, $plugin );
+ $this->plugin = $plugin;
+
+ /*
+ * The stub update_source table survives across runs — the WP test
+ * installer only drops core tables — so clear leftovers that would
+ * collide with this run's plugin ID or read as a served version.
+ */
+ global $wpdb;
+ $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_id' => $this->plugin->ID ) );
+ $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => $this->plugin->post_name ) );
+
+ update_post_meta( $this->plugin->ID, 'version', self::NEW_VERSION );
+ update_post_meta( $this->plugin->ID, 'last_version', self::BLOCKED_VERSION );
+ update_post_meta( $this->plugin->ID, 'last_stable_tag', self::BLOCKED_VERSION );
+
+ $this->request_body = null;
+ $this->http_mock = function ( mixed $preempt, array $parsed_args, string $url ): array {
+ $this->assertSame( Plugin_Scan_Gandalf::ENDPOINT, $url );
+ $this->request_body = json_decode( $parsed_args['body'], true );
+
+ return array(
+ 'headers' => array(),
+ 'body' => wp_json_encode( array( 'scan_id' => $this->request_body['scan_id'] ) ),
+ 'response' => array(
+ 'code' => 200,
+ 'message' => 'OK',
+ ),
+ );
+ };
+ add_filter( 'pre_http_request', $this->http_mock, 10, 3 );
+ }
+
+ /**
+ * Unhook the HTTP mock.
+ */
+ protected function tearDown(): void {
+ remove_filter( 'pre_http_request', $this->http_mock, 10 );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Insert an update_source row serving a version.
+ *
+ * @param string $version The served version.
+ * @param string $stable_tag The served stable tag.
+ */
+ private function stage_served_release( string $version, string $stable_tag ): void {
+ global $wpdb;
+
+ $wpdb->insert(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'plugin_id' => $this->plugin->ID,
+ 'plugin_slug' => $this->plugin->post_name,
+ 'available' => 1,
+ 'version' => $version,
+ 'stable_tag' => $stable_tag,
+ 'plugin_name' => $this->plugin->post_title,
+ 'requires_plugins' => '',
+ 'last_updated' => $this->plugin->post_modified,
+ )
+ );
+ }
+
+ /**
+ * Dispatch a scan for NEW_VERSION and return the captured request body.
+ *
+ * @return array The captured scan request body.
+ */
+ private function dispatch_scan(): array {
+ // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- A successful dispatch logs an E_USER_NOTICE; keep it out of the test output.
+ set_error_handler( static fn (): bool => true, E_USER_NOTICE );
+
+ try {
+ $dispatched = Plugin_Scan_Gandalf::dispatch_from_import_context(
+ $this->plugin,
+ array(
+ 'stable_tag' => self::NEW_VERSION,
+ 'old_stable_tag' => self::BLOCKED_VERSION,
+ 'changed_svn_tags' => array( self::NEW_VERSION ),
+ )
+ );
+ } finally {
+ restore_error_handler();
+ }
+
+ $this->assertTrue( $dispatched );
+ $this->assertIsArray( $this->request_body );
+
+ return $this->request_body;
+ }
+
+ /**
+ * With a version held out of update_source, the follow-up release is diffed
+ * against the served release, not the held one.
+ */
+ public function test_served_release_is_baseline(): void {
+ $this->stage_served_release( self::SERVED_VERSION, self::SERVED_VERSION );
+
+ $request = $this->dispatch_scan();
+
+ $this->assertSame( self::NEW_VERSION, $request['version'] );
+ $this->assertSame( self::SERVED_VERSION, $request['previous_version'] );
+ $this->assertSame( self::SERVED_VERSION, $request['previous_release_ref'] );
+ $this->assertSame(
+ 'https://downloads.wordpress.org/plugin/' . $this->plugin->post_name . '.' . self::SERVED_VERSION . '.zip',
+ $request['previous_zip_url']
+ );
+ }
+
+ /**
+ * When the row already serves the scanned version — imports without a
+ * cooldown write it before the scan dispatches — the last imported release
+ * remains the baseline.
+ */
+ public function test_falls_back_to_last_release_when_scanned_version_is_served(): void {
+ $this->stage_served_release( self::NEW_VERSION, self::NEW_VERSION );
+
+ $request = $this->dispatch_scan();
+
+ $this->assertSame( self::BLOCKED_VERSION, $request['previous_version'] );
+ $this->assertSame( self::BLOCKED_VERSION, $request['previous_release_ref'] );
+ }
+
+ /**
+ * A plugin with no update_source row falls back to the last imported release.
+ */
+ public function test_falls_back_to_last_release_without_served_release(): void {
+ $request = $this->dispatch_scan();
+
+ $this->assertSame( self::BLOCKED_VERSION, $request['previous_version'] );
+ $this->assertSame( self::BLOCKED_VERSION, $request['previous_release_ref'] );
+ }
+
+ /**
+ * A baseline candidate that is itself a blocked release is dropped: with
+ * no served row, the last imported release would be the baseline, but the
+ * scan must run without one — a full scan — to surface its content.
+ */
+ public function test_no_baseline_when_last_release_is_blocked(): void {
+ Plugin_Directory::add_release(
+ $this->plugin,
+ array(
+ 'tag' => self::BLOCKED_VERSION,
+ 'version' => self::BLOCKED_VERSION,
+ 'release_block' => array( 'blocked_at' => time() ),
+ )
+ );
+
+ $request = $this->dispatch_scan();
+
+ $this->assertNull( $request['previous_version'] );
+ $this->assertNull( $request['previous_release_ref'] );
+ $this->assertNull( $request['previous_zip_url'] );
+ }
+
+ /**
+ * A trunk-served release has no retrievable previous ZIP — its build was
+ * overwritten — so the scan carries no baseline.
+ */
+ public function test_no_baseline_when_serving_from_trunk(): void {
+ $this->stage_served_release( self::SERVED_VERSION, 'trunk' );
+
+ $request = $this->dispatch_scan();
+
+ $this->assertNull( $request['previous_version'] );
+ $this->assertNull( $request['previous_release_ref'] );
+ $this->assertNull( $request['previous_zip_url'] );
+ }
+}