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
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..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
@@ -46,15 +46,22 @@ 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 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;
@@ -65,13 +72,21 @@ 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();
+
+ /*
+ * 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;
}
@@ -79,21 +94,35 @@ protected static function display_release_cooldown() {
@@ -116,12 +156,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 +181,155 @@ 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.
+ self::add_notice(
+ sprintf(
+ /* translators: 1: submitted version, 2: current version */
+ __( 'No action taken: version %1$s is no longer the current version — %2$s has since been committed.', 'wporg-plugins' ),
+ $submitted_version,
+ $version
+ )
+ );
+
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 ) {
+ self::add_notice( __( 'No action taken: a reason is required to block or force-release a version.', 'wporg-plugins' ) );
+
+ return;
+ }
+
+ if ( $is_force_release ) {
+ self::handle_force_release( $post, $version, $reason );
+ } else {
+ self::handle_block( $post, $version, $reason );
+ }
+ }
+
+ /**
+ * Force-release the current version and report the outcome to the reviewer.
+ *
+ * @param \WP_Post $post The plugin post.
+ * @param string $version The version being released.
+ * @param string $reason The reason recorded in the audit log.
+ */
+ protected static function handle_force_release( $post, $version, $reason ) {
+ if ( API_Update_Updater::force_release( $post->post_name, $reason ) ) {
+ self::add_notice(
+ sprintf(
+ /* translators: %s: version */
+ __( 'Version %s has been released and is being served to sites.', 'wporg-plugins' ),
+ $version
+ ),
+ 'updated'
+ );
+
return;
}
- API_Update_Updater::force_release( $post->post_name, $reason );
+ self::add_notice(
+ sprintf(
+ /* translators: %s: version */
+ __( 'Version %s could not be released. Nothing was changed — please try again.', 'wporg-plugins' ),
+ $version
+ )
+ );
+ }
+
+ /**
+ * Hold the current version back and report the outcome to the reviewer.
+ *
+ * A refusal is indistinguishable from a success from the edit screen alone — the release
+ * section disappears either way once the version is served — so every outcome says which
+ * it was.
+ *
+ * @param \WP_Post $post The plugin post.
+ * @param string $version The version being held.
+ * @param string $reason The reason recorded against the block and in the audit log.
+ */
+ protected static function handle_block( $post, $version, $reason ) {
+ $held = API_Update_Updater::block_release(
+ $post->post_name,
+ array(
+ 'reason' => $reason,
+ 'blocked_by' => wp_get_current_user()->user_login,
+ )
+ );
+
+ if ( $held ) {
+ self::add_notice(
+ sprintf(
+ /* translators: %s: version */
+ __( 'Version %s is now held from sites. Force-releasing it overrides the block.', 'wporg-plugins' ),
+ $version
+ ),
+ 'updated'
+ );
+
+ return;
+ }
+
+ if ( API_Update_Updater::get_served_version( $post->post_name ) === (string) $version ) {
+ self::add_notice(
+ sprintf(
+ /* translators: %s: version */
+ __( 'Version %s is already being served to sites and can no longer be held back. Close or disable the plugin if it needs to stop being offered.', 'wporg-plugins' ),
+ $version
+ )
+ );
+
+ return;
+ }
+
+ self::add_notice(
+ sprintf(
+ /* translators: %s: version */
+ __( 'Version %s could not be held. Nothing was changed — please try again.', 'wporg-plugins' ),
+ $version
+ )
+ );
+ }
+
+ /**
+ * Queue a notice for the reviewer, to be shown after the post editor redirects.
+ *
+ * Reuses the `wporg-plugins` settings-errors group that Admin\Customizations renders on
+ * `all_admin_notices`; the redirect is flagged so `settings_errors()` picks the transient
+ * up on the way back.
+ *
+ * @param string $message The message to display.
+ * @param string $type Notice type, 'error' or 'updated'. Default 'error'.
+ */
+ protected static function add_notice( $message, $type = 'error' ) {
+ set_transient(
+ 'settings_errors',
+ array(
+ array(
+ 'setting' => 'wporg-plugins',
+ 'code' => 'release-action',
+ 'message' => $message,
+ 'type' => $type,
+ ),
+ )
+ );
+
+ add_filter( 'redirect_post_location', array( __CLASS__, 'flag_settings_updated' ) );
+ }
+
+ /**
+ * Mark the post-editor redirect so queued notices are displayed on arrival.
+ *
+ * @param string $location The redirect destination.
+ * @return string
+ */
+ public static function flag_settings_updated( $location ) {
+ return add_query_arg( 'settings-updated', 'true', $location );
}
/**
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..b17e94904d 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,20 +86,27 @@ 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 );
+ /*
+ * Hold a blocked version out of the row: the previously-served version keeps being
+ * served, and any deferred serve is cancelled rather than merely postponed. Only the
+ * availability and closure fields are refreshed, since every other column would
+ * describe the version being held.
+ */
+ if ( self::is_release_blocked( $release ) && $existing_version !== (string) $version ) {
+ wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" );
+
+ return self::sync_availability( $post );
+ }
+
/*
* Defer the write for new versions still inside the cooldown window. While
* deferred, the existing `update_source` row (carrying the previous version)
* continues to be served by the update API. Reviewers force-release by setting
- * `release_delay = 0` on the release meta.
+ * `release_delay = 0` and `unblock` on the release meta — see force_release().
*
* The deferred cron fires at exactly $cooldown_until, so by definition this
* gate is false when called from cron_trigger_release() and no explicit bypass
@@ -109,7 +116,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 );
- return true;
+
+ return self::sync_availability( $post );
}
}
@@ -127,16 +135,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'] ) ) {
@@ -177,6 +176,30 @@ public static function update_single_plugin( $plugin_slug ) {
}
}
+ self::flush_update_caches( $plugin_slug );
+
+ // Sync the latest version to Stats.
+ if ( function_exists( '\WordPressdotorg\Stats\sync_latest_version' ) ) {
+ \WordPressdotorg\Stats\sync_latest_version(
+ 'plugin',
+ array(
+ $plugin_slug => $version,
+ )
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Purge the caches the update and info APIs answer `update_source` queries from.
+ *
+ * Every write to the row has to be followed by this, or the change doesn't reach sites
+ * until the cached entries expire.
+ *
+ * @param string $plugin_slug The plugin slug.
+ */
+ protected static function flush_update_caches( $plugin_slug ) {
// ~34char prefix, Memcache limit of 255char per key.
$plugin_details_cache_key = 'plugin_details:' . ( strlen( $plugin_slug ) > 200 ? 'md5:' . md5( $plugin_slug ) : $plugin_slug );
wp_cache_delete( $plugin_details_cache_key, 'update-check-3' );
@@ -194,20 +217,152 @@ public static function update_single_plugin( $plugin_slug ) {
wp_cache_delete( $cache_key, 'plugin_api_info' );
}
}
+ }
- // Sync the latest version to Stats.
- if ( function_exists( '\WordPressdotorg\Stats\sync_latest_version' ) ) {
- \WordPressdotorg\Stats\sync_latest_version(
- 'plugin',
- array(
- $plugin_slug => $version
- )
- );
+ /**
+ * 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.
+ *
+ * `version` and `last_updated` are deliberately left behind with it, so the plugin keeps
+ * matching cron_trigger()'s out-of-date query for as long as the version is held. The
+ * repeated write is idempotent; converging would mean recording the held version as served.
+ *
+ * @param \WP_Post $post The plugin post.
+ * @return bool False when the row couldn't be read or written, true otherwise —
+ * including when there's no row, which is nothing to correct rather than
+ * a failure.
+ */
+ 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
+ );
+
+ if ( ! $row ) {
+ // A failed lookup reads the same as no row, so tell them apart before giving up.
+ if ( $wpdb->last_error ) {
+ return false;
+ }
+
+ // Nothing is being served, so there's no availability to correct.
+ return true;
+ }
+
+ $meta = array();
+ if ( $row['meta'] ) {
+ $meta = maybe_unserialize( $row['meta'] );
+
+ /*
+ * Bail rather than overwrite. Rebuilding from an unreadable value would discard
+ * `release_time` and `rollout`, which govern the phased rollout of the version
+ * currently being served — worse than leaving availability stale.
+ */
+ if ( ! is_array( $meta ) ) {
+ return false;
+ }
+ }
+
+ // Clear the closure fields before re-deriving them, so re-opening a plugin drops them.
+ unset( $meta['closed_at'], $meta['closed_reason'] );
+ $meta = array_merge( $meta, self::get_closed_meta( $post ) );
+
+ $updated = $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 )
+ );
+
+ if ( false === $updated ) {
+ return false;
+ }
+
+ /*
+ * The update API answers from cache, so the row write on its own wouldn't reach sites
+ * — which is the entire reason availability is synced here.
+ */
+ self::flush_update_caches( $post->post_name );
+
return true;
}
+ /**
+ * 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.
+ *
+ * Blocks are recorded on the release meta as `release_block`, and cleared by
+ * Plugin_Directory::add_release() with `unblock => true`.
+ *
+ * @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 +438,136 @@ 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
+ * checks neither the cooldown state nor who's asking, and will hold any release that
+ * isn't already served. It refuses when there's nothing left to hold: no plugin, no
+ * release row, the version already being served, or a hold already recorded against it.
+ *
+ * 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'. 'blocked_at'
+ * is added here.
+ * @return bool True when the version is held as a result, false when it isn't — including
+ * when the hold couldn't be recorded, or the version was served while it was
+ * being recorded and the hold was rolled back.
+ */
+ 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;
+ }
+
+ /*
+ * Already held. Recording a second block would merge it into the first rather than
+ * replace it, leaving a hybrid of the two reasons — and an unblock here would then
+ * discard the original hold rather than this one.
+ */
+ if ( self::is_release_blocked( $release ) ) {
+ return false;
+ }
+
+ $block['blocked_at'] = time();
+
+ Plugin_Directory::add_release(
+ $post,
+ array(
+ 'tag' => $release['tag'],
+ 'release_block' => $block,
+ )
+ );
+
+ /*
+ * Confirm the hold from stored state rather than from add_release()'s return value,
+ * which is update_post_meta()'s and is also false for an unchanged write.
+ */
+ if ( ! self::is_release_blocked( Plugin_Directory::get_release( $post, $version ) ) ) {
+ return false;
+ }
+
+ // Re-run so a version scheduled to serve at cooldown-end is held now instead.
+ self::update_single_plugin( $plugin_slug );
+
+ /*
+ * The deferred serve can fire between the check above and this point, and a block
+ * can't un-ship a live version. Undo it rather than leave a block recorded against
+ * one, which would have the metabox reporting a hold that isn't in effect.
+ */
+ if ( self::get_served_version( $plugin_slug ) === (string) $version ) {
+ Plugin_Directory::add_release(
+ $post,
+ array(
+ 'tag' => $release['tag'],
+ 'unblock' => true,
+ )
+ );
+
+ return false;
+ }
+
+ // Logged once the hold is known to be in effect, so the trail records what happened.
+ Tools::audit_log(
+ sprintf(
+ 'Blocked version %1$s from being served to sites. Reason: %2$s',
+ $version,
+ $block['reason'] ?? ''
+ ),
+ $post
+ );
+
+ 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.php
tests/wporg-url-schemes.php
tests/wporg-plugin-api.php
tests/wporg-plugin-api-performance.php
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..cb61b3dfdf 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 @@
'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() ) );
+
+ /*
+ * Scoped to this fixture's slug rather than truncated: `update_source` is shared, and
+ * TRUNCATE would both destroy other tests' rows and force an implicit commit, taking
+ * any enclosing WP_UnitTestCase transaction with it.
+ */
+ $this->unserve();
+ $this->serve( static::SERVED_VERSION );
+ }
+
+ /**
+ * Remove everything setUp() created: the plugin, its meta, any audit-log notes, the
+ * `update_source` row, the deferred cron event and the faked request state. 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 );
+
+ // Removes every comment on the post; the fixture's are all audit-log notes.
+ foreach ( get_comments( array( 'post_id' => $this->plugin->ID ) ) as $note ) {
+ wp_delete_comment( $note->comment_ID, true );
+ }
+
+ $this->unserve();
+ wp_delete_post( $this->plugin->ID, true );
+
+ unset( $_SERVER['REMOTE_ADDR'] );
+
+ 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.
+ * @param array $meta Values for the serialized `meta` column. Empty leaves it NULL,
+ * as it is for a plugin with no rollout or closure data.
+ */
+ protected function serve( $version, $meta = array() ) {
+ 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,
+ 'meta' => $meta ? serialize( $meta ) : null,
+ 'last_updated' => current_time( 'mysql' ),
+ )
+ );
+ }
+
+ /**
+ * Drop this plugin's `update_source` row, leaving other tests' rows alone.
+ */
+ protected function unserve() {
+ global $wpdb;
+
+ // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- `update_source` lives outside WordPress; there is no API for it.
+ $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => static::SLUG ) );
+ }
+
+ /**
+ * The deserialized `meta` column of the served row.
+ *
+ * @return array Empty when the column is NULL or holds no data.
+ */
+ protected function get_served_meta() {
+ $row = $this->get_served_row();
+
+ if ( ! $row || ! $row['meta'] ) {
+ return array();
+ }
+
+ return (array) maybe_unserialize( $row['meta'] );
+ }
+
+ /**
+ * 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..0c7fd0bef6
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Reviewer_Release_Block_Test.php
@@ -0,0 +1,598 @@
+ $login,
+ 'user_pass' => 'password',
+ 'user_email' => $login . '@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']
+ );
+
+ delete_transient( 'settings_errors' );
+ remove_filter( 'redirect_post_location', array( Controls::class, 'flag_settings_updated' ) );
+
+ 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() {
+ /*
+ * Run the updater first so the cooldown actually schedules the deferred serve. Without
+ * it there's no event to cancel, and the assertion below passes whether or not the
+ * block clears one.
+ */
+ API_Update_Updater::update_single_plugin( self::SLUG );
+ $this->assertNotFalse(
+ wp_next_scheduled( 'release_to_update_api:' . self::SLUG ),
+ 'precondition: the cooldown scheduled a deferred serve'
+ );
+
+ $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 as an override of the block, rather
+ * than as the plain cooldown bypass.
+ */
+ 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() );
+ }
+
+ /**
+ * A second block is refused rather than merged into the first, so the recorded reason and
+ * reviewer stay those of the hold that's actually in effect.
+ */
+ public function test_a_second_block_is_refused() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $result = API_Update_Updater::block_release(
+ self::SLUG,
+ array(
+ 'reason' => 'A different reviewer, a different reason.',
+ 'blocked_by' => 'someone-else',
+ )
+ );
+
+ $this->assertFalse( $result );
+ $this->assertSame( 'Suspicious obfuscated code.', $this->get_release_block()['reason'] );
+ $this->assertSame( $this->reviewer->user_login, $this->get_release_block()['blocked_by'] );
+ }
+
+ /**
+ * Syncing availability rewrites only the closure fields: the rollout data describing the
+ * version still being served has to survive a close and a re-open untouched, or the
+ * phased rollout silently turns into a full one.
+ */
+ public function test_holding_a_version_preserves_the_served_rollout_meta() {
+ $this->unserve();
+ $this->serve(
+ self::SERVED_VERSION,
+ array(
+ 'release_time' => 1700000000,
+ 'rollout' => array( 'strategy' => 'manual-updates-24hr' ),
+ )
+ );
+
+ 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 );
+ }
+
+ $meta = $this->get_served_meta();
+
+ $this->assertSame( 1700000000, $meta['release_time'] );
+ $this->assertSame( array( 'strategy' => 'manual-updates-24hr' ), $meta['rollout'] );
+ $this->assertArrayNotHasKey( 'closed_at', $meta, 're-opening drops the closure fields' );
+ $this->assertSame( self::SERVED_VERSION, $this->get_served_version() );
+ }
+
+ /**
+ * Closing a plugin while a version is held records the closure in the served row's meta.
+ */
+ public function test_closing_a_plugin_records_the_closure_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 );
+
+ $this->assertArrayHasKey( 'closed_at', $this->get_served_meta() );
+ }
+
+ /**
+ * With no `update_source` row there's nothing being served to correct, so holding a
+ * version is still recorded and nothing is written.
+ */
+ public function test_a_block_without_a_served_row_still_holds() {
+ $this->unserve();
+
+ $result = API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+
+ $this->assertTrue( $result );
+ $this->assertNull( $this->get_served_row() );
+ }
+
+ /**
+ * Render the release section of the Controls metabox for the plugin under test.
+ *
+ * The method is protected and reads the post from the global, so both have to be worked
+ * around to exercise it. Asserting on which controls appear rather than on the markup
+ * keeps this from breaking on copy changes.
+ *
+ * @return string The rendered markup.
+ */
+ protected function render_controls() {
+ wp_set_current_user( $this->reviewer->ID );
+
+ // phpcs:ignore WordPress.WP.GlobalVariablesOverride -- display_release_cooldown() reads the post from the global; there's no way to inject it.
+ $GLOBALS['post'] = $this->plugin;
+
+ $display = new ReflectionMethod( Controls::class, 'display_release_cooldown' );
+ $display->setAccessible( true );
+
+ ob_start();
+ $display->invoke( null );
+ $output = ob_get_clean();
+
+ unset( $GLOBALS['post'] );
+
+ return $output;
+ }
+
+ /**
+ * A held version stays force-releasable after its cooldown window has passed — otherwise
+ * the block would have no way to be lifted from the edit screen.
+ */
+ public function test_the_metabox_offers_force_release_on_a_held_version_past_its_cooldown() {
+ API_Update_Updater::block_release( self::SLUG, $this->reviewer_block() );
+ $this->elapse_cooldown();
+
+ $output = $this->render_controls();
+
+ $this->assertStringContainsString( 'name="force_release_version"', $output );
+ $this->assertStringNotContainsString( 'name="block_release_version"', $output );
+ }
+
+ /**
+ * The Block button survives the gap between the cooldown expiring and the deferred serve
+ * actually running — the version isn't served yet, so it can still be held.
+ */
+ public function test_the_metabox_offers_block_after_the_cooldown_but_before_the_serve() {
+ $this->elapse_cooldown();
+
+ $output = $this->render_controls();
+
+ $this->assertStringContainsString( 'name="block_release_version"', $output );
+ }
+
+ /**
+ * Once the version is being served and isn't held, there's nothing left to act on.
+ */
+ public function test_the_metabox_shows_nothing_once_the_version_is_served() {
+ $this->elapse_cooldown();
+ API_Update_Updater::update_single_plugin( self::SLUG );
+
+ $this->assertSame( self::NEW_VERSION, $this->get_served_version(), 'precondition: version is live' );
+ $this->assertSame( '', trim( $this->render_controls() ) );
+ }
+
+ /**
+ * A refused block tells the reviewer why, rather than leaving them to infer it from the
+ * release section having disappeared.
+ */
+ public function test_the_metabox_reports_a_block_that_was_refused() {
+ 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 )
+ );
+
+ $this->submit_controls(
+ array(
+ 'block_release_version' => self::NEW_VERSION,
+ 'release_action_reason' => 'Suspicious obfuscated code.',
+ )
+ );
+
+ $notice = get_transient( 'settings_errors' );
+
+ $this->assertNotEmpty( $notice );
+ $this->assertSame( 'error', $notice[0]['type'] );
+ $this->assertStringContainsString( 'already being served', $notice[0]['message'] );
+ }
+
+ /**
+ * A block with no reason says so, instead of silently doing nothing.
+ */
+ public function test_the_metabox_reports_a_missing_reason() {
+ $this->submit_controls(
+ array(
+ 'block_release_version' => self::NEW_VERSION,
+ 'release_action_reason' => ' ',
+ )
+ );
+
+ $notice = get_transient( 'settings_errors' );
+
+ $this->assertNotEmpty( $notice );
+ $this->assertStringContainsString( 'a reason is required', $notice[0]['message'] );
+ }
+
+ /**
+ * A successful block is confirmed, so the reviewer knows the hold took effect.
+ */
+ public function test_the_metabox_confirms_a_successful_block() {
+ $this->submit_controls(
+ array(
+ 'block_release_version' => self::NEW_VERSION,
+ 'release_action_reason' => 'Suspicious obfuscated code.',
+ )
+ );
+
+ $notice = get_transient( 'settings_errors' );
+
+ $this->assertNotEmpty( $notice );
+ $this->assertSame( 'updated', $notice[0]['type'] );
+ }
+}
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;
+ }
+ }
+);