From da6502cf7ff260a63cc86e0359dc45ce4319a463 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 15 May 2026 18:00:53 +1000 Subject: [PATCH 01/15] Plugin Directory: Defer update_source writes by 48hrs to gate plugin releases. Introduces a release cooldown that holds new versions back from the update API for RELEASE_COOL_DOWN_DELAY (48hrs) after commit / final author confirmation. The previous version continues to be served during the window, giving scanners and reviewers time to flag supply-chain attacks before the new version ships to sites. Plugin reviewers can bypass the cooldown for urgent security fixes via a new Force-release control on the Plugin Controls metabox (requires a reason; logged via Tools::audit_log). Closed/disabled plugins, rebuild scripts, and other status-change paths bypass the cooldown so closures take effect immediately. release_time is re-anchored to the moment the version actually becomes public so the existing phased_rollout manual-updates-24hr window still measures from public availability. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/class-customizations.php | 1 + .../admin/metabox/class-controls.php | 134 ++++++++++ .../bin/rebuild-update_source-table.php | 3 +- .../jobs/class-api-update-updater.php | 229 ++++++++++++++++- .../plugin-directory/jobs/class-manager.php | 11 +- .../plugin-directory/plugin-directory.php | 10 + .../shortcodes/class-release-confirmation.php | 75 ++++++ .../tests/Plugin_Update_Cooldown_Test.php | 234 ++++++++++++++++++ 8 files changed, 679 insertions(+), 18 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-customizations.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-customizations.php index 79ff49ea5a..f965fa9780 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-customizations.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-customizations.php @@ -79,6 +79,7 @@ private function __construct() { add_action( 'save_post', array( __NAMESPACE__ . '\Metabox\Release_Confirmation', 'save_post' ) ); add_action( 'save_post', array( __NAMESPACE__ . '\Metabox\Author_Notice', 'save_post' ) ); add_action( 'save_post', array( __NAMESPACE__ . '\Metabox\Reviewer', 'save_post' ) ); + add_action( 'save_post', array( __NAMESPACE__ . '\Metabox\Controls', 'save_post' ) ); } /** 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 052f6f5a15..5f06d1b058 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 @@ -2,7 +2,10 @@ namespace WordPressdotorg\Plugin_Directory\Admin\Metabox; use WordPressdotorg\Plugin_Directory\Admin\Status_Transitions; +use WordPressdotorg\Plugin_Directory\Jobs\API_Update_Updater; +use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Template; +use const WordPressdotorg\Plugin_Directory\RELEASE_COOL_DOWN_DELAY; /** * The Plugin Controls / Publish metabox. @@ -27,6 +30,7 @@ static function display() {
@@ -42,6 +46,136 @@ static function display() { post_status ) { + return; + } + + $version = get_post_meta( $post->ID, 'version', true ); + if ( ! $version ) { + return; + } + + $release = Plugin_Directory::get_release( $post, $version ); + if ( ! $release ) { + return; + } + + $release_time = API_Update_Updater::compute_release_time( $post, $release ); + $cooldown_until = $release_time + RELEASE_COOL_DOWN_DELAY; + $force_released = ! empty( $release['force_released'] ); + + // Already force-released — show audit info to reviewers, nothing to authors. + if ( $force_released ) { + if ( current_user_can( 'plugin_review', $post ) ) { + $user = get_userdata( (int) ( $release['force_released_by'] ?? 0 ) ); + printf( + '

%s

', + sprintf( + /* translators: 1: version, 2: relative time, 3: user display name */ + esc_html__( 'Version %1$s was force-released %2$s ago by %3$s, bypassing the release cooldown.', 'wporg-plugins' ), + esc_html( $version ), + esc_html( human_time_diff( (int) ( $release['force_released_at'] ?? time() ) ) ), + esc_html( $user ? ( $user->display_name ? $user->display_name : $user->user_login ) : __( 'unknown', 'wporg-plugins' ) ) + ) + ); + } + return; + } + + // Cooldown already elapsed — nothing to show. + if ( $cooldown_until <= time() ) { + return; + } + + ?> +
+

+ +

+ +

+ + +

+

+ ID, '_force_release_nonce' ); ?> + +

