From b9ac1860b2283ae9a27b532fe225c2bb50483045 Mon Sep 17 00:00:00 2001
From: Konstantin Obenland
Date: Mon, 20 Jul 2026 13:23:52 -0500
Subject: [PATCH 1/3] Plugin Directory: Fix the test environment so DB and REST
tests can run.
The test environment was missing three things the development environment
already had, which between them made whole classes of test impossible:
- `update_source` was never created, so anything touching the update API's
table failed.
- `wp-content/env-bin` was not mapped, so `database-tables.sql` was not
reachable from the test container to import.
- `PLUGINS_TABLE_PREFIX` was undefined, so `API\Base::load_routes()` fatalled
and no `plugins/v1` route was registered.
Shared verbatim with #717; whichever of the two PRs lands second should drop
this commit.
Co-Authored-By: Claude Opus 4.8 (1M context)
Claude-Session: https://claude.ai/code/session_01UNnLdnE7etihGNvsWY1qGE
---
environments/plugin-directory/.wp-env.test.json | 6 ++++++
environments/plugin-directory/bin/after-start-test.sh | 6 +++++-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/environments/plugin-directory/.wp-env.test.json b/environments/plugin-directory/.wp-env.test.json
index 6bffb7da9c..145db9cad1 100644
--- a/environments/plugin-directory/.wp-env.test.json
+++ b/environments/plugin-directory/.wp-env.test.json
@@ -4,7 +4,13 @@
"plugins": [
"../wordpress.org/public_html/wp-content/plugins/plugin-directory"
],
+ "mappings": {
+ "wp-content/env-bin": "./plugin-directory/bin"
+ },
"lifecycleScripts": {
"afterStart": "bash plugin-directory/bin/after-start-test.sh"
+ },
+ "config": {
+ "PLUGINS_TABLE_PREFIX": "wp_"
}
}
diff --git a/environments/plugin-directory/bin/after-start-test.sh b/environments/plugin-directory/bin/after-start-test.sh
index 13d416d04a..b68d50afcb 100755
--- a/environments/plugin-directory/bin/after-start-test.sh
+++ b/environments/plugin-directory/bin/after-start-test.sh
@@ -1,7 +1,7 @@
#!/bin/bash
#
# Runs after wp-env start for the test environment.
-# Installs PHPUnit 11 and Yoast polyfills in the test container.
+# Installs PHPUnit 11 and Yoast polyfills, and creates the stub tables tests read.
#
CONFIG="--config plugin-directory/.wp-env.test.json"
@@ -10,3 +10,7 @@ RUN="npx wp-env $CONFIG run tests-cli"
echo "Installing PHPUnit 11 and polyfills..."
$RUN composer global require -W phpunit/phpunit:^11.0 2>&1
$RUN composer require --dev yoast/phpunit-polyfills:^4.0 --working-dir=/wordpress-phpunit 2>&1
+
+# Create stub database tables that exist outside WordPress on production.
+echo "Creating stub database tables..."
+$RUN -- wp db import wp-content/env-bin/database-tables.sql
From ee1dd058ee0ae6ce0fa773be3152e8b124d2a501 Mon Sep 17 00:00:00 2001
From: Konstantin Obenland
Date: Wed, 22 Jul 2026 13:46:10 -0500
Subject: [PATCH 2/3] Plugin Directory: Let reviewers block a release from
being served.
Reviewers could already skip the release cooldown, but had no way to hold a
release back short of closing the whole plugin. Add a Block control to the
Controls metabox, alongside the force-release one and sharing a single reason
field, so a version still inside its cooldown can be held out of `update_source`
until it's force-released.
- API_Update_Updater::block_release() is the counterpart to force_release(): it
records a `release_block` on the release row, and a single gate in
update_single_plugin() honours it, so both the deferred cooldown cron and the
backup reconciliation cron keep the version out of `update_source`.
- A version that's already live can't be un-shipped, so a block on one is
refused rather than recorded as a hold that never takes effect.
- force_release() clears the block and notes the override in the audit log.
Holding a version back means update_single_plugin() can't rewrite the row at
all, since every version-specific column describes the held version. That was
already true of the cooldown, and it means a status change never reached sites
while one was in flight: closing a plugin whose next version was deferred left
`update_source` serving the current version with `available = 1`. A block makes
that unbounded rather than a few hours, so sync_availability() now writes the
availability and closure fields on their own and leaves the served version
alone.
Covered by Reviewer_Release_Block_Test, which exercises the block through the
same path the metabox uses, including closing and re-opening a plugin while a
version is held. Its fixture lives in Release_Block_Test_Case, resolved by a
small autoloader in the test bootstrap.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
phpcs.xml.dist | 10 +
.../admin/metabox/class-controls.php | 84 ++--
.../class-plugin-directory.php | 9 +
.../jobs/class-api-update-updater.php | 221 +++++++++--
.../plugins/plugin-directory/phpunit.xml | 1 +
.../tests/Release_Block_Test_Case.php | 224 +++++++++++
.../tests/Reviewer_Release_Block_Test.php | 362 ++++++++++++++++++
.../plugin-directory/tests/bootstrap.php | 17 +
8 files changed, 876 insertions(+), 52 deletions(-)
create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Block_Test_Case.php
create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
index 447c8ac037..c4aeb02ab3 100644
--- a/phpcs.xml.dist
+++ b/phpcs.xml.dist
@@ -138,4 +138,14 @@
+
+
+
+
+
+
+
+
+
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 3d85569f28..2126922bbc 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
@@ -46,11 +46,12 @@ static function display() {
}
/**
- * Display the release cooldown status and (for reviewers) a force-release control.
+ * Display the release hold status and (for reviewers) force-release and block controls.
*
- * Bails when there's no current release to gate, when the release has no cooldown
- * delay (feature off at release-creation, or already force-released), or when the
- * cooldown window has elapsed.
+ * Shows one of two messages: a countdown while a release is cooling down, or a block
+ * notice when the release is being held (which outlasts the cooldown window). Reviewers
+ * can force-release either way, and can block a version that's still cooling down. Bails
+ * when there's no current release, or when it's neither held nor still cooling down.
*/
protected static function display_release_cooldown() {
$post = get_post();
@@ -65,13 +66,13 @@ protected static function display_release_cooldown() {
return;
}
- $release_delay = (int) ( $release['release_delay'] ?? 0 );
- if ( ! $release_delay ) {
- return;
- }
-
+ $blocked = API_Update_Updater::is_release_blocked( $release );
+ $release_delay = (int) ( $release['release_delay'] ?? 0 );
$cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay;
- if ( $cooldown_until <= time() ) {
+ $in_cooldown = $release_delay && $cooldown_until > time();
+
+ // Nothing to surface unless the release is held or still cooling down.
+ if ( ! $blocked && ! $in_cooldown ) {
return;
}
@@ -79,21 +80,29 @@ protected static function display_release_cooldown() {
-
+
@@ -116,12 +136,14 @@ protected static function display_release_cooldown() {
}
/**
- * Save handler for reviewer force-release submissions from the Controls metabox.
+ * Save handler for reviewer force-release and block submissions from the Controls metabox.
*
* @param int $post_id The post being saved.
*/
public static function save_post( $post_id ) {
- if ( empty( $_POST['force_release_version'] ) ) {
+ $is_force_release = ! empty( $_POST['force_release_version'] );
+ $is_block = ! empty( $_POST['block_release_version'] );
+ if ( ! $is_force_release && ! $is_block ) {
return;
}
@@ -139,20 +161,30 @@ public static function save_post( $post_id ) {
check_admin_referer( 'update-post_' . $post_id );
$version = get_post_meta( $post->ID, 'version', true );
- $submitted_version = sanitize_text_field( wp_unslash( $_POST['force_release_version'] ) );
+ $submitted_version = sanitize_text_field( wp_unslash( $is_force_release ? $_POST['force_release_version'] : $_POST['block_release_version'] ) );
if ( $submitted_version !== $version ) {
// Submitted version doesn't match current — a newer commit landed since the form was rendered.
return;
}
- $reason = isset( $_POST['force_release_reason'] )
- ? trim( sanitize_textarea_field( wp_unslash( $_POST['force_release_reason'] ) ) )
+ $reason = isset( $_POST['release_action_reason'] )
+ ? trim( sanitize_textarea_field( wp_unslash( $_POST['release_action_reason'] ) ) )
: '';
if ( ! $reason ) {
return;
}
- API_Update_Updater::force_release( $post->post_name, $reason );
+ if ( $is_force_release ) {
+ API_Update_Updater::force_release( $post->post_name, $reason );
+ } else {
+ API_Update_Updater::block_release(
+ $post->post_name,
+ array(
+ 'reason' => $reason,
+ 'blocked_by' => wp_get_current_user()->user_login,
+ )
+ );
+ }
}
/**
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php
index 105c678dfc..c6fe0f9bd8 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php
@@ -1754,6 +1754,15 @@ public static function add_release( $plugin, $data ) {
unset( $release['discarded'] );
}
+ /*
+ * Clear a release block so the release can be served.
+ * See Jobs\API_Update_Updater::force_release().
+ */
+ if ( ! empty( $data['unblock'] ) ) {
+ unset( $release['release_block'] );
+ }
+ unset( $release['unblock'] );
+
$releases = self::get_releases( $plugin );
// Find any other releases using this slug (as in the case of updates) and remove it.
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 f24821258a..bbee0b4cb0 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
@@ -86,15 +86,17 @@ public static function update_single_plugin( $plugin_slug ) {
$requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true );
$release = Plugin_Directory::get_release( $post, $version );
$release_time = self::compute_release_time( $post, $release );
- $existing_version = (string) $wpdb->get_var(
- $wpdb->prepare(
- "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
- $post->post_name
- )
- );
+ $existing_version = self::get_served_version( $post->post_name );
$release_delay = (int) ( $release['release_delay'] ?? 0 );
+ if ( self::is_release_blocked( $release ) && $existing_version !== (string) $version ) {
+ wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" );
+ self::sync_availability( $post );
+
+ return true;
+ }
+
/*
* Defer the write for new versions still inside the cooldown window. While
* deferred, the existing `update_source` row (carrying the previous version)
@@ -109,6 +111,8 @@ public static function update_single_plugin( $plugin_slug ) {
$cooldown_until = $release_time + $release_delay;
if ( $cooldown_until > time() ) {
self::queue_release_to_update_api( $post->post_name, $cooldown_until );
+ self::sync_availability( $post );
+
return true;
}
}
@@ -127,16 +131,7 @@ public static function update_single_plugin( $plugin_slug ) {
'last_stable_tag' => $post->last_stable_tag ?? '',
);
- if ( in_array( $post->post_status, array( 'disabled', 'closed' ) ) ) {
- $closed_data = Template::get_close_data( $post );
- if ( $closed_data ) {
- // Close date is sometimes unknown, only include the Day of closure.
- $meta['closed_at'] = $closed_data['date'] ? gmdate( 'Y-m-d', strtotime( $closed_data['date'] ) ) : false;
- if ( $closed_data['public'] ) {
- $meta['closed_reason'] = $closed_data['reason'] ?: 'unknown';
- }
- }
- }
+ $meta = array_merge( $meta, self::get_closed_meta( $post ) );
// Add phased rollout strategy data if needed.
if ( $release && ! empty( $release['rollout_strategy'] ) ) {
@@ -198,7 +193,7 @@ public static function update_single_plugin( $plugin_slug ) {
// Sync the latest version to Stats.
if ( function_exists( '\WordPressdotorg\Stats\sync_latest_version' ) ) {
\WordPressdotorg\Stats\sync_latest_version(
- 'plugin',
+ 'plugin',
array(
$plugin_slug => $version
)
@@ -208,6 +203,111 @@ public static function update_single_plugin( $plugin_slug ) {
return true;
}
+ /**
+ * The closure fields recorded in `update_source`'s `meta` column.
+ *
+ * @param \WP_Post $post The plugin post.
+ * @return array Empty for a plugin that is neither closed nor disabled.
+ */
+ protected static function get_closed_meta( $post ) {
+ if ( ! in_array( $post->post_status, array( 'disabled', 'closed' ) ) ) {
+ return array();
+ }
+
+ $closed_data = Template::get_close_data( $post );
+ if ( ! $closed_data ) {
+ return array();
+ }
+
+ // Close date is sometimes unknown, only include the Day of closure.
+ $meta = array(
+ 'closed_at' => $closed_data['date'] ? gmdate( 'Y-m-d', strtotime( $closed_data['date'] ) ) : false,
+ );
+
+ if ( $closed_data['public'] ) {
+ $meta['closed_reason'] = $closed_data['reason'] ?: 'unknown';
+ }
+
+ return $meta;
+ }
+
+ /**
+ * Apply the plugin's availability and closure state to the `update_source` row without
+ * disturbing the version it serves.
+ *
+ * Whenever a new version is held back — by the release cooldown or by a block —
+ * update_single_plugin() returns early, because the row has to keep serving the previous
+ * version and the version-specific columns are all derived from post meta describing the
+ * held one. A status change still has to reach sites immediately though: closing a plugin
+ * whose next version is on hold must stop the current version being offered. So the
+ * availability and closure fields are written on their own, and the rest of the row is
+ * left as it is.
+ *
+ * @param \WP_Post $post The plugin post.
+ */
+ protected static function sync_availability( $post ) {
+ global $wpdb;
+
+ // Fetched as a row, not a single value: `meta` is nullable, so a null there would be
+ // indistinguishable from the plugin having no row at all.
+ $row = $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT `meta` FROM `{$wpdb->prefix}update_source` WHERE `plugin_slug` = %s",
+ $post->post_name
+ ),
+ ARRAY_A
+ );
+
+ // Nothing is being served, so there's no availability to correct.
+ if ( ! $row ) {
+ return;
+ }
+
+ $meta = $row['meta'] ? (array) maybe_unserialize( $row['meta'] ) : array();
+
+ // Rebuild rather than merge, so re-opening a plugin drops the closure fields again.
+ unset( $meta['closed_at'], $meta['closed_reason'] );
+ $meta = array_merge( $meta, self::get_closed_meta( $post ) );
+
+ $wpdb->update(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'available' => (int) in_array( $post->post_status, array( 'publish', 'disabled' ) ),
+ 'meta' => $meta ? serialize( $meta ) : '',
+ ),
+ array( 'plugin_slug' => $post->post_name )
+ );
+ }
+
+ /**
+ * The version currently served from `update_source`.
+ *
+ * @param string $plugin_slug The plugin slug.
+ * @return string The served version, or '' when the plugin isn't in `update_source`.
+ */
+ public static function get_served_version( $plugin_slug ) {
+ global $wpdb;
+
+ return (string) $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
+ $plugin_slug
+ )
+ );
+ }
+
+ /**
+ * Whether a release is being held out of `update_source` by a block.
+ *
+ * A block is set by a reviewer, and cleared by a force-release.
+ *
+ * @param array|bool $release The release row from Plugin_Directory::get_release(), or false.
+ * @return bool True when the release is being held out of `update_source`.
+ */
+ public static function is_release_blocked( $release ) {
+ return is_array( $release ) && ! empty( $release['release_block'] );
+ }
+
/**
* Determine the release timestamp for a plugin version.
*
@@ -283,27 +383,96 @@ public static function force_release( $plugin_slug, $reason, $user = null ) {
return false;
}
- Tools::audit_log(
- sprintf(
- 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s',
- $version,
- (int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS,
- $reason
- ),
- $post
- );
+ // A force-release also overrides a block; note that in the audit trail.
+ if ( self::is_release_blocked( $release ) ) {
+ Tools::audit_log(
+ sprintf(
+ 'Force-released version %1$s, overriding the release block. Reason: %2$s',
+ $version,
+ $reason
+ ),
+ $post
+ );
+ } else {
+ Tools::audit_log(
+ sprintf(
+ 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s',
+ $version,
+ (int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS,
+ $reason
+ ),
+ $post
+ );
+ }
Plugin_Directory::add_release(
$post,
array(
'tag' => $release['tag'],
'release_delay' => 0,
+ // Clear any release block so update_single_plugin() serves the version.
+ 'unblock' => true,
)
);
return self::update_single_plugin( $plugin_slug );
}
+ /**
+ * Hold a plugin's current version out of `update_source` until it's force-released.
+ *
+ * The counterpart to force_release(). Callers apply their own preconditions first; this
+ * only refuses when there's nothing left to hold.
+ *
+ * Capability checks must be performed by the caller.
+ *
+ * @param string $plugin_slug The plugin slug.
+ * @param array $block The block to record: 'reason' and 'blocked_by'.
+ * @return bool True when the version was held, false when there was nothing to hold.
+ */
+ public static function block_release( $plugin_slug, $block ) {
+ $post = Plugin_Directory::get_plugin_post( $plugin_slug );
+ if ( ! $post ) {
+ return false;
+ }
+
+ $version = get_post_meta( $post->ID, 'version', true );
+ $release = Plugin_Directory::get_release( $post, $version );
+
+ if ( ! $release ) {
+ return false;
+ }
+
+ // Already live: the version is being served, so there's nothing left to hold back.
+ if ( self::get_served_version( $plugin_slug ) === (string) $version ) {
+ return false;
+ }
+
+ $block['blocked_at'] = time();
+
+ Plugin_Directory::add_release(
+ $post,
+ array(
+ 'tag' => $release['tag'],
+ 'release_block' => $block,
+ )
+ );
+
+ Tools::audit_log(
+ sprintf(
+ 'Blocked version %1$s from being served to sites. Reason: %2$s',
+ $version,
+ $block['reason']
+ ),
+ $post
+ );
+
+ // Re-run so a version scheduled to serve at cooldown-end is held now instead.
+ self::update_single_plugin( $plugin_slug );
+
+ return true;
+ }
+
static function get_plugin_assets( $post ) {
$icons = $banners = $banners_rtl = array();
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml b/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml
index dfcbdaedc0..067bbfe6dc 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/phpunit.xml
@@ -7,6 +7,7 @@
tests/tests/bootstrap.php
+ tests/Release_Block_Test_Case.phptests/wporg-url-schemes.phptests/wporg-plugin-api.phptests/wporg-plugin-api-performance.php
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Block_Test_Case.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Block_Test_Case.php
new file mode 100644
index 0000000000..5b77093033
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Block_Test_Case.php
@@ -0,0 +1,224 @@
+ 'plugin',
+ 'post_name' => static::SLUG,
+ 'post_title' => static::SLUG,
+ 'post_status' => 'publish',
+ 'post_modified' => current_time( 'mysql' ),
+ 'post_modified_gmt' => current_time( 'mysql', 1 ),
+ ),
+ true
+ );
+
+ $this->assertNotInstanceOf( WP_Error::class, $plugin_id );
+
+ $this->plugin = get_post( $plugin_id );
+
+ update_post_meta( $plugin_id, 'version', static::NEW_VERSION );
+ update_post_meta( $plugin_id, 'stable_tag', static::NEW_VERSION );
+ update_post_meta( $plugin_id, 'header_name', static::SLUG );
+ update_post_meta( $plugin_id, 'header_author', 'WordPress' );
+ update_post_meta( $plugin_id, 'version_date', gmdate( 'Y-m-d H:i:s', time() ) );
+
+ $this->set_releases( array( $this->release() ) );
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->query( "TRUNCATE TABLE `{$wpdb->prefix}update_source`" );
+ $this->serve( static::SERVED_VERSION );
+ }
+
+ /**
+ * Remove the plugin, its meta, any audit-log notes, and any deferred cron event. There's
+ * no transaction to roll back without WP_UnitTestCase, so state would otherwise leak.
+ */
+ protected function tearDown(): void {
+ wp_clear_scheduled_hook( 'release_to_update_api:' . static::SLUG );
+
+ foreach ( get_comments( array( 'post_id' => $this->plugin->ID ) ) as $note ) {
+ wp_delete_comment( $note->comment_ID, true );
+ }
+
+ wp_delete_post( $this->plugin->ID, true );
+
+ parent::tearDown();
+ }
+
+ /**
+ * A complete release row for NEW_VERSION: confirmed, built, inside its delay.
+ * get_releases() reads keys beyond the ones under test.
+ *
+ * @param array $overrides Values to override on the default release.
+ * @return array
+ */
+ protected function release( $overrides = array() ) {
+ return array_merge(
+ array(
+ 'date' => time(),
+ 'tag' => static::NEW_VERSION,
+ 'version' => static::NEW_VERSION,
+ 'zips_built' => true,
+ 'confirmations' => array(),
+ 'confirmed' => true,
+ 'confirmations_required' => 0,
+ 'committer' => array(),
+ 'revision' => array(),
+ 'release_delay' => static::DELAY,
+ ),
+ $overrides
+ );
+ }
+
+ /**
+ * Seed the releases meta directly: get_releases() otherwise falls back to
+ * prefill_releases_meta(), which reaches out to SVN.
+ *
+ * @param array $releases The releases to store.
+ */
+ protected function set_releases( $releases ) {
+ update_post_meta( $this->plugin->ID, 'releases', $releases );
+ }
+
+ /**
+ * Put a version into `update_source`, standing in for the currently-served release.
+ *
+ * @param string $version The version to serve.
+ */
+ protected function serve( $version ) {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->insert(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'plugin_id' => $this->plugin->ID,
+ 'plugin_slug' => static::SLUG,
+ 'available' => 1,
+ 'version' => $version,
+ 'last_updated' => current_time( 'mysql' ),
+ )
+ );
+ }
+
+ /**
+ * The version currently served from `update_source`.
+ *
+ * @return string|null
+ */
+ protected function get_served_version() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress, and a cached read would defeat the assertion.
+ return $wpdb->get_var(
+ $wpdb->prepare(
+ "SELECT `version` FROM `{$wpdb->prefix}update_source` WHERE `plugin_slug` = %s",
+ static::SLUG
+ )
+ );
+ }
+
+ /**
+ * The whole `update_source` row, for assertions about availability rather than version.
+ *
+ * @return array|null
+ */
+ protected function get_served_row() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress, and a cached read would defeat the assertion.
+ return $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT * FROM `{$wpdb->prefix}update_source` WHERE `plugin_slug` = %s",
+ static::SLUG
+ ),
+ ARRAY_A
+ );
+ }
+
+ /**
+ * Move the committed version outside its delay, so the next update run would serve it.
+ */
+ protected function elapse_cooldown() {
+ update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s', time() - ( static::DELAY * 2 ) ) );
+ }
+
+ /**
+ * The block recorded against a release, if any.
+ *
+ * @param string|null $tag The release tag. Defaults to NEW_VERSION.
+ * @return array|null The `release_block` value, or null when the release isn't held.
+ */
+ protected function get_release_block( $tag = null ) {
+ if ( null === $tag ) {
+ $tag = static::NEW_VERSION;
+ }
+
+ foreach ( (array) get_post_meta( $this->plugin->ID, 'releases', true ) as $release ) {
+ if ( $tag === $release['tag'] ) {
+ return $release['release_block'] ?? null;
+ }
+ }
+
+ return null;
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php
new file mode 100644
index 0000000000..f7997422e6
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php
@@ -0,0 +1,362 @@
+ 'reviewer-block-user',
+ 'user_pass' => 'password',
+ 'user_email' => 'reviewer-block-user@example.org',
+ )
+ );
+ $this->assertNotInstanceOf( WP_Error::class, $reviewer_id );
+
+ $this->reviewer = get_user_by( 'id', $reviewer_id );
+ $this->reviewer->add_cap( 'plugin_review' );
+ }
+
+ /**
+ * Remove the reviewer, and the request state the metabox tests leave behind.
+ */
+ protected function tearDown(): void {
+ unset(
+ $_POST['force_release_version'],
+ $_POST['block_release_version'],
+ $_POST['release_action_reason'],
+ $_REQUEST['_wpnonce']
+ );
+
+ wp_set_current_user( 0 );
+ wp_delete_user( $this->reviewer->ID );
+
+ parent::tearDown();
+ }
+
+ /**
+ * The block payload the Controls metabox sends for a reviewer block.
+ *
+ * @return array
+ */
+ protected function reviewer_block() {
+ return array(
+ 'reason' => 'Suspicious obfuscated code.',
+ 'blocked_by' => $this->reviewer->user_login,
+ );
+ }
+
+ /**
+ * Submit the Controls metabox as the reviewer would, with a valid nonce.
+ *
+ * @param array $fields The release-action fields to post.
+ */
+ protected function submit_controls( $fields ) {
+ wp_set_current_user( $this->reviewer->ID );
+
+ foreach ( $fields as $name => $value ) {
+ $_POST[ $name ] = $value;
+ }
+
+ $_REQUEST['_wpnonce'] = wp_create_nonce( 'update-post_' . $this->plugin->ID );
+
+ Controls::save_post( $this->plugin->ID );
+ }
+
+ /**
+ * A reviewer block holds the in-cooldown version: the previous one keeps being served, the
+ * block is recorded with the reason and reviewer, and any deferred serve is cancelled.
+ */
+ public function test_a_block_holds_the_in_cooldown_version() {
+ $result = API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $this->assertTrue( $result );
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ $this->assertSame( 'Suspicious obfuscated code.', $this->get_release_block()['reason'] );
+ $this->assertSame( $this->reviewer->user_login, $this->get_release_block()['blocked_by'] );
+ $this->assertFalse( wp_next_scheduled( 'release_to_update_api:' . self::SLUG ) );
+ }
+
+ /**
+ * The block is recorded in the audit log with the supplied reason.
+ */
+ public function test_a_block_records_an_audit_note() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $notes = get_comments(
+ array(
+ 'post_id' => $this->plugin->ID,
+ 'type' => 'internal-note',
+ )
+ );
+
+ $block_notes = array_filter(
+ $notes,
+ function ( $note ) {
+ return false !== strpos( $note->comment_content, 'Blocked version 2.0 from being served' );
+ }
+ );
+
+ $this->assertCount( 1, $block_notes );
+ }
+
+ /**
+ * A force-release lifts a reviewer block: the held version is served and the block cleared.
+ */
+ public function test_force_release_clears_a_reviewer_block_and_serves() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $result = API_Update_Updater::force_release( self::SLUG, 'Reviewed with the author; resolved.' );
+
+ $this->assertTrue( $result );
+ $this->assertNull( $this->get_release_block() );
+ $this->assertSame( self::NEW_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * The force-release over a reviewer block is recorded, without a risk score to name.
+ */
+ public function test_force_release_over_a_reviewer_block_records_the_override() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ API_Update_Updater::force_release( self::SLUG, 'Reviewed with the author; resolved.' );
+
+ $notes = get_comments(
+ array(
+ 'post_id' => $this->plugin->ID,
+ 'type' => 'internal-note',
+ )
+ );
+
+ $override = array_filter(
+ $notes,
+ function ( $note ) {
+ return false !== strpos( $note->comment_content, 'overriding the release block' );
+ }
+ );
+
+ $this->assertCount( 1, $override );
+ }
+
+ /**
+ * A version that's already live can't be un-shipped by a block; `update_source` is left alone.
+ */
+ public function test_a_block_leaves_an_already_served_version_untouched() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->update(
+ $wpdb->prefix . 'update_source',
+ array( 'version' => self::NEW_VERSION ),
+ array( 'plugin_slug' => self::SLUG )
+ );
+
+ $result = API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $this->assertFalse( $result );
+ $this->assertNull( $this->get_release_block() );
+ $this->assertSame( self::NEW_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * With no release row for the current version there's nothing to hold, so the block is a no-op.
+ */
+ public function test_a_block_without_a_release_does_nothing() {
+ update_post_meta( $this->plugin->ID, 'version', '3.0' );
+
+ $result = API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $this->assertFalse( $result );
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * Closing a plugin has to reach sites even while a version is held: the held version stays
+ * held, but the one still being served stops being offered for update.
+ */
+ public function test_closing_a_plugin_stops_serving_it_while_a_version_is_held() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ wp_update_post(
+ array(
+ 'ID' => $this->plugin->ID,
+ 'post_status' => 'closed',
+ )
+ );
+ clean_post_cache( $this->plugin->ID );
+
+ API_Update_Updater::update_single_plugin( self::SLUG );
+
+ $row = $this->get_served_row();
+
+ $this->assertEquals( 0, $row['available'] );
+ $this->assertSame( self::SERVED_VERSION, $row['version'] );
+ }
+
+ /**
+ * The same holds for a version still inside its cooldown: a close isn't deferred with it.
+ */
+ public function test_closing_a_plugin_stops_serving_it_during_the_cooldown() {
+ wp_update_post(
+ array(
+ 'ID' => $this->plugin->ID,
+ 'post_status' => 'closed',
+ )
+ );
+ clean_post_cache( $this->plugin->ID );
+
+ API_Update_Updater::update_single_plugin( self::SLUG );
+
+ $row = $this->get_served_row();
+
+ $this->assertEquals( 0, $row['available'] );
+ $this->assertSame( self::SERVED_VERSION, $row['version'] );
+ }
+
+ /**
+ * And re-opening it puts the served version back on offer, still without shipping the
+ * held one.
+ */
+ public function test_reopening_a_plugin_serves_the_previous_version_again() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ foreach ( array( 'closed', 'publish' ) as $status ) {
+ wp_update_post(
+ array(
+ 'ID' => $this->plugin->ID,
+ 'post_status' => $status,
+ )
+ );
+ clean_post_cache( $this->plugin->ID );
+
+ API_Update_Updater::update_single_plugin( self::SLUG );
+ }
+
+ $row = $this->get_served_row();
+
+ $this->assertEquals( 1, $row['available'] );
+ $this->assertSame( self::SERVED_VERSION, $row['version'] );
+ }
+
+ /**
+ * The metabox path end to end: a reviewer submits the Block button and the version is held.
+ */
+ public function test_the_metabox_blocks_the_version() {
+ $this->submit_controls(
+ array(
+ 'block_release_version' => self::NEW_VERSION,
+ 'release_action_reason' => 'Suspicious obfuscated code.',
+ )
+ );
+
+ $this->assertSame( 'Suspicious obfuscated code.', $this->get_release_block()['reason'] );
+ $this->assertSame( $this->reviewer->user_login, $this->get_release_block()['blocked_by'] );
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * A submission from a user without the reviewer capability does nothing.
+ */
+ public function test_the_metabox_ignores_a_user_without_the_review_capability() {
+ $this->reviewer->remove_cap( 'plugin_review' );
+
+ $this->submit_controls(
+ array(
+ 'block_release_version' => self::NEW_VERSION,
+ 'release_action_reason' => 'Suspicious obfuscated code.',
+ )
+ );
+
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A save that isn't a release action at all is left alone.
+ */
+ public function test_the_metabox_ignores_an_unrelated_save() {
+ $this->submit_controls( array( 'release_action_reason' => 'Suspicious obfuscated code.' ) );
+
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A reason is required.
+ */
+ public function test_the_metabox_refuses_a_block_without_a_reason() {
+ $this->submit_controls(
+ array(
+ 'block_release_version' => self::NEW_VERSION,
+ 'release_action_reason' => ' ',
+ )
+ );
+
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * A form rendered before a newer commit landed doesn't block the wrong version.
+ */
+ public function test_the_metabox_refuses_a_stale_version() {
+ update_post_meta( $this->plugin->ID, 'version', '3.0' );
+
+ $this->submit_controls(
+ array(
+ 'block_release_version' => self::NEW_VERSION,
+ 'release_action_reason' => 'Suspicious obfuscated code.',
+ )
+ );
+
+ $this->assertNull( $this->get_release_block() );
+ }
+
+ /**
+ * The force-release button ships the held version.
+ */
+ public function test_the_metabox_force_releases() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $this->submit_controls(
+ array(
+ 'force_release_version' => self::NEW_VERSION,
+ 'release_action_reason' => 'Reviewed with the author; resolved.',
+ )
+ );
+
+ $this->assertNull( $this->get_release_block() );
+ $this->assertSame( self::NEW_VERSION, $this->get_served_version() );
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php
index 633d06519c..20b5b2fee5 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/bootstrap.php
@@ -52,3 +52,20 @@ function manually_load_plugin() {
// Start up the WP testing environment.
require $_tests_dir . '/includes/bootstrap.php';
+
+/*
+ * Resolve test classes from this directory by name, so the shared `*_Test_Case` bases load
+ * on demand rather than needing to be required in dependency order. PHPUnit loads the test
+ * files themselves; this only ever fires for a base a test file extends.
+ *
+ * Registered last, so it's consulted only for classes nothing else resolved.
+ */
+spl_autoload_register(
+ function ( $class ) {
+ $file = __DIR__ . '/' . $class . '.php';
+
+ if ( file_exists( $file ) ) {
+ require_once $file;
+ }
+ }
+);
From 9afcb884a794413fb83f3ea68be2b330e30c3a13 Mon Sep 17 00:00:00 2001
From: Konstantin Obenland
Date: Thu, 23 Jul 2026 11:02:01 -0500
Subject: [PATCH 3/3] Plugin Directory: Harden the reviewer release block
against review findings.
Address correctness and feedback gaps in the release-block feature:
- Flush the update/info caches when syncing availability, so a status
change on a plugin with a held version reaches sites instead of waiting
out the cache TTL. Extracted into flush_update_caches().
- Make sync_availability() return a bool, tell a failed lookup apart from
no row, bail rather than overwrite on unreadable meta, and check the
update. Both held-path early returns propagate it so cron_trigger()'s
retry works again.
- Guard block_release() against the deferred serve racing it: confirm the
hold persisted, then roll it back if the version was served in the
meantime, so no block is recorded against a live version. Refuse a
second block rather than merging it into the first. Audit-log the
outcome, not the intent.
- Report every save-handler outcome to the reviewer via an admin notice
(success, stale version, missing reason, already-served, failure),
instead of a silent no-op that reads like success.
- Key the metabox display and Block button on whether the version is
actually served, not on the cooldown window, so the controls survive
the gap between cooldown expiry and the deferred write.
- Suppress the committer-facing cooldown notices while a version is held,
rather than promising a serve time that no longer applies.
Tests: scope the fixture's cleanup to its own slug (no more shared-table
TRUNCATE), uniquify the reviewer login, give serve() a meta argument, fix
the vacuous scheduled-hook assertion, and cover meta preservation, the
double-block refusal, the metabox render gates, and the new notices.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
.../admin/metabox/class-controls.php | 173 ++++++++++++-
.../jobs/class-api-update-updater.php | 161 +++++++++---
.../shortcodes/class-release-confirmation.php | 25 +-
.../tests/Release_Block_Test_Case.php | 55 +++-
.../tests/Reviewer_Release_Block_Test.php | 242 +++++++++++++++++-
5 files changed, 596 insertions(+), 60 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 2126922bbc..70f8c6ab91 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
@@ -48,14 +48,20 @@ static function display() {
/**
* Display the release hold status and (for reviewers) force-release and block controls.
*
- * Shows one of two messages: a countdown while a release is cooling down, or a block
- * notice when the release is being held (which outlasts the cooldown window). Reviewers
- * can force-release either way, and can block a version that's still cooling down. Bails
- * when there's no current release, or when it's neither held nor still cooling down.
+ * Shows one of three messages: a countdown while a release is cooling down, a block notice
+ * when the release is being held (which outlasts the cooldown window), or a plain notice
+ * that the version hasn't reached sites yet. Reviewers can force-release in any of them,
+ * and can block any version that isn't already served. Bails when there's no current
+ * release, or when the version is being served and isn't held.
*/
protected static function display_release_cooldown() {
$post = get_post();
+ // Only plugins the update API serves from have a release to gate.
+ if ( ! in_array( $post->post_status, array( 'publish', 'disabled', 'closed' ), true ) ) {
+ return;
+ }
+
$version = get_post_meta( $post->ID, 'version', true );
if ( ! $version ) {
return;
@@ -71,8 +77,16 @@ protected static function display_release_cooldown() {
$cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay;
$in_cooldown = $release_delay && $cooldown_until > time();
- // Nothing to surface unless the release is held or still cooling down.
- if ( ! $blocked && ! $in_cooldown ) {
+ /*
+ * A version can be held right up until it's written to `update_source`, which lags the
+ * end of the cooldown by however long the deferred event takes to run. The controls key
+ * on whether it's actually being served — the same condition block_release() applies —
+ * so they don't disappear during that window.
+ */
+ $unserved = ( API_Update_Updater::get_served_version( $post->post_name ) !== (string) $version );
+
+ // Nothing to surface once the version is being served and isn't held.
+ if ( ! $blocked && ! $unserved ) {
return;
}
@@ -86,7 +100,7 @@ protected static function display_release_cooldown() {
esc_html__( 'Version %s is blocked and is being held from sites. Force-releasing overrides the block.', 'wporg-plugins' ),
esc_html( $version )
);
- } else {
+ } elseif ( $in_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' ),
@@ -94,6 +108,12 @@ protected static function display_release_cooldown() {
esc_html( human_time_diff( time(), $cooldown_until ) ),
esc_html( gmdate( 'Y-m-d H:i', $cooldown_until ) )
);
+ } else {
+ printf(
+ /* translators: %s: version */
+ esc_html__( 'Version %s has not been served to sites yet.', 'wporg-plugins' ),
+ esc_html( $version )
+ );
}
?>