Making WordPress.org

Changeset 15168


Ignore:
Timestamp:
09/09/2026 06:10:53 PM (11 days ago)
Author:
obenland
Message:

Plugin Directory: Resolve the served release by ref in the update-source writer

Closes https://github.com/WordPress/wordpress.org/pull/892.

Location:
sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory
Files:
3 edited

Legend:

Unmodified
Added
Removed
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php

    r15106 r15168  
    9090                $existing_row     = $wpdb->get_row(
    9191                        $wpdb->prepare(
    92                                 "SELECT version, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
     92                                "SELECT version, stable_tag, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
    9393                                $post->post_name
    9494                        )
    9595                );
    96                 $existing_version = (string) ( $existing_row->version ?? '' );
    9796
    9897                $release_delay = (int) ( $release['release_delay'] ?? 0 );
    9998
    100                 // `update_source.version` is varchar(128); mirror cron_trigger()'s `left( pm.meta_value, 128 )` truncation allowance.
    101                 $is_new_version = substr( (string) $version, 0, 128 ) !== $existing_version;
     99                // Never judged by the Version header: an author can keep the served version's label on a new tag.
     100                $is_new_release = ! $existing_row || ! self::is_current_ref_served( $post, $existing_row );
    102101
    103102                /*
    104103                 * Hold a blocked release out of the row: the previous version keeps being
    105104                 * served, the deferred serve is cancelled, and status changes still reach
    106                  * the row. Gated on the block alone — a header renamed to the served
    107                  * version turns the $is_new_version proxy false, and block_release()
    108                  * refuses served releases, so a held release is never already in the row.
     105                 * the row. Gated on the block alone — block_release() refuses served
     106                 * releases, so a held release is never already in the row.
    109107                 */
    110108                if ( self::is_release_blocked( $release ) ) {
     
    134132                 * repeats as a no-op.
    135133                 */
    136                 if ( $release_delay && $is_new_version ) {
     134                if ( $release_delay && $is_new_release ) {
    137135                        $cooldown_until = $release_time + $release_delay;
    138136                        if ( $cooldown_until > time() ) {
     
    151149                // phased_rollout()'s `manual-updates-24hr` window measuring from public availability,
    152150                // even if the commit/confirmation was long ago because the cooldown deferred the write.
    153                 if ( $release_delay && $is_new_version ) {
     151                if ( $release_delay && $is_new_release ) {
    154152                        $release_time = time();
    155153                }
     
    235233
    236234        /**
     235         * The ref a plugin's current version is served from: its stable tag, or
     236         * `trunk@{version}` for trunk-stable plugins, as release rows are keyed.
     237         *
     238         * @param \WP_Post $post The plugin post.
     239         * @return string The ref.
     240         */
     241        public static function get_current_ref( $post ) {
     242                return self::build_ref(
     243                        get_post_meta( $post->ID, 'stable_tag', true ),
     244                        get_post_meta( $post->ID, 'version', true )
     245                );
     246        }
     247
     248        /**
     249         * Whether a plugin's current ref is the one its `update_source` row serves.
     250         *
     251         * Compared by ref rather than by the release the ref resolves to: a
     252         * fallback-resolved release keeps its own tag, and the Version header alone
     253         * can be kept at the served label on a new tag. The row's columns are
     254         * varchar(128), so the meta is truncated to match before the refs are built.
     255         *
     256         * @param \WP_Post $post   The plugin post.
     257         * @param object   $served The `update_source` row, with `version` and `stable_tag`.
     258         * @return bool
     259         */
     260        public static function is_current_ref_served( $post, $served ) {
     261                $current_ref = self::build_ref(
     262                        substr( (string) get_post_meta( $post->ID, 'stable_tag', true ), 0, 128 ),
     263                        substr( (string) get_post_meta( $post->ID, 'version', true ), 0, 128 )
     264                );
     265
     266                return self::build_ref( $served->stable_tag, $served->version ) === $current_ref;
     267        }
     268
     269        /**
     270         * Build the ref a stable tag and version are served from.
     271         *
     272         * @param string $stable_tag The stable tag; empty or 'trunk' for trunk-stable plugins.
     273         * @param string $version    The version, keying trunk-stable refs.
     274         * @return string The ref.
     275         */
     276        protected static function build_ref( $stable_tag, $version ) {
     277                return ( ! $stable_tag || 'trunk' === $stable_tag )
     278                        ? 'trunk@' . $version
     279                        : (string) $stable_tag;
     280        }
     281
     282        /**
    237283         * The release being served or held for a plugin's current version.
    238284         *
     
    254300         */
    255301        public static function get_current_release( $post ) {
    256                 $stable_tag = get_post_meta( $post->ID, 'stable_tag', true );
    257 
    258                 $target = ( ! $stable_tag || 'trunk' === $stable_tag )
    259                         ? 'trunk@' . get_post_meta( $post->ID, 'version', true )
    260                         : $stable_tag;
    261 
     302                $target   = self::get_current_ref( $post );
    262303                $releases = (array) Plugin_Directory::get_releases( $post );
    263304
     
    326367                }
    327368
    328                 // 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.
     369                // Already live: a block can't un-ship a served release.
    329370                $served = self::get_served_release( $plugin_slug );
    330                 if ( $served ) {
    331                         if ( $served->stable_tag && 'trunk' !== $served->stable_tag ) {
    332                                 $is_served = substr( (string) $release['tag'], 0, 128 ) === (string) $served->stable_tag;
    333                         } else {
    334                                 $is_served = '' !== (string) $served->version
    335                                         && substr( (string) ( $release['version'] ?? '' ), 0, 128 ) === (string) $served->version;
    336                         }
    337 
    338                         if ( $is_served ) {
    339                                 return false;
    340                         }
     371                if ( $served && self::is_current_ref_served( $post, $served ) ) {
     372                        return false;
    341373                }
    342374
     
    455487         * Determine the release timestamp for a plugin version.
    456488         *
    457          * Anchored on the version's commit time (`version_date`), falling back to the
    458          * release row's own date — unlike post_modified, neither slides on unrelated
    459          * post edits — and replaced by the latest committer-confirmation time when
    460          * release confirmations are required (the version isn't really "released"
    461          * until the last confirmation lands).
     489         * Anchored on the later of the version's commit time (`version_date`) and the
     490         * release row's own date — a new tag at an unchanged version, or a re-opened
     491         * release, moves only the latter, and unlike post_modified neither slides on
     492         * unrelated post edits — and replaced by the latest committer-confirmation
     493         * time when release confirmations are required (the version isn't really
     494         * "released" until the last confirmation lands).
    462495         *
    463496         * @param \WP_Post   $post    The plugin post.
     
    466499         */
    467500        public static function compute_release_time( $post, $release ) {
    468                 if ( $post->version_date ) {
    469                         $release_time = strtotime( $post->version_date );
    470                 } elseif ( ! empty( $release['date'] ) ) {
    471                         $release_time = (int) $release['date'];
    472                 } else {
     501                $release_time = max(
     502                        $post->version_date ? (int) strtotime( $post->version_date ) : 0,
     503                        (int) ( $release['date'] ?? 0 )
     504                );
     505                if ( ! $release_time ) {
    473506                        $release_time = strtotime( $post->post_modified );
    474507                }
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Current_Release_Resolution_Test.php

    r15078 r15168  
    138138
    139139        /**
     140         * The stable tag currently in the plugin's update_source row.
     141         *
     142         * @return string The served stable tag.
     143         */
     144        private function served_stable_tag(): string {
     145                return (string) ( API_Update_Updater::get_served_release( $this->plugin->post_name )->stable_tag ?? '' );
     146        }
     147
     148        /**
    140149         * A renamed Version header inside a tag under cooldown keeps the hold.
    141150         */
     
    146155
    147156                $this->assertSame( self::SERVED_VERSION, $this->served_version() );
     157                $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
     158        }
     159
     160        /**
     161         * A new tag whose header keeps the served version is still a new release:
     162         * the cooldown gate keys on the release's identity, not the header proxy.
     163         */
     164        public function test_new_tag_with_served_header_keeps_cooldown_hold(): void {
     165                update_post_meta( $this->plugin->ID, 'version', self::SERVED_VERSION );
     166                $this->add_release( self::HELD_TAG, self::SERVED_VERSION );
     167
     168                $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
     169
     170                $this->assertSame( self::SERVED_VERSION, $this->served_stable_tag() );
     171                $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
     172        }
     173
     174        /**
     175         * The full evasion: a new tag keeps the served header, so the importer bumps
     176         * neither the version nor its version_date. The hold must survive the stale
     177         * plugin-wide anchor and measure the cooldown from the release itself.
     178         */
     179        public function test_new_tag_with_served_header_and_stale_version_date_keeps_cooldown_hold(): void {
     180                update_post_meta( $this->plugin->ID, 'version', self::SERVED_VERSION );
     181                update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) );
     182                $this->add_release( self::HELD_TAG, self::SERVED_VERSION );
     183
     184                $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
     185
     186                $this->assertSame( self::SERVED_VERSION, $this->served_stable_tag() );
     187                $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
     188        }
     189
     190        /**
     191         * Switching a trunk-stable plugin to a tag at an unchanged version is a new
     192         * release: the trunk row's identity is trunk@{version}, which no tag matches.
     193         */
     194        public function test_tag_switch_from_served_trunk_keeps_cooldown_hold(): void {
     195                global $wpdb;
     196
     197                $wpdb->update(
     198                        $wpdb->prefix . 'update_source',
     199                        array( 'stable_tag' => 'trunk' ),
     200                        array( 'plugin_slug' => $this->plugin->post_name )
     201                );
     202                update_post_meta( $this->plugin->ID, 'version', self::SERVED_VERSION );
     203                $this->add_release( self::HELD_TAG, self::SERVED_VERSION );
     204
     205                $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
     206
     207                $this->assertSame( 'trunk', $this->served_stable_tag() );
     208                $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
     209        }
     210
     211        /**
     212         * A tag-to-trunk flip at an unchanged version creates no trunk release, so
     213         * the current version resolves to the old tag's record. Once the row serves
     214         * trunk, that fallback is the served release and a block is refused.
     215         */
     216        public function test_block_refused_for_served_trunk_fallback_release(): void {
     217                $this->serve_trunk_fallback();
     218
     219                $this->assertFalse( API_Update_Updater::block_release( $this->plugin->post_name, array( 'risk_score' => 9.8 ) ) );
     220                $this->assertFalse( API_Update_Updater::is_release_blocked( Plugin_Directory::get_release( get_post( $this->plugin->ID ), self::SERVED_VERSION ) ) );
     221        }
     222
     223        /**
     224         * The served trunk fallback is not a new release: the row is rewritten
     225         * without a cooldown hold.
     226         */
     227        public function test_served_trunk_fallback_release_schedules_no_cooldown(): void {
     228                $this->serve_trunk_fallback();
     229
     230                $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
     231
     232                $this->assertSame( 'trunk', $this->served_stable_tag() );
     233                $this->assertFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
     234        }
     235
     236        /**
     237         * Serve trunk at the served version with only the old tag's release record,
     238         * so the current version resolves through the version-named fallback.
     239         */
     240        private function serve_trunk_fallback(): void {
     241                global $wpdb;
     242
     243                $wpdb->update(
     244                        $wpdb->prefix . 'update_source',
     245                        array( 'stable_tag' => 'trunk' ),
     246                        array( 'plugin_slug' => $this->plugin->post_name )
     247                );
     248                update_post_meta( $this->plugin->ID, 'version', self::SERVED_VERSION );
     249                update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' );
     250                $this->add_release( self::SERVED_VERSION, self::SERVED_VERSION );
     251
     252                $this->assertSame( self::SERVED_VERSION, API_Update_Updater::get_current_release( get_post( $this->plugin->ID ) )['tag'] );
     253        }
     254
     255        /**
     256         * Trunk versions are compared on the full 128 bytes the row stores: the
     257         * `trunk@` prefix must not eat into the allowance, or two long versions
     258         * differing past byte 122 read as the same release.
     259         */
     260        public function test_long_trunk_versions_differing_past_prefix_allowance_are_distinct(): void {
     261                global $wpdb;
     262
     263                $served_version = str_repeat( '1', 130 );
     264                $new_version    = substr_replace( $served_version, '2', 124, 1 );
     265
     266                $wpdb->update(
     267                        $wpdb->prefix . 'update_source',
     268                        array(
     269                                'stable_tag' => 'trunk',
     270                                'version'    => substr( $served_version, 0, 128 ),
     271                        ),
     272                        array( 'plugin_slug' => $this->plugin->post_name )
     273                );
     274                update_post_meta( $this->plugin->ID, 'stable_tag', 'trunk' );
     275                update_post_meta( $this->plugin->ID, 'version', $new_version );
     276                $this->add_release( 'trunk@' . $new_version, $new_version );
     277
     278                $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
     279
     280                $this->assertSame( substr( $served_version, 0, 128 ), $this->served_version() );
    148281                $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
    149282        }
     
    438571
    439572                $this->assertSame( $version_time, API_Update_Updater::compute_release_time( get_post( $this->plugin->ID ), $release ) );
     573        }
     574
     575        /**
     576         * A release row newer than the plugin's version_date anchors the clock: a
     577         * new tag at an unchanged version, or a re-commit that re-opens a release,
     578         * bumps only the release date.
     579         */
     580        public function test_release_time_prefers_newer_release_date_over_version_date(): void {
     581                update_post_meta( $this->plugin->ID, 'version_date', gmdate( 'Y-m-d H:i:s', time() - WEEK_IN_SECONDS ) );
     582
     583                $release = array(
     584                        'date'                   => time() - DAY_IN_SECONDS,
     585                        'confirmations_required' => 0,
     586                        'confirmations'          => array(),
     587                );
     588
     589                $this->assertSame( $release['date'], API_Update_Updater::compute_release_time( get_post( $this->plugin->ID ), $release ) );
    440590        }
    441591
  • sites/trunk/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Hold_Test.php

    r15077 r15168  
    360360
    361361                update_post_meta( $this->plugin->ID, 'version', $long_version );
     362                update_post_meta( $this->plugin->ID, 'stable_tag', $long_version );
    362363                update_post_meta( $this->plugin->ID, 'releases', array( $release ) );
    363364                $this->insert_served_row( substr( $long_version, 0, 128 ) );
     
    379380
    380381                update_post_meta( $this->plugin->ID, 'version', $long_version );
     382                update_post_meta( $this->plugin->ID, 'stable_tag', $long_version );
    381383                update_post_meta( $this->plugin->ID, 'releases', array( $release ) );
    382384                $this->insert_served_row( substr( $long_version, 0, 128 ) );
Note: See TracChangeset for help on using the changeset viewer.