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 ) );