+ +
+ post_type ) { + return; + } + + if ( ! current_user_can( 'plugin_review', $post ) ) { + return; + } + + check_admin_referer( 'force_release_' . $post_id, '_force_release_nonce' ); + + $version = get_post_meta( $post->ID, 'version', true ); + $submitted_version = sanitize_text_field( wp_unslash( $_POST['force_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'] ) ) ) + : ''; + if ( ! $reason ) { + return; + } + + API_Update_Updater::force_release( $post->post_name, $reason ); + } + /** * Get button label for setting the plugin status. * diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/rebuild-update_source-table.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/rebuild-update_source-table.php index 7ac682dcee..bb82efe232 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/rebuild-update_source-table.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/rebuild-update_source-table.php @@ -47,7 +47,8 @@ foreach ( $slugs as $i => $slug ) { echo ++$i . '/' . count( $slugs ) . "\t" . $slug . "\n"; - Jobs\API_Update_Updater::update_single_plugin( $slug ); + // Rebuild bypasses the release cooldown — the intent is to reflect current state. + Jobs\API_Update_Updater::update_single_plugin( $slug, true ); clear_memory_caches(); } 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 21a83ecb2d..776a832fc1 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 @@ -3,6 +3,7 @@ use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Template; +use const WordPressdotorg\Plugin_Directory\RELEASE_COOL_DOWN_DELAY; /** * Handles interfacing with the api.WordPress.org/plugin/update-check/ API. @@ -67,20 +68,77 @@ public static function cron_trigger() { /** * Updates a single plugins `update_source` data. + * + * @param string $plugin_slug The plugin slug. + * @param bool $bypass_cooldown Whether to bypass the release cooldown gate. True when called + * from the deferred release cron, the reviewer force-release + * action, or from contexts that must publish immediately (status + * transitions, rebuild scripts). When true, `release_time` in the + * stored meta is anchored to the moment of the write rather than + * the original commit time, so the phased-rollout + * `manual-updates-24hr` window measures from public availability. + * @return bool */ - public static function update_single_plugin( $plugin_slug, $self_loop = false ) { + public static function update_single_plugin( $plugin_slug, $bypass_cooldown = false ) { global $wpdb; $post = Plugin_Directory::get_plugin_post( $plugin_slug ); if ( ! $post || ! in_array( $post->post_status, array( 'publish', 'disabled', 'closed' ) ) ) { $wpdb->delete( $wpdb->prefix . 'update_source', compact( 'plugin_slug' ) ); + wp_clear_scheduled_hook( "release_to_update_api:{$plugin_slug}" ); return true; } $version = get_post_meta( $post->ID, 'version', true ); $requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true ); - $meta = array( - 'release_time' => strtotime( $post->version_date ?: $post->post_modified ), + $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 + ) + ); + + /* + * 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. + * + * Bypassed when: + * - The caller explicitly bypasses (deferred-event fire, reviewer force-release, etc.). + * - The plugin is closed/disabled — closure must take effect immediately, see + * get_cooldown_defer_time(). + * - The reviewer has already force-released this version. + * - This isn't actually a new-version write (same version as already in the table). + */ + if ( ! $bypass_cooldown ) { + $cooldown_until = self::get_cooldown_defer_time( + $release_time, + ! empty( $release['force_released'] ), + $post->post_status, + $existing_version, + (string) $version + ); + + if ( $cooldown_until ) { + self::queue_release_to_update_api( $post->post_name, $cooldown_until ); + return true; + } + } + + // When this write is publishing a new version (existing row had a different version, or + // no row existed) anchor `release_time` to now — that's the moment the version is + // actually available to sites. Keeps phased_rollout()'s `manual-updates-24hr` window + // measuring from public availability, even if the commit/confirmation was long ago + // because the cooldown deferred the write. Rebuild/status/meta-sync paths reach this + // code with existing_version == version and so retain the original release_time. + if ( $existing_version !== (string) $version && 'publish' === $post->post_status ) { + $release_time = time(); + } + + $meta = array( + 'release_time' => $release_time, 'last_version' => $post->last_version ?? '', 'last_stable_tag' => $post->last_stable_tag ?? '', ); @@ -96,15 +154,6 @@ public static function update_single_plugin( $plugin_slug, $self_loop = false ) } } - $release = Plugin_Directory::get_release( $post, $version ); - if ( - $release && - $release['confirmations_required'] && - $release['confirmations'] - ) { - $meta['release_time'] = max( $release['confirmations'] ); - } - // Add phased rollout strategy data if needed. if ( $release && ! empty( $release['rollout_strategy'] ) ) { $meta['rollout'] = array( @@ -112,6 +161,10 @@ public static function update_single_plugin( $plugin_slug, $self_loop = false ) ); } + // The deferred event (if any) has either fired or been pre-empted by a force-release + // or status change. Clear any leftover schedule so the cron table doesn't grow. + wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" ); + $data = array( 'plugin_id' => $post->ID, 'plugin_slug' => $post->post_name, @@ -171,6 +224,158 @@ public static function update_single_plugin( $plugin_slug, $self_loop = false ) return true; } + /** + * Determine the release timestamp for a plugin version. + * + * Falls back through the commit timestamp on the plugin post, and is replaced by the + * latest committer-confirmation time when release confirmations are required (the + * version isn't really "released" until the last confirmation lands). + * + * @param \WP_Post $post The plugin post. + * @param array|bool $release The release row from Plugin_Directory::get_release(), or false. + * @return int Unix timestamp. + */ + public static function compute_release_time( $post, $release ) { + $release_time = strtotime( $post->version_date ? $post->version_date : $post->post_modified ); + + if ( + $release && + $release['confirmations_required'] && + $release['confirmations'] + ) { + $release_time = max( $release['confirmations'] ); + } + + return $release_time; + } + + /** + * Pure-logic helper: decide whether a new-version write should be deferred, + * and return the cooldown expiry timestamp if so. + * + * @param int $release_time When the release was committed / final-confirmed. + * @param bool $force_released Whether a reviewer has force-released this version. + * @param string $post_status The plugin's current post_status. + * @param string $existing_version The version currently sitting in update_source (or ''). + * @param string $new_version The version proposed for this write. + * @param int $now Current time, injectable for tests. + * @return int|false Cooldown expiry timestamp if deferral applies, false otherwise. + */ + public static function get_cooldown_defer_time( $release_time, $force_released, $post_status, $existing_version, $new_version, $now = null ) { + if ( null === $now ) { + $now = time(); + } + + if ( $force_released ) { + return false; + } + + if ( 'publish' !== $post_status ) { + return false; + } + + if ( $existing_version === $new_version ) { + return false; + } + + $cooldown_until = $release_time + RELEASE_COOL_DOWN_DELAY; + if ( $cooldown_until <= $now ) { + return false; + } + + return $cooldown_until; + } + + /** + * Schedule a deferred release-to-update-api cron event for a plugin. + * + * If an event is already scheduled at the desired time, this is a no-op. If a + * different time is scheduled (e.g. an earlier commit's cooldown), the event is + * rescheduled to the later time so a follow-up commit fully resets the window. + * + * @param string $plugin_slug The plugin slug. + * @param int $cooldown_until Unix timestamp when the deferred event should fire. + */ + public static function queue_release_to_update_api( $plugin_slug, $cooldown_until ) { + $hook = "release_to_update_api:{$plugin_slug}"; + + $existing = wp_next_scheduled( $hook, array( $plugin_slug ) ); + if ( $existing === $cooldown_until ) { + return; + } + + if ( $existing ) { + wp_unschedule_event( $existing, $hook, array( $plugin_slug ) ); + } + + wp_schedule_single_event( $cooldown_until, $hook, array( $plugin_slug ) ); + } + + /** + * Cron handler for `release_to_update_api:{slug}`. Fires when the cooldown + * expires; writes the new version to `update_source` immediately. + * + * @param string $plugin_slug The plugin slug. + */ + public static function cron_trigger_release( $plugin_slug ) { + self::update_single_plugin( $plugin_slug, true ); + } + + /** + * Reviewer force-release: clear the cooldown for a plugin's current version and + * write it to `update_source` immediately. Logs the action with the supplied reason. + * + * Capability checks must be performed by the caller. + * + * @param string $plugin_slug The plugin slug. + * @param string $reason Free-text reason recorded in the audit log. + * @param \WP_User $user The acting user. Defaults to the current user. + * @return bool True on success. + */ + public static function force_release( $plugin_slug, $reason, $user = null ) { + if ( ! $user ) { + $user = wp_get_current_user(); + } + + $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; + } + + Plugin_Directory::add_release( + $post, + array( + 'tag' => $release['tag'], + 'force_released' => true, + 'force_released_by' => $user->ID, + 'force_released_at' => time(), + 'force_released_reason' => $reason, + ) + ); + + \WordPressdotorg\Plugin_Directory\Tools::audit_log( + sprintf( + 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s', + $version, + RELEASE_COOL_DOWN_DELAY / HOUR_IN_SECONDS, + $reason + ), + $post, + $user + ); + + wp_clear_scheduled_hook( "release_to_update_api:{$plugin_slug}" ); + + return self::update_single_plugin( $plugin_slug, true ); + } + static function get_plugin_assets( $post ) { $icons = $banners = $banners_rtl = array(); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php index ebc56fc74e..386552165a 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-manager.php @@ -18,11 +18,12 @@ class Manager { * @var array */ public static $wildcard_cron_tasks = array( - 'import_plugin' => array( __NAMESPACE__ . '\Plugin_Import', 'cron_trigger' ), - 'import_plugin_i18n' => array( __NAMESPACE__ . '\Plugin_i18n_Import', 'cron_trigger' ), - 'import_zip' => array( __NAMESPACE__ . '\Plugin_ZIP_Import', 'cron_trigger' ), - 'scan_plugin' => array( __NAMESPACE__ . '\Plugin_Scan', 'cron_trigger' ), - 'create_svn_repo' => array( __NAMESPACE__ . '\SVN_Repo_Creation', 'cron_trigger' ), + 'import_plugin' => array( __NAMESPACE__ . '\Plugin_Import', 'cron_trigger' ), + 'import_plugin_i18n' => array( __NAMESPACE__ . '\Plugin_i18n_Import', 'cron_trigger' ), + 'import_zip' => array( __NAMESPACE__ . '\Plugin_ZIP_Import', 'cron_trigger' ), + 'scan_plugin' => array( __NAMESPACE__ . '\Plugin_Scan', 'cron_trigger' ), + 'create_svn_repo' => array( __NAMESPACE__ . '\SVN_Repo_Creation', 'cron_trigger' ), + 'release_to_update_api' => array( __NAMESPACE__ . '\API_Update_Updater', 'cron_trigger_release' ), ); /** diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/plugin-directory.php index 6d7e745265..003f208705 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/plugin-directory.php @@ -25,6 +25,16 @@ */ define( __NAMESPACE__ . '\PLUGIN_DIR', __DIR__ ); +/** + * Delay between a plugin release being committed (or its final author confirmation) + * and the new version being written to the `update_source` table — and so served to + * sites by the api.wordpress.org plugin update-check API. The previous version remains + * served until the cooldown elapses. Mitigates supply-chain attacks by giving scanners + * and humans a window to flag bad releases. Plugin reviewers can bypass the cooldown + * via the wp-admin force-release action; see Jobs\API_Update_Updater::update_single_plugin(). + */ +define( __NAMESPACE__ . '\RELEASE_COOL_DOWN_DELAY', 48 * HOUR_IN_SECONDS ); + // Register an Autoloader for all files require __DIR__ . '/class-autoloader.php'; Autoloader\register_class_path( __NAMESPACE__, __DIR__ ); 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 977fd2a4f4..cc1574073c 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 @@ -10,6 +10,7 @@ Revalidation\get_js_url as get_revalidation_js_url, get_onboarding_account_url as get_2fa_onboarding_url }; +use const WordPressdotorg\Plugin_Directory\RELEASE_COOL_DOWN_DELAY; /** * The [release-confirmation] shortcode handler. @@ -86,6 +87,19 @@ class_exists( 'Two_Factor_Core' ) && /* translators: %s: plugins@wordpress.org */ echo '

