Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) );
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -27,6 +29,7 @@ static function display() {
<div id="misc-publishing-actions">
<?php
self::display_meta();
self::display_release_cooldown();
self::display_post_status();
?>
</div>
Expand All @@ -42,6 +45,116 @@ static function display() {
<?php
}

/**
* Display the release cooldown status and (for reviewers) a force-release control.
*
* Bails when there's no current release to gate, when the release has no cooldown
* delay (feature off at release-creation, or already force-released), or when the
* cooldown window has elapsed.
*/
protected static function display_release_cooldown() {
$post = get_post();

$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;
}

$cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay;
if ( $cooldown_until <= time() ) {
return;
}

?>
<div class="misc-pub-section misc-pub-release-cooldown">
<p>
<?php
printf(
/* translators: 1: version, 2: relative time until cooldown expires, 3: absolute UTC timestamp */
esc_html__( 'Version %1$s is in the release cooldown — it will be served to sites in %2$s (at %3$s UTC).', 'wporg-plugins' ),
esc_html( $version ),
esc_html( human_time_diff( time(), $cooldown_until ) ),
esc_html( gmdate( 'Y-m-d H:i', $cooldown_until ) )
);
?>
</p>
<?php if ( current_user_can( 'plugin_review', $post ) ) : ?>
<p>
<label for="force_release_reason"><?php esc_html_e( 'Force-release reason (required):', 'wporg-plugins' ); ?></label>
<textarea
id="force_release_reason"
name="force_release_reason"
rows="2"
style="width: 100%;"
placeholder="<?php esc_attr_e( 'e.g. urgent security fix for CVE-…', 'wporg-plugins' ); ?>"
></textarea>
</p>
<p>
<button type="submit" name="force_release_version" value="<?php echo esc_attr( $version ); ?>" class="button">
<?php
printf(
/* translators: %s: version */
esc_html__( 'Force-release %s now', 'wporg-plugins' ),
esc_html( $version )
);
?>
</button>
</p>
<?php endif; ?>
</div>
<?php
}

/**
* Save handler for reviewer force-release submissions from the Controls metabox.
*
* @param int $post_id The post being saved.
*/
public static function save_post( $post_id ) {
if ( empty( $_POST['force_release_version'] ) ) {
return;
}

$post = get_post( $post_id );
if ( ! $post || 'plugin' !== $post->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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 ?? '',
);
Expand All @@ -96,22 +138,17 @@ 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(
'strategy' => $release['rollout_strategy'],
);
}

// 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,
Expand Down Expand Up @@ -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
);
Comment thread
dd32 marked this conversation as resolved.

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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
);

/**
Expand Down
Loading
Loading