Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -55,16 +55,14 @@ static function display() {
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 );
// Resolved from the stable tag, so the hold shows even when the Version header is empty or disagrees.
$release = API_Update_Updater::get_current_release( $post );
if ( ! $release ) {
return;
}

$release_version = $release['version'];

$release_delay = (int) ( $release['release_delay'] ?? 0 );
if ( ! $release_delay ) {
return;
Expand All @@ -82,7 +80,7 @@ protected static function display_release_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( $release_version ),
esc_html( human_time_diff( time(), $cooldown_until ) ),
esc_html( gmdate( 'Y-m-d H:i', $cooldown_until ) )
);
Expand All @@ -100,12 +98,12 @@ protected static function display_release_cooldown() {
></textarea>
</p>
<p>
<button type="submit" name="force_release_version" value="<?php echo esc_attr( $version ); ?>" class="button">
<button type="submit" name="force_release_tag" value="<?php echo esc_attr( $release['tag'] ); ?>" class="button">
<?php
printf(
/* translators: %s: version */
esc_html__( 'Force-release %s now', 'wporg-plugins' ),
esc_html( $version )
esc_html( $release_version )
);
?>
</button>
Expand All @@ -121,7 +119,7 @@ protected static function display_release_cooldown() {
* @param int $post_id The post being saved.
*/
public static function save_post( $post_id ) {
if ( empty( $_POST['force_release_version'] ) ) {
if ( empty( $_POST['force_release_tag'] ) ) {
return;
}

Expand All @@ -138,10 +136,10 @@ public static function save_post( $post_id ) {
// 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.
// Staleness guard on the release identity: a commit that moved the stable tag since the form rendered lands a different release here.
$release = API_Update_Updater::get_current_release( $post );
$submitted_tag = sanitize_text_field( wp_unslash( $_POST['force_release_tag'] ) );
if ( ! $release || $submitted_tag !== (string) $release['tag'] ) {
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1722,7 +1722,18 @@ public static function add_release( $plugin, $data ) {

$plugin = self::get_plugin_post( $plugin );

$release = self::get_release( $plugin, $data['tag'] ) ?: [
$releases = self::get_releases( $plugin );

// Strict match only: get_release()'s loose lookup ('1.4' == '1.40') could merge onto the wrong release. Only one release can exist in any given tag.
$release = false;
foreach ( $releases as $i => $r ) {
if ( isset( $r['tag'] ) && (string) $r['tag'] === $data['tag'] ) {
$release = $release ?: $r;
unset( $releases[ $i ] );
}
}

$release = $release ?: [
'date' => time(),
'tag' => '',
'version' => '',
Expand Down Expand Up @@ -1767,16 +1778,6 @@ public static function add_release( $plugin, $data ) {
}
unset( $release['unblock'] );

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

// Find any other releases using this slug (as in the case of updates) and remove it.
// Only one release can exist in any given tag.
foreach ( $releases as $i => $r ) {
if ( $r['tag'] === $release['tag'] ) {
unset( $releases[ $i ] );
}
}

// Add this release in
$releases[] = $release;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ public static function update_single_plugin( $plugin_slug ) {

$version = get_post_meta( $post->ID, 'version', true );
$requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true );
$release = Plugin_Directory::get_release( $post, $version );
$release = self::get_current_release( $post );
$release_time = self::compute_release_time( $post, $release );
$existing_row = $wpdb->get_row(
$wpdb->prepare(
Expand All @@ -101,11 +101,13 @@ public static function update_single_plugin( $plugin_slug ) {
$is_new_version = substr( (string) $version, 0, 128 ) !== $existing_version;

/*
* Hold a blocked version out of the row: the previously served version keeps
* being served, and the deferred serve is cancelled rather than postponed.
* Status changes still reach the row right away.
* Hold a blocked release out of the row: the previous version keeps being
* served, the deferred serve is cancelled, and status changes still reach
* the row. Gated on the block alone — a header renamed to the served
* version turns the $is_new_version proxy false, and block_release()
* refuses served releases, so a held release is never already in the row.
*/
if ( self::is_release_blocked( $release ) && $is_new_version ) {
if ( self::is_release_blocked( $release ) ) {
wp_clear_scheduled_hook( "release_to_update_api:{$post->post_name}" );

if ( $existing_row ) {
Expand Down Expand Up @@ -214,16 +216,6 @@ public static function update_single_plugin( $plugin_slug ) {
return true;
}

/**
* The version currently served from `update_source`.
*
* @param string $plugin_slug The plugin slug.
* @return string The served version, or '' when the plugin isn't in `update_source`.
*/
public static function get_served_version( $plugin_slug ) {
return (string) ( self::get_served_release( $plugin_slug )->version ?? '' );
}

/**
* The release currently served from `update_source`.
*
Expand All @@ -241,6 +233,58 @@ public static function get_served_release( $plugin_slug ) {
);
}

/**
* The release being served or held for a plugin's current version.
*
* Resolved strictly by the stable tag — the source of the served package —
* so a Version header that disagrees with its tag cannot redirect a hold
* onto a different release. Trunk-stable plugins resolve by their
* `trunk@{version}` key instead; their identity is the header version.
* Strict matching, unlike Plugin_Directory::get_release()'s loose lookup
* (`'1.4' == '1.40'`), which could land on the wrong release.
*
* When the ref has no release row (a stable tag flipped to trunk at an
* unchanged version creates none), the version-named — then version-
* carrying — release is a fallback: a miss would fail the block and
* cooldown gates open, and the fallback never overrides a ref-resolved
* hold.
*
* @param \WP_Post $post The plugin post.
* @return array|false The matching release row, or false when none exists.
*/
public static function get_current_release( $post ) {
$stable_tag = get_post_meta( $post->ID, 'stable_tag', true );

$target = ( ! $stable_tag || 'trunk' === $stable_tag )
? 'trunk@' . get_post_meta( $post->ID, 'version', true )
: $stable_tag;

$releases = (array) Plugin_Directory::get_releases( $post );

foreach ( $releases as $release ) {
if ( isset( $release['tag'] ) && (string) $release['tag'] === (string) $target ) {
return $release;
}
}

$version = (string) get_post_meta( $post->ID, 'version', true );
if ( '' === $version ) {
return false;
}

$carrying = false;
foreach ( $releases as $release ) {
if ( (string) ( $release['tag'] ?? '' ) === $version ) {
return $release;
}
if ( ! $carrying && (string) ( $release['version'] ?? '' ) === $version ) {
$carrying = $release;
}
}

return $carrying;
}

/**
* Whether a release is being held out of `update_source` by a block.
*
Expand All @@ -262,7 +306,7 @@ public static function is_release_blocked( $release ) {
* version itself stays blocked until a reviewer force-releases it.
*
* The counterpart to force_release(). It refuses when the version cannot be
* held: no plugin, no release, or the version already being served. Blocking
* held: no plugin, no release, or the release already being served. Blocking
* an already-held release is a no-op success that preserves the existing
* block. Capability checks and audit logging are the caller's.
*
Expand All @@ -276,15 +320,24 @@ public static function block_release( $plugin_slug, array $block ) {
return false;
}

$version = get_post_meta( $post->ID, 'version', true );
$release = Plugin_Directory::get_release( $post, $version );
$release = self::get_current_release( $post );
if ( ! $release ) {
return false;
}

// Already live: a block can't un-ship a served version (compared with the column's varchar(128) truncation).
if ( self::get_served_version( $plugin_slug ) === substr( (string) $version, 0, 128 ) ) {
return false;
// Already live: a block can't un-ship a served release — compared by identity (the ref for tagged rows, the version for ref-less trunk-stable rows), with the columns' varchar(128) truncation.
$served = self::get_served_release( $plugin_slug );
if ( $served ) {
if ( $served->stable_tag && 'trunk' !== $served->stable_tag ) {
$is_served = substr( (string) $release['tag'], 0, 128 ) === (string) $served->stable_tag;
} else {
$is_served = '' !== (string) $served->version
&& substr( (string) ( $release['version'] ?? '' ), 0, 128 ) === (string) $served->version;
}

if ( $is_served ) {
return false;
}
}

// Already held; recording a second block would merge it into the first.
Expand Down Expand Up @@ -401,16 +454,24 @@ protected static function clear_plugin_caches( $plugin_slug ) {
/**
* 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).
* Anchored on the version's commit time (`version_date`), falling back to the
* release row's own date — unlike post_modified, neither slides on unrelated
* post edits — and 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 ( $post->version_date ) {
$release_time = strtotime( $post->version_date );
} elseif ( ! empty( $release['date'] ) ) {
$release_time = (int) $release['date'];
} else {
$release_time = strtotime( $post->post_modified );
}

if (
$release &&
Expand Down Expand Up @@ -467,13 +528,14 @@ public static function force_release( $plugin_slug, $reason, $user = null ) {
return false;
}

$version = get_post_meta( $post->ID, 'version', true );
$release = Plugin_Directory::get_release( $post, $version );
$release = self::get_current_release( $post );

if ( ! $release ) {
return false;
}

$version = $release['version'];

// Log only what is actually lifted: a deleted block's only trace, and the cooldown only while it still runs.
$lifted = array();

Expand Down
Loading