Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions environments/plugin-directory/.wp-env.test.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,13 @@
"plugins": [
"../wordpress.org/public_html/wp-content/plugins/plugin-directory"
],
"mappings": {
"wp-content/env-bin": "./plugin-directory/bin"
},
"lifecycleScripts": {
"afterStart": "bash plugin-directory/bin/after-start-test.sh"
},
"config": {
"PLUGINS_TABLE_PREFIX": "wp_"
}
}
6 changes: 5 additions & 1 deletion environments/plugin-directory/bin/after-start-test.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/bash
#
# Runs after wp-env start for the test environment.
# Installs PHPUnit 11 and Yoast polyfills in the test container.
# Installs PHPUnit 11 and Yoast polyfills, and creates the stub tables tests read.
#

CONFIG="--config plugin-directory/.wp-env.test.json"
Expand All @@ -10,3 +10,7 @@ RUN="npx wp-env $CONFIG run tests-cli"
echo "Installing PHPUnit 11 and polyfills..."
$RUN composer global require -W phpunit/phpunit:^11.0 2>&1
$RUN composer require --dev yoast/phpunit-polyfills:^4.0 --working-dir=/wordpress-phpunit 2>&1

# Create stub database tables that exist outside WordPress on production.
echo "Creating stub database tables..."
$RUN -- wp db import wp-content/env-bin/database-tables.sql
10 changes: 10 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,14 @@
<exclude name="WordPress.Security.NonceVerification.Missing" />
<exclude name="WordPress.Security.NonceVerification.Recommended" />
</rule>

<!-- Recognise project test-case bases so the FileName sniff exempts their subclasses (whose
names must match their class, not be hyphenated, so PHPUnit can discover them). -->
<rule ref="WordPress.Files.FileName">
<properties>
<property name="custom_test_classes" type="array">
<element value="Release_Block_Test_Case"/>
</property>
</properties>
</rule>
</ruleset>
Original file line number Diff line number Diff line change
Expand Up @@ -46,15 +46,22 @@ static function display() {
}

/**
* Display the release cooldown status and (for reviewers) a force-release control.
* Display the release hold status and (for reviewers) force-release and block controls.
*
* 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.
* Shows one of three messages: a countdown while a release is cooling down, a block notice
* when the release is being held (which outlasts the cooldown window), or a plain notice
* that the version hasn't reached sites yet. Reviewers can force-release in any of them,
* and can block any version that isn't already served. Bails when there's no current
* release, or when the version is being served and isn't held.
*/
protected static function display_release_cooldown() {
$post = get_post();

// Only plugins the update API serves from have a release to gate.
if ( ! in_array( $post->post_status, array( 'publish', 'disabled', 'closed' ), true ) ) {
return;
}

$version = get_post_meta( $post->ID, 'version', true );
if ( ! $version ) {
return;
Expand All @@ -65,35 +72,57 @@ protected static function display_release_cooldown() {
return;
}

$release_delay = (int) ( $release['release_delay'] ?? 0 );
if ( ! $release_delay ) {
return;
}

$blocked = API_Update_Updater::is_release_blocked( $release );
$release_delay = (int) ( $release['release_delay'] ?? 0 );
$cooldown_until = API_Update_Updater::compute_release_time( $post, $release ) + $release_delay;
if ( $cooldown_until <= time() ) {
$in_cooldown = $release_delay && $cooldown_until > time();

/*
* A version can be held right up until it's written to `update_source`, which lags the
* end of the cooldown by however long the deferred event takes to run. The controls key
* on whether it's actually being served — the same condition block_release() applies —
* so they don't disappear during that window.
*/
$unserved = ( API_Update_Updater::get_served_version( $post->post_name ) !== (string) $version );

// Nothing to surface once the version is being served and isn't held.
if ( ! $blocked && ! $unserved ) {
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 ) )
);
if ( $blocked ) {
printf(
/* translators: %s: version */
esc_html__( 'Version %s is blocked and is being held from sites. Force-releasing overrides the block.', 'wporg-plugins' ),
esc_html( $version )
);
} elseif ( $in_cooldown ) {
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 ) )
);
} else {
printf(
/* translators: %s: version */
esc_html__( 'Version %s has not been served to sites yet.', 'wporg-plugins' ),
esc_html( $version )
);
}
?>
</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>
<label for="release_action_reason"><?php esc_html_e( 'Reason (required):', 'wporg-plugins' ); ?></label>
<textarea
id="force_release_reason"
name="force_release_reason"
id="release_action_reason"
name="release_action_reason"
rows="2"
style="width: 100%;"
placeholder="<?php esc_attr_e( 'e.g. urgent security fix for CVE-…', 'wporg-plugins' ); ?>"
Expand All @@ -109,19 +138,32 @@ protected static function display_release_cooldown() {
);
?>
</button>
<?php if ( $unserved && ! $blocked ) : ?>
<button type="submit" name="block_release_version" value="<?php echo esc_attr( $version ); ?>" class="button">
<?php
printf(
/* translators: %s: version */
esc_html__( 'Block %s from sites', 'wporg-plugins' ),
esc_html( $version )
);
?>
</button>
<?php endif; ?>
</p>
<?php endif; ?>
</div>
<?php
}

