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/class-status-transitions.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/admin/class-status-transitions.php
index c80606cdc1..12c6c46c2b 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,7 +482,7 @@ public function clear_reviewer( $post ) {
* Flush the caches for the plugin.
*/
protected function flush_caches( $post ) {
- // Update the API endpoints with the new data
+ // Update the API endpoints with the new data.
API_Update_Updater::update_single_plugin( $post->post_name );
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 052f6f5a15..3d85569f28 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,6 +2,8 @@
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;
/**
@@ -27,6 +29,7 @@ static function display() {
@@ -42,6 +45,116 @@ static function display() {
ID, 'version', true );
+ if ( ! $version ) {
+ return;
+ }
+
+ $release = Plugin_Directory::get_release( $post, $version );
+ if ( ! $release ) {
+ return;
+ }
+
+ $release_delay = (int) ( $release['release_delay'] ?? 0 );
+ if ( ! $release_delay ) {
+ return;
+ }
+
+ $cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay;
+ if ( $cooldown_until <= time() ) {
+ return;
+ }
+
+ ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ post_type ) {
+ return;
+ }
+
+ if ( ! current_user_can( 'plugin_review', $post ) ) {
+ return;
+ }
+
+ // Re-verify the post.php form nonce that core already checked, to satisfy phpcs
+ // and to make the security boundary explicit.
+ check_admin_referer( 'update-post_' . $post_id );
+
+ $version = get_post_meta( $post->ID, 'version', true );
+ $submitted_version = sanitize_text_field( wp_unslash( $_POST['force_release_version'] ) );
+ 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/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php
index f0699662ec..2661bc989f 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php
@@ -1729,6 +1729,10 @@ public static function add_release( $plugin, $data ) {
'confirmations_required' => (int) $plugin->release_confirmation,
'committer' => [],
'revision' => [],
+ // Captures the release cooldown active at creation time so future filter/constant
+ // changes don't retroactively affect in-flight releases. Reviewers force-release
+ // by overriding this to 0 — see API_Update_Updater::force_release().
+ 'release_delay' => get_release_cooldown_delay( $plugin->post_name ),
];
// Fill the $release with the newish data. This could/should use wp_parse_args()?
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..f24821258a 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 WordPressdotorg\Plugin_Directory\Tools;
/**
* Handles interfacing with the api.WordPress.org/plugin/update-check/ API.
@@ -67,20 +68,61 @@ public static function cron_trigger() {
/**
* Updates a single plugins `update_source` data.
+ *
+ * @param string $plugin_slug The plugin slug.
+ * @return bool
*/
- public static function update_single_plugin( $plugin_slug, $self_loop = false ) {
+ public static function update_single_plugin( $plugin_slug ) {
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
+ )
+ );
+
+ $release_delay = (int) ( $release['release_delay'] ?? 0 );
+
+ /*
+ * Defer the write for new versions still inside the cooldown window. While
+ * deferred, the existing `update_source` row (carrying the previous version)
+ * continues to be served by the update API. Reviewers force-release by setting
+ * `release_delay = 0` on the release meta.
+ *
+ * The deferred cron fires at exactly $cooldown_until, so by definition this
+ * gate is false when called from cron_trigger_release() and no explicit bypass
+ * is needed.
+ */
+ if ( $release_delay && $existing_version !== (string) $version ) {
+ $cooldown_until = $release_time + $release_delay;
+ if ( $cooldown_until > time() ) {
+ self::queue_release_to_update_api( $post->post_name, $cooldown_until );
+ return true;
+ }
+ }
+
+ // When publishing a new version under an active cooldown, 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.
+ if ( $release_delay && $existing_version !== (string) $version ) {
+ $release_time = time();
+ }
+
+ $meta = array(
+ 'release_time' => $release_time,
'last_version' => $post->last_version ?? '',
'last_stable_tag' => $post->last_stable_tag ?? '',
);
@@ -96,15 +138,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 +145,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 +208,102 @@ 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;
+ }
+
+ /**
+ * Schedule a deferred release-to-update-api cron event for a plugin, replacing
+ * any earlier event so a follow-up commit fully resets the cooldown 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 ) {
+ wp_clear_scheduled_hook( "release_to_update_api:{$plugin_slug}" );
+ wp_schedule_single_event( $cooldown_until, "release_to_update_api:{$plugin_slug}" );
+ }
+
+ /**
+ * Cron handler for `release_to_update_api:{slug}`. Fires when the cooldown
+ * expires; writes the new version to `update_source` immediately. The slug
+ * is recovered from the dynamic hook name so no args need flow through cron.
+ */
+ public static function cron_trigger_release() {
+ list( , $plugin_slug ) = explode( ':', current_filter(), 2 );
+ self::update_single_plugin( $plugin_slug );
+ }
+
+ /**
+ * 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;
+ }
+
+ Tools::audit_log(
+ sprintf(
+ 'Force-released version %s, bypassing the %d-hour release cooldown. Reason: %s',
+ $version,
+ (int) ( $release['release_delay'] ?? 0 ) / HOUR_IN_SECONDS,
+ $reason
+ ),
+ $post
+ );
+
+ Plugin_Directory::add_release(
+ $post,
+ array(
+ 'tag' => $release['tag'],
+ 'release_delay' => 0,
+ )
+ );
+
+ return self::update_single_plugin( $plugin_slug );
+ }
+
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..45b61e3a2b 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,53 @@
*/
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().
+ *
+ * Defers to the shared WPORG_PLUGIN_THEME_RELEASE_DELAY constant when it's defined
+ * so the plugin and theme directories can be tuned (or disabled) in lockstep from a
+ * single override point.
+ *
+ * Defaults to 0 (cooldown disabled, releases served immediately) for now; this will be
+ * raised once the surrounding workflow is ready. Can be pre-defined in global config to
+ * override the default.
+ */
+if ( ! defined( __NAMESPACE__ . '\RELEASE_COOL_DOWN_DELAY' ) ) {
+ define( __NAMESPACE__ . '\RELEASE_COOL_DOWN_DELAY', defined( 'WPORG_PLUGIN_THEME_RELEASE_DELAY' ) ? WPORG_PLUGIN_THEME_RELEASE_DELAY : 0 );
+}
+
+/**
+ * Returns the release cooldown delay, in seconds, for a plugin.
+ *
+ * The RELEASE_COOL_DOWN_DELAY constant provides the default, which is then passed through
+ * the `wporg_plugins_release_cooldown_delay` filter so the delay can be shortened,
+ * extended, or removed (return 0 to disable the cooldown) on a per-plugin basis. The
+ * plugin slug is passed to the filter when it is known.
+ *
+ * This is captured onto each release at creation time (see Plugin_Directory::add_release()),
+ * so changing the filter does not retroactively alter the cooldown of in-flight releases.
+ *
+ * @param string $plugin_slug The slug of the plugin being released, if known.
+ * @return int Delay in seconds. 0 disables the cooldown (the version is served immediately).
+ */
+function get_release_cooldown_delay( $plugin_slug = '' ) {
+ /**
+ * Filters the release cooldown delay for a plugin.
+ *
+ * Return 0 to disable the cooldown (the version is served as soon as it's imported), or
+ * a larger/smaller number of seconds to lengthen or shorten the delay for this plugin.
+ *
+ * @param int $delay The default delay in seconds (the RELEASE_COOL_DOWN_DELAY constant).
+ * @param string $plugin_slug The slug of the plugin being released, or '' when not known.
+ */
+ return (int) apply_filters( 'wporg_plugins_release_cooldown_delay', RELEASE_COOL_DOWN_DELAY, $plugin_slug );
+}
+
// 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..862b1630b0 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
@@ -265,6 +265,8 @@ static function get_approval_text( $plugin, $data ) {
);
}
+ self::render_cooldown_status( $data );
+
echo '';
$text = ob_get_clean();
@@ -280,6 +282,48 @@ 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.
+ * Skipped for releases without a cooldown delay (feature off at release creation, or
+ * force-released), discarded releases, releases that haven't moved past
+ * confirmation/processing, or where the cooldown window has elapsed.
+ *
+ * @param array $data The release row from Plugin_Directory::get_releases().
+ */
+ protected static function render_cooldown_status( $data ) {
+ $release_delay = (int) ( $data['release_delay'] ?? 0 );
+ if ( ! $release_delay ) {
+ return;
+ }
+
+ 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_delay;
+
+ if ( $cooldown_until <= time() ) {
+ return;
+ }
+
+ $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 )
+ );
+ }
+
static function get_actions( $plugin, $data ) {
$buttons = [];
@@ -422,6 +466,63 @@ static function template_redirect() {
add_filter( 'wporg_noindex_request', '__return_true' );
}
+ /**
+ * Surfaces an in-cooldown notice to committers on the plugin's public page.
+ *
+ * Bails when the viewer isn't a committer, when there's no current release in
+ * an active cooldown window, or when the release was force-released
+ * (release_delay = 0 ⇒ no cooldown).
+ *
+ * @param WP_Post $post The currently displayed post.
+ */
+ public static function frontend_cooldown_notice( $post = null ) {
+ $post = get_post( $post );
+
+ if ( ! $post || ! current_user_can( 'plugin_admin_edit', $post ) ) {
+ return;
+ }
+
+ $version = get_post_meta( $post->ID, 'version', true );
+ if ( ! $version ) {
+ return;
+ }
+
+ $release = Plugin_Directory::get_release( $post, $version );
+ if ( ! $release ) {
+ return;
+ }
+
+ $release_delay = (int) ( $release['release_delay'] ?? 0 );
+ if ( ! $release_delay ) {
+ return;
+ }
+
+ $release_time = $release['confirmations'] ? max( $release['confirmations'] ) : (int) $release['date'];
+ $cooldown_until = $release_time + $release_delay;
+
+ if ( $cooldown_until <= time() ) {
+ return;
+ }
+
+ printf(
+ '',
+ wp_kses(
+ sprintf(
+ /* translators: 1: plugin version, 2: relative time until cooldown expires, 3: delay duration in hours, 4: plugins@wordpress.org link */
+ __( 'Version %1$s will be released to sites in about %2$s. WordPress.org currently delays plugin updates by %3$d hours so moderators and security scanners can review changes before they reach users. If this update fixes a security issue that needs to ship sooner, contact %4$s.', 'wporg-plugins' ),
+ '' . esc_html( $version ) . '',
+ esc_html( human_time_diff( time(), $cooldown_until ) ),
+ (int) ( $release_delay / HOUR_IN_SECONDS ),
+ 'plugins@wordpress.org'
+ ),
+ array(
+ 'code' => array(),
+ 'a' => array( 'href' => true ),
+ )
+ )
+ );
+ }
+
/**
* Displays the notice on the plugin front-end.
*
diff --git a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/inc/template-tags.php b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/inc/template-tags.php
index 10dd3ec7d7..3357c335d3 100644
--- a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/inc/template-tags.php
+++ b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/inc/template-tags.php
@@ -262,6 +262,13 @@ function the_unconfirmed_releases_notice() {
return Release_Confirmation::frontend_unconfirmed_releases_notice();
}
+/**
+ * Render the in-cooldown release notice for committers on a plugin's public page.
+ */
+function the_release_cooldown_notice() {
+ return Release_Confirmation::frontend_cooldown_notice();
+}
+
function the_no_self_management_notice() {
$post = get_post();
diff --git a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/template-parts/plugin-single.php b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/template-parts/plugin-single.php
index b28bcffb99..cdf32fc5e7 100644
--- a/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/template-parts/plugin-single.php
+++ b/wordpress.org/public_html/wp-content/themes/pub/wporg-plugins-2024/template-parts/plugin-single.php
@@ -27,6 +27,7 @@
+