From 9071681991243775af217dfbadc827c142fde27e Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 02:52:04 +0000 Subject: [PATCH 01/11] Plugin Directory: Store releases in CPT records --- .../class-plugin-directory.php | 164 +---- .../plugin-directory/class-plugin-release.php | 611 ++++++++++++++++++ .../tests/Plugin_Release_Test.php | 218 +++++++ 3 files changed, 839 insertions(+), 154 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-release.php create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Test.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php index 5a33a8bb26..ce63e5d4da 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php @@ -69,6 +69,9 @@ private function __construct() { // Search Plugin_Search::instance(); + // Releases. + Plugin_Release::instance(); + // Add upload size limit to limit plugin ZIP file uploads to 10M add_filter( 'upload_size_limit', function( $size ) { return 10 * MB_IN_BYTES; @@ -1602,80 +1605,19 @@ public function split_post_content_into_pages( $content ) { * Get a list of all Plugin Releases. */ public static function get_releases( $plugin ) { - $plugin = self::get_plugin_post( $plugin ); - $releases = get_post_meta( $plugin->ID, 'releases', true ); - - // Data doesn't exist yet? Lets fill it out. - if ( false === $releases || ! is_array( $releases ) ) { - $releases = self::prefill_releases_meta( $plugin ); - } - - /** - * If confirmations weren't required, claim that the ZIPs were built. - * - * This is needed for data pre-[12816]. - * @see https://meta.trac.wordpress.org/changeset/12816 - */ - foreach ( $releases as &$release ) { - if ( ! $release['confirmations_required'] && ! $release['zips_built'] ) { - $release['zips_built'] = true; - } - } - - return $releases; + return Plugin_Release::instance()->get_releases( $plugin ); } /** - * Prefill the releases meta items for a plugin. + * Prefill the releases CPT items for a plugin. * * @param \WP_Post $plugin Plugin post object. * @return array */ public static function prefill_releases_meta( $plugin ) { - if ( ! $plugin->releases ) { - update_post_meta( $plugin->ID, 'releases', [] ); - } - - $tags = get_post_meta( $plugin->ID, 'tags', true ); - if ( $tags ) { - foreach ( $tags as $tag_version => $tag ) { - self::add_release( $plugin, [ - 'date' => strtotime( $tag['date'] ), - 'tag' => $tag['tag'], - 'version' => $tag_version, - 'committer' => [ $tag['author'] ], - 'zips_built' => true, // Old release, assume they were built. - 'confirmations_required' => 0, // Old release, assume it's released. - ] ); - } - } else { - // Pull from SVN directly. - $svn_tags = Tools\SVN::ls( "https://plugins.svn.wordpress.org/{$plugin->post_name}/tags/", true ) ?: []; - foreach ( $svn_tags as $entry ) { - // Discard files - if ( 'dir' !== $entry['kind'] ) { - continue; - } + Plugin_Release::instance()->maybe_backfill_releases( $plugin, true ); - $tag = $entry['filename']; - - // Prefix the 0 for plugin versions like 0.1 - if ( '.' == substr( $tag, 0, 1 ) ) { - $tag = "0{$tag}"; - } - - self::add_release( $plugin, [ - 'date' => strtotime( $entry['date'] ), - 'tag' => $entry['filename'], - 'version' => $tag, - 'committer' => [ $entry['author'] ], - 'zips_built' => true, // Old release, assume they were built. - 'confirmations_required' => 0, // Old release, assume it's released. - ] ); - } - } - - return get_post_meta( $plugin->ID, 'releases', true ) ?: []; + return self::get_releases( $plugin ); } /** @@ -1686,21 +1628,7 @@ public static function prefill_releases_meta( $plugin ) { * @return array|bool */ public static function get_release( $plugin, $tag ) { - $releases = self::get_releases( $plugin ); - - // Look for the version released as a tag. - $filtered = wp_list_filter( $releases, compact( 'tag' ) ); - if ( $filtered ) { - return array_shift( $filtered ); - } - - // Look for the tag as a trunk version. - $filtered = wp_list_filter( $releases, [ 'tag' => "trunk@{$tag}", 'version' => $tag ] ); - if ( $filtered ) { - return array_shift( $filtered ); - } - - return false; + return Plugin_Release::instance()->get_release( $plugin, $tag ); } /** @@ -1711,66 +1639,7 @@ public static function get_release( $plugin, $tag ) { * @return bool */ public static function add_release( $plugin, $data ) { - if ( ! isset( $data['tag'] ) ) { - return false; - } - $plugin = self::get_plugin_post( $plugin ); - - $release = self::get_release( $plugin, $data['tag'] ) ?: [ - 'date' => time(), - 'tag' => '', - 'version' => '', - // Assume zips built if no release confirmation. - 'zips_built' => ! $plugin->release_confirmation, - 'zips_built_from_revision' => 0, - 'confirmations' => [], - // Confirmed by default if no release confiration. - 'confirmed' => ! $plugin->release_confirmation, - '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()? - foreach ( $data as $k => $v ) { - if ( isset( $release[ $k ] ) && is_array( $release[ $k ] ) ) { - $release[ $k ] = array_unique( array_merge( $release[ $k ], $v ) ); - } else { - $release[ $k ] = $v; - } - } - - /* - * Allow a discarded release to be reset. - * See API\Routes\Plugin_Release_Confirmation::undo_discard_release() - */ - if ( isset( $data['undo-discard'] ) && ! empty( $release['discarded'] ) && empty( $data['discarded'] ) ) { - unset( $release['discarded'] ); - } - - $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; - - // Sort releases most recent first. - uasort( $releases, function( $a, $b ) { - return $b['date'] <=> $a['date']; - } ); - - return update_post_meta( $plugin->ID, 'releases', $releases ); + return Plugin_Release::instance()->add_release( $plugin, $data ); } /** @@ -1781,20 +1650,7 @@ public static function add_release( $plugin, $data ) { * @return bool */ public static function remove_release( $plugin, $tag ) { - $result = false; - $plugin = self::get_plugin_post( $plugin ); - $releases = self::get_releases( $plugin ); - - // Remove the release in question. - foreach ( $releases as $i => $r ) { - if ( $r['tag'] === $tag && ! $r['confirmed'] ) { - unset( $releases[ $i ] ); - - $result = update_post_meta( $plugin->ID, 'releases', $releases ); - } - } - - return $result; + return Plugin_Release::instance()->remove_release( $plugin, $tag ); } /** diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-release.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-release.php new file mode 100644 index 0000000000..313d2a4782 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-release.php @@ -0,0 +1,611 @@ + array( + 'name' => __( 'Releases', 'wporg-plugins' ), + 'singular_name' => __( 'Release', 'wporg-plugins' ), + ), + 'public' => false, + 'show_ui' => false, + 'exclude_from_search' => true, + 'publicly_queryable' => false, + 'show_in_rest' => false, + 'supports' => array( 'title', 'custom-fields' ), + 'rewrite' => false, + 'query_var' => false, + 'hierarchical' => false, + 'delete_with_user' => false, + ) + ); + } + + /** + * Ensure release CPT queries and writes can run before `init` in CLI contexts. + */ + private function ensure_post_type() { + if ( ! post_type_exists( self::POST_TYPE ) ) { + $this->register_post_type(); + } + } + + /** + * Get all releases for a plugin as legacy release arrays. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @return array + */ + public function get_releases( $plugin ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return array(); + } + + $release_posts = $this->get_release_posts( $plugin ); + if ( ! $release_posts ) { + $this->maybe_backfill_releases( $plugin ); + $release_posts = $this->get_release_posts( $plugin ); + } + + $releases = array_map( + function ( $release_post ) use ( $plugin ) { + return $this->post_to_release_data( $release_post, $plugin ); + }, + $release_posts + ); + + uasort( + $releases, + function ( $a, $b ) { + return $b['date'] <=> $a['date']; + } + ); + + return array_values( $releases ); + } + + /** + * Check if a plugin has any CPT release records. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @return bool + */ + public function has_releases( $plugin ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + return $plugin && (bool) $this->get_release_posts( $plugin, 1 ); + } + + /** + * Backfill release CPTs from legacy release metadata, tags metadata, or SVN. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param bool $force Whether to run even if CPT releases exist. + * @return array|false|\WP_Error Backfilled release arrays, false when skipped. + */ + public function maybe_backfill_releases( $plugin, $force = false ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return new \WP_Error( 'invalid_plugin', 'Invalid plugin' ); + } + + if ( ! $force ) { + if ( $this->has_releases( $plugin ) ) { + return false; + } + + if ( get_post_meta( $plugin->ID, self::BACKFILLED_META, true ) ) { + return false; + } + } + + $legacy_releases = get_post_meta( $plugin->ID, 'releases', true ); + if ( is_array( $legacy_releases ) ) { + $releases = $legacy_releases; + } else { + $releases = $this->get_prefill_releases( $plugin ); + } + + $this->backfilling = true; + try { + foreach ( $releases as $release ) { + $this->add_release( $plugin, $release ); + } + } finally { + $this->backfilling = false; + } + + update_post_meta( $plugin->ID, self::BACKFILLED_META, time() ); + + return $releases; + } + + /** + * Get prefill release data from old tags metadata or SVN tags. + * + * @param \WP_Post $plugin Plugin post object. + * @return array + */ + private function get_prefill_releases( $plugin ) { + $releases = array(); + $tags = get_post_meta( $plugin->ID, 'tags', true ); + + if ( $tags ) { + foreach ( $tags as $tag_version => $tag ) { + $releases[] = array( + 'date' => strtotime( $tag['date'] ), + 'tag' => $tag['tag'], + 'version' => $tag_version, + 'committer' => array( $tag['author'] ), + 'zips_built' => true, + 'confirmations_required' => 0, + ); + } + + return $releases; + } + + $svn_tags = Tools\SVN::ls( "https://plugins.svn.wordpress.org/{$plugin->post_name}/tags/", true ); + $svn_tags = $svn_tags ? $svn_tags : array(); + foreach ( $svn_tags as $entry ) { + if ( 'dir' !== $entry['kind'] ) { + continue; + } + + $tag = $entry['filename']; + if ( '.' === substr( $tag, 0, 1 ) ) { + $tag = "0{$tag}"; + } + + $releases[] = array( + 'date' => strtotime( $entry['date'] ), + 'tag' => $entry['filename'], + 'version' => $tag, + 'committer' => array( $entry['author'] ), + 'zips_built' => true, + 'confirmations_required' => 0, + ); + } + + return $releases; + } + + /** + * Fetch a specific release of the plugin, by tag. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param string $tag Plugin version / release tag. + * @return array|bool + */ + public function get_release( $plugin, $tag ) { + $releases = $this->get_releases( $plugin ); + + $filtered = wp_list_filter( $releases, compact( 'tag' ) ); + if ( $filtered ) { + return array_shift( $filtered ); + } + + $filtered = wp_list_filter( + $releases, + array( + 'tag' => "trunk@{$tag}", + 'version' => $tag, + ) + ); + if ( $filtered ) { + return array_shift( $filtered ); + } + + return false; + } + + /** + * Add or update a Plugin Release. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param array $data Release data. + * @return bool + */ + public function add_release( $plugin, $data ) { + if ( ! isset( $data['tag'] ) ) { + return false; + } + + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return false; + } + + if ( ! $this->backfilling && ! $this->has_releases( $plugin ) && ! get_post_meta( $plugin->ID, self::BACKFILLED_META, true ) ) { + $this->maybe_backfill_releases( $plugin ); + } + + $existing_post = $this->get_release_post_by_tag( $plugin, $data['tag'] ); + $release = $existing_post ? $this->post_to_release_data( $existing_post, $plugin ) : $this->get_default_release_data( $plugin ); + + foreach ( $data as $key => $value ) { + if ( isset( $release[ $key ] ) && is_array( $release[ $key ] ) ) { + $release[ $key ] = array_unique( array_merge( $release[ $key ], (array) $value ) ); + } else { + $release[ $key ] = $value; + } + } + + if ( isset( $data['undo-discard'] ) && ! empty( $release['discarded'] ) && empty( $data['discarded'] ) ) { + unset( $release['discarded'] ); + } + unset( $release['undo-discard'] ); + + $release = $this->normalize_release_data( $release, $plugin ); + + $release_id = $this->save_release_post( $plugin, $release, $existing_post ); + if ( ! $release_id || is_wp_error( $release_id ) ) { + return false; + } + + $this->delete_duplicate_release_posts( $plugin, $release['tag'], $release_id ); + + return true; + } + + /** + * Remove an unconfirmed Plugin Release. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param string $tag Release tag. + * @return bool + */ + public function remove_release( $plugin, $tag ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return false; + } + + if ( ! $this->has_releases( $plugin ) && ! get_post_meta( $plugin->ID, self::BACKFILLED_META, true ) ) { + $this->maybe_backfill_releases( $plugin ); + } + + $release_post = $this->get_release_post_by_tag( $plugin, $tag ); + if ( ! $release_post ) { + return false; + } + + $release = $this->post_to_release_data( $release_post, $plugin ); + if ( ! empty( $release['confirmed'] ) ) { + return false; + } + + return (bool) wp_delete_post( $release_post->ID, true ); + } + + /** + * Query release CPT posts for a plugin. + * + * @param \WP_Post $plugin Plugin post object. + * @param int $limit Maximum number of posts. + * @return \WP_Post[] + */ + private function get_release_posts( $plugin, $limit = -1 ) { + $this->ensure_post_type(); + + return get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => $limit, + 'post_parent' => $plugin->ID, + 'post_status' => 'any', + 'orderby' => 'date', + 'order' => 'DESC', + 'suppress_filters' => true, + ) + ); + } + + /** + * Query one release CPT post for an exact release tag. + * + * @param \WP_Post $plugin Plugin post object. + * @param string $tag Release tag. + * @return \WP_Post|null + */ + private function get_release_post_by_tag( $plugin, $tag ) { + $this->ensure_post_type(); + + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => 1, + 'post_parent' => $plugin->ID, + 'post_status' => 'any', + 'meta_key' => 'release_tag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $tag, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'orderby' => 'date', + 'order' => 'DESC', + 'suppress_filters' => true, + ) + ); + + return $posts ? $posts[0] : null; + } + + /** + * Save a release array as a CPT post. + * + * @param \WP_Post $plugin Plugin post object. + * @param array $release Release data. + * @param \WP_Post|null $existing_post Existing release post, if any. + * @return int|\WP_Error + */ + private function save_release_post( $plugin, $release, $existing_post = null ) { + $this->ensure_post_type(); + + $title = $release['version'] ? $release['version'] : $release['tag']; + if ( 'trunk' === $release['tag'] ) { + $title = 'trunk'; + } + + $date = (int) $release['date']; + $date = $date ? $date : time(); + $post = array( + 'post_type' => self::POST_TYPE, + 'post_title' => $title, + 'post_name' => sanitize_title( $plugin->post_name . '-' . $release['tag'] ), + 'post_parent' => $plugin->ID, + 'post_status' => ( 'trunk' === $release['tag'] ) ? 'draft' : 'publish', + 'post_date' => gmdate( 'Y-m-d H:i:s', $date ), + 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $date ), + 'post_content' => '', + 'comment_status' => 'closed', + 'ping_status' => 'closed', + ); + + if ( $existing_post ) { + $post['ID'] = $existing_post->ID; + $release_id = wp_update_post( $post, true ); + } else { + $release_id = wp_insert_post( $post, true ); + } + + if ( ! $release_id || is_wp_error( $release_id ) ) { + return $release_id; + } + + $this->update_release_meta( $release_id, $release ); + + return $release_id; + } + + /** + * Update full and mirrored release postmeta. + * + * @param int $release_id Release post ID. + * @param array $release Release data. + */ + private function update_release_meta( $release_id, $release ) { + update_post_meta( $release_id, self::DATA_META_KEY, $release ); + + $mirrored_fields = array( + 'date' => 'release_date', + 'tag' => 'release_tag', + 'version' => 'release_version', + 'committer' => 'release_committer', + 'zips_built' => 'release_zips_built', + 'zips_built_from_revision' => 'release_zips_built_from_revision', + 'confirmations' => 'release_confirmations', + 'confirmed' => 'release_confirmed', + 'confirmations_required' => 'release_confirmations_required', + 'revision' => 'release_revision', + 'revision_final' => 'release_revision_final', + 'revision_prior' => 'release_revision_prior', + 'commit_log' => 'release_commit_log', + 'tested' => 'release_tested', + 'requires_php' => 'release_requires_php', + 'requires_wp' => 'release_requires_wp', + 'requires_plugins' => 'release_requires_plugins', + 'discarded' => 'release_discarded', + 'rollout_strategy' => 'release_rollout_strategy', + 'release_delay' => 'release_delay', + ); + + foreach ( $mirrored_fields as $field => $meta_key ) { + if ( array_key_exists( $field, $release ) ) { + update_post_meta( $release_id, $meta_key, $release[ $field ] ); + } else { + delete_post_meta( $release_id, $meta_key ); + } + } + } + + /** + * Delete duplicate release posts for a tag after an upsert. + * + * @param \WP_Post $plugin Plugin post object. + * @param string $tag Release tag. + * @param int $release_id Release post that should remain. + */ + private function delete_duplicate_release_posts( $plugin, $tag, $release_id ) { + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => -1, + 'post_parent' => $plugin->ID, + 'post_status' => 'any', + 'meta_key' => 'release_tag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_value' => $tag, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'fields' => 'ids', + 'suppress_filters' => true, + ) + ); + + foreach ( $posts as $post_id ) { + if ( (int) $post_id !== (int) $release_id ) { + wp_delete_post( $post_id, true ); + } + } + } + + /** + * Convert a release CPT post to the legacy release array shape. + * + * @param \WP_Post $release_post Release post object. + * @param \WP_Post $plugin Plugin post object. + * @return array + */ + private function post_to_release_data( $release_post, $plugin ) { + $data = get_post_meta( $release_post->ID, self::DATA_META_KEY, true ); + $data = is_array( $data ) ? $data : array(); + + $legacy_meta_fields = array( + 'date' => 'release_date', + 'tag' => 'release_tag', + 'version' => 'release_version', + 'committer' => 'release_committer', + 'zips_built' => 'release_zips_built', + 'zips_built_from_revision' => 'release_zips_built_from_revision', + 'confirmations' => 'release_confirmations', + 'confirmed' => 'release_confirmed', + 'confirmations_required' => 'release_confirmations_required', + 'revision' => 'release_revision', + 'revision_final' => 'release_revision_final', + 'revision_prior' => 'release_revision_prior', + 'commit_log' => 'release_commit_log', + 'tested' => 'release_tested', + 'requires_php' => 'release_requires_php', + 'requires_wp' => 'release_requires_wp', + 'requires_plugins' => 'release_requires_plugins', + 'discarded' => 'release_discarded', + 'rollout_strategy' => 'release_rollout_strategy', + 'release_delay' => 'release_delay', + ); + + foreach ( $legacy_meta_fields as $field => $meta_key ) { + if ( array_key_exists( $field, $data ) ) { + continue; + } + + if ( metadata_exists( 'post', $release_post->ID, $meta_key ) ) { + $data[ $field ] = get_post_meta( $release_post->ID, $meta_key, true ); + } + } + + if ( empty( $data['date'] ) ) { + $data['date'] = strtotime( $release_post->post_date_gmt ? $release_post->post_date_gmt : $release_post->post_date ); + } + if ( empty( $data['tag'] ) ) { + $tag = get_post_meta( $release_post->ID, 'release_tag', true ); + $data['tag'] = $tag ? $tag : $release_post->post_title; + } + if ( empty( $data['version'] ) ) { + $version = get_post_meta( $release_post->ID, 'release_version', true ); + $data['version'] = $version ? $version : $release_post->post_title; + } + + return $this->normalize_release_data( $data, $plugin ); + } + + /** + * Get the default legacy release array for a plugin. + * + * @param \WP_Post $plugin Plugin post object. + * @return array + */ + private function get_default_release_data( $plugin ) { + return array( + 'date' => time(), + 'tag' => '', + 'version' => '', + 'zips_built' => ! $plugin->release_confirmation, + 'zips_built_from_revision' => 0, + 'confirmations' => array(), + 'confirmed' => ! $plugin->release_confirmation, + 'confirmations_required' => (int) $plugin->release_confirmation, + 'committer' => array(), + 'revision' => array(), + 'release_delay' => get_release_cooldown_delay( $plugin->post_name ), + ); + } + + /** + * Normalize a release array to match the legacy storage contract. + * + * @param array $release Release data. + * @param \WP_Post $plugin Plugin post object. + * @return array + */ + private function normalize_release_data( $release, $plugin ) { + $release = wp_parse_args( $release, $this->get_default_release_data( $plugin ) ); + + $release['date'] = (int) $release['date']; + $release['tag'] = (string) $release['tag']; + $release['version'] = (string) $release['version']; + $release['committer'] = array_values( array_unique( array_filter( (array) $release['committer'] ) ) ); + $release['revision'] = array_values( array_unique( array_filter( (array) $release['revision'] ) ) ); + $release['confirmations'] = is_array( $release['confirmations'] ) ? $release['confirmations'] : array(); + $release['confirmations_required'] = (int) $release['confirmations_required']; + $release['zips_built'] = (bool) $release['zips_built']; + $release['zips_built_from_revision'] = (int) $release['zips_built_from_revision']; + $release['confirmed'] = (bool) $release['confirmed']; + $release['release_delay'] = (int) $release['release_delay']; + + if ( ! $release['confirmations_required'] && ! $release['zips_built'] ) { + $release['zips_built'] = true; + } + + return $release; + } +} diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Test.php new file mode 100644 index 0000000000..04c120fe4f --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Test.php @@ -0,0 +1,218 @@ +post->create( + array( + 'post_type' => 'plugin', + 'post_name' => $slug, + 'post_title' => 'Release CPT Test', + 'post_status' => 'publish', + ) + ); + + update_post_meta( $post_id, 'releases', array() ); + + return get_post( $post_id ); + } + + /** + * Get release CPT posts for a plugin. + * + * @param WP_Post $plugin Plugin post. + * @return WP_Post[] + */ + private function get_release_posts( $plugin ) { + return get_posts( + array( + 'post_type' => Plugin_Release::POST_TYPE, + 'post_parent' => $plugin->ID, + 'post_status' => 'any', + 'posts_per_page' => -1, + ) + ); + } + + /** + * The legacy add_release() API writes a release CPT. + */ + public function test_add_release_writes_cpt_and_preserves_legacy_shape() { + $plugin = $this->create_plugin(); + + $result = Plugin_Directory::add_release( + $plugin, + array( + 'date' => 1700000000, + 'tag' => '1.0.0', + 'version' => '1.0.0', + 'committer' => array( 'alice' ), + 'revision' => array( 123 ), + 'confirmations_required' => 1, + 'confirmed' => false, + 'zips_built' => false, + 'zips_built_from_revision' => 0, + 'release_delay' => HOUR_IN_SECONDS, + ) + ); + + $this->assertTrue( $result ); + + $release_posts = $this->get_release_posts( $plugin ); + $this->assertCount( 1, $release_posts ); + $this->assertSame( 'plugin_release', $release_posts[0]->post_type ); + $this->assertSame( '1.0.0', get_post_meta( $release_posts[0]->ID, 'release_tag', true ) ); + + $release = Plugin_Directory::get_release( $plugin, '1.0.0' ); + $this->assertSame( '1.0.0', $release['tag'] ); + $this->assertSame( '1.0.0', $release['version'] ); + $this->assertSame( array( 'alice' ), $release['committer'] ); + $this->assertSame( array( 123 ), $release['revision'] ); + $this->assertSame( HOUR_IN_SECONDS, $release['release_delay'] ); + $this->assertFalse( $release['confirmed'] ); + } + + /** + * Legacy release metadata is lazily backfilled to release CPTs. + */ + public function test_legacy_releases_meta_is_lazily_backfilled_to_cpts() { + $plugin = $this->create_plugin( 'legacy-release-cpt-test' ); + $legacy = array( + array( + 'date' => 1700000000, + 'tag' => '1.0.0', + 'version' => '1.0.0', + 'committer' => array( 'alice' ), + 'revision' => array( 100 ), + 'zips_built' => true, + 'confirmations_required' => 0, + 'release_delay' => 0, + ), + array( + 'date' => 1710000000, + 'tag' => '1.1.0', + 'version' => '1.1.0', + 'committer' => array( 'bob' ), + 'revision' => array( 200 ), + 'zips_built' => false, + 'confirmations_required' => 0, + 'release_delay' => 2 * HOUR_IN_SECONDS, + ), + ); + update_post_meta( $plugin->ID, 'releases', $legacy ); + + $releases = Plugin_Directory::get_releases( $plugin ); + + $this->assertCount( 2, $releases ); + $this->assertSame( '1.1.0', $releases[0]['tag'] ); + $this->assertTrue( $releases[0]['zips_built'], 'Legacy no-confirmation releases should still report built ZIPs.' ); + $this->assertSame( 2 * HOUR_IN_SECONDS, $releases[0]['release_delay'] ); + $this->assertCount( 2, $this->get_release_posts( $plugin ) ); + + Plugin_Directory::get_releases( $plugin ); + $this->assertCount( 2, $this->get_release_posts( $plugin ), 'Backfill should not duplicate release CPTs.' ); + } + + /** + * Existing release tags are updated instead of duplicated. + */ + public function test_add_release_updates_existing_tag_and_merges_array_fields() { + $plugin = $this->create_plugin( 'merge-release-cpt-test' ); + + Plugin_Directory::add_release( + $plugin, + array( + 'tag' => '1.0.0', + 'version' => '1.0.0', + 'committer' => array( 'alice' ), + 'revision' => array( 100 ), + ) + ); + + Plugin_Directory::add_release( + $plugin, + array( + 'tag' => '1.0.0', + 'committer' => array( 'bob' ), + 'revision' => array( 101 ), + 'confirmed' => true, + ) + ); + + $release = Plugin_Directory::get_release( $plugin, '1.0.0' ); + $this->assertSame( array( 'alice', 'bob' ), $release['committer'] ); + $this->assertSame( array( 100, 101 ), $release['revision'] ); + $this->assertTrue( $release['confirmed'] ); + $this->assertCount( 1, $this->get_release_posts( $plugin ) ); + } + + /** + * Only unconfirmed releases can be removed. + */ + public function test_remove_release_only_deletes_unconfirmed_releases() { + $plugin = $this->create_plugin( 'remove-release-cpt-test' ); + + Plugin_Directory::add_release( + $plugin, + array( + 'tag' => '1.0.0', + 'version' => '1.0.0', + 'confirmed' => false, + 'confirmations_required' => 1, + ) + ); + Plugin_Directory::add_release( + $plugin, + array( + 'tag' => '2.0.0', + 'version' => '2.0.0', + 'confirmed' => true, + ) + ); + + $this->assertTrue( Plugin_Directory::remove_release( $plugin, '1.0.0' ) ); + $this->assertFalse( Plugin_Directory::get_release( $plugin, '1.0.0' ) ); + + $this->assertFalse( Plugin_Directory::remove_release( $plugin, '2.0.0' ) ); + $this->assertIsArray( Plugin_Directory::get_release( $plugin, '2.0.0' ) ); + } + + /** + * The legacy trunk@version lookup fallback is preserved. + */ + public function test_get_release_keeps_trunk_version_fallback() { + $plugin = $this->create_plugin( 'trunk-release-cpt-test' ); + + Plugin_Directory::add_release( + $plugin, + array( + 'tag' => 'trunk@1.2.3', + 'version' => '1.2.3', + ) + ); + + $release = Plugin_Directory::get_release( $plugin, '1.2.3' ); + + $this->assertSame( 'trunk@1.2.3', $release['tag'] ); + $this->assertSame( '1.2.3', $release['version'] ); + } +} From 7107e477bb017eb8dcbc1db712c33aedab1040e0 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 03:13:38 +0000 Subject: [PATCH 02/11] Plugin Directory: Sync imports through release CPTs --- .../class-plugin-directory.php | 12 +- ...-plugin-release.php => class-releases.php} | 405 ++++++++++++++++-- .../plugin-directory/cli/class-import.php | 260 +++++------ .../jobs/class-api-update-updater.php | 31 +- ...gin_Release_Test.php => Releases_Test.php} | 6 +- 5 files changed, 537 insertions(+), 177 deletions(-) rename wordpress.org/public_html/wp-content/plugins/plugin-directory/{class-plugin-release.php => class-releases.php} (57%) rename wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/{Plugin_Release_Test.php => Releases_Test.php} (97%) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php index ce63e5d4da..32e45aeeab 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php @@ -70,7 +70,7 @@ private function __construct() { Plugin_Search::instance(); // Releases. - Plugin_Release::instance(); + Releases::instance(); // Add upload size limit to limit plugin ZIP file uploads to 10M add_filter( 'upload_size_limit', function( $size ) { @@ -1605,7 +1605,7 @@ public function split_post_content_into_pages( $content ) { * Get a list of all Plugin Releases. */ public static function get_releases( $plugin ) { - return Plugin_Release::instance()->get_releases( $plugin ); + return Releases::instance()->get_releases( $plugin ); } /** @@ -1615,7 +1615,7 @@ public static function get_releases( $plugin ) { * @return array */ public static function prefill_releases_meta( $plugin ) { - Plugin_Release::instance()->maybe_backfill_releases( $plugin, true ); + Releases::instance()->maybe_backfill_releases( $plugin, true ); return self::get_releases( $plugin ); } @@ -1628,7 +1628,7 @@ public static function prefill_releases_meta( $plugin ) { * @return array|bool */ public static function get_release( $plugin, $tag ) { - return Plugin_Release::instance()->get_release( $plugin, $tag ); + return Releases::instance()->get_release( $plugin, $tag ); } /** @@ -1639,7 +1639,7 @@ public static function get_release( $plugin, $tag ) { * @return bool */ public static function add_release( $plugin, $data ) { - return Plugin_Release::instance()->add_release( $plugin, $data ); + return Releases::instance()->add_release( $plugin, $data ); } /** @@ -1650,7 +1650,7 @@ public static function add_release( $plugin, $data ) { * @return bool */ public static function remove_release( $plugin, $tag ) { - return Plugin_Release::instance()->remove_release( $plugin, $tag ); + return Releases::instance()->remove_release( $plugin, $tag ); } /** diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-release.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php similarity index 57% rename from wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-release.php rename to wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php index 313d2a4782..b396719f08 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-release.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php @@ -1,6 +1,6 @@ array( - 'name' => __( 'Releases', 'wporg-plugins' ), - 'singular_name' => __( 'Release', 'wporg-plugins' ), + 'name' => 'Releases', + 'singular_name' => 'Release', ), 'public' => false, 'show_ui' => false, @@ -233,22 +233,27 @@ private function get_prefill_releases( $plugin ) { * @return array|bool */ public function get_release( $plugin, $tag ) { - $releases = $this->get_releases( $plugin ); + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return false; + } - $filtered = wp_list_filter( $releases, compact( 'tag' ) ); - if ( $filtered ) { - return array_shift( $filtered ); + $release_post = $this->get_release_post_by_tag( $plugin, $tag ); + if ( ! $release_post && ! $this->has_releases( $plugin ) && ! get_post_meta( $plugin->ID, self::BACKFILLED_META, true ) ) { + $this->maybe_backfill_releases( $plugin ); + $release_post = $this->get_release_post_by_tag( $plugin, $tag ); } - $filtered = wp_list_filter( - $releases, - array( - 'tag' => "trunk@{$tag}", - 'version' => $tag, - ) - ); - if ( $filtered ) { - return array_shift( $filtered ); + if ( $release_post ) { + return $this->post_to_release_data( $release_post, $plugin ); + } + + $trunk_release_post = $this->get_release_post_by_tag( $plugin, "trunk@{$tag}" ); + if ( $trunk_release_post ) { + $release = $this->post_to_release_data( $trunk_release_post, $plugin ); + if ( $tag === $release['version'] ) { + return $release; + } } return false; @@ -278,8 +283,9 @@ public function add_release( $plugin, $data ) { $existing_post = $this->get_release_post_by_tag( $plugin, $data['tag'] ); $release = $existing_post ? $this->post_to_release_data( $existing_post, $plugin ) : $this->get_default_release_data( $plugin ); + $merge_list_fields = array( 'committer', 'revision' ); foreach ( $data as $key => $value ) { - if ( isset( $release[ $key ] ) && is_array( $release[ $key ] ) ) { + if ( in_array( $key, $merge_list_fields, true ) && isset( $release[ $key ] ) ) { $release[ $key ] = array_unique( array_merge( $release[ $key ], (array) $value ) ); } else { $release[ $key ] = $value; @@ -366,19 +372,21 @@ private function get_release_posts( $plugin, $limit = -1 ) { private function get_release_post_by_tag( $plugin, $tag ) { $this->ensure_post_type(); + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'posts_per_page' => 1, 'post_parent' => $plugin->ID, 'post_status' => 'any', - 'meta_key' => 'release_tag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - 'meta_value' => $tag, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'meta_key' => 'release_tag', + 'meta_value' => $tag, 'orderby' => 'date', 'order' => 'DESC', 'suppress_filters' => true, ) ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value return $posts ? $posts[0] : null; } @@ -399,16 +407,25 @@ private function save_release_post( $plugin, $release, $existing_post = null ) { $title = 'trunk'; } - $date = (int) $release['date']; - $date = $date ? $date : time(); + $date = (int) $release['date']; + $date = $date ? $date : time(); + $date_gmt = gmdate( 'Y-m-d H:i:s', $date ); + $post_status = $release['post_status'] ?? ''; + if ( ! $post_status && $existing_post ) { + $post_status = $existing_post->post_status; + } + if ( ! $post_status ) { + $post_status = ( 'trunk' === $release['tag'] ) ? 'draft' : 'publish'; + } + $post = array( 'post_type' => self::POST_TYPE, 'post_title' => $title, 'post_name' => sanitize_title( $plugin->post_name . '-' . $release['tag'] ), 'post_parent' => $plugin->ID, - 'post_status' => ( 'trunk' === $release['tag'] ) ? 'draft' : 'publish', - 'post_date' => gmdate( 'Y-m-d H:i:s', $date ), - 'post_date_gmt' => gmdate( 'Y-m-d H:i:s', $date ), + 'post_status' => $post_status, + 'post_date' => get_date_from_gmt( $date_gmt ), + 'post_date_gmt' => $date_gmt, 'post_content' => '', 'comment_status' => 'closed', 'ping_status' => 'closed', @@ -460,6 +477,9 @@ private function update_release_meta( $release_id, $release ) { 'discarded' => 'release_discarded', 'rollout_strategy' => 'release_rollout_strategy', 'release_delay' => 'release_delay', + 'sync_status' => 'release_sync_status', + 'sync_after' => 'release_sync_after', + 'synced_at' => 'release_synced_at', ); foreach ( $mirrored_fields as $field => $meta_key ) { @@ -479,18 +499,20 @@ private function update_release_meta( $release_id, $release ) { * @param int $release_id Release post that should remain. */ private function delete_duplicate_release_posts( $plugin, $tag, $release_id ) { + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'posts_per_page' => -1, 'post_parent' => $plugin->ID, 'post_status' => 'any', - 'meta_key' => 'release_tag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - 'meta_value' => $tag, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value + 'meta_key' => 'release_tag', + 'meta_value' => $tag, 'fields' => 'ids', 'suppress_filters' => true, ) ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value foreach ( $posts as $post_id ) { if ( (int) $post_id !== (int) $release_id ) { @@ -531,6 +553,9 @@ private function post_to_release_data( $release_post, $plugin ) { 'discarded' => 'release_discarded', 'rollout_strategy' => 'release_rollout_strategy', 'release_delay' => 'release_delay', + 'sync_status' => 'release_sync_status', + 'sync_after' => 'release_sync_after', + 'synced_at' => 'release_synced_at', ); foreach ( $legacy_meta_fields as $field => $meta_key ) { @@ -554,10 +579,315 @@ private function post_to_release_data( $release_post, $plugin ) { $version = get_post_meta( $release_post->ID, 'release_version', true ); $data['version'] = $version ? $version : $release_post->post_title; } + if ( empty( $data['post_status'] ) ) { + $data['post_status'] = $release_post->post_status; + } return $this->normalize_release_data( $data, $plugin ); } + /** + * Sync a stored release snapshot to the main plugin post. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param string|\WP_Post $release_ref Release tag or release post. + * @param array $args Optional arguments. + * @return bool + */ + public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return false; + } + + $release_post = ( $release_ref instanceof \WP_Post ) ? $release_ref : $this->get_release_post_by_tag( $plugin, $release_ref ); + if ( ! $release_post ) { + return false; + } + + $release = $this->post_to_release_data( $release_post, $plugin ); + if ( empty( $release['plugin_snapshot'] ) || ! is_array( $release['plugin_snapshot'] ) ) { + return false; + } + + $force = ! empty( $args['force'] ); + $sync_after = $this->get_release_sync_after( $plugin, $release ); + if ( ! $force && $sync_after > time() ) { + $release['post_status'] = 'draft'; + $release['sync_status'] = 'pending'; + $release['sync_after'] = $sync_after; + $this->add_release( $plugin, $release ); + Jobs\API_Update_Updater::queue_release_to_update_api( $plugin->post_name, $sync_after ); + + return false; + } + + /** + * Filters whether an imported release snapshot is ready to sync to the plugin post. + * + * Scanner integrations can return false here while a draft release is still being + * verified, then call sync_pending_release() when the verification passes. + * + * @param bool $ready Whether the release can sync now. + * @param \WP_Post $plugin Plugin post object. + * @param array $release Release data. + * @param \WP_Post $release_post Release CPT post. + */ + $ready = apply_filters( 'wporg_plugins_release_ready_for_sync', true, $plugin, $release, $release_post ); + if ( ! $force && ! $ready ) { + $release['post_status'] = 'draft'; + $release['sync_status'] = 'pending'; + $release['sync_after'] = $sync_after; + $this->add_release( $plugin, $release ); + + return false; + } + + if ( $force ) { + $release['release_delay'] = 0; + $this->add_release( $plugin, $release ); + } + + $result = $this->apply_plugin_snapshot( $plugin, $release['plugin_snapshot'] ); + if ( ! $result || is_wp_error( $result ) ) { + return false; + } + + $release['post_status'] = 'publish'; + $release['sync_status'] = 'synced'; + $release['sync_after'] = 0; + $release['synced_at'] = time(); + $this->add_release( $plugin, $release ); + + $plugin = get_post( $plugin->ID ); + $snapshot = $release['plugin_snapshot']; + + /** + * Action that fires after a plugin import snapshot is synced to the plugin post. + * + * @param \WP_Post $plugin The plugin updated. + * @param string $stable_tag The new stable tag for the plugin. + * @param string $old_stable_tag The previous stable tag for the plugin. + * @param array $changed_tags The list of SVN tags/trunk affected to trigger the import. + * @param int $svn_revision The SVN revision that triggered the import. + * @param array $warnings The list of warnings generated during the import process. + */ + do_action( + 'wporg_plugins_imported', + $plugin, + $snapshot['stable_tag'] ?? $release['tag'], + $snapshot['old_stable_tag'] ?? '', + $snapshot['changed_tags'] ?? array(), + $snapshot['svn_revision'] ?? 0, + $snapshot['warnings'] ?? array() + ); + + /** + * Fires after a release CPT snapshot is synced to its plugin post. + * + * @param \WP_Post $plugin The plugin updated. + * @param array $release Release data. + * @param array $snapshot Stored plugin snapshot. + */ + do_action( 'wporg_plugins_release_synced', $plugin, $release, $snapshot ); + + return true; + } + + /** + * Sync the newest pending release snapshot for a plugin. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param bool $force Whether to bypass cooldown/readiness checks. + * @return bool + */ + public function sync_pending_release( $plugin, $force = false ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return false; + } + + $release_post = $this->get_pending_release_post( $plugin, $force ); + if ( ! $release_post ) { + return false; + } + + return $this->sync_release_to_plugin( $plugin, $release_post, compact( 'force' ) ); + } + + /** + * Get the release timestamp used for cooldown calculations. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param array $release Release data. + * @return int + */ + public function get_release_time( $plugin, $release ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + + if ( + ! empty( $release['confirmations_required'] ) && + ! empty( $release['confirmations'] ) && + is_array( $release['confirmations'] ) + ) { + return max( array_map( 'intval', $release['confirmations'] ) ); + } + + if ( ! empty( $release['date'] ) ) { + return (int) $release['date']; + } + + if ( $plugin ) { + $version_date = get_post_meta( $plugin->ID, 'version_date', true ); + $release_time = strtotime( $version_date ? $version_date : $plugin->post_modified ); + if ( $release_time ) { + return $release_time; + } + } + + return time(); + } + + /** + * Get the time when a release snapshot may sync to the plugin post. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param array $release Release data. + * @return int Unix timestamp, or 0 when no delayed sync is required. + */ + public function get_release_sync_after( $plugin, $release ) { + $release_delay = (int) ( $release['release_delay'] ?? 0 ); + if ( ! $release_delay ) { + return 0; + } + + if ( isset( $release['plugin_snapshot']['is_new_version'] ) && ! $release['plugin_snapshot']['is_new_version'] ) { + return 0; + } + + return $this->get_release_time( $plugin, $release ) + $release_delay; + } + + /** + * Query the newest pending release snapshot. + * + * @param \WP_Post $plugin Plugin post object. + * @param bool $force Whether sync_after should be ignored. + * @return \WP_Post|null + */ + private function get_pending_release_post( $plugin, $force = false ) { + $this->ensure_post_type(); + + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => -1, + 'post_parent' => $plugin->ID, + 'post_status' => 'draft', + 'meta_key' => 'release_sync_status', + 'meta_value' => 'pending', + 'orderby' => 'date', + 'order' => 'DESC', + 'suppress_filters' => true, + ) + ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value + + foreach ( $posts as $post ) { + $release = $this->post_to_release_data( $post, $plugin ); + if ( $force || empty( $release['sync_after'] ) || (int) $release['sync_after'] <= time() ) { + return $post; + } + } + + return null; + } + + /** + * Apply a stored plugin snapshot to the main plugin post. + * + * @param \WP_Post $plugin Plugin post object. + * @param array $snapshot Release plugin snapshot. + * @return bool|\WP_Error + */ + private function apply_plugin_snapshot( $plugin, $snapshot ) { + if ( ! empty( $snapshot['post'] ) && is_array( $snapshot['post'] ) ) { + $post = array_merge( + array( + 'ID' => $plugin->ID, + 'post_type' => 'plugin', + ), + $snapshot['post'] + ); + + $updated = wp_update_post( $post, true ); + if ( ! $updated || is_wp_error( $updated ) ) { + return $updated; + } + } + + if ( ! empty( $snapshot['default_categories'] ) && ! wp_get_object_terms( $plugin->ID, 'plugin_category', array( 'fields' => 'ids' ) ) ) { + wp_set_object_terms( $plugin->ID, $snapshot['default_categories'], 'plugin_category' ); + } + + foreach ( (array) ( $snapshot['terms'] ?? array() ) as $taxonomy => $terms ) { + wp_set_object_terms( $plugin->ID, $terms, $taxonomy ); + } + + foreach ( (array) ( $snapshot['plugin_section'] ?? array() ) as $term => $mode ) { + if ( 'set' === $mode ) { + wp_set_object_terms( $plugin->ID, $term, 'plugin_section' ); + } elseif ( 'add' === $mode ) { + wp_add_object_terms( $plugin->ID, $term, 'plugin_section' ); + } else { + wp_remove_object_terms( $plugin->ID, $term, 'plugin_section' ); + } + } + + if ( ! empty( $snapshot['sync_committers'] ) ) { + Tools::sync_plugin_committers_with_taxonomy( $plugin->post_name ); + } + + foreach ( (array) ( $snapshot['delete_meta'] ?? array() ) as $meta_key ) { + delete_post_meta( $plugin->ID, $meta_key ); + } + + foreach ( (array) ( $snapshot['meta'] ?? array() ) as $meta_key => $value ) { + update_post_meta( $plugin->ID, $meta_key, wp_slash( $value ) ); + } + + foreach ( (array) ( $snapshot['raw_meta'] ?? array() ) as $meta_key => $value ) { + update_post_meta( $plugin->ID, $meta_key, $value ); + } + + foreach ( (array) ( $snapshot['multi_meta'] ?? array() ) as $meta_key => $values ) { + delete_post_meta( $plugin->ID, $meta_key ); + foreach ( (array) $values as $value ) { + if ( '' === $value ) { + continue; + } + add_post_meta( $plugin->ID, $meta_key, $value, false ); + } + } + + foreach ( (array) ( $snapshot['add_meta_unique'] ?? array() ) as $meta_key => $value ) { + add_post_meta( $plugin->ID, $meta_key, wp_slash( $value ), true ); + } + + if ( ! empty( $snapshot['block_files'] ) && has_term( 'block', 'plugin_section', $plugin->ID ) ) { + update_post_meta( $plugin->ID, 'block_files', $snapshot['block_files'] ); + } else { + delete_post_meta( $plugin->ID, 'block_files' ); + } + + clean_post_cache( $plugin->ID ); + Jobs\API_Update_Updater::update_single_plugin( $plugin->post_name ); + Standalone\Plugins_Info_API::flush_plugin_information_cache( $plugin->post_name ); + + return true; + } + /** * Get the default legacy release array for a plugin. * @@ -602,6 +932,19 @@ private function normalize_release_data( $release, $plugin ) { $release['confirmed'] = (bool) $release['confirmed']; $release['release_delay'] = (int) $release['release_delay']; + if ( isset( $release['plugin_snapshot'] ) && ! is_array( $release['plugin_snapshot'] ) ) { + $release['plugin_snapshot'] = array(); + } + if ( isset( $release['sync_after'] ) ) { + $release['sync_after'] = (int) $release['sync_after']; + } + if ( isset( $release['synced_at'] ) ) { + $release['synced_at'] = (int) $release['synced_at']; + } + if ( isset( $release['post_status'] ) && ! in_array( $release['post_status'], array( 'draft', 'publish' ), true ) ) { + unset( $release['post_status'] ); + } + if ( ! $release['confirmations_required'] && ! $release['zips_built'] ) { $release['zips_built'] = true; } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php index 3a1785c472..0026f866b7 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php @@ -2,12 +2,11 @@ namespace WordPressdotorg\Plugin_Directory\CLI; use Exception; -use WordPressdotorg\Plugin_Directory\Jobs\API_Update_Updater; use WordPressdotorg\Plugin_Directory\Block_JSON; use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Email\Release_Confirmation as Release_Confirmation_Email; use WordPressdotorg\Plugin_Directory\Readme\{ Parser as Readme_Parser, Validator as Readme_Validator }; -use WordPressdotorg\Plugin_Directory\Standalone\Plugins_Info_API; +use WordPressdotorg\Plugin_Directory\Releases; use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools; use WordPressdotorg\Plugin_Directory\Tools\Filesystem; @@ -229,10 +228,12 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk Plugin_Directory::add_release( $plugin, [ - 'tag' => $svn_changed_tag, - 'version' => $release_version, - 'committer' => [ $last_committer ], - 'revision' => [ $last_revision ] + 'tag' => $svn_changed_tag, + 'version' => $release_version, + 'committer' => [ $last_committer ], + 'revision' => [ $last_revision ], + 'post_status' => 'draft', + 'sync_status' => 'pending_confirmation', ] ); @@ -360,19 +361,31 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk $content = "\n{$headers->Description}"; } + $current_version = get_post_meta( $plugin->ID, 'version', true ); + $current_version_date = get_post_meta( $plugin->ID, 'version_date', true ); + $is_new_version = $version && $version !== $current_version; + + $post = array( + 'post_title' => $plugin->post_title, + 'post_content' => trim( $content ) ?: $plugin->post_content, + 'post_excerpt' => trim( $readme->short_description ) ?: $headers->Description ?: $plugin->post_excerpt, + 'post_modified' => $plugin->post_modified, + 'post_modified_gmt' => $plugin->post_modified_gmt, + 'post_status' => $plugin->post_status, + 'post_date' => $plugin->post_date, + 'post_date_gmt' => $plugin->post_date_gmt, + ); + // Use the Readme name, as long as it's not the plugin slug. if ( $readme->name && $readme->name !== $plugin->post_name ) { - $plugin->post_title = $readme->name; + $post['post_title'] = $readme->name; } elseif ( $headers->Name ) { - $plugin->post_title = strip_tags( $headers->Name ); + $post['post_title'] = strip_tags( $headers->Name ); } - $plugin->post_content = trim( $content ) ?: $plugin->post_content; - $plugin->post_excerpt = trim( $readme->short_description ) ?: $headers->Description ?: $plugin->post_excerpt; - /* * Bump last updated if: * - The version has changed. @@ -380,14 +393,14 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk * - A tag (or trunk) commit is made to the current stable. The build has changed, even if not new version. */ if ( - ( ! $version || $version != get_post_meta( $plugin->ID, 'version', true ) ) || + ( ! $version || $version != $current_version ) || $plugin->post_modified == '0000-00-00 00:00:00' || ( $svn_changed_tags && in_array( ( $stable_tag ?: 'trunk' ), $svn_changed_tags, true ) ) ) { if ( $last_modified ) { - $plugin->post_modified = $plugin->post_modified_gmt = $last_modified; + $post['post_modified'] = $post['post_modified_gmt'] = $last_modified; } else { - $plugin->post_modified = $plugin->post_modified_gmt = current_time( 'mysql' ); + $post['post_modified'] = $post['post_modified_gmt'] = current_time( 'mysql' ); } } @@ -395,42 +408,21 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk // `export_and_parse_plugin()` will throw an exception in the case where plugin files cannot be found, // so by this time the plugin should be live. if ( 'approved' === $plugin->post_status ) { - $plugin->post_status = 'publish'; + $post['post_status'] = 'publish'; // The post date should be set to when the plugin is first set live. - $plugin->post_date = $plugin->post_date_gmt = current_time( 'mysql' ); - } - - wp_update_post( $plugin ); - - // Set categories if there aren't any yet. wp-admin takes precedent. - if ( ! wp_get_object_terms( $plugin->ID, 'plugin_category', array( 'fields' => 'ids' ) ) ) { - wp_set_object_terms( $plugin->ID, Tag_To_Category::map( $readme->tags ), 'plugin_category' ); - } - - // Set tags from the readme - wp_set_object_terms( $plugin->ID, $readme->tags, 'plugin_tags' ); - - // Update the contributors list - wp_set_object_terms( $plugin->ID, $readme->contributors, 'plugin_contributors' ); - - // Update the committers list - Tools::sync_plugin_committers_with_taxonomy( $plugin->post_name ); - - if ( in_array( 'adopt-me', $readme->tags ) ) { - wp_set_object_terms( $plugin->ID, 'adopt-me', 'plugin_section' ); - } else { - wp_remove_object_terms( $plugin->ID, 'adopt-me', 'plugin_section' ); + $post['post_date'] = $post['post_date_gmt'] = current_time( 'mysql' ); } // Update all readme meta + $meta = array(); foreach ( $this->readme_fields as $readme_field ) { - update_post_meta( $plugin->ID, $readme_field, wp_slash( $readme->$readme_field ) ); + $meta[ $readme_field ] = $readme->$readme_field; } // Store the plugin headers we need. Note that 'Version', 'RequiresWP', and 'RequiresPHP' are handled below. foreach ( $this->plugin_headers as $plugin_header => $meta_field ) { - update_post_meta( $plugin->ID, $meta_field, ( isset( $headers->$plugin_header ) ? wp_slash( $headers->$plugin_header ) : '' ) ); + $meta[ $meta_field ] = ( isset( $headers->$plugin_header ) ? $headers->$plugin_header : '' ); } // Update the Requires, Requires PHP, and Tested up to fields, prefering those from the Plugin Headers. @@ -463,19 +455,19 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk if ( ! isset( $plugin_names[ $headers->Name ] ) ) { // [ 'Plugin Name' => '1.2.3', 'Plugin New Name' => '4.5.6' ] $plugin_names[ $headers->Name ] = $headers->Version; - update_post_meta( $plugin->ID, 'plugin_name_history', wp_slash( $plugin_names ) ); + $meta['plugin_name_history'] = $plugin_names; } - update_post_meta( $plugin->ID, 'requires_plugins', wp_slash( $requires_plugins ) ); - update_post_meta( $plugin->ID, 'requires', wp_slash( $requires ) ); - update_post_meta( $plugin->ID, 'requires_php', wp_slash( $requires_php ) ); - update_post_meta( $plugin->ID, 'tested', wp_slash( $tested ) ); - update_post_meta( $plugin->ID, 'tagged_versions', wp_slash( array_keys( $tagged_versions ) ) ); - update_post_meta( $plugin->ID, 'sections', wp_slash( array_keys( $readme->sections ) ) ); - update_post_meta( $plugin->ID, 'assets_screenshots', wp_slash( $assets['screenshot'] ) ); - update_post_meta( $plugin->ID, 'assets_icons', wp_slash( $assets['icon'] ) ); - update_post_meta( $plugin->ID, 'assets_banners', wp_slash( $assets['banner'] ) ); - update_post_meta( $plugin->ID, 'last_updated', wp_slash( $plugin->post_modified_gmt ) ); + $meta['requires_plugins'] = $requires_plugins; + $meta['requires'] = $requires; + $meta['requires_php'] = $requires_php; + $meta['tested'] = $tested; + $meta['tagged_versions'] = array_keys( $tagged_versions ); + $meta['sections'] = array_keys( $readme->sections ); + $meta['assets_screenshots'] = $assets['screenshot']; + $meta['assets_icons'] = $assets['icon']; + $meta['assets_banners'] = $assets['banner']; + $meta['last_updated'] = $post['post_modified_gmt']; // Calculate the 'plugin color' from the average color of the banner if provided. This is used for fallback icons. $banner_average_color = ''; @@ -483,115 +475,127 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk // The Banners are not stored locally, which is why a URL is used here $banner_average_color = Tools::get_image_average_color( Template::get_asset_url( $plugin, $first_banner, false /* no CDN */ ) ); } - update_post_meta( $plugin->ID, 'assets_banners_color', wp_slash( $banner_average_color ) ); + $meta['assets_banners_color'] = $banner_average_color; + $delete_meta = array(); + $add_meta_unique = array(); // Store the content of blueprint files, if they're available and valid. if ( isset( $assets['blueprint'] ) && count( $assets['blueprint'] ) > 0 ) { - update_post_meta( $plugin->ID, 'assets_blueprints', wp_slash( $assets['blueprint'] ) ); + $meta['assets_blueprints'] = $assets['blueprint']; } else { - delete_post_meta( $plugin->ID, 'assets_blueprints' ); + $delete_meta[] = 'assets_blueprints'; // TODO: maybe if ( $touches_stable_tag )? - add_post_meta( $plugin->ID, '_missing_blueprint_notice', 1, true ); + $add_meta_unique['_missing_blueprint_notice'] = 1; } + $raw_meta = array(); + $multi_meta = array( + 'block_name' => array(), + 'block_title' => array(), + 'dashboard_widget_name' => array(), + ); + // Store the block data, if known if ( count( $blocks ) ) { - $changed = update_post_meta( $plugin->ID, 'all_blocks', $blocks ); - if ( $changed || count ( get_post_meta( $plugin->ID, 'block_name' ) ) !== count ( $blocks ) ) { - delete_post_meta( $plugin->ID, 'block_name' ); - delete_post_meta( $plugin->ID, 'block_title' ); - - foreach ( $blocks as $block ) { - add_post_meta( $plugin->ID, 'block_name', $block->name, false ); - add_post_meta( $plugin->ID, 'block_title', $block->title, false ); - } + $raw_meta['all_blocks'] = $blocks; + foreach ( $blocks as $block ) { + $multi_meta['block_name'][] = $block->name; + $multi_meta['block_title'][] = $block->title; } } else { - delete_post_meta( $plugin->ID, 'all_blocks' ); - delete_post_meta( $plugin->ID, 'block_name' ); - delete_post_meta( $plugin->ID, 'block_title' ); + $delete_meta[] = 'all_blocks'; } - // Only store block_files for plugins in the block directory - if ( count( $block_files ) && has_term( 'block', 'plugin_section', $plugin->ID ) ) { - update_post_meta( $plugin->ID, 'block_files', $block_files ); - } else { - delete_post_meta( $plugin->ID, 'block_files' ); - } + $plugin_section = array( + 'adopt-me' => in_array( 'adopt-me', $readme->tags, true ) ? 'set' : 'remove', + ); // Dashboard widgets: assign the section term and store widget names. if ( $dashboard_widgets ) { - wp_add_object_terms( $plugin->ID, 'dashboard-widgets', 'plugin_section' ); - - delete_post_meta( $plugin->ID, 'dashboard_widget_name' ); + $plugin_section['dashboard-widgets'] = 'add'; foreach ( $dashboard_widgets as $widget_name ) { if ( '' === $widget_name ) { continue; } - add_post_meta( $plugin->ID, 'dashboard_widget_name', $widget_name, false ); + $multi_meta['dashboard_widget_name'][] = $widget_name; } } else { - wp_remove_object_terms( $plugin->ID, 'dashboard-widgets', 'plugin_section' ); - delete_post_meta( $plugin->ID, 'dashboard_widget_name' ); - } - - // Add the release to storage. - if ( 'trunk' != $stable_tag ) { - Plugin_Directory::add_release( - $plugin, - [ - 'tag' => $stable_tag, - 'version' => $version, - 'committer' => [ $last_committer ], - 'revision' => [ $last_revision ] - ] - ); - } elseif ( 'trunk' === $stable_tag && version_compare( $version, $plugin->version, '>' ) ) { - // This is a new version, released from trunk. - Plugin_Directory::add_release( - $plugin, - [ - 'tag' => "trunk@{$version}", - 'version' => $version, - 'committer' => [ $last_committer ], - 'revision' => [ $last_revision ] - ] - ); + $plugin_section['dashboard-widgets'] = 'remove'; } - $this->rebuild_affected_zips( $plugin_slug, $stable_tag, $current_stable_tag, $svn_changed_tags, $svn_revision_triggered ); - // If we've got a new version, store the last version in the plugin meta. - if ( $version && $version !== $plugin->version ) { - update_post_meta( $plugin->ID, 'last_version', wp_slash( $plugin->version ) ); - update_post_meta( $plugin->ID, 'last_stable_tag', wp_slash( $current_stable_tag ) ); - update_post_meta( $plugin->ID, 'last_version_date', wp_slash( $plugin->version_date ) ); + if ( $is_new_version ) { + $meta['last_version'] = $current_version; + $meta['last_stable_tag'] = $current_stable_tag; + $meta['last_version_date'] = $current_version_date; // Keep the date of the last version change, this often differs from the last_updated/post_modified dates. - update_post_meta( $plugin->ID, 'version_date', wp_slash( current_time( 'mysql' ) ) ); + $meta['version_date'] = current_time( 'mysql' ); } - // Finally, set the new version live. - update_post_meta( $plugin->ID, 'stable_tag', wp_slash( $stable_tag ) ); - update_post_meta( $plugin->ID, 'version', wp_slash( $version ) ); + $meta['stable_tag'] = $stable_tag; + $meta['version'] = $version; // Update the list of tags last, as it controls which ZIPs are present in the 'Previous versions' section and info API. - update_post_meta( $plugin->ID, 'tags', wp_slash( $tagged_versions ) ); + $meta['tags'] = $tagged_versions; - // Ensure that the API gets the updated data - API_Update_Updater::update_single_plugin( $plugin->post_name ); - Plugins_Info_API::flush_plugin_information_cache( $plugin->post_name ); + $release_tag = $stable_tag; + if ( 'trunk' === $stable_tag && $is_new_version ) { + $release_tag = "trunk@{$version}"; + } - /** - * Action that fires after a plugin is imported. - * - * @param WP_Post $plugin The plugin updated. - * @param string $stable_tag The new stable tag for the plugin. - * @param string $old_stable_tag The previous stable tag for the plugin. - * @param array $changed_tags The list of SVN tags/trunk affected to trigger the import. - * @param int $svn_revision The SVN revision that triggered the import. - * @param array $warnings The list of warnings generated during the import process. - */ - do_action( 'wporg_plugins_imported', $plugin, $stable_tag, $current_stable_tag, $svn_changed_tags, $svn_revision_triggered, $this->warnings ); + Plugin_Directory::add_release( + $plugin, + array( + 'tag' => $release_tag, + 'version' => $version, + 'committer' => array( $last_committer ), + 'revision' => array( $last_revision ), + 'post_status' => 'draft', + 'sync_status' => 'pending', + 'sync_after' => 0, + 'plugin_snapshot' => array( + 'stable_tag' => $stable_tag, + 'old_stable_tag' => $current_stable_tag, + 'changed_tags' => array_values( $svn_changed_tags ), + 'svn_revision' => (int) $svn_revision_triggered, + 'warnings' => $this->warnings, + 'is_new_version' => (bool) $is_new_version, + 'post' => $post, + 'default_categories' => Tag_To_Category::map( $readme->tags ), + 'terms' => array( + 'plugin_tags' => $readme->tags, + 'plugin_contributors' => $readme->contributors, + ), + 'plugin_section' => $plugin_section, + 'sync_committers' => true, + 'meta' => $meta, + 'raw_meta' => $raw_meta, + 'delete_meta' => array_values( array_unique( $delete_meta ) ), + 'multi_meta' => $multi_meta, + 'add_meta_unique' => $add_meta_unique, + 'block_files' => $block_files, + ), + ) + ); + + $this->rebuild_affected_zips( $plugin_slug, $stable_tag, $current_stable_tag, $svn_changed_tags, $svn_revision_triggered ); + + $imported_release = Plugin_Directory::get_release( $plugin, $release_tag ); + if ( $imported_release ) { + /** + * Action that fires after a plugin import updates a draft release CPT snapshot. + * + * Scanner integrations can use this to start verification before the release + * snapshot is synced to the main plugin post. + * + * @param \WP_Post $plugin The plugin being imported. + * @param array $release Release data. + * @param array $snapshot Stored plugin snapshot. + */ + do_action( 'wporg_plugins_release_imported', $plugin, $imported_release, $imported_release['plugin_snapshot'] ?? array() ); + } + + Releases::instance()->sync_release_to_plugin( $plugin, $release_tag ); return true; } diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php index f24821258a..5426bb55b8 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php @@ -2,6 +2,7 @@ namespace WordPressdotorg\Plugin_Directory\Jobs; use WordPressdotorg\Plugin_Directory\Plugin_Directory; +use WordPressdotorg\Plugin_Directory\Releases; use WordPressdotorg\Plugin_Directory\Template; use WordPressdotorg\Plugin_Directory\Tools; @@ -220,17 +221,12 @@ public static function update_single_plugin( $plugin_slug ) { * @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'] ); + if ( $release ) { + return Releases::instance()->get_release_time( $post, $release ); } - return $release_time; + $version_date = get_post_meta( $post->ID, 'version_date', true ); + return strtotime( $version_date ? $version_date : $post->post_modified ); } /** @@ -252,6 +248,11 @@ public static function queue_release_to_update_api( $plugin_slug, $cooldown_unti */ public static function cron_trigger_release() { list( , $plugin_slug ) = explode( ':', current_filter(), 2 ); + + if ( Releases::instance()->sync_pending_release( $plugin_slug ) ) { + return; + } + self::update_single_plugin( $plugin_slug ); } @@ -276,6 +277,18 @@ public static function force_release( $plugin_slug, $reason, $user = null ) { return false; } + if ( Releases::instance()->sync_pending_release( $post, true ) ) { + Tools::audit_log( + sprintf( + 'Force-released a pending release, bypassing the release cooldown. Reason: %s', + $reason + ), + $post + ); + + return true; + } + $version = get_post_meta( $post->ID, 'version', true ); $release = Plugin_Directory::get_release( $post, $version ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php similarity index 97% rename from wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Test.php rename to wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php index 04c120fe4f..a89dbd0f93 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Plugin_Release_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php @@ -6,14 +6,14 @@ */ use WordPressdotorg\Plugin_Directory\Plugin_Directory; -use WordPressdotorg\Plugin_Directory\Plugin_Release; +use WordPressdotorg\Plugin_Directory\Releases; /** * Release CPT storage tests. * * @group releases */ -class Plugin_Release_Test extends WP_UnitTestCase { +class Releases_Test extends WP_UnitTestCase { /** * Create a plugin post for release tests. @@ -45,7 +45,7 @@ private function create_plugin( $slug = 'release-cpt-test' ) { private function get_release_posts( $plugin ) { return get_posts( array( - 'post_type' => Plugin_Release::POST_TYPE, + 'post_type' => Releases::POST_TYPE, 'post_parent' => $plugin->ID, 'post_status' => 'any', 'posts_per_page' => -1, From 7914acbc2d5ae307844fe831f86bde1f2c751f81 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 03:16:47 +0000 Subject: [PATCH 03/11] Plugin Directory: Keep delayed releases in draft --- .../plugin-directory/class-releases.php | 2 ++ .../jobs/class-api-update-updater.php | 5 +-- .../plugin-directory/tests/Releases_Test.php | 35 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php index b396719f08..3c03755067 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php @@ -613,6 +613,8 @@ public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) $force = ! empty( $args['force'] ); $sync_after = $this->get_release_sync_after( $plugin, $release ); if ( ! $force && $sync_after > time() ) { + // Keep the main plugin post on the previous version during cooldown, + // so both update checks and new installs continue to see the old release. $release['post_status'] = 'draft'; $release['sync_status'] = 'pending'; $release['sync_after'] = $sync_after; diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php index 5426bb55b8..edb235117a 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php @@ -243,8 +243,9 @@ public static function queue_release_to_update_api( $plugin_slug, $cooldown_unti /** * 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. + * expires; first syncs any pending release CPT snapshot to the plugin post, + * then writes the new version to `update_source`. 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 ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php index a89dbd0f93..3f98be4111 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php @@ -215,4 +215,39 @@ public function test_get_release_keeps_trunk_version_fallback() { $this->assertSame( 'trunk@1.2.3', $release['tag'] ); $this->assertSame( '1.2.3', $release['version'] ); } + + /** + * Delayed imports remain draft releases and do not update the plugin post. + */ + public function test_cooldown_keeps_import_snapshot_draft_until_release_time() { + $plugin = $this->create_plugin( 'cooldown-release-cpt-test' ); + update_post_meta( $plugin->ID, 'version', '1.0.0' ); + + Plugin_Directory::add_release( + $plugin, + array( + 'date' => time(), + 'tag' => '2.0.0', + 'version' => '2.0.0', + 'release_delay' => HOUR_IN_SECONDS, + 'post_status' => 'draft', + 'sync_status' => 'pending', + 'plugin_snapshot' => array( + 'is_new_version' => true, + 'meta' => array( + 'stable_tag' => '2.0.0', + 'version' => '2.0.0', + ), + ), + ) + ); + + $this->assertFalse( Releases::instance()->sync_release_to_plugin( $plugin, '2.0.0' ) ); + $this->assertSame( '1.0.0', get_post_meta( $plugin->ID, 'version', true ) ); + + $release = Plugin_Directory::get_release( $plugin, '2.0.0' ); + $this->assertSame( 'draft', $release['post_status'] ); + $this->assertSame( 'pending', $release['sync_status'] ); + $this->assertGreaterThan( time(), $release['sync_after'] ); + } } From cd2ad28acb34edbb4bb6d241c7bdb4d2db3ec856 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 03:18:31 +0000 Subject: [PATCH 04/11] Plugin Directory: Fix import snapshot standards --- .../plugins/plugin-directory/cli/class-import.php | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php index 0026f866b7..cd3ad05a9f 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php @@ -368,6 +368,7 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk $post = array( 'post_title' => $plugin->post_title, 'post_content' => trim( $content ) ?: $plugin->post_content, + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase 'post_excerpt' => trim( $readme->short_description ) ?: $headers->Description ?: $plugin->post_excerpt, 'post_modified' => $plugin->post_modified, 'post_modified_gmt' => $plugin->post_modified_gmt, @@ -383,6 +384,7 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk ) { $post['post_title'] = $readme->name; } elseif ( $headers->Name ) { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $post['post_title'] = strip_tags( $headers->Name ); } @@ -398,9 +400,11 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk ( $svn_changed_tags && in_array( ( $stable_tag ?: 'trunk' ), $svn_changed_tags, true ) ) ) { if ( $last_modified ) { - $post['post_modified'] = $post['post_modified_gmt'] = $last_modified; + $post['post_modified'] = $last_modified; + $post['post_modified_gmt'] = $last_modified; } else { - $post['post_modified'] = $post['post_modified_gmt'] = current_time( 'mysql' ); + $post['post_modified'] = current_time( 'mysql' ); + $post['post_modified_gmt'] = $post['post_modified']; } } @@ -411,7 +415,8 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk $post['post_status'] = 'publish'; // The post date should be set to when the plugin is first set live. - $post['post_date'] = $post['post_date_gmt'] = current_time( 'mysql' ); + $post['post_date'] = current_time( 'mysql' ); + $post['post_date_gmt'] = $post['post_date']; } // Update all readme meta From d104cf4b0364ba5a6083b584b0937212965b1405 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 03:22:32 +0000 Subject: [PATCH 05/11] Plugin Directory: Avoid WP test annotation path --- .../plugin-directory/tests/Releases_Test.php | 46 +++++++++++++++++-- 1 file changed, 42 insertions(+), 4 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php index 3f98be4111..256a1cc610 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php @@ -5,15 +5,50 @@ * @package WordPressdotorg\Plugin_Directory\Tests */ -use WordPressdotorg\Plugin_Directory\Plugin_Directory; +use PHPUnit\Framework\TestCase; use WordPressdotorg\Plugin_Directory\Releases; +use WordPressdotorg\Plugin_Directory\Plugin_Directory; /** * Release CPT storage tests. * * @group releases */ -class Releases_Test extends WP_UnitTestCase { +class Releases_Test extends TestCase { + + /** + * Plugin posts created by a test. + * + * @var WP_Post[] + */ + private $plugins = array(); + + /** + * Clean up posts and scheduled release syncs created by tests. + */ + protected function tearDown(): void { + foreach ( $this->plugins as $plugin ) { + wp_clear_scheduled_hook( "release_to_update_api:{$plugin->post_name}" ); + + $release_posts = get_posts( + array( + 'post_type' => Releases::POST_TYPE, + 'post_parent' => $plugin->ID, + 'post_status' => 'any', + 'posts_per_page' => -1, + ) + ); + + foreach ( $release_posts as $release_post ) { + wp_delete_post( $release_post->ID, true ); + } + + wp_delete_post( $plugin->ID, true ); + } + + $this->plugins = array(); + parent::tearDown(); + } /** * Create a plugin post for release tests. @@ -22,7 +57,7 @@ class Releases_Test extends WP_UnitTestCase { * @return WP_Post */ private function create_plugin( $slug = 'release-cpt-test' ) { - $post_id = self::factory()->post->create( + $post_id = wp_insert_post( array( 'post_type' => 'plugin', 'post_name' => $slug, @@ -33,7 +68,10 @@ private function create_plugin( $slug = 'release-cpt-test' ) { update_post_meta( $post_id, 'releases', array() ); - return get_post( $post_id ); + $plugin = get_post( $post_id ); + $this->plugins[] = $plugin; + + return $plugin; } /** From a9ee27449e0bdab7f6a397c0dc1c7ec020a248b6 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 03:26:14 +0000 Subject: [PATCH 06/11] Plugin Directory: Set release test post dates --- .../plugin-directory/tests/Releases_Test.php | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php index 256a1cc610..73be947435 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php @@ -57,12 +57,17 @@ protected function tearDown(): void { * @return WP_Post */ private function create_plugin( $slug = 'release-cpt-test' ) { + $now = current_time( 'mysql' ); $post_id = wp_insert_post( array( - 'post_type' => 'plugin', - 'post_name' => $slug, - 'post_title' => 'Release CPT Test', - 'post_status' => 'publish', + 'post_type' => 'plugin', + 'post_name' => $slug, + 'post_title' => 'Release CPT Test', + 'post_status' => 'publish', + 'post_date' => $now, + 'post_date_gmt' => $now, + 'post_modified' => $now, + 'post_modified_gmt' => $now, ) ); From aa91b56591672b50f1afdf587de6b7eb6812703b Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 06:55:58 +0000 Subject: [PATCH 07/11] Plugin Directory: Simplify release section snapshots --- .../plugins/plugin-directory/class-releases.php | 12 ++++++++---- .../plugins/plugin-directory/cli/class-import.php | 13 ++++++------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php index 3c03755067..37f3966df0 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php @@ -837,10 +837,14 @@ private function apply_plugin_snapshot( $plugin, $snapshot ) { wp_set_object_terms( $plugin->ID, $terms, $taxonomy ); } - foreach ( (array) ( $snapshot['plugin_section'] ?? array() ) as $term => $mode ) { - if ( 'set' === $mode ) { - wp_set_object_terms( $plugin->ID, $term, 'plugin_section' ); - } elseif ( 'add' === $mode ) { + $plugin_sections = (array) ( $snapshot['plugin_sections'] ?? array() ); + $managed_plugin_sections = array( + 'adopt-me', + 'dashboard-widgets', + ); + + foreach ( $managed_plugin_sections as $term ) { + if ( in_array( $term, $plugin_sections, true ) ) { wp_add_object_terms( $plugin->ID, $term, 'plugin_section' ); } else { wp_remove_object_terms( $plugin->ID, $term, 'plugin_section' ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php index cd3ad05a9f..e9bfc87671 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php @@ -511,21 +511,20 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk $delete_meta[] = 'all_blocks'; } - $plugin_section = array( - 'adopt-me' => in_array( 'adopt-me', $readme->tags, true ) ? 'set' : 'remove', - ); + $plugin_sections = array(); + if ( in_array( 'adopt-me', $readme->tags, true ) ) { + $plugin_sections[] = 'adopt-me'; + } // Dashboard widgets: assign the section term and store widget names. if ( $dashboard_widgets ) { - $plugin_section['dashboard-widgets'] = 'add'; + $plugin_sections[] = 'dashboard-widgets'; foreach ( $dashboard_widgets as $widget_name ) { if ( '' === $widget_name ) { continue; } $multi_meta['dashboard_widget_name'][] = $widget_name; } - } else { - $plugin_section['dashboard-widgets'] = 'remove'; } // If we've got a new version, store the last version in the plugin meta. @@ -571,7 +570,7 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk 'plugin_tags' => $readme->tags, 'plugin_contributors' => $readme->contributors, ), - 'plugin_section' => $plugin_section, + 'plugin_sections' => $plugin_sections, 'sync_committers' => true, 'meta' => $meta, 'raw_meta' => $raw_meta, From bd1063282227cf4dbd645bde7fc0a75808f95345 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 07:10:18 +0000 Subject: [PATCH 08/11] Plugin Directory: Store import data on release drafts --- .../plugin-directory/class-releases.php | 178 ++++++++++++------ .../plugin-directory/cli/class-import.php | 127 ++++++++----- .../plugin-directory/tests/Releases_Test.php | 60 +++++- 3 files changed, 257 insertions(+), 108 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php index 37f3966df0..06d90a10c0 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php @@ -259,6 +259,22 @@ public function get_release( $plugin, $tag ) { return false; } + /** + * Fetch a release CPT post by tag. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @param string $tag Plugin version / release tag. + * @return \WP_Post|null + */ + public function get_release_post( $plugin, $tag ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return null; + } + + return $this->get_release_post_by_tag( $plugin, $tag ); + } + /** * Add or update a Plugin Release. * @@ -587,7 +603,7 @@ private function post_to_release_data( $release_post, $plugin ) { } /** - * Sync a stored release snapshot to the main plugin post. + * Sync stored release import data to the main plugin post. * * @param string|\WP_Post $plugin Plugin slug or post object. * @param string|\WP_Post $release_ref Release tag or release post. @@ -606,7 +622,7 @@ public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) } $release = $this->post_to_release_data( $release_post, $plugin ); - if ( empty( $release['plugin_snapshot'] ) || ! is_array( $release['plugin_snapshot'] ) ) { + if ( empty( $release['plugin'] ) || ! is_array( $release['plugin'] ) ) { return false; } @@ -625,7 +641,7 @@ public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) } /** - * Filters whether an imported release snapshot is ready to sync to the plugin post. + * Filters whether an imported release is ready to sync to the plugin post. * * Scanner integrations can return false here while a draft release is still being * verified, then call sync_pending_release() when the verification passes. @@ -650,7 +666,7 @@ public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) $this->add_release( $plugin, $release ); } - $result = $this->apply_plugin_snapshot( $plugin, $release['plugin_snapshot'] ); + $result = $this->apply_release_to_plugin( $plugin, $release_post, $release ); if ( ! $result || is_wp_error( $result ) ) { return false; } @@ -662,10 +678,10 @@ public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) $this->add_release( $plugin, $release ); $plugin = get_post( $plugin->ID ); - $snapshot = $release['plugin_snapshot']; + $release_plugin = $release['plugin']; /** - * Action that fires after a plugin import snapshot is synced to the plugin post. + * Action that fires after a plugin release is synced to the plugin post. * * @param \WP_Post $plugin The plugin updated. * @param string $stable_tag The new stable tag for the plugin. @@ -677,27 +693,27 @@ public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) do_action( 'wporg_plugins_imported', $plugin, - $snapshot['stable_tag'] ?? $release['tag'], - $snapshot['old_stable_tag'] ?? '', - $snapshot['changed_tags'] ?? array(), - $snapshot['svn_revision'] ?? 0, - $snapshot['warnings'] ?? array() + $release_plugin['stable_tag'] ?? $release['tag'], + $release_plugin['old_stable_tag'] ?? '', + $release_plugin['changed_tags'] ?? array(), + $release_plugin['svn_revision'] ?? 0, + $release_plugin['warnings'] ?? array() ); /** - * Fires after a release CPT snapshot is synced to its plugin post. + * Fires after a release CPT import is synced to its plugin post. * * @param \WP_Post $plugin The plugin updated. * @param array $release Release data. - * @param array $snapshot Stored plugin snapshot. + * @param array $release_plugin Stored plugin data. */ - do_action( 'wporg_plugins_release_synced', $plugin, $release, $snapshot ); + do_action( 'wporg_plugins_release_synced', $plugin, $release, $release_plugin ); return true; } /** - * Sync the newest pending release snapshot for a plugin. + * Sync the newest pending release import for a plugin. * * @param string|\WP_Post $plugin Plugin slug or post object. * @param bool $force Whether to bypass cooldown/readiness checks. @@ -751,7 +767,7 @@ public function get_release_time( $plugin, $release ) { } /** - * Get the time when a release snapshot may sync to the plugin post. + * Get the time when a release import may sync to the plugin post. * * @param string|\WP_Post $plugin Plugin slug or post object. * @param array $release Release data. @@ -763,7 +779,7 @@ public function get_release_sync_after( $plugin, $release ) { return 0; } - if ( isset( $release['plugin_snapshot']['is_new_version'] ) && ! $release['plugin_snapshot']['is_new_version'] ) { + if ( isset( $release['plugin']['is_new_version'] ) && ! $release['plugin']['is_new_version'] ) { return 0; } @@ -771,7 +787,7 @@ public function get_release_sync_after( $plugin, $release ) { } /** - * Query the newest pending release snapshot. + * Query the newest pending release import. * * @param \WP_Post $plugin Plugin post object. * @param bool $force Whether sync_after should be ignored. @@ -807,20 +823,23 @@ private function get_pending_release_post( $plugin, $force = false ) { } /** - * Apply a stored plugin snapshot to the main plugin post. + * Apply a release CPT import to the main plugin post. * - * @param \WP_Post $plugin Plugin post object. - * @param array $snapshot Release plugin snapshot. + * @param \WP_Post $plugin Plugin post object. + * @param \WP_Post $release_post Release post object. + * @param array $release Release data. * @return bool|\WP_Error */ - private function apply_plugin_snapshot( $plugin, $snapshot ) { - if ( ! empty( $snapshot['post'] ) && is_array( $snapshot['post'] ) ) { + private function apply_release_to_plugin( $plugin, $release_post, $release ) { + $release_plugin = $release['plugin']; + + if ( ! empty( $release_plugin['post'] ) && is_array( $release_plugin['post'] ) ) { $post = array_merge( array( 'ID' => $plugin->ID, 'post_type' => 'plugin', ), - $snapshot['post'] + $release_plugin['post'] ); $updated = wp_update_post( $post, true ); @@ -829,15 +848,20 @@ private function apply_plugin_snapshot( $plugin, $snapshot ) { } } - if ( ! empty( $snapshot['default_categories'] ) && ! wp_get_object_terms( $plugin->ID, 'plugin_category', array( 'fields' => 'ids' ) ) ) { - wp_set_object_terms( $plugin->ID, $snapshot['default_categories'], 'plugin_category' ); + $default_categories = wp_get_object_terms( $release_post->ID, 'plugin_category', array( 'fields' => 'ids' ) ); + $default_categories = is_wp_error( $default_categories ) ? array() : $default_categories; + if ( $default_categories && ! wp_get_object_terms( $plugin->ID, 'plugin_category', array( 'fields' => 'ids' ) ) ) { + wp_set_object_terms( $plugin->ID, array_map( 'intval', $default_categories ), 'plugin_category' ); } - foreach ( (array) ( $snapshot['terms'] ?? array() ) as $taxonomy => $terms ) { - wp_set_object_terms( $plugin->ID, $terms, $taxonomy ); + foreach ( array( 'plugin_tags', 'plugin_contributors' ) as $taxonomy ) { + $term_ids = wp_get_object_terms( $release_post->ID, $taxonomy, array( 'fields' => 'ids' ) ); + $term_ids = is_wp_error( $term_ids ) ? array() : $term_ids; + wp_set_object_terms( $plugin->ID, array_map( 'intval', $term_ids ), $taxonomy ); } - $plugin_sections = (array) ( $snapshot['plugin_sections'] ?? array() ); + $plugin_sections = wp_get_object_terms( $release_post->ID, 'plugin_section', array( 'fields' => 'slugs' ) ); + $plugin_sections = is_wp_error( $plugin_sections ) ? array() : $plugin_sections; $managed_plugin_sections = array( 'adopt-me', 'dashboard-widgets', @@ -851,25 +875,81 @@ private function apply_plugin_snapshot( $plugin, $snapshot ) { } } - if ( ! empty( $snapshot['sync_committers'] ) ) { - Tools::sync_plugin_committers_with_taxonomy( $plugin->post_name ); + Tools::sync_plugin_committers_with_taxonomy( $plugin->post_name ); + + $this->copy_release_import_meta_to_plugin( $release_post, $plugin ); + + if ( metadata_exists( 'post', $release_post->ID, 'block_files' ) && has_term( 'block', 'plugin_section', $plugin->ID ) ) { + update_post_meta( $plugin->ID, 'block_files', get_post_meta( $release_post->ID, 'block_files', true ) ); + } else { + delete_post_meta( $plugin->ID, 'block_files' ); } - foreach ( (array) ( $snapshot['delete_meta'] ?? array() ) as $meta_key ) { - delete_post_meta( $plugin->ID, $meta_key ); + clean_post_cache( $plugin->ID ); + Jobs\API_Update_Updater::update_single_plugin( $plugin->post_name ); + Standalone\Plugins_Info_API::flush_plugin_information_cache( $plugin->post_name ); + + return true; + } + + /** + * Copy importer-owned release postmeta to the main plugin post. + * + * @param \WP_Post $release_post Release post object. + * @param \WP_Post $plugin Plugin post object. + */ + private function copy_release_import_meta_to_plugin( $release_post, $plugin ) { + $replace_meta_keys = array( + 'donate_link', + 'license', + 'license_uri', + 'upgrade_notice', + 'screenshots', + 'header_name', + 'header_plugin_uri', + 'header_author', + 'header_author_uri', + 'header_textdomain', + 'requires_plugins', + 'requires', + 'requires_php', + 'tested', + 'tagged_versions', + 'sections', + 'assets_screenshots', + 'assets_icons', + 'assets_banners', + 'last_updated', + 'assets_banners_color', + 'assets_blueprints', + 'stable_tag', + 'version', + 'tags', + ); + + foreach ( $replace_meta_keys as $meta_key ) { + if ( metadata_exists( 'post', $release_post->ID, $meta_key ) ) { + update_post_meta( $plugin->ID, $meta_key, wp_slash( get_post_meta( $release_post->ID, $meta_key, true ) ) ); + } else { + delete_post_meta( $plugin->ID, $meta_key ); + } } - foreach ( (array) ( $snapshot['meta'] ?? array() ) as $meta_key => $value ) { - update_post_meta( $plugin->ID, $meta_key, wp_slash( $value ) ); + foreach ( array( 'plugin_name_history', 'last_version', 'last_stable_tag', 'last_version_date', 'version_date' ) as $meta_key ) { + if ( metadata_exists( 'post', $release_post->ID, $meta_key ) ) { + update_post_meta( $plugin->ID, $meta_key, wp_slash( get_post_meta( $release_post->ID, $meta_key, true ) ) ); + } } - foreach ( (array) ( $snapshot['raw_meta'] ?? array() ) as $meta_key => $value ) { - update_post_meta( $plugin->ID, $meta_key, $value ); + if ( metadata_exists( 'post', $release_post->ID, 'all_blocks' ) ) { + update_post_meta( $plugin->ID, 'all_blocks', get_post_meta( $release_post->ID, 'all_blocks', true ) ); + } else { + delete_post_meta( $plugin->ID, 'all_blocks' ); } - foreach ( (array) ( $snapshot['multi_meta'] ?? array() ) as $meta_key => $values ) { + foreach ( array( 'block_name', 'block_title', 'dashboard_widget_name' ) as $meta_key ) { delete_post_meta( $plugin->ID, $meta_key ); - foreach ( (array) $values as $value ) { + foreach ( get_post_meta( $release_post->ID, $meta_key ) as $value ) { if ( '' === $value ) { continue; } @@ -877,21 +957,9 @@ private function apply_plugin_snapshot( $plugin, $snapshot ) { } } - foreach ( (array) ( $snapshot['add_meta_unique'] ?? array() ) as $meta_key => $value ) { - add_post_meta( $plugin->ID, $meta_key, wp_slash( $value ), true ); - } - - if ( ! empty( $snapshot['block_files'] ) && has_term( 'block', 'plugin_section', $plugin->ID ) ) { - update_post_meta( $plugin->ID, 'block_files', $snapshot['block_files'] ); - } else { - delete_post_meta( $plugin->ID, 'block_files' ); + if ( metadata_exists( 'post', $release_post->ID, '_missing_blueprint_notice' ) ) { + add_post_meta( $plugin->ID, '_missing_blueprint_notice', get_post_meta( $release_post->ID, '_missing_blueprint_notice', true ), true ); } - - clean_post_cache( $plugin->ID ); - Jobs\API_Update_Updater::update_single_plugin( $plugin->post_name ); - Standalone\Plugins_Info_API::flush_plugin_information_cache( $plugin->post_name ); - - return true; } /** @@ -938,8 +1006,8 @@ private function normalize_release_data( $release, $plugin ) { $release['confirmed'] = (bool) $release['confirmed']; $release['release_delay'] = (int) $release['release_delay']; - if ( isset( $release['plugin_snapshot'] ) && ! is_array( $release['plugin_snapshot'] ) ) { - $release['plugin_snapshot'] = array(); + if ( isset( $release['plugin'] ) && ! is_array( $release['plugin'] ) ) { + $release['plugin'] = array(); } if ( isset( $release['sync_after'] ) ) { $release['sync_after'] = (int) $release['sync_after']; diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php index e9bfc87671..9852e1c613 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php @@ -365,6 +365,31 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk $current_version_date = get_post_meta( $plugin->ID, 'version_date', true ); $is_new_version = $version && $version !== $current_version; + $release_tag = $stable_tag; + if ( 'trunk' === $stable_tag && $is_new_version ) { + $release_tag = "trunk@{$version}"; + } + + $release_data = array( + 'tag' => $release_tag, + 'version' => $version, + 'committer' => array( $last_committer ), + 'revision' => array( $last_revision ), + 'post_status' => 'draft', + 'sync_status' => 'importing', + 'sync_after' => 0, + ); + + if ( ! Plugin_Directory::add_release( $plugin, $release_data ) ) { + throw new Exception( "Plugin release {$release_tag} could not be created." ); + } + + $release_post = Releases::instance()->get_release_post( $plugin, $release_tag ); + if ( ! $release_post ) { + throw new Exception( "Plugin release {$release_tag} not found." ); + } + $release_id = $release_post->ID; + $post = array( 'post_title' => $plugin->post_title, 'post_content' => trim( $content ) ?: $plugin->post_content, @@ -419,6 +444,10 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk $post['post_date_gmt'] = $post['post_date']; } + wp_set_object_terms( $release_id, Tag_To_Category::map( $readme->tags ), 'plugin_category' ); + wp_set_object_terms( $release_id, $readme->tags, 'plugin_tags' ); + wp_set_object_terms( $release_id, $readme->contributors, 'plugin_contributors' ); + // Update all readme meta $meta = array(); foreach ( $this->readme_fields as $readme_field ) { @@ -461,6 +490,8 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk // [ 'Plugin Name' => '1.2.3', 'Plugin New Name' => '4.5.6' ] $plugin_names[ $headers->Name ] = $headers->Version; $meta['plugin_name_history'] = $plugin_names; + } else { + delete_post_meta( $release_id, 'plugin_name_history' ); } $meta['requires_plugins'] = $requires_plugins; @@ -482,15 +513,14 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk } $meta['assets_banners_color'] = $banner_average_color; - $delete_meta = array(); - $add_meta_unique = array(); // Store the content of blueprint files, if they're available and valid. if ( isset( $assets['blueprint'] ) && count( $assets['blueprint'] ) > 0 ) { $meta['assets_blueprints'] = $assets['blueprint']; + delete_post_meta( $release_id, '_missing_blueprint_notice' ); } else { - $delete_meta[] = 'assets_blueprints'; + delete_post_meta( $release_id, 'assets_blueprints' ); // TODO: maybe if ( $touches_stable_tag )? - $add_meta_unique['_missing_blueprint_notice'] = 1; + add_post_meta( $release_id, '_missing_blueprint_notice', 1, true ); } $raw_meta = array(); @@ -508,7 +538,7 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk $multi_meta['block_title'][] = $block->title; } } else { - $delete_meta[] = 'all_blocks'; + delete_post_meta( $release_id, 'all_blocks' ); } $plugin_sections = array(); @@ -526,6 +556,7 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk $multi_meta['dashboard_widget_name'][] = $widget_name; } } + wp_set_object_terms( $release_id, $plugin_sections, 'plugin_section' ); // If we've got a new version, store the last version in the plugin meta. if ( $is_new_version ) { @@ -535,6 +566,10 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk // Keep the date of the last version change, this often differs from the last_updated/post_modified dates. $meta['version_date'] = current_time( 'mysql' ); + } else { + foreach ( array( 'last_version', 'last_stable_tag', 'last_version_date', 'version_date' ) as $meta_key ) { + delete_post_meta( $release_id, $meta_key ); + } } $meta['stable_tag'] = $stable_tag; @@ -542,61 +577,59 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk // Update the list of tags last, as it controls which ZIPs are present in the 'Previous versions' section and info API. $meta['tags'] = $tagged_versions; - $release_tag = $stable_tag; - if ( 'trunk' === $stable_tag && $is_new_version ) { - $release_tag = "trunk@{$version}"; + foreach ( $meta as $meta_key => $value ) { + update_post_meta( $release_id, $meta_key, wp_slash( $value ) ); } - Plugin_Directory::add_release( - $plugin, - array( - 'tag' => $release_tag, - 'version' => $version, - 'committer' => array( $last_committer ), - 'revision' => array( $last_revision ), - 'post_status' => 'draft', - 'sync_status' => 'pending', - 'sync_after' => 0, - 'plugin_snapshot' => array( - 'stable_tag' => $stable_tag, - 'old_stable_tag' => $current_stable_tag, - 'changed_tags' => array_values( $svn_changed_tags ), - 'svn_revision' => (int) $svn_revision_triggered, - 'warnings' => $this->warnings, - 'is_new_version' => (bool) $is_new_version, - 'post' => $post, - 'default_categories' => Tag_To_Category::map( $readme->tags ), - 'terms' => array( - 'plugin_tags' => $readme->tags, - 'plugin_contributors' => $readme->contributors, - ), - 'plugin_sections' => $plugin_sections, - 'sync_committers' => true, - 'meta' => $meta, - 'raw_meta' => $raw_meta, - 'delete_meta' => array_values( array_unique( $delete_meta ) ), - 'multi_meta' => $multi_meta, - 'add_meta_unique' => $add_meta_unique, - 'block_files' => $block_files, - ), - ) + foreach ( $raw_meta as $meta_key => $value ) { + update_post_meta( $release_id, $meta_key, $value ); + } + + foreach ( $multi_meta as $meta_key => $values ) { + delete_post_meta( $release_id, $meta_key ); + foreach ( $values as $value ) { + if ( '' === $value ) { + continue; + } + add_post_meta( $release_id, $meta_key, $value, false ); + } + } + + if ( count( $block_files ) ) { + update_post_meta( $release_id, 'block_files', $block_files ); + } else { + delete_post_meta( $release_id, 'block_files' ); + } + + $release_data['sync_status'] = 'pending'; + $release_data['plugin'] = array( + 'stable_tag' => $stable_tag, + 'old_stable_tag' => $current_stable_tag, + 'changed_tags' => array_values( $svn_changed_tags ), + 'svn_revision' => (int) $svn_revision_triggered, + 'warnings' => $this->warnings, + 'is_new_version' => (bool) $is_new_version, + 'post' => $post, ); + if ( ! Plugin_Directory::add_release( $plugin, $release_data ) ) { + throw new Exception( "Plugin release {$release_tag} could not be updated." ); + } $this->rebuild_affected_zips( $plugin_slug, $stable_tag, $current_stable_tag, $svn_changed_tags, $svn_revision_triggered ); $imported_release = Plugin_Directory::get_release( $plugin, $release_tag ); if ( $imported_release ) { /** - * Action that fires after a plugin import updates a draft release CPT snapshot. + * Action that fires after a plugin import updates a draft release CPT. * * Scanner integrations can use this to start verification before the release - * snapshot is synced to the main plugin post. + * import data is synced to the main plugin post. * - * @param \WP_Post $plugin The plugin being imported. - * @param array $release Release data. - * @param array $snapshot Stored plugin snapshot. + * @param \WP_Post $plugin The plugin being imported. + * @param array $release Release data. + * @param array $release_plugin Stored plugin data. */ - do_action( 'wporg_plugins_release_imported', $plugin, $imported_release, $imported_release['plugin_snapshot'] ?? array() ); + do_action( 'wporg_plugins_release_imported', $plugin, $imported_release, $imported_release['plugin'] ?? array() ); } Releases::instance()->sync_release_to_plugin( $plugin, $release_tag ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php index 73be947435..1b921e8942 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php @@ -262,7 +262,7 @@ public function test_get_release_keeps_trunk_version_fallback() { /** * Delayed imports remain draft releases and do not update the plugin post. */ - public function test_cooldown_keeps_import_snapshot_draft_until_release_time() { + public function test_cooldown_keeps_release_import_draft_until_release_time() { $plugin = $this->create_plugin( 'cooldown-release-cpt-test' ); update_post_meta( $plugin->ID, 'version', '1.0.0' ); @@ -275,12 +275,8 @@ public function test_cooldown_keeps_import_snapshot_draft_until_release_time() { 'release_delay' => HOUR_IN_SECONDS, 'post_status' => 'draft', 'sync_status' => 'pending', - 'plugin_snapshot' => array( + 'plugin' => array( 'is_new_version' => true, - 'meta' => array( - 'stable_tag' => '2.0.0', - 'version' => '2.0.0', - ), ), ) ); @@ -293,4 +289,56 @@ public function test_cooldown_keeps_import_snapshot_draft_until_release_time() { $this->assertSame( 'pending', $release['sync_status'] ); $this->assertGreaterThan( time(), $release['sync_after'] ); } + + /** + * Release post import data is synced to the main plugin post. + */ + public function test_release_import_data_syncs_to_plugin_post() { + $plugin = $this->create_plugin( 'release-import-sync-test' ); + update_post_meta( $plugin->ID, 'version', '1.0.0' ); + + Plugin_Directory::add_release( + $plugin, + array( + 'date' => time() - HOUR_IN_SECONDS, + 'tag' => '2.0.0', + 'version' => '2.0.0', + 'post_status' => 'draft', + 'sync_status' => 'pending', + 'plugin' => array( + 'is_new_version' => true, + 'stable_tag' => '2.0.0', + 'old_stable_tag' => '1.0.0', + 'changed_tags' => array( '2.0.0' ), + 'svn_revision' => 123, + 'warnings' => array(), + 'post' => array( + 'post_title' => 'Release Import Sync Test', + 'post_content' => 'Imported release content.', + 'post_excerpt' => 'Imported release excerpt.', + ), + ), + ) + ); + + $release_post = Releases::instance()->get_release_post( $plugin, '2.0.0' ); + $this->assertInstanceOf( WP_Post::class, $release_post ); + + update_post_meta( $release_post->ID, 'stable_tag', '2.0.0' ); + update_post_meta( $release_post->ID, 'version', '2.0.0' ); + update_post_meta( $release_post->ID, 'requires', '6.5' ); + add_post_meta( $release_post->ID, 'dashboard_widget_name', 'Release Widget', false ); + wp_set_object_terms( $release_post->ID, array( 'dashboard-widgets' ), 'plugin_section' ); + + $this->assertTrue( Releases::instance()->sync_release_to_plugin( $plugin, '2.0.0' ) ); + + $plugin = get_post( $plugin->ID ); + $this->assertSame( 'Release Import Sync Test', $plugin->post_title ); + $this->assertSame( 'Imported release content.', $plugin->post_content ); + $this->assertSame( '2.0.0', get_post_meta( $plugin->ID, 'stable_tag', true ) ); + $this->assertSame( '2.0.0', get_post_meta( $plugin->ID, 'version', true ) ); + $this->assertSame( '6.5', get_post_meta( $plugin->ID, 'requires', true ) ); + $this->assertSame( array( 'Release Widget' ), get_post_meta( $plugin->ID, 'dashboard_widget_name' ) ); + $this->assertTrue( has_term( 'dashboard-widgets', 'plugin_section', $plugin ) ); + } } From 6a24a276c209da0dbeea2c0229bf86348df00882 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 07:12:43 +0000 Subject: [PATCH 09/11] Plugin Directory: Supersede pending release imports --- .../plugin-directory/class-releases.php | 41 ++++++++++++++ .../plugin-directory/cli/class-import.php | 35 ++++-------- .../plugin-directory/tests/Releases_Test.php | 55 +++++++++++++++++-- 3 files changed, 100 insertions(+), 31 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php index 06d90a10c0..f8ac943897 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php @@ -676,6 +676,7 @@ public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) $release['sync_after'] = 0; $release['synced_at'] = time(); $this->add_release( $plugin, $release ); + $this->supersede_pending_release_imports( $plugin, $release_post->ID ); $plugin = get_post( $plugin->ID ); $release_plugin = $release['plugin']; @@ -822,6 +823,46 @@ private function get_pending_release_post( $plugin, $force = false ) { return null; } + /** + * Mark older pending release imports as superseded after one release syncs. + * + * @param \WP_Post $plugin Plugin post object. + * @param int $synced_release_id Release post ID that just synced. + */ + private function supersede_pending_release_imports( $plugin, $synced_release_id ) { + $this->ensure_post_type(); + + // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value + $posts = get_posts( + array( + 'post_type' => self::POST_TYPE, + 'posts_per_page' => -1, + 'post_parent' => $plugin->ID, + 'post_status' => 'draft', + 'meta_key' => 'release_sync_status', + 'meta_value' => 'pending', + 'suppress_filters' => true, + ) + ); + // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value + + foreach ( $posts as $post ) { + if ( (int) $post->ID === (int) $synced_release_id ) { + continue; + } + + $release = $this->post_to_release_data( $post, $plugin ); + if ( empty( $release['plugin'] ) || ! is_array( $release['plugin'] ) ) { + continue; + } + + $release['post_status'] = 'draft'; + $release['sync_status'] = 'superseded'; + $release['sync_after'] = 0; + $this->add_release( $plugin, $release ); + } + } + /** * Apply a release CPT import to the main plugin post. * diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php index 9852e1c613..45ccde201e 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php @@ -523,22 +523,20 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk add_post_meta( $release_id, '_missing_blueprint_notice', 1, true ); } - $raw_meta = array(); - $multi_meta = array( - 'block_name' => array(), - 'block_title' => array(), - 'dashboard_widget_name' => array(), - ); - // Store the block data, if known if ( count( $blocks ) ) { - $raw_meta['all_blocks'] = $blocks; + update_post_meta( $release_id, 'all_blocks', $blocks ); + delete_post_meta( $release_id, 'block_name' ); + delete_post_meta( $release_id, 'block_title' ); + foreach ( $blocks as $block ) { - $multi_meta['block_name'][] = $block->name; - $multi_meta['block_title'][] = $block->title; + add_post_meta( $release_id, 'block_name', $block->name, false ); + add_post_meta( $release_id, 'block_title', $block->title, false ); } } else { delete_post_meta( $release_id, 'all_blocks' ); + delete_post_meta( $release_id, 'block_name' ); + delete_post_meta( $release_id, 'block_title' ); } $plugin_sections = array(); @@ -547,13 +545,14 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk } // Dashboard widgets: assign the section term and store widget names. + delete_post_meta( $release_id, 'dashboard_widget_name' ); if ( $dashboard_widgets ) { $plugin_sections[] = 'dashboard-widgets'; foreach ( $dashboard_widgets as $widget_name ) { if ( '' === $widget_name ) { continue; } - $multi_meta['dashboard_widget_name'][] = $widget_name; + add_post_meta( $release_id, 'dashboard_widget_name', $widget_name, false ); } } wp_set_object_terms( $release_id, $plugin_sections, 'plugin_section' ); @@ -581,20 +580,6 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk update_post_meta( $release_id, $meta_key, wp_slash( $value ) ); } - foreach ( $raw_meta as $meta_key => $value ) { - update_post_meta( $release_id, $meta_key, $value ); - } - - foreach ( $multi_meta as $meta_key => $values ) { - delete_post_meta( $release_id, $meta_key ); - foreach ( $values as $value ) { - if ( '' === $value ) { - continue; - } - add_post_meta( $release_id, $meta_key, $value, false ); - } - } - if ( count( $block_files ) ) { update_post_meta( $release_id, 'block_files', $block_files ); } else { diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php index 1b921e8942..6349f42507 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php @@ -300,12 +300,13 @@ public function test_release_import_data_syncs_to_plugin_post() { Plugin_Directory::add_release( $plugin, array( - 'date' => time() - HOUR_IN_SECONDS, - 'tag' => '2.0.0', - 'version' => '2.0.0', - 'post_status' => 'draft', - 'sync_status' => 'pending', - 'plugin' => array( + 'date' => time() - HOUR_IN_SECONDS, + 'tag' => '2.0.0', + 'version' => '2.0.0', + 'release_delay' => 0, + 'post_status' => 'draft', + 'sync_status' => 'pending', + 'plugin' => array( 'is_new_version' => true, 'stable_tag' => '2.0.0', 'old_stable_tag' => '1.0.0', @@ -341,4 +342,46 @@ public function test_release_import_data_syncs_to_plugin_post() { $this->assertSame( array( 'Release Widget' ), get_post_meta( $plugin->ID, 'dashboard_widget_name' ) ); $this->assertTrue( has_term( 'dashboard-widgets', 'plugin_section', $plugin ) ); } + + /** + * Syncing one release import supersedes older pending imports. + */ + public function test_sync_supersedes_other_pending_release_imports() { + $plugin = $this->create_plugin( 'superseded-release-import-test' ); + update_post_meta( $plugin->ID, 'version', '1.0.0' ); + + foreach ( array( '2.0.0', '3.0.0' ) as $version ) { + Plugin_Directory::add_release( + $plugin, + array( + 'date' => time() - HOUR_IN_SECONDS, + 'tag' => $version, + 'version' => $version, + 'release_delay' => 0, + 'post_status' => 'draft', + 'sync_status' => 'pending', + 'plugin' => array( + 'is_new_version' => true, + 'stable_tag' => $version, + 'old_stable_tag' => '1.0.0', + 'post' => array( + 'post_title' => "Release {$version}", + ), + ), + ) + ); + + $release_post = Releases::instance()->get_release_post( $plugin, $version ); + update_post_meta( $release_post->ID, 'stable_tag', $version ); + update_post_meta( $release_post->ID, 'version', $version ); + } + + $this->assertTrue( Releases::instance()->sync_release_to_plugin( $plugin, '3.0.0' ) ); + + $superseded_release = Plugin_Directory::get_release( $plugin, '2.0.0' ); + $this->assertSame( 'superseded', $superseded_release['sync_status'] ); + $this->assertSame( '3.0.0', get_post_meta( $plugin->ID, 'version', true ) ); + $this->assertFalse( Releases::instance()->sync_pending_release( $plugin, true ) ); + $this->assertSame( '3.0.0', get_post_meta( $plugin->ID, 'version', true ) ); + } } From a8b20c0512cc1750d469e0e335306bf30163f9e2 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 07:15:05 +0000 Subject: [PATCH 10/11] Plugin Directory: Escape release import exceptions --- .../plugins/plugin-directory/cli/class-import.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php index 45ccde201e..086527a2fe 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php @@ -381,12 +381,12 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk ); if ( ! Plugin_Directory::add_release( $plugin, $release_data ) ) { - throw new Exception( "Plugin release {$release_tag} could not be created." ); + throw new Exception( sprintf( 'Plugin release %s could not be created.', esc_html( $release_tag ) ) ); } $release_post = Releases::instance()->get_release_post( $plugin, $release_tag ); if ( ! $release_post ) { - throw new Exception( "Plugin release {$release_tag} not found." ); + throw new Exception( sprintf( 'Plugin release %s not found.', esc_html( $release_tag ) ) ); } $release_id = $release_post->ID; @@ -597,7 +597,7 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk 'post' => $post, ); if ( ! Plugin_Directory::add_release( $plugin, $release_data ) ) { - throw new Exception( "Plugin release {$release_tag} could not be updated." ); + throw new Exception( sprintf( 'Plugin release %s could not be updated.', esc_html( $release_tag ) ) ); } $this->rebuild_affected_zips( $plugin_slug, $stable_tag, $current_stable_tag, $svn_changed_tags, $svn_revision_triggered ); From 988322f327f6cc9818fcc589c9d03ead5e062deb Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 5 Jun 2026 07:18:15 +0000 Subject: [PATCH 11/11] Plugin Directory: Guard release committer sync --- .../wp-content/plugins/plugin-directory/class-releases.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php index f8ac943897..87144a8ff0 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php @@ -916,7 +916,9 @@ private function apply_release_to_plugin( $plugin, $release_post, $release ) { } } - Tools::sync_plugin_committers_with_taxonomy( $plugin->post_name ); + if ( defined( __NAMESPACE__ . '\PLUGINS_TABLE_PREFIX' ) || defined( 'PLUGINS_TABLE_PREFIX' ) ) { + Tools::sync_plugin_committers_with_taxonomy( $plugin->post_name ); + } $this->copy_release_import_meta_to_plugin( $release_post, $plugin );