Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
a89397e
Add the phased rollout general option to plugin advanced section.
dd32 Jul 7, 2025
c89df53
Add functions that would otherwise live in the closed-source plugins …
dd32 Jul 9, 2025
42aadbb
format the code for easier reading.
dd32 Jul 9, 2025
448049b
Merge branch 'trunk' into plugins/phased-rollout
dd32 Jul 9, 2025
8e93771
Clarify comments and TODOs.
dd32 Jul 9, 2025
c5ca4a7
Somewhat cleaner get_site_percentage().
dd32 Jul 9, 2025
7614815
Use the plugin slug/version in the percent calc.
dd32 Jul 9, 2025
5d14a8c
Fix the release curves.
dd32 Jul 9, 2025
086708b
Somewhat document the curve..
dd32 Jul 9, 2025
4ae1938
Don't need to check for time availability.
dd32 Jul 9, 2025
99404d6
Use the Release Confirmation confirmed time if it's known, use the pe…
dd32 Jul 9, 2025
52f94e2
parse_url() returning false, strtolower() only liking strings.
dd32 Jul 9, 2025
3c220da
Avoid Notice when $wp_url hasn't been set.
dd32 Jul 9, 2025
1b9ac96
Store the previous version / stable tag upon version increase.
dd32 Jul 15, 2025
d0f48fa
Sync through the metadata updates.
dd32 Jul 15, 2025
b5f0da7
Reorganise how this adjusts the API, by filtering the response data f…
dd32 Jul 15, 2025
9455a12
Add a phased rollout of 'Manual user-initiated updates for first 24hr…
dd32 Jul 15, 2025
ad89083
Merge branch 'trunk' into plugins/phased-rollout
dd32 Jul 15, 2025
b768acb
Tweak when the last_stable_tag record is done, as we only want it for…
dd32 Jul 16, 2025
57bcb7e
Move strategies to a helper method.
dd32 Jul 22, 2025
5776c04
Merge branch 'trunk' into plugins/phased-rollout
dd32 Jul 28, 2025
09d8156
Initial release strategy UI code for plugin confirmations.
dd32 Jul 28, 2025
0e10a66
Move a comment around.
dd32 Jul 28, 2025
45f023c
Fix broken merge
dd32 Jul 28, 2025
e6bf3b2
Remove ther version check here, the API handles it.
dd32 Jul 28, 2025
4ba57db
These globals aren't used.
dd32 Jul 28, 2025
bdae38f
Redirect back to the plugin releaes anchor.
dd32 Jul 31, 2025
01e53d9
More minimal Release Confirmation UI.
dd32 Jul 31, 2025
57a45e1
Store the rollout strategy with the release.
dd32 Jul 31, 2025
2785b76
Merge branch 'trunk' into plugins/phased-rollout
dd32 Jul 31, 2025
cc27a12
HTML tweak
dd32 Jul 31, 2025
dfe16bb
Consistency of wording and function naming.
dd32 Jul 31, 2025
003c622
Merge branch 'trunk' into plugins/phased-rollout
dd32 Jul 31, 2025
9c4a31a
Docblocks.
dd32 Jul 31, 2025
b113ce0
Use the last_stable_tag too if possible.
dd32 Jul 31, 2025
40d45ee
Merge branch 'trunk' into plugins/phased-rollout
dd32 Jul 31, 2025
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 @@ -1001,6 +1001,22 @@ public static function get_release_confirmation_link( $tag, $post = null, $what
);
}

/**
* Generates a link to enable Release Confirmations.
*
* @param int|\WP_Post|null $post Optional. Post ID or post object. Defaults to global $post.
* @return string URL to enable confirmations.
*/
public static function get_phased_rollout_link( $post = null ) {
$post = get_post( $post );

return add_query_arg(
array( '_wpnonce' => wp_create_nonce( 'wp_rest' ) ),
home_url( 'wp-json/plugins/v1/plugin/' . $post->post_name . '/phased-rollout' )
);
}