' . sprintf( __( 'Release confirmations can be enabled on the Advanced view of plugin pages. If you need to disable release confirmations for a plugin, please contact %s.', 'wporg-plugins' ), 'plugins@wordpress.org' ) . '

'; + printf( + '

%s

', + wp_kses( + sprintf( + /* translators: 1: cooldown duration in hours, 2: plugins@wordpress.org link */ + __( 'New releases are served to sites %1$d hours after they\'re committed (or after the final confirmation, if release confirmations are enabled). This gives security scanners and reviewers a window to catch malicious commits before they ship. If you have an urgent security fix that needs to be released sooner, contact %2$s.', 'wporg-plugins' ), + (int) ( RELEASE_COOL_DOWN_DELAY / HOUR_IN_SECONDS ), + 'plugins@wordpress.org' + ), + array( 'a' => array( 'href' => true ) ) + ) + ); + $not_enabled = []; foreach ( $plugins as $plugin ) { printf( @@ -265,6 +279,8 @@ static function get_approval_text( $plugin, $data ) { ); } + self::render_cooldown_status( $data ); + echo ''; $text = ob_get_clean(); @@ -280,6 +296,65 @@ static function get_approval_text( $plugin, $data ) { return apply_filters( 'wporg_plugins_release_approval_text', $text, $plugin, $data ); } + /** + * Render a single line describing the cooldown state of a release: pending serve time, + * served-on time, or force-released-by-reviewer time. Skipped when the release isn't yet + * confirmed/processed (the existing confirmation messaging already speaks to that state). + * + * @param array $data The release row from Plugin_Directory::get_releases(). + */ + protected static function render_cooldown_status( $data ) { + if ( ! empty( $data['discarded'] ) ) { + return; + } + + // Skip when the release hasn't moved past the confirmation/processing stage yet. + if ( $data['confirmations_required'] && ( ! $data['confirmed'] || ! $data['zips_built'] ) ) { + return; + } + + $release_time = $data['confirmations'] + ? max( $data['confirmations'] ) + : (int) $data['date']; + + $cooldown_until = $release_time + RELEASE_COOL_DOWN_DELAY; + + if ( ! empty( $data['force_released'] ) ) { + $message = sprintf( + /* translators: %s: relative time */ + __( 'Force-released by a plugin reviewer %s ago.', 'wporg-plugins' ), + human_time_diff( (int) ( $data['force_released_at'] ?? $release_time ) ) + ); + printf( '%s
', esc_html( $message ) ); + return; + } + + if ( $cooldown_until > time() ) { + $message = sprintf( + /* translators: %s: relative time until cooldown expires */ + __( 'Will be served to sites in %s.', 'wporg-plugins' ), + human_time_diff( time(), $cooldown_until ) + ); + printf( + '%s
', + esc_attr( gmdate( 'Y-m-d H:i:s', $cooldown_until ) ), + esc_html( $message ) + ); + return; + } + + $message = sprintf( + /* translators: %s: relative time */ + __( 'Serving to sites since %s ago.', 'wporg-plugins' ), + human_time_diff( $cooldown_until ) + ); + printf( + '%s
', + esc_attr( gmdate( 'Y-m-d H:i:s', $cooldown_until ) ), + esc_html( $message ) + ); + } + static function get_actions( $plugin, $data ) { $buttons = []; diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php new file mode 100644 index 0000000000..f1cc635d3d --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php @@ -0,0 +1,234 @@ +assertSame( $release_time + RELEASE_COOL_DOWN_DELAY, $cooldown_until ); + } + + /** + * Once the cooldown has elapsed the write should go straight through. + */ + public function test_does_not_defer_when_cooldown_elapsed() { + $now = 1700000000; + $release_time = $now - RELEASE_COOL_DOWN_DELAY - HOUR_IN_SECONDS; + + $result = API_Update_Updater::get_cooldown_defer_time( + $release_time, + false, + 'publish', + '1.0', + '1.1', + $now + ); + + $this->assertFalse( $result ); + } + + /** + * Status changes or metadata refreshes that leave the version unchanged should not defer. + */ + public function test_does_not_defer_when_version_unchanged() { + $now = 1700000000; + $release_time = $now - HOUR_IN_SECONDS; + + $result = API_Update_Updater::get_cooldown_defer_time( + $release_time, + false, + 'publish', + '1.0', + '1.0', + $now + ); + + $this->assertFalse( $result ); + } + + /** + * A reviewer's force-release flag should let the new version through immediately. + */ + public function test_does_not_defer_when_force_released() { + $now = 1700000000; + $release_time = $now - HOUR_IN_SECONDS; + + $result = API_Update_Updater::get_cooldown_defer_time( + $release_time, + true, + 'publish', + '1.0', + '1.1', + $now + ); + + $this->assertFalse( $result ); + } + + /** + * Closure/disabling must take effect immediately and not get held back by the cooldown. + */ + public function test_does_not_defer_for_closed_or_disabled_plugins() { + $now = 1700000000; + $release_time = $now - HOUR_IN_SECONDS; + + $this->assertFalse( + API_Update_Updater::get_cooldown_defer_time( + $release_time, + false, + 'closed', + '1.0', + '1.1', + $now + ) + ); + + $this->assertFalse( + API_Update_Updater::get_cooldown_defer_time( + $release_time, + false, + 'disabled', + '1.0', + '1.1', + $now + ) + ); + } + + /** + * Cron-backup paths shouldn't accidentally re-defer ancient commits whose cooldown is long past. + */ + public function test_does_not_defer_first_release_with_old_release_time() { + $now = 1700000000; + $release_time = $now - ( 7 * DAY_IN_SECONDS ); + + $result = API_Update_Updater::get_cooldown_defer_time( + $release_time, + false, + 'publish', + '', + '1.0', + $now + ); + + $this->assertFalse( $result ); + } + + /** + * A brand-new plugin's first commit should still be subject to the cooldown. + */ + public function test_defers_first_release_with_recent_release_time() { + $now = 1700000000; + $release_time = $now - 60; + + $result = API_Update_Updater::get_cooldown_defer_time( + $release_time, + false, + 'publish', + '', + '1.0', + $now + ); + + $this->assertSame( $release_time + RELEASE_COOL_DOWN_DELAY, $result ); + } + + /** + * Without release confirmations the commit timestamp drives release_time. + */ + public function test_compute_release_time_uses_version_date_by_default() { + $post = (object) array( + 'version_date' => '2026-05-15 12:00:00', + 'post_modified' => '2026-05-10 00:00:00', + ); + + $this->assertSame( + strtotime( '2026-05-15 12:00:00' ), + API_Update_Updater::compute_release_time( $post, false ) + ); + } + + /** + * If version_date is missing the post's last modified time is the fallback. + */ + public function test_compute_release_time_falls_back_to_post_modified() { + $post = (object) array( + 'version_date' => '', + 'post_modified' => '2026-05-10 00:00:00', + ); + + $this->assertSame( + strtotime( '2026-05-10 00:00:00' ), + API_Update_Updater::compute_release_time( $post, false ) + ); + } + + /** + * With release confirmation required, release_time is the latest committer confirmation. + */ + public function test_compute_release_time_uses_latest_confirmation_when_required() { + $post = (object) array( + 'version_date' => '2026-05-15 12:00:00', + 'post_modified' => '2026-05-10 00:00:00', + ); + + $release = array( + 'confirmations_required' => 2, + 'confirmations' => array( + 'alice' => 1700000100, + 'bob' => 1700000500, + ), + ); + + $this->assertSame( + 1700000500, + API_Update_Updater::compute_release_time( $post, $release ) + ); + } + + /** + * Confirmations on releases that don't require them should be ignored. + */ + public function test_compute_release_time_ignores_confirmations_when_not_required() { + $post = (object) array( + 'version_date' => '2026-05-15 12:00:00', + 'post_modified' => '2026-05-10 00:00:00', + ); + + $release = array( + 'confirmations_required' => 0, + 'confirmations' => array( 'alice' => 1700000100 ), + ); + + $this->assertSame( + strtotime( '2026-05-15 12:00:00' ), + API_Update_Updater::compute_release_time( $post, $release ) + ); + } +} From 2c7addd94605b04ad5f4f4deddd3669c25c71ab4 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 15 May 2026 18:03:30 +1000 Subject: [PATCH 02/15] Plugin Directory: Apply the release cooldown to disabled plugins, not just publish. Disabled plugins keep available=1 in update_source and continue to serve updates through the API. The cooldown gate was only checking for publish, so a new version committed to a disabled plugin would write through immediately, defeating the purpose for that subset. Closed plugins remain excluded -- available flips to 0, so there is nothing to gate. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/metabox/class-controls.php | 4 ++- .../jobs/class-api-update-updater.php | 12 +++++-- .../tests/Plugin_Update_Cooldown_Test.php | 32 ++++++++++++------- 3 files changed, 34 insertions(+), 14 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 5f06d1b058..ba59ee4aa9 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 @@ -56,7 +56,9 @@ static function display() { protected static function display_release_cooldown() { $post = get_post(); - if ( 'publish' !== $post->post_status ) { + // Closed plugins don't serve updates (available=0), so the cooldown is irrelevant. + // publish + disabled both serve updates and are subject to the cooldown gate. + if ( ! in_array( $post->post_status, array( 'publish', 'disabled' ), true ) ) { 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 776a832fc1..d3cdd42418 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 @@ -133,7 +133,10 @@ public static function update_single_plugin( $plugin_slug, $bypass_cooldown = fa // measuring from public availability, even if the commit/confirmation was long ago // because the cooldown deferred the write. Rebuild/status/meta-sync paths reach this // code with existing_version == version and so retain the original release_time. - if ( $existing_version !== (string) $version && 'publish' === $post->post_status ) { + if ( + $existing_version !== (string) $version && + in_array( $post->post_status, array( 'publish', 'disabled' ), true ) + ) { $release_time = time(); } @@ -253,6 +256,11 @@ public static function compute_release_time( $post, $release ) { * Pure-logic helper: decide whether a new-version write should be deferred, * and return the cooldown expiry timestamp if so. * + * Applies to `publish` and `disabled` plugins — both keep `available = 1` + * in `update_source` and so serve new versions through the update API. + * Closed plugins write through immediately (available flips to 0; there's + * nothing to gate). + * * @param int $release_time When the release was committed / final-confirmed. * @param bool $force_released Whether a reviewer has force-released this version. * @param string $post_status The plugin's current post_status. @@ -270,7 +278,7 @@ public static function get_cooldown_defer_time( $release_time, $force_released, return false; } - if ( 'publish' !== $post_status ) { + if ( ! in_array( $post_status, array( 'publish', 'disabled' ), true ) ) { return false; } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php index f1cc635d3d..1699c748f8 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php @@ -92,9 +92,10 @@ public function test_does_not_defer_when_force_released() { } /** - * Closure/disabling must take effect immediately and not get held back by the cooldown. + * Closure must take effect immediately and not get held back by the cooldown. + * `closed` plugins set available=0 in update_source — there's nothing to gate. */ - public function test_does_not_defer_for_closed_or_disabled_plugins() { + public function test_does_not_defer_for_closed_plugins() { $now = 1700000000; $release_time = $now - HOUR_IN_SECONDS; @@ -108,17 +109,26 @@ public function test_does_not_defer_for_closed_or_disabled_plugins() { $now ) ); + } - $this->assertFalse( - API_Update_Updater::get_cooldown_defer_time( - $release_time, - false, - 'disabled', - '1.0', - '1.1', - $now - ) + /** + * Disabled plugins still serve updates (available=1) so the cooldown applies the + * same way as for publish. + */ + public function test_defers_new_version_for_disabled_plugins() { + $now = 1700000000; + $release_time = $now - HOUR_IN_SECONDS; + + $result = API_Update_Updater::get_cooldown_defer_time( + $release_time, + false, + 'disabled', + '1.0', + '1.1', + $now ); + + $this->assertSame( $release_time + RELEASE_COOL_DOWN_DELAY, $result ); } /** From d73600a2cff4f392e813905c93fb8ddedc0b88fe Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 15 May 2026 18:14:33 +1000 Subject: [PATCH 03/15] Plugin Directory: Simplify the cooldown helper to pure cooldown math. Hoist the force_released early-exit into the caller so the helper signature shrinks to (release_time, existing_version, new_version) plus an injectable now. Drop the post_status check from both the helper and the release_time anchor -- callers are responsible for bypassing when their context calls for it. Status_Transitions::flush_caches() now passes bypass=true so closures and disables take effect immediately rather than waiting for an in-flight cooldown window. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/class-status-transitions.php | 6 +- .../admin/metabox/class-controls.php | 12 +- .../jobs/class-api-update-updater.php | 47 ++------ .../tests/Plugin_Update_Cooldown_Test.php | 111 ++---------------- 4 files changed, 27 insertions(+), 149 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php index c80606cdc1..6abfe37dc3 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php @@ -482,8 +482,10 @@ public function clear_reviewer( $post ) { * Flush the caches for the plugin. */ protected function flush_caches( $post ) { - // Update the API endpoints with the new data - API_Update_Updater::update_single_plugin( $post->post_name ); + // Update the API endpoints with the new data. Bypass the release cooldown + // so status transitions (closure, disable, reopen) take effect immediately + // rather than getting held back inside an in-flight cooldown window. + API_Update_Updater::update_single_plugin( $post->post_name, true ); Plugins_Info_API::flush_plugin_information_cache( $post->post_name ); } } 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 ba59ee4aa9..6e062b8fc8 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 @@ -49,19 +49,13 @@ static function display() { /** * Display the release cooldown status and (for reviewers) a force-release control. * - * The cooldown only applies to publishable, version-bumping releases; for any other - * state (closed/disabled plugins, releases past the cooldown window, releases already - * force-released) this section is skipped entirely. + * Bails when there's no current release to gate, when the cooldown has already + * elapsed, or when the release was already force-released (reviewers see an audit + * line in that case; authors see no UI). */ protected static function display_release_cooldown() { $post = get_post(); - // Closed plugins don't serve updates (available=0), so the cooldown is irrelevant. - // publish + disabled both serve updates and are subject to the cooldown gate. - if ( ! in_array( $post->post_status, array( 'publish', 'disabled' ), true ) ) { - return; - } - $version = get_post_meta( $post->ID, 'version', true ); if ( ! $version ) { 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 d3cdd42418..8e9c5bdffc 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 @@ -103,20 +103,13 @@ public static function update_single_plugin( $plugin_slug, $bypass_cooldown = fa /* * 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. - * - * Bypassed when: - * - The caller explicitly bypasses (deferred-event fire, reviewer force-release, etc.). - * - The plugin is closed/disabled — closure must take effect immediately, see - * get_cooldown_defer_time(). - * - The reviewer has already force-released this version. - * - This isn't actually a new-version write (same version as already in the table). + * continues to be served by the update API. Callers that need immediate writes + * (status transitions, reviewer force-release, the deferred event firing, meta + * sync, rebuild) pass $bypass_cooldown = true. */ - if ( ! $bypass_cooldown ) { + if ( ! $bypass_cooldown && empty( $release['force_released'] ) ) { $cooldown_until = self::get_cooldown_defer_time( $release_time, - ! empty( $release['force_released'] ), - $post->post_status, $existing_version, (string) $version ); @@ -133,10 +126,7 @@ public static function update_single_plugin( $plugin_slug, $bypass_cooldown = fa // measuring from public availability, even if the commit/confirmation was long ago // because the cooldown deferred the write. Rebuild/status/meta-sync paths reach this // code with existing_version == version and so retain the original release_time. - if ( - $existing_version !== (string) $version && - in_array( $post->post_status, array( 'publish', 'disabled' ), true ) - ) { + if ( $existing_version !== (string) $version ) { $release_time = time(); } @@ -256,32 +246,17 @@ public static function compute_release_time( $post, $release ) { * Pure-logic helper: decide whether a new-version write should be deferred, * and return the cooldown expiry timestamp if so. * - * Applies to `publish` and `disabled` plugins — both keep `available = 1` - * in `update_source` and so serve new versions through the update API. - * Closed plugins write through immediately (available flips to 0; there's - * nothing to gate). - * - * @param int $release_time When the release was committed / final-confirmed. - * @param bool $force_released Whether a reviewer has force-released this version. - * @param string $post_status The plugin's current post_status. - * @param string $existing_version The version currently sitting in update_source (or ''). - * @param string $new_version The version proposed for this write. - * @param int $now Current time, injectable for tests. - * @return int|false Cooldown expiry timestamp if deferral applies, false otherwise. + * @param int $release_time When the release was committed / final-confirmed. + * @param string $existing_version The version currently sitting in update_source (or ''). + * @param string $new_version The version proposed for this write. + * @param int $now Current time, injectable for tests. + * @return int|false Cooldown expiry timestamp if deferral applies, false otherwise. */ - public static function get_cooldown_defer_time( $release_time, $force_released, $post_status, $existing_version, $new_version, $now = null ) { + public static function get_cooldown_defer_time( $release_time, $existing_version, $new_version, $now = null ) { if ( null === $now ) { $now = time(); } - if ( $force_released ) { - return false; - } - - if ( ! in_array( $post_status, array( 'publish', 'disabled' ), true ) ) { - return false; - } - if ( $existing_version === $new_version ) { return false; } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php index 1699c748f8..54bf481384 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php @@ -22,14 +22,7 @@ class Plugin_Update_Cooldown_Test extends TestCase { public function test_defers_new_version_inside_cooldown_window() { $now = 1700000000; $release_time = $now - HOUR_IN_SECONDS; - $cooldown_until = API_Update_Updater::get_cooldown_defer_time( - $release_time, - false, - 'publish', - '1.0', - '1.1', - $now - ); + $cooldown_until = API_Update_Updater::get_cooldown_defer_time( $release_time, '1.0', '1.1', $now ); $this->assertSame( $release_time + RELEASE_COOL_DOWN_DELAY, $cooldown_until ); } @@ -41,16 +34,9 @@ public function test_does_not_defer_when_cooldown_elapsed() { $now = 1700000000; $release_time = $now - RELEASE_COOL_DOWN_DELAY - HOUR_IN_SECONDS; - $result = API_Update_Updater::get_cooldown_defer_time( - $release_time, - false, - 'publish', - '1.0', - '1.1', - $now + $this->assertFalse( + API_Update_Updater::get_cooldown_defer_time( $release_time, '1.0', '1.1', $now ) ); - - $this->assertFalse( $result ); } /** @@ -60,77 +46,11 @@ public function test_does_not_defer_when_version_unchanged() { $now = 1700000000; $release_time = $now - HOUR_IN_SECONDS; - $result = API_Update_Updater::get_cooldown_defer_time( - $release_time, - false, - 'publish', - '1.0', - '1.0', - $now - ); - - $this->assertFalse( $result ); - } - - /** - * A reviewer's force-release flag should let the new version through immediately. - */ - public function test_does_not_defer_when_force_released() { - $now = 1700000000; - $release_time = $now - HOUR_IN_SECONDS; - - $result = API_Update_Updater::get_cooldown_defer_time( - $release_time, - true, - 'publish', - '1.0', - '1.1', - $now - ); - - $this->assertFalse( $result ); - } - - /** - * Closure must take effect immediately and not get held back by the cooldown. - * `closed` plugins set available=0 in update_source — there's nothing to gate. - */ - public function test_does_not_defer_for_closed_plugins() { - $now = 1700000000; - $release_time = $now - HOUR_IN_SECONDS; - $this->assertFalse( - API_Update_Updater::get_cooldown_defer_time( - $release_time, - false, - 'closed', - '1.0', - '1.1', - $now - ) + API_Update_Updater::get_cooldown_defer_time( $release_time, '1.0', '1.0', $now ) ); } - /** - * Disabled plugins still serve updates (available=1) so the cooldown applies the - * same way as for publish. - */ - public function test_defers_new_version_for_disabled_plugins() { - $now = 1700000000; - $release_time = $now - HOUR_IN_SECONDS; - - $result = API_Update_Updater::get_cooldown_defer_time( - $release_time, - false, - 'disabled', - '1.0', - '1.1', - $now - ); - - $this->assertSame( $release_time + RELEASE_COOL_DOWN_DELAY, $result ); - } - /** * Cron-backup paths shouldn't accidentally re-defer ancient commits whose cooldown is long past. */ @@ -138,16 +58,9 @@ public function test_does_not_defer_first_release_with_old_release_time() { $now = 1700000000; $release_time = $now - ( 7 * DAY_IN_SECONDS ); - $result = API_Update_Updater::get_cooldown_defer_time( - $release_time, - false, - 'publish', - '', - '1.0', - $now + $this->assertFalse( + API_Update_Updater::get_cooldown_defer_time( $release_time, '', '1.0', $now ) ); - - $this->assertFalse( $result ); } /** @@ -157,16 +70,10 @@ public function test_defers_first_release_with_recent_release_time() { $now = 1700000000; $release_time = $now - 60; - $result = API_Update_Updater::get_cooldown_defer_time( - $release_time, - false, - 'publish', - '', - '1.0', - $now + $this->assertSame( + $release_time + RELEASE_COOL_DOWN_DELAY, + API_Update_Updater::get_cooldown_defer_time( $release_time, '', '1.0', $now ) ); - - $this->assertSame( $release_time + RELEASE_COOL_DOWN_DELAY, $result ); } /** From 997baa063b388f1f5b616dd8bff71f47bdf1e4f9 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 15 May 2026 18:19:15 +1000 Subject: [PATCH 04/15] Plugin Directory: Disable release cooldown UI when the delay constant is 0; tidy. When RELEASE_COOL_DOWN_DELAY is 0 the feature is off -- skip the deferral, the release_time anchor, the wp-admin Controls metabox section, the shortcode info banner, and the per-release status line. Sites running with the cooldown disabled see no related UI at all. Simplifications: - Inline get_cooldown_defer_time() (single caller; pure cooldown math is 4 lines). - Drop the metabox force-released audit line; Tools::audit_log writes an internal note that the Internal Notes metabox already renders. - Drop the "Serving to sites since X ago" line in the shortcode -- the existing "Released X ago by Y" line above already covers that state. - Drop the unused $user arg to audit_log() in force_release(); it defaults to wp_get_current_user() which is what we set $user to anyway. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../admin/metabox/class-controls.php | 36 +++------- .../jobs/class-api-update-updater.php | 62 +++++------------ .../shortcodes/class-release-confirmation.php | 66 +++++++++---------- .../tests/Plugin_Update_Cooldown_Test.php | 65 +----------------- 4 files changed, 59 insertions(+), 170 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 6e062b8fc8..f0953dc199 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 @@ -49,11 +49,15 @@ static function display() { /** * Display the release cooldown status and (for reviewers) a force-release control. * - * Bails when there's no current release to gate, when the cooldown has already - * elapsed, or when the release was already force-released (reviewers see an audit - * line in that case; authors see no UI). + * Bails when the cooldown feature is disabled (constant is 0), when there's no + * current release to gate, when the release was force-released (the audit-log + * internal note covers that for reviewers), or when the cooldown has elapsed. */ protected static function display_release_cooldown() { + if ( RELEASE_COOL_DOWN_DELAY <= 0 ) { + return; + } + $post = get_post(); $version = get_post_meta( $post->ID, 'version', true ); @@ -62,33 +66,11 @@ protected static function display_release_cooldown() { } $release = Plugin_Directory::get_release( $post, $version ); - if ( ! $release ) { - return; - } - - $release_time = API_Update_Updater::compute_release_time( $post, $release ); - $cooldown_until = $release_time + RELEASE_COOL_DOWN_DELAY; - $force_released = ! empty( $release['force_released'] ); - - // Already force-released — show audit info to reviewers, nothing to authors. - if ( $force_released ) { - if ( current_user_can( 'plugin_review', $post ) ) { - $user = get_userdata( (int) ( $release['force_released_by'] ?? 0 ) ); - printf( - '

%s

', - sprintf( - /* translators: 1: version, 2: relative time, 3: user display name */ - esc_html__( 'Version %1$s was force-released %2$s ago by %3$s, bypassing the release cooldown.', 'wporg-plugins' ), - esc_html( $version ), - esc_html( human_time_diff( (int) ( $release['force_released_at'] ?? time() ) ) ), - esc_html( $user ? ( $user->display_name ? $user->display_name : $user->user_login ) : __( 'unknown', 'wporg-plugins' ) ) - ) - ); - } + if ( ! $release || ! empty( $release['force_released'] ) ) { return; } - // Cooldown already elapsed — nothing to show. + $cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + RELEASE_COOL_DOWN_DELAY; if ( $cooldown_until <= time() ) { 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 8e9c5bdffc..31f439cc9d 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 @@ -106,27 +106,29 @@ public static function update_single_plugin( $plugin_slug, $bypass_cooldown = fa * continues to be served by the update API. Callers that need immediate writes * (status transitions, reviewer force-release, the deferred event firing, meta * sync, rebuild) pass $bypass_cooldown = true. + * + * Skipped entirely when RELEASE_COOL_DOWN_DELAY is 0 — that's the feature-flag + * off switch, callers see the original commit/confirmation release_time. */ - if ( ! $bypass_cooldown && empty( $release['force_released'] ) ) { - $cooldown_until = self::get_cooldown_defer_time( - $release_time, - $existing_version, - (string) $version - ); - - if ( $cooldown_until ) { + if ( + RELEASE_COOL_DOWN_DELAY > 0 && + ! $bypass_cooldown && + empty( $release['force_released'] ) && + $existing_version !== (string) $version + ) { + $cooldown_until = $release_time + RELEASE_COOL_DOWN_DELAY; + if ( $cooldown_until > time() ) { self::queue_release_to_update_api( $post->post_name, $cooldown_until ); return true; } } - // When this write is publishing a new version (existing row had a different version, or - // no row existed) anchor `release_time` to now — that's the moment the version is - // actually available to sites. Keeps phased_rollout()'s `manual-updates-24hr` window - // measuring from public availability, even if the commit/confirmation was long ago - // because the cooldown deferred the write. Rebuild/status/meta-sync paths reach this - // code with existing_version == version and so retain the original release_time. - if ( $existing_version !== (string) $version ) { + // When the cooldown is enabled and this write is publishing a new version, anchor + // `release_time` to now — that's the moment the version is actually available to + // sites. Keeps phased_rollout()'s `manual-updates-24hr` window measuring from public + // availability, even if the commit/confirmation was long ago because the cooldown + // deferred the write. With the cooldown disabled, keep the original semantics. + if ( RELEASE_COOL_DOWN_DELAY > 0 && $existing_version !== (string) $version ) { $release_time = time(); } @@ -242,33 +244,6 @@ public static function compute_release_time( $post, $release ) { return $release_time; } - /** - * Pure-logic helper: decide whether a new-version write should be deferred, - * and return the cooldown expiry timestamp if so. - * - * @param int $release_time When the release was committed / final-confirmed. - * @param string $existing_version The version currently sitting in update_source (or ''). - * @param string $new_version The version proposed for this write. - * @param int $now Current time, injectable for tests. - * @return int|false Cooldown expiry timestamp if deferral applies, false otherwise. - */ - public static function get_cooldown_defer_time( $release_time, $existing_version, $new_version, $now = null ) { - if ( null === $now ) { - $now = time(); - } - - if ( $existing_version === $new_version ) { - return false; - } - - $cooldown_until = $release_time + RELEASE_COOL_DOWN_DELAY; - if ( $cooldown_until <= $now ) { - return false; - } - - return $cooldown_until; - } - /** * Schedule a deferred release-to-update-api cron event for a plugin. * @@ -350,8 +325,7 @@ public static function force_release( $plugin_slug, $reason, $user = null ) { RELEASE_COOL_DOWN_DELAY / HOUR_IN_SECONDS, $reason ), - $post, - $user + $post ); wp_clear_scheduled_hook( "release_to_update_api:{$plugin_slug}" ); 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 cc1574073c..5cb7831e34 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 @@ -87,18 +87,20 @@ class_exists( 'Two_Factor_Core' ) && /* translators: %s: plugins@wordpress.org */ echo '

' . sprintf( __( 'Release confirmations can be enabled on the Advanced view of plugin pages. If you need to disable release confirmations for a plugin, please contact %s.', 'wporg-plugins' ), 'plugins@wordpress.org' ) . '

'; - printf( - '

%s

', - wp_kses( - sprintf( - /* translators: 1: cooldown duration in hours, 2: plugins@wordpress.org link */ - __( 'New releases are served to sites %1$d hours after they\'re committed (or after the final confirmation, if release confirmations are enabled). This gives security scanners and reviewers a window to catch malicious commits before they ship. If you have an urgent security fix that needs to be released sooner, contact %2$s.', 'wporg-plugins' ), - (int) ( RELEASE_COOL_DOWN_DELAY / HOUR_IN_SECONDS ), - 'plugins@wordpress.org' - ), - array( 'a' => array( 'href' => true ) ) - ) - ); + if ( RELEASE_COOL_DOWN_DELAY > 0 ) { + printf( + '

%s

', + wp_kses( + sprintf( + /* translators: 1: cooldown duration in hours, 2: plugins@wordpress.org link */ + __( 'New releases are served to sites %1$d hours after they\'re committed (or after the final confirmation, if release confirmations are enabled). This gives security scanners and reviewers a window to catch malicious commits before they ship. If you have an urgent security fix that needs to be released sooner, contact %2$s.', 'wporg-plugins' ), + (int) ( RELEASE_COOL_DOWN_DELAY / HOUR_IN_SECONDS ), + 'plugins@wordpress.org' + ), + array( 'a' => array( 'href' => true ) ) + ) + ); + } $not_enabled = []; foreach ( $plugins as $plugin ) { @@ -297,13 +299,18 @@ static function get_approval_text( $plugin, $data ) { } /** - * Render a single line describing the cooldown state of a release: pending serve time, - * served-on time, or force-released-by-reviewer time. Skipped when the release isn't yet - * confirmed/processed (the existing confirmation messaging already speaks to that state). + * Render a single line describing the cooldown state of a release: pending serve time + * or force-released-by-reviewer. Skipped when the cooldown feature is disabled, when + * the release was discarded, when it hasn't moved past confirmation/processing, or + * when the cooldown has already elapsed without force-release. * * @param array $data The release row from Plugin_Directory::get_releases(). */ protected static function render_cooldown_status( $data ) { + if ( RELEASE_COOL_DOWN_DELAY <= 0 ) { + return; + } + if ( ! empty( $data['discarded'] ) ) { return; } @@ -313,40 +320,27 @@ protected static function render_cooldown_status( $data ) { return; } - $release_time = $data['confirmations'] - ? max( $data['confirmations'] ) - : (int) $data['date']; - - $cooldown_until = $release_time + RELEASE_COOL_DOWN_DELAY; - if ( ! empty( $data['force_released'] ) ) { $message = sprintf( /* translators: %s: relative time */ __( 'Force-released by a plugin reviewer %s ago.', 'wporg-plugins' ), - human_time_diff( (int) ( $data['force_released_at'] ?? $release_time ) ) + human_time_diff( (int) ( $data['force_released_at'] ?? $data['date'] ) ) ); printf( '%s
', esc_html( $message ) ); return; } - if ( $cooldown_until > time() ) { - $message = sprintf( - /* translators: %s: relative time until cooldown expires */ - __( 'Will be served to sites in %s.', 'wporg-plugins' ), - human_time_diff( time(), $cooldown_until ) - ); - printf( - '%s
', - esc_attr( gmdate( 'Y-m-d H:i:s', $cooldown_until ) ), - esc_html( $message ) - ); + $release_time = $data['confirmations'] ? max( $data['confirmations'] ) : (int) $data['date']; + $cooldown_until = $release_time + RELEASE_COOL_DOWN_DELAY; + + if ( $cooldown_until <= time() ) { return; } $message = sprintf( - /* translators: %s: relative time */ - __( 'Serving to sites since %s ago.', 'wporg-plugins' ), - human_time_diff( $cooldown_until ) + /* translators: %s: relative time until cooldown expires */ + __( 'Will be served to sites in %s.', 'wporg-plugins' ), + human_time_diff( time(), $cooldown_until ) ); printf( '%s
', diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php index 54bf481384..cd81461c34 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Update_Cooldown_Test.php @@ -1,81 +1,20 @@ assertSame( $release_time + RELEASE_COOL_DOWN_DELAY, $cooldown_until ); - } - - /** - * Once the cooldown has elapsed the write should go straight through. - */ - public function test_does_not_defer_when_cooldown_elapsed() { - $now = 1700000000; - $release_time = $now - RELEASE_COOL_DOWN_DELAY - HOUR_IN_SECONDS; - - $this->assertFalse( - API_Update_Updater::get_cooldown_defer_time( $release_time, '1.0', '1.1', $now ) - ); - } - - /** - * Status changes or metadata refreshes that leave the version unchanged should not defer. - */ - public function test_does_not_defer_when_version_unchanged() { - $now = 1700000000; - $release_time = $now - HOUR_IN_SECONDS; - - $this->assertFalse( - API_Update_Updater::get_cooldown_defer_time( $release_time, '1.0', '1.0', $now ) - ); - } - - /** - * Cron-backup paths shouldn't accidentally re-defer ancient commits whose cooldown is long past. - */ - public function test_does_not_defer_first_release_with_old_release_time() { - $now = 1700000000; - $release_time = $now - ( 7 * DAY_IN_SECONDS ); - - $this->assertFalse( - API_Update_Updater::get_cooldown_defer_time( $release_time, '', '1.0', $now ) - ); - } - - /** - * A brand-new plugin's first commit should still be subject to the cooldown. - */ - public function test_defers_first_release_with_recent_release_time() { - $now = 1700000000; - $release_time = $now - 60; - - $this->assertSame( - $release_time + RELEASE_COOL_DOWN_DELAY, - API_Update_Updater::get_cooldown_defer_time( $release_time, '', '1.0', $now ) - ); - } - /** * Without release confirmations the commit timestamp drives release_time. */ From da553417b7e845f2483f62b2a614d33209e1b209 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 15 May 2026 18:23:42 +1000 Subject: [PATCH 05/15] Plugin Directory: Use truthy checks against RELEASE_COOL_DOWN_DELAY. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/plugin-directory/admin/metabox/class-controls.php | 2 +- .../plugin-directory/jobs/class-api-update-updater.php | 4 ++-- .../shortcodes/class-release-confirmation.php | 4 ++-- 3 files changed, 5 insertions(+), 5 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 f0953dc199..3be7bf0bda 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 @@ -54,7 +54,7 @@ static function display() { * internal note covers that for reviewers), or when the cooldown has elapsed. */ protected static function display_release_cooldown() { - if ( RELEASE_COOL_DOWN_DELAY <= 0 ) { + if ( ! RELEASE_COOL_DOWN_DELAY ) { 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 31f439cc9d..88ae295f36 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 @@ -111,7 +111,7 @@ public static function update_single_plugin( $plugin_slug, $bypass_cooldown = fa * off switch, callers see the original commit/confirmation release_time. */ if ( - RELEASE_COOL_DOWN_DELAY > 0 && + RELEASE_COOL_DOWN_DELAY && ! $bypass_cooldown && empty( $release['force_released'] ) && $existing_version !== (string) $version @@ -128,7 +128,7 @@ public static function update_single_plugin( $plugin_slug, $bypass_cooldown = fa // sites. Keeps phased_rollout()'s `manual-updates-24hr` window measuring from public // availability, even if the commit/confirmation was long ago because the cooldown // deferred the write. With the cooldown disabled, keep the original semantics. - if ( RELEASE_COOL_DOWN_DELAY > 0 && $existing_version !== (string) $version ) { + if ( RELEASE_COOL_DOWN_DELAY && $existing_version !== (string) $version ) { $release_time = time(); } 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 5cb7831e34..f103d032f4 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 @@ -87,7 +87,7 @@ class_exists( 'Two_Factor_Core' ) && /* translators: %s: plugins@wordpress.org */ echo '

' . sprintf( __( 'Release confirmations can be enabled on the Advanced view of plugin pages. If you need to disable release confirmations for a plugin, please contact %s.', 'wporg-plugins' ), 'plugins@wordpress.org' ) . '

'; - if ( RELEASE_COOL_DOWN_DELAY > 0 ) { + if ( RELEASE_COOL_DOWN_DELAY ) { printf( '

%s

', wp_kses( @@ -307,7 +307,7 @@ static function get_approval_text( $plugin, $data ) { * @param array $data The release row from Plugin_Directory::get_releases(). */ protected static function render_cooldown_status( $data ) { - if ( RELEASE_COOL_DOWN_DELAY <= 0 ) { + if ( ! RELEASE_COOL_DOWN_DELAY ) { return; } From ebace1c10bfd506937e0c86cd78a016a08162761 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 15 May 2026 18:47:06 +1000 Subject: [PATCH 06/15] Plugin Directory: Drop the redundant force-release nonce. The form only renders for users with the plugin_review capability, the save_post handler re-checks the same capability, and the whole thing posts inside wp-admins post.php form which is already nonced by core. The extra _force_release_nonce was just noise. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plugins/plugin-directory/admin/metabox/class-controls.php | 3 --- 1 file changed, 3 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 3be7bf0bda..57f582175b 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 @@ -100,7 +100,6 @@ protected static function display_release_cooldown() { >

- ID, '_force_release_nonce' ); ?>