From 2de7dcf50b59f030e77cb4512913c8ec8510cab2 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 13 Aug 2026 14:32:32 -0500 Subject: [PATCH 01/14] Plugin Directory: Resolve release holds from the stable tag. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A release block or cooldown was resolved by looking up the release named after the plugin's Version header. Renaming the header inside a held tag orphaned that lookup — no release found, so no block and no cooldown — and the tag's rebuilt package shipped immediately under the new version. Resolve the current release from the stable tag first, the source of the served package, falling back to the version for trunk releases, which are keyed trunk@{version}. Co-Authored-By: Claude Fable 5 --- .../admin/metabox/class-controls.php | 2 +- .../jobs/class-api-update-updater.php | 22 +- .../shortcodes/class-release-confirmation.php | 3 +- .../tests/Current_Release_Resolution_Test.php | 235 ++++++++++++++++++ 4 files changed, 257 insertions(+), 5 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php index 575559210c..d9318fdb78 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php @@ -60,7 +60,7 @@ protected static function display_release_cooldown() { return; } - $release = Plugin_Directory::get_release( $post, $version ); + $release = API_Update_Updater::get_current_release( $post ); if ( ! $release ) { return; } 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..213112bf64 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 @@ -85,7 +85,7 @@ public static function update_single_plugin( $plugin_slug ) { $version = get_post_meta( $post->ID, 'version', true ); $requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true ); - $release = Plugin_Directory::get_release( $post, $version ); + $release = self::get_current_release( $post ); $release_time = self::compute_release_time( $post, $release ); $existing_row = $wpdb->get_row( $wpdb->prepare( @@ -231,6 +231,22 @@ public static function get_served_version( $plugin_slug ) { ); } + /** + * The release being served or held for a plugin's current version. + * + * Resolved from the stable tag first — the served package is built from it, + * and a Version header that doesn't match its tag must not orphan the + * release's block or cooldown — falling back to the version for trunk + * releases, which are keyed `trunk@{version}`. + * + * @param \WP_Post $post The plugin post. + * @return array|false The release row from Plugin_Directory::get_release(), or false when none exists. + */ + public static function get_current_release( $post ) { + return Plugin_Directory::get_release( $post, get_post_meta( $post->ID, 'stable_tag', true ) ) + ?: Plugin_Directory::get_release( $post, get_post_meta( $post->ID, 'version', true ) ); + } + /** * Whether a release is being held out of `update_source` by a block. * @@ -267,7 +283,7 @@ public static function block_release( $plugin_slug, array $block ) { } $version = get_post_meta( $post->ID, 'version', true ); - $release = Plugin_Directory::get_release( $post, $version ); + $release = self::get_current_release( $post ); if ( ! $release ) { return false; } @@ -458,7 +474,7 @@ public static function force_release( $plugin_slug, $reason, $user = null ) { } $version = get_post_meta( $post->ID, 'version', true ); - $release = Plugin_Directory::get_release( $post, $version ); + $release = self::get_current_release( $post ); if ( ! $release ) { return false; diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php index 862b1630b0..02a6b2a092 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php @@ -1,6 +1,7 @@ 'resolution-test-' . ( ++self::$plugin_count ), + 'post_title' => 'Release Resolution 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 ) ); + + $wpdb->insert( + $wpdb->prefix . 'update_source', + array( + 'plugin_id' => $this->plugin->ID, + 'plugin_slug' => $this->plugin->post_name, + 'available' => 1, + 'version' => self::SERVED_VERSION, + 'stable_tag' => self::SERVED_VERSION, + 'plugin_name' => $this->plugin->post_title, + 'requires_plugins' => '', + 'last_updated' => $this->plugin->post_modified, + ) + ); + + update_post_meta( $this->plugin->ID, 'version', self::RENAMED_VERSION ); + update_post_meta( $this->plugin->ID, 'stable_tag', self::HELD_TAG ); + } + + /** + * Register a release for a tag. + * + * @param string $tag The release tag. + * @param string $version The release version. + * @param array $overrides Fields to override. + */ + private function add_release( string $tag, string $version, array $overrides = array() ): void { + Plugin_Directory::add_release( + $this->plugin, + array_merge( + array( + 'tag' => $tag, + 'version' => $version, + 'zips_built' => true, + 'zips_built_from_revision' => 0, + 'confirmed' => true, + 'confirmations_required' => 0, + 'release_delay' => DAY_IN_SECONDS, + ), + $overrides + ) + ); + } + + /** + * The version currently in the plugin's update_source row. + * + * @return string The served version. + */ + private function served_version(): string { + return API_Update_Updater::get_served_version( $this->plugin->post_name ); + } + + /** + * A renamed Version header inside a tag under cooldown keeps the hold. + */ + public function test_renamed_header_keeps_cooldown_hold(): void { + $this->add_release( self::HELD_TAG, self::RENAMED_VERSION ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } + + /** + * A renamed Version header inside a blocked tag keeps the block's hold. + */ + public function test_renamed_header_keeps_block_hold(): void { + $this->add_release( + self::HELD_TAG, + self::RENAMED_VERSION, + array( + 'release_block' => array( + 'scan_id' => 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', + 'risk_score' => 9.8, + 'blocked_at' => time(), + ), + ) + ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + $this->assertFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } + + /** + * A header renamed to another, clean release's version still resolves the + * hold from the stable tag serving the package. + */ + public function test_header_renamed_to_other_release_version_keeps_hold(): void { + $this->add_release( '2.0', '2.0', array( 'release_delay' => 0 ) ); + $this->add_release( + self::HELD_TAG, + self::RENAMED_VERSION, + array( + 'release_block' => array( 'blocked_at' => time() ), + ) + ); + update_post_meta( $this->plugin->ID, 'version', '2.0' ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + } + + /** + * A scan-driven block lands on the stable tag's release despite the rename. + */ + public function test_block_release_records_block_on_stable_tag_release(): void { + $this->add_release( self::HELD_TAG, self::RENAMED_VERSION ); + + $this->assertTrue( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) ); + + $release = Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::HELD_TAG ); + $this->assertTrue( API_Update_Updater::is_release_blocked( $release ) ); + } + + /** + * A reviewer force-release lifts the hold on the stable tag's release and + * serves the version despite the rename. + */ + public function test_force_release_serves_renamed_version(): void { + $this->add_release( + self::HELD_TAG, + self::RENAMED_VERSION, + array( + 'release_block' => array( 'blocked_at' => time() ), + ) + ); + + $this->assertTrue( API_Update_Updater::force_release( $this->plugin->post_name, 'Reviewed; findings are a false positive.' ) ); + + $this->assertSame( self::RENAMED_VERSION, $this->served_version() ); + } + + /** + * Trunk releases keep resolving by version: their rows are keyed + * `trunk@{version}` and there is no stable tag release to prefer. + */ + public function test_trunk_release_still_resolves_by_version(): void { + update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' ); + $this->add_release( 'trunk@' . self::RENAMED_VERSION, self::RENAMED_VERSION ); + + $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) ); + + $this->assertSame( self::SERVED_VERSION, $this->served_version() ); + $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); + } +} From 99e55b15017ae36e4b2f95b06fe312b13ec5bf4d Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 13 Aug 2026 15:23:43 -0500 Subject: [PATCH 02/14] Resolve holds consistently from the stable-tag release, not the header. Review fixes: - get_current_release() no longer falls back to the header version for tagged plugins (only trunk-stable ones, which are keyed trunk@{version}), closing a fail-open that reverted tagged plugins to header-keyed resolution and removing a redundant get_release pass per plugin. - block_release()'s already-served guard and force_release()'s audit note now key on the resolved release's version instead of the header meta. - Both cooldown notices name the resolved release; the committer notice computes its window from compute_release_time() to match enforcement. Co-Authored-By: Claude Fable 5 --- .../admin/metabox/class-controls.php | 7 ++- .../jobs/class-api-update-updater.php | 27 +++++++---- .../shortcodes/class-release-confirmation.php | 6 +-- .../tests/Current_Release_Resolution_Test.php | 46 +++++++++++++++++++ 4 files changed, 71 insertions(+), 15 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php index d9318fdb78..3cbf8a9549 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php @@ -65,6 +65,9 @@ protected static function display_release_cooldown() { return; } + // The held release is resolved from the stable tag, so name it — not the Version header, which may disagree. + $release_version = $release['version']; + $release_delay = (int) ( $release['release_delay'] ?? 0 ); if ( ! $release_delay ) { return; @@ -82,7 +85,7 @@ protected static function display_release_cooldown() { printf( /* translators: 1: version, 2: relative time until cooldown expires, 3: absolute UTC timestamp */ esc_html__( 'Version %1$s is in the release cooldown — it will be served to sites in %2$s (at %3$s UTC).', 'wporg-plugins' ), - esc_html( $version ), + esc_html( $release_version ), esc_html( human_time_diff( time(), $cooldown_until ) ), esc_html( gmdate( 'Y-m-d H:i', $cooldown_until ) ) ); @@ -105,7 +108,7 @@ protected static function display_release_cooldown() { printf( /* translators: %s: version */ esc_html__( 'Force-release %s now', 'wporg-plugins' ), - esc_html( $version ) + esc_html( $release_version ) ); ?> 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 213112bf64..9d92b2c906 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 @@ -234,17 +234,24 @@ public static function get_served_version( $plugin_slug ) { /** * The release being served or held for a plugin's current version. * - * Resolved from the stable tag first — the served package is built from it, - * and a Version header that doesn't match its tag must not orphan the - * release's block or cooldown — falling back to the version for trunk - * releases, which are keyed `trunk@{version}`. + * Tagged plugins resolve strictly by the stable tag — the source the served + * package is built from — so a Version header that disagrees with its tag + * cannot redirect the release's block or cooldown onto a different release. + * Trunk-stable plugins have no tag-named release row (theirs are keyed + * `trunk@{version}`), so they resolve by version; their identity is the + * header itself, so a header rename there is out of this method's reach. * * @param \WP_Post $post The plugin post. * @return array|false The release row from Plugin_Directory::get_release(), or false when none exists. */ public static function get_current_release( $post ) { - return Plugin_Directory::get_release( $post, get_post_meta( $post->ID, 'stable_tag', true ) ) - ?: Plugin_Directory::get_release( $post, get_post_meta( $post->ID, 'version', true ) ); + $stable_tag = get_post_meta( $post->ID, 'stable_tag', true ); + + if ( ! $stable_tag || 'trunk' === $stable_tag ) { + return Plugin_Directory::get_release( $post, get_post_meta( $post->ID, 'version', true ) ); + } + + return Plugin_Directory::get_release( $post, $stable_tag ); } /** @@ -282,14 +289,13 @@ public static function block_release( $plugin_slug, array $block ) { return false; } - $version = get_post_meta( $post->ID, 'version', true ); $release = self::get_current_release( $post ); if ( ! $release ) { return false; } - // Already live: a block can't un-ship a served version (compared with the column's varchar(128) truncation). - if ( self::get_served_version( $plugin_slug ) === substr( (string) $version, 0, 128 ) ) { + // Already live: a block can't un-ship the resolved release once it's the served version (compared with the column's varchar(128) truncation). + if ( self::get_served_version( $plugin_slug ) === substr( (string) ( $release['version'] ?? '' ), 0, 128 ) ) { return false; } @@ -473,13 +479,14 @@ public static function force_release( $plugin_slug, $reason, $user = null ) { return false; } - $version = get_post_meta( $post->ID, 'version', true ); $release = self::get_current_release( $post ); if ( ! $release ) { return false; } + $version = $release['version']; + // Log only what is actually lifted: a deleted block's only trace, and the cooldown only while it still runs. $lifted = array(); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php index 02a6b2a092..6b19e8a68b 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/shortcodes/class-release-confirmation.php @@ -498,8 +498,8 @@ public static function frontend_cooldown_notice( $post = null ) { return; } - $release_time = $release['confirmations'] ? max( $release['confirmations'] ) : (int) $release['date']; - $cooldown_until = $release_time + $release_delay; + // Match the enforced window: compute_release_time() is what update_single_plugin() gates on. + $cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay; if ( $cooldown_until <= time() ) { return; @@ -511,7 +511,7 @@ public static function frontend_cooldown_notice( $post = null ) { sprintf( /* translators: 1: plugin version, 2: relative time until cooldown expires, 3: delay duration in hours, 4: plugins@wordpress.org link */ __( 'Version %1$s will be released to sites in about %2$s. WordPress.org currently delays plugin updates by %3$d hours so moderators and security scanners can review changes before they reach users. If this update fixes a security issue that needs to ship sooner, contact %4$s.', 'wporg-plugins' ), - '' . esc_html( $version ) . '', + '' . esc_html( $release['version'] ) . '', esc_html( human_time_diff( time(), $cooldown_until ) ), (int) ( $release_delay / HOUR_IN_SECONDS ), 'plugins@wordpress.org' diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php index 3e0ee9595f..365aff3067 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php @@ -232,4 +232,50 @@ public function test_trunk_release_still_resolves_by_version(): void { $this->assertSame( self::SERVED_VERSION, $this->served_version() ); $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) ); } + + /** + * A tagged plugin resolves strictly by its stable tag: a release row that + * exists only for the header version is not a fallback. + */ + public function test_tagged_plugin_ignores_version_release_row(): void { + $this->add_release( self::RENAMED_VERSION, self::RENAMED_VERSION, array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $this->assertFalse( API_Update_Updater::get_current_release( get_post( $this->plugin->ID ) ) ); + } + + /** + * The block's already-served guard compares the resolved release's version, + * not the header: a header renamed to equal the served version cannot make + * a different, unserved release look already-live and refuse the block. + */ + public function test_block_release_guard_uses_resolved_release_version(): void { + update_post_meta( $this->plugin->ID, 'version', self::SERVED_VERSION ); + $this->add_release( self::HELD_TAG, '2.0' ); + + $this->assertTrue( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) ); + + $release = Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::HELD_TAG ); + $this->assertTrue( API_Update_Updater::is_release_blocked( $release ) ); + } + + /** + * The force-release audit note names the release actually unblocked — the + * stable-tag-resolved one — not the plugin's Version header. + */ + public function test_force_release_audit_log_names_resolved_release(): void { + update_post_meta( $this->plugin->ID, 'version', '9.9.9' ); + $this->add_release( self::HELD_TAG, '7.7.7', array( 'release_block' => array( 'blocked_at' => time() ) ) ); + + $this->assertTrue( API_Update_Updater::force_release( $this->plugin->post_name, 'Reviewed.' ) ); + + $notes = get_comments( + array( + 'post_id' => $this->plugin->ID, + 'type' => 'internal-note', + ) + ); + $this->assertNotEmpty( $notes ); + $this->assertStringContainsString( 'version 7.7.7', $notes[0]->comment_content ); + $this->assertStringNotContainsString( '9.9.9', $notes[0]->comment_content ); + } } From b58593b319429df75c8101cbd9db68258a4dda88 Mon Sep 17 00:00:00 2001 From: Konstantin Obenland Date: Thu, 13 Aug 2026 16:05:10 -0500 Subject: [PATCH 03/14] Harden hold resolution: strict tag match, block gate, UI on empty header. Second review round: - get_current_release() matches the release strictly by tag instead of through Plugin_Directory::get_release(), whose loose == collapses numerically-equal tags ('1.4'/'1.40') and whose tag-first lookup lets a dormant tag shadow the trunk@{version} row. - The block hold no longer gates on the header-derived is_new_version, so a header renamed to match the served version can't write the blocked tag live. - block_release()'s already-served guard no longer reads empty === empty as a match. - The cooldown UIs resolve and key off the stable-tag release: the metabox force-release staleness token is the release tag (not the header version), and both notices no longer bail on an empty header version, so an enforced hold stays visible and liftable. Co-Authored-By: Claude Fable 5 --- .../admin/metabox/class-controls.php | 20 +++---- .../jobs/class-api-update-updater.php | 35 ++++++++--- .../shortcodes/class-release-confirmation.php | 6 +- .../tests/Current_Release_Resolution_Test.php | 58 +++++++++++++++++++ 4 files changed, 93 insertions(+), 26 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php index 3cbf8a9549..376aafe1d1 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/metabox/class-controls.php @@ -55,17 +55,12 @@ static function display() { protected static function display_release_cooldown() { $post = get_post(); - $version = get_post_meta( $post->ID, 'version', true ); - if ( ! $version ) { - return; - } - + // Resolved from the stable tag, so the hold shows even when the Version header is empty or disagrees. $release = API_Update_Updater::get_current_release( $post ); if ( ! $release ) { return; } - // The held release is resolved from the stable tag, so name it — not the Version header, which may disagree. $release_version = $release['version']; $release_delay = (int) ( $release['release_delay'] ?? 0 ); @@ -103,7 +98,7 @@ protected static function display_release_cooldown() { >

-