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() {
+ 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() {
>