/**
* Save handler for reviewer force-release submissions from the Controls metabox.
* Save handler for reviewer force-release and block 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'] ) ) {
$is_force_release = ! empty( $_POST['force_release_version'] );
$is_block = ! empty( $_POST['block_release_version'] );
if ( ! $is_force_release && ! $is_block ) {
return;
}

Expand All @@ -139,20 +181,155 @@ public static function save_post( $post_id ) {
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'] ) );
$submitted_version = sanitize_text_field( wp_unslash( $is_force_release ? $_POST['force_release_version'] : $_POST['block_release_version'] ) );
if ( $submitted_version !== $version ) {
// Submitted version doesn't match current — a newer commit landed since the form was rendered.
self::add_notice(
sprintf(
/* translators: 1: submitted version, 2: current version */
__( 'No action taken: version %1$s is no longer the current version — %2$s has since been committed.', 'wporg-plugins' ),
$submitted_version,
$version
)
);

return;
}

$reason = isset( $_POST['force_release_reason'] )
? trim( sanitize_textarea_field( wp_unslash( $_POST['force_release_reason'] ) ) )
$reason = isset( $_POST['release_action_reason'] )
? trim( sanitize_textarea_field( wp_unslash( $_POST['release_action_reason'] ) ) )
: '';
if ( ! $reason ) {
self::add_notice( __( 'No action taken: a reason is required to block or force-release a version.', 'wporg-plugins' ) );

return;
}

if ( $is_force_release ) {
self::handle_force_release( $post, $version, $reason );
} else {
self::handle_block( $post, $version, $reason );
}
}

/**
* Force-release the current version and report the outcome to the reviewer.
*
* @param \WP_Post $post The plugin post.
* @param string $version The version being released.
* @param string $reason The reason recorded in the audit log.
*/
protected static function handle_force_release( $post, $version, $reason ) {
if ( API_Update_Updater::force_release( $post->post_name, $reason ) ) {
self::add_notice(
sprintf(
/* translators: %s: version */
__( 'Version %s has been released and is being served to sites.', 'wporg-plugins' ),
$version
),
'updated'
);

return;
}

API_Update_Updater::force_release( $post->post_name, $reason );
self::add_notice(
sprintf(
/* translators: %s: version */
__( 'Version %s could not be released. Nothing was changed — please try again.', 'wporg-plugins' ),
$version
)
);
}

/**
* Hold the current version back and report the outcome to the reviewer.
*
* A refusal is indistinguishable from a success from the edit screen alone — the release
* section disappears either way once the version is served — so every outcome says which
* it was.
*
* @param \WP_Post $post The plugin post.
* @param string $version The version being held.
* @param string $reason The reason recorded against the block and in the audit log.
*/
protected static function handle_block( $post, $version, $reason ) {
$held = API_Update_Updater::block_release(
$post->post_name,
array(
'reason' => $reason,
'blocked_by' => wp_get_current_user()->user_login,
)
);

if ( $held ) {
self::add_notice(
sprintf(
/* translators: %s: version */
__( 'Version %s is now held from sites. Force-releasing it overrides the block.', 'wporg-plugins' ),
$version
),
'updated'
);

return;
}

if ( API_Update_Updater::get_served_version( $post->post_name ) === (string) $version ) {
self::add_notice(
sprintf(
/* translators: %s: version */
__( 'Version %s is already being served to sites and can no longer be held back. Close or disable the plugin if it needs to stop being offered.', 'wporg-plugins' ),
$version
)
);

return;
}

self::add_notice(
sprintf(
/* translators: %s: version */
__( 'Version %s could not be held. Nothing was changed — please try again.', 'wporg-plugins' ),
$version
)
);
}

/**
* Queue a notice for the reviewer, to be shown after the post editor redirects.
*
* Reuses the `wporg-plugins` settings-errors group that Admin\Customizations renders on
* `all_admin_notices`; the redirect is flagged so `settings_errors()` picks the transient
* up on the way back.
*
* @param string $message The message to display.
* @param string $type Notice type, 'error' or 'updated'. Default 'error'.
*/
protected static function add_notice( $message, $type = 'error' ) {
set_transient(
'settings_errors',
array(
array(
'setting' => 'wporg-plugins',
'code' => 'release-action',
'message' => $message,
'type' => $type,
),
)
);

add_filter( 'redirect_post_location', array( __CLASS__, 'flag_settings_updated' ) );
}

/**
* Mark the post-editor redirect so queued notices are displayed on arrival.
*
* @param string $location The redirect destination.
* @return string
*/
public static function flag_settings_updated( $location ) {
return add_query_arg( 'settings-updated', 'true', $location );
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1754,6 +1754,15 @@ public static function add_release( $plugin, $data ) {
unset( $release['discarded'] );
}

/*
* Clear a release block so the release can be served.
* See Jobs\API_Update_Updater::force_release().
*/
if ( ! empty( $data['unblock'] ) ) {
unset( $release['release_block'] );
}
unset( $release['unblock'] );

$releases = self::get_releases( $plugin );

// Find any other releases using this slug (as in the case of updates) and remove it.
Expand Down
Loading
Loading