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 fba93c07d9..14e0077aad 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 @@ -89,23 +89,31 @@ public static function update_single_plugin( $plugin_slug ) { $release_time = self::compute_release_time( $post, $release ); $existing_row = $wpdb->get_row( $wpdb->prepare( - "SELECT version, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", + "SELECT version, stable_tag, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", $post->post_name ) ); $existing_version = (string) ( $existing_row->version ?? '' ); + $existing_ref = (string) ( $existing_row->stable_tag ?? '' ); + $stable_tag = (string) get_post_meta( $post->ID, 'stable_tag', true ); $release_delay = (int) ( $release['release_delay'] ?? 0 ); - // `update_source.version` is varchar(128); mirror cron_trigger()'s `left( pm.meta_value, 128 )` truncation allowance. - $is_new_version = substr( (string) $version, 0, 128 ) !== $existing_version; + /* + * A new release is a change to the served version OR the served ref: a new tag + * carrying the same version still ships different content, so it must pass through + * the cooldown and block gates rather than skip them on the unchanged version alone. + * Both columns are varchar(128); mirror cron_trigger()'s `left( …, 128 )` allowance. + */ + $is_new_release = substr( (string) $version, 0, 128 ) !== $existing_version + || substr( $stable_tag, 0, 128 ) !== $existing_ref; /* * Hold a blocked version out of the row: the previously served version keeps * being served, and the deferred serve is cancelled rather than postponed. * Status changes still reach the row right away. */ - if ( self::is_release_blocked( $release ) && $is_new_version ) { + if ( self::is_release_blocked( $release ) && $is_new_release ) { wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" ); if ( $existing_row ) { @@ -125,13 +133,13 @@ public static function update_single_plugin( $plugin_slug ) { * gate is false when called from cron_trigger_release() and no explicit bypass * is needed. * - * Only the version bump waits for the cooldown: a status change made + * Only the new release waits for the cooldown: a status change made * mid-cooldown (a closure, a reopen) reaches the existing row right away, * while it keeps serving the previous release's data. Until the cooldown * expires, cron_trigger() keeps re-selecting the plugin and this write * repeats as a no-op. */ - if ( $release_delay && $is_new_version ) { + if ( $release_delay && $is_new_release ) { $cooldown_until = $release_time + $release_delay; if ( $cooldown_until > time() ) { self::queue_release_to_update_api( $post->post_name, $cooldown_until ); @@ -148,7 +156,7 @@ public static function update_single_plugin( $plugin_slug ) { // to now — that's the moment the version is actually available to sites. Keeps // phased_rollout()'s `manual-updates-24hr` window measuring from public availability, // even if the commit/confirmation was long ago because the cooldown deferred the write. - if ( $release_delay && $is_new_version ) { + if ( $release_delay && $is_new_release ) { $release_time = time(); } @@ -176,7 +184,7 @@ public static function update_single_plugin( $plugin_slug ) { 'plugin_slug' => $post->post_name, 'available' => (int) self::is_available( $post ), 'version' => $version, - 'stable_tag' => get_post_meta( $post->ID, 'stable_tag', true ), + 'stable_tag' => $stable_tag, 'plugin_name' => strip_tags( get_post_meta( $post->ID, 'header_name', true ) ), 'plugin_name_san' => sanitize_title_with_dashes( strip_tags( get_post_meta( $post->ID, 'header_name', true ) ) ), 'plugin_author' => strip_tags( get_post_meta( $post->ID, 'header_author', true ) ), diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Newness_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Newness_Test.php new file mode 100644 index 0000000000..219bd1a028 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Newness_Test.php @@ -0,0 +1,205 @@ + 'newness-test-' . ( ++self::$plugin_count ), + 'post_title' => 'Release Newness 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::VERSION ); + update_post_meta( $this->plugin->ID, 'stable_tag', self::NEW_TAG ); + } + + /** + * Insert an update_source row serving a version from a ref. + * + * @param string $version The served version. + * @param string $stable_tag The served ref. + */ + private function insert_served_row( 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, + ) + ); + } + + /** + * Register a release for the new tag. + * + * @param array $overrides Fields to override. + */ + private function add_new_tag_release( array $overrides = array() ): void { + update_post_meta( + $this->plugin->ID, + 'releases', + array( + array_merge( + array( + 'date' => time(), + 'tag' => self::NEW_TAG, + 'version' => self::VERSION, + 'zips_built' => true, + 'zips_built_from_revision' => 0, + 'confirmations' => array(), + 'confirmed' => true, + 'confirmations_required' => 0, + 'committer' => array(), + 'revision' => array(), + 'release_delay' => DAY_IN_SECONDS, + ), + $overrides + ), + ) + ); + } + + /** + * Fetch the plugin's update_source row. + * + * @return object|null The row, or null when none exists. + */ + private function get_row(): ?object { + global $wpdb; + + return $wpdb->get_row( + $wpdb->prepare( + "SELECT version, stable_tag FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s", + $this->plugin->post_name + ) + ); + } + + /** + * A new ref at the already-served version is held by the cooldown, not + * written straight into the row. + */ + public function test_new_ref_same_version_defers_under_cooldown(): void { + $this->insert_served_row( self::VERSION, self::SERVED_REF ); + $this->add_new_tag_release(); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $row = $this->get_row(); + $this->assertSame( self::SERVED_REF, $row->stable_tag ); + $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } + + /** + * A new ref at the already-served version that is blocked is held out of the + * row, and its deferred serve is cancelled. + */ + public function test_new_ref_same_version_blocked_is_held(): void { + $this->insert_served_row( self::VERSION, self::SERVED_REF ); + $this->add_new_tag_release( array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $row = $this->get_row(); + $this->assertSame( self::SERVED_REF, $row->stable_tag ); + $this->assertFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } + + /** + * An unchanged release — same version and same ref — is not treated as new, + * so the cooldown does not spuriously defer it. + */ + public function test_unchanged_release_is_not_deferred(): void { + $this->insert_served_row( self::VERSION, self::NEW_TAG ); + $this->add_new_tag_release(); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $row = $this->get_row(); + $this->assertSame( self::NEW_TAG, $row->stable_tag ); + $this->assertFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php index bfea9d556b..8d07f58fc0 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php @@ -378,6 +378,7 @@ public function test_served_truncated_version_is_not_deferred(): void { $release['version'] = $long_version; update_post_meta( $this->plugin->ID, 'version', $long_version ); + update_post_meta( $this->plugin->ID, 'stable_tag', $long_version ); update_post_meta( $this->plugin->ID, 'releases', array( $release ) ); $this->insert_served_row( substr( $long_version, 0, 128 ) );