/**
* Returns the reasons for closing or disabling a plugin.
*
Expand Down Expand Up @@ -1385,6 +1401,23 @@ static function get_rollout_strategies() {
'name' => __( 'Manual updates only (24 hours)', 'wporg-plugins' ),
'description' => __( 'Plugin updates will be released to all sites, but automatic updates will be disabled for 24 hours. After that, sites will receive the update as normal.', 'wporg-plugins' ),
],
/*
[
'name' => __( 'Slow rollout', 'wporg-plugins' ),
'slug' => 'slow',
'description' => __( 'Plugin updates will be released to 5% of sites for the first 6 hours, increasing to 100% over the next 2 days.', 'wporg-plugins' ),
],
[
'name' => __( 'Extra slow rollout', 'wporg-plugins' ),
'slug' => 'extra-slow',
'description' => __( 'Plugin updates will be released to 5% of sites for the first 6 hours, increasing to 100% over the next 3 days.', 'wporg-plugins' ),
],
[
'name' => __( 'Cautious rollout', 'wporg-plugins' ),
'slug' => 'cautious',
'description' => __( 'Plugin updates will be released to 1% of sites for the first 6 hours, increasing to 10% by day 2, and to 100% of sites within 5 days.', 'wporg-plugins' ),
]
*/
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ public static function update_single_plugin( $plugin_slug, $self_loop = false )
$meta['rollout'] = array(
'strategy' => $release['rollout_strategy'],
);
} elseif ( $post->rollout_strategy ) {
$meta['rollout'] = array(
'strategy' => $post->rollout_strategy,
);
}

$data = array(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,156 @@ function phased_rollout( $plugin_info, $plugin_details, $installed_version ) {
$hours_since_release <= 24
) {
$plugin_info->disable_autoupdate = true;

// Early return to avoid further processing.
return $plugin_info;
}

$do_not_offer_update = false;

// Handle the percent-based strategies.
$plugin_percent_rollout = phased_rollout_get_plugin_percent( $strategy, $hours_since_release, $plugin_details );
if ( $plugin_percent_rollout !== false ) {

$site_percent = get_site_percentage( $plugin_details->plugin_slug, $plugin_details->version );

if ( $site_percent > $plugin_percent_rollout ) {
$do_not_offer_update = true;
}
}

// If the site should not update, we'll return the last-version if possible.
if ( $do_not_offer_update ) {
$plugin_info->version = ( $plugin_details->meta->last_version ?? '' ) ?: $installed_version;
$plugin_info->stable_tag = ( $plugin_details->meta->last_stable_tag ?? '' ) ?: $installed_version;

// Match update-check API.
unset(
$plugin_info->tested,
$plugin_info->requires_php,
$plugin_info->requires_plugins,
$plugin_info->compatibility,
$plugin_info->upgrade_notice
);
}

return $plugin_info;
}

/**
* Return the current sites update-percentage.
*
* @global string $wp_url The WordPress site URL. Extracted from the HTTP User Agent header.
*
* @param string $slug The plugin slug.
* @param string $version The plugin version.
*
* @return float 0...100.00
*/
function get_site_percentage( string $slug = '', string $version = '' ) {
global $wp_url;

/*
* If the site URL hasn't been extracted already, pull it from the global.
* NOTE: This may be set by the tests or other codepaths that run before this function.
*/
if ( empty( $wp_url ) && preg_match( '#^WordPress/.+; (http.+)$#i', $_SERVER['HTTP_USER_AGENT'] ?? '', $m ) ) {
$wp_url = $m[1];
}

$site_domain = strtolower( parse_url( $wp_url, PHP_URL_HOST ) ?: '' );

// If we've reached this point and have no URL, delay the update until 100% is reached.
if ( ! $site_domain ) {
return 100;
}

// $site_step represents an integer from 0 to 4095.
$site_step = base_convert( substr( md5( "{$site_domain}|{$slug}|{$version}" ), 0, 3 ), 16, 10 );
$site_percent = $site_step / 4095 * 100;

return $site_percent;
}

/**
* Get the percentage of sites that should receive the update for the plugin.
*
* @link https://www.desmos.com/calculator/59sl7efajq
*
* @param string $strategy The rollout strategy.
* @param float $hours_since_release The number of hours since the plugin was released.
* @param object $update_details The plugin update details.
*
* @return float|false The percentage of sites that should receive the update, or false invalid details.
*/
function phased_rollout_get_plugin_percent( string $strategy, float $hours_since_release, object $update_details ) {
$percent_based_strategies = [
'custom',
'slow',
'extra-slow',
'cautious',
];

$phase_details = $update_details->meta->rollout ?? false;

if (
! $phase_details ||
! in_array( $strategy, $percent_based_strategies, true )
) {
return false;
}

switch( $strategy ) {
default:
return false;

// Custom defined by the plugin author, they must update this value in settings.
case 'custom':
return $phase_details['percentage'] ?? 100;

/*
* Straight curve, start at 5%, increases to 100% over the next 48hrs (2d).
*
* At 6 hours, the percentage is 5 + (6/48) * 95 = 16.875%
* At 12 hours, the percentage is 5 + (12/48) * 95 = 28.75%
* At 24 hours, the percentage is 5 + (24/48) * 95 = 52.5%
* At 36 hours, the percentage is 5 + (36/48) * 95 = 72.25%
* At 48 hours, the percentage is 5 + (48/48) * 95 = 100%
*/
case 'slow':
return 5 + ( $hours_since_release / 48 ) * 95;

/*
* Polynomial curve, starts at 5%, increases to 100% over the next 72hrs (3d).
*
* At 6 hours, the percentage is 9 * ( 1.0345 ** 6 ) - 3.9 = 7.13%
* At 12 hours, the percentage is 9 * ( 1.0345 ** 12 ) - 3.9 = 9.62%
* At 24 hours, the percentage is 9 * ( 1.0345 ** 24 ) - 3.9 = 16.41%
* At 36 hours, the percentage is 9 * ( 1.0345 ** 36 ) - 3.9 = 26.61%
* At 48 hours, the percentage is 9 * ( 1.0345 ** 48 ) - 3.9 = 41.95%
* At 60 hours, the percentage is 9 * ( 1.0345 ** 60 ) - 3.9 = 64.97%
* At 72 hours, the percentage is 9 * ( 1.0345 ** 72 ) - 3.9 = 100%
*/
case 'extra-slow':
return 9 * ( 1.0345 ** $hours_since_release ) - 3.9;

/*
* Polynomial curve, starts at 1%, with an increase to 100% over the next 120hrs (5d).
*
* At 6 hours, the percentage is 11 * ( 1.0195 ** 6 ) - 10 = 2.35%
* At 12 hours, the percentage is 11 * ( 1.0195 ** 12 ) - 10 = 3.87%
* At 24 hours, the percentage is 11 * ( 1.0195 ** 24 ) - 10 = 7.49%
* At 36 hours, the percentage is 11 * ( 1.0195 ** 36 ) - 10 = 12%
* At 48 hours, the percentage is 11 * ( 1.0195 ** 48 ) - 10 = 17.8%
* At 72 hours, the percentage is 11 * ( 1.0195 ** 72 ) - 10 = 34.18%
* At 96 hours, the percentage is 11 * ( 1.0195 ** 96 ) - 10 = 60.24%
* At 120 hours, the percentage is 11 * ( 1.0195 ** 120 ) - 10 = 101.65 ~= 100%
*
*/
case 'cautious':
return 11 * ( 1.0195 ** $hours_since_release ) - 10;
}

// If we reach this point, something is wrong.
return false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,9 @@ function the_plugin_danger_zone() {
// Output the Release Confirmation form.
the_plugin_release_confirmation_form();

// Output the rollout settings.
the_rollout_settings();

if ( 'publish' != $post->post_status ) {
// A reminder of the closed status.
the_active_plugin_notice();
Expand Down Expand Up @@ -772,3 +775,45 @@ function the_author_notice( $post = null ) {
);
}
}

/**
* Displays the "phased rollout" settings for a plugin.
*/
function the_rollout_settings() {
$post = get_post();
if ( ! current_user_can( 'plugin_manage_releases', $post ) ) {
return;
}
$rollout = $post->phased_rollout ?: '';

echo '<h4>' . __( 'Rollout Strategy', 'wporg-plugins' ) . '</h4>';

echo '<p>' .
__( 'Phased rollout of a plugin initially delivers updates to a small selection of sites, increasing over time.', 'wporg-plugins' ) .
' ' .
__( 'This allows for the plugin author to limit the impact of a change in a plugin which may negatively impact user experience, to receive that feedback, and resolve the issue before the plugin update is delivered to all websites.', 'wporg-plugins' ) .
'</p>';

echo '<form method="POST" action="' . esc_url( Template::get_phased_rollout_link() ) . '">';
echo '<select
id="rollout_strategy"
name="rollout_strategy"
onchange="this.nextElementSibling.innerText = this.options[this.selectedIndex].dataset.description;"
>';
foreach ( Template::get_rollout_strategies() as $slug => $set ) {
printf(
'<option value="%s" data-description="%s" %s>%s</option>',
esc_attr( $slug ),
esc_attr( $set['description'] ),
selected( $rollout, $slug, false ),
esc_html( $set['name'] )
);
}
echo '</select>';
echo '<div class="help">' . esc_html( Template::get_rollout_strategies()[ $rollout ]['description'] ?? '' ) . '</div>';

echo '<p class="wp-block-button is-small">';
echo '<input class="wp-block-button__link" type="submit" value="' . esc_attr__( 'Save', 'wporg-plugins' ) . '" />';
echo '</p>';
echo '</form>';
}