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 1033e576551987e51655551debaced170695149b Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Wed, 10 Jun 2026 14:40:49 +1000 Subject: [PATCH 02/11] Plugin Directory: Move release backfill to a migration script Releases no longer backfill CPT records lazily on read/write. Backfill is now driven explicitly by bin/backfill-release-cpts.php, which walks every plugin and calls maybe_backfill_releases(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bin/backfill-release-cpts.php | 102 ++++++++++++++++++ .../plugin-directory/class-plugin-release.php | 31 +----- .../tests/Plugin_Release_Test.php | 33 +++++- 3 files changed, 137 insertions(+), 29 deletions(-) create mode 100644 wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php new file mode 100644 index 0000000000..5bcd747753 --- /dev/null +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php @@ -0,0 +1,102 @@ +get_col( + $wpdb->prepare( + "SELECT post_name FROM {$wpdb->posts} WHERE post_type = 'plugin' AND post_name = %s", + $opts['plugin'] + ) + ); +} else { + // All plugins. + $slugs = $wpdb->get_col( "SELECT post_name FROM {$wpdb->posts} WHERE post_type = 'plugin'" ); +} + +if ( ! $slugs ) { + fwrite( STDERR, "Error! The plugin(s) could not be located.\n" ); + die(); +} + +$releases = Plugin_Release::instance(); +$total = count( $slugs ); + +foreach ( $slugs as $i => $slug ) { + $result = $releases->maybe_backfill_releases( $slug, $force ); + + if ( is_wp_error( $result ) ) { + $message = 'error: ' . $result->get_error_message(); + } elseif ( false === $result ) { + $message = 'skipped (already migrated)'; + } else { + $message = count( $result ) . ' release(s) backfilled'; + } + + fwrite( STDOUT, sprintf( "%d/%d\t%s\t%s\n", $i + 1, $total, $slug, $message ) ); + + clear_memory_caches(); +} + +/** + * Reset in-memory caches between plugins to keep memory usage flat. + */ +function clear_memory_caches() { + global $wpdb, $wp_object_cache; + + $wpdb->queries = []; + + if ( is_object( $wp_object_cache ) ) { + $wp_object_cache->cache = []; + $wp_object_cache->group_ops = []; + $wp_object_cache->memcache_debug = []; + $wp_object_cache->stats = [ + 'get' => 0, + 'delete' => 0, + 'add' => 0, + ]; + } +} 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 index 313d2a4782..db367775c3 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-plugin-release.php @@ -22,13 +22,6 @@ class Plugin_Release { const DATA_META_KEY = 'release_data'; const BACKFILLED_META = '_releases_cpt_backfilled'; - /** - * Tracks lazy backfill so add_release() can avoid recursive backfills. - * - * @var bool - */ - private $backfilling = false; - /** * Fetch the instance of the Plugin_Release class. * @@ -98,10 +91,6 @@ public function get_releases( $plugin ) { } $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 ) { @@ -134,6 +123,9 @@ public function has_releases( $plugin ) { /** * Backfill release CPTs from legacy release metadata, tags metadata, or SVN. * + * This is intended to be driven by the one-off migration script + * (bin/backfill-release-cpts.php) rather than run lazily on reads or writes. + * * @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. @@ -161,13 +153,8 @@ public function maybe_backfill_releases( $plugin, $force = false ) { $releases = $this->get_prefill_releases( $plugin ); } - $this->backfilling = true; - try { - foreach ( $releases as $release ) { - $this->add_release( $plugin, $release ); - } - } finally { - $this->backfilling = false; + foreach ( $releases as $release ) { + $this->add_release( $plugin, $release ); } update_post_meta( $plugin->ID, self::BACKFILLED_META, time() ); @@ -271,10 +258,6 @@ public function add_release( $plugin, $data ) { 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 ); @@ -316,10 +299,6 @@ public function remove_release( $plugin, $tag ) { 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; 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 index 04c120fe4f..1d2f2c2860 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/Plugin_Release_Test.php @@ -92,10 +92,34 @@ public function test_add_release_writes_cpt_and_preserves_legacy_shape() { } /** - * Legacy release metadata is lazily backfilled to release CPTs. + * Legacy release metadata is not backfilled on read; the migration handles it. */ - public function test_legacy_releases_meta_is_lazily_backfilled_to_cpts() { + public function test_legacy_releases_meta_is_not_backfilled_on_read() { $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, + ), + ); + update_post_meta( $plugin->ID, 'releases', $legacy ); + + // Reads no longer trigger an automatic backfill. + $this->assertSame( array(), Plugin_Directory::get_releases( $plugin ) ); + $this->assertCount( 0, $this->get_release_posts( $plugin ) ); + } + + /** + * The migration backfills legacy release metadata to release CPTs. + */ + public function test_migration_backfills_legacy_releases_meta_to_cpts() { + $plugin = $this->create_plugin( 'migrate-release-cpt-test' ); $legacy = array( array( 'date' => 1700000000, @@ -120,6 +144,8 @@ public function test_legacy_releases_meta_is_lazily_backfilled_to_cpts() { ); update_post_meta( $plugin->ID, 'releases', $legacy ); + Plugin_Release::instance()->maybe_backfill_releases( $plugin ); + $releases = Plugin_Directory::get_releases( $plugin ); $this->assertCount( 2, $releases ); @@ -128,7 +154,8 @@ public function test_legacy_releases_meta_is_lazily_backfilled_to_cpts() { $this->assertSame( 2 * HOUR_IN_SECONDS, $releases[0]['release_delay'] ); $this->assertCount( 2, $this->get_release_posts( $plugin ) ); - Plugin_Directory::get_releases( $plugin ); + // Re-running the migration is idempotent and does not duplicate CPTs. + Plugin_Release::instance()->maybe_backfill_releases( $plugin ); $this->assertCount( 2, $this->get_release_posts( $plugin ), 'Backfill should not duplicate release CPTs.' ); } From 98a69aaa2239c6dec791f82322aed4a1386764f3 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Wed, 10 Jun 2026 14:45:07 +1000 Subject: [PATCH 03/11] Plugin Directory: Use plain PHPUnit TestCase for release tests Avoid the WP_UnitTestCase annotation path that is incompatible with the CI PHPUnit version, and clean up created posts manually in tearDown(). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../tests/Plugin_Release_Test.php | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) 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 index 1d2f2c2860..ab17e166d4 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/Plugin_Release_Test.php @@ -5,6 +5,7 @@ * @package WordPressdotorg\Plugin_Directory\Tests */ +use PHPUnit\Framework\TestCase; use WordPressdotorg\Plugin_Directory\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Plugin_Release; @@ -13,7 +14,39 @@ * * @group releases */ -class Plugin_Release_Test extends WP_UnitTestCase { +class Plugin_Release_Test extends TestCase { + + /** + * Plugin posts created by a test. + * + * @var WP_Post[] + */ + private $plugins = array(); + + /** + * Clean up posts created by tests. + */ + protected function tearDown(): void { + foreach ( $this->plugins as $plugin ) { + $release_posts = get_posts( + array( + 'post_type' => Plugin_Release::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,18 +55,26 @@ class Plugin_Release_Test extends WP_UnitTestCase { * @return WP_Post */ private function create_plugin( $slug = 'release-cpt-test' ) { - $post_id = self::factory()->post->create( + $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, ) ); update_post_meta( $post_id, 'releases', array() ); - return get_post( $post_id ); + $plugin = get_post( $post_id ); + $this->plugins[] = $plugin; + + return $plugin; } /** From b39145cabd56d72991357661dca9c215fa7d3ad1 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 11 Jun 2026 14:19:08 +1000 Subject: [PATCH 04/11] Plugin Directory: Rename Plugin_Release to Release, backfill lazily on writes. Renames the Plugin_Release class to Release and strips the redundant release suffixes from its methods (add/remove/get/get_all/has/maybe_backfill). The legacy Plugin_Directory::{add,get,remove}_release() wrappers are unchanged. add() and remove() now run maybe_backfill() first, so plugins not yet migrated are converted on first write. The backfilled marker is set before the backfill runs to avoid two concurrent migrations. Co-Authored-By: Claude Fable 5 --- .../bin/backfill-release-cpts.php | 6 +- .../class-plugin-directory.php | 12 +-- ...s-plugin-release.php => class-release.php} | 87 ++++++++++--------- ...ugin_Release_Test.php => Release_Test.php} | 50 +++++++++-- 4 files changed, 99 insertions(+), 56 deletions(-) rename wordpress.org/public_html/wp-content/plugins/plugin-directory/{class-plugin-release.php => class-release.php} (86%) rename wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/{Plugin_Release_Test.php => Release_Test.php} (83%) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php index 5bcd747753..d05eef9fdd 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php @@ -12,7 +12,7 @@ namespace WordPressdotorg\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Plugin_Directory; -use WordPressdotorg\Plugin_Directory\Plugin_Release; +use WordPressdotorg\Plugin_Directory\Release; // This script should only be called in a CLI environment. if ( 'cli' != php_sapi_name() ) { @@ -62,11 +62,11 @@ die(); } -$releases = Plugin_Release::instance(); +$releases = Release::instance(); $total = count( $slugs ); foreach ( $slugs as $i => $slug ) { - $result = $releases->maybe_backfill_releases( $slug, $force ); + $result = $releases->maybe_backfill( $slug, $force ); if ( is_wp_error( $result ) ) { $message = 'error: ' . $result->get_error_message(); 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 bd4fb41d59..7ffcb4ad24 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(); + Release::instance(); // Add upload size limit to limit plugin ZIP file uploads to 10M add_filter( 'upload_size_limit', function( $size ) { @@ -1607,7 +1607,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 Release::instance()->get_all( $plugin ); } /** @@ -1617,7 +1617,7 @@ public static function get_releases( $plugin ) { * @return array */ public static function prefill_releases_meta( $plugin ) { - Plugin_Release::instance()->maybe_backfill_releases( $plugin, true ); + Release::instance()->maybe_backfill( $plugin, true ); return self::get_releases( $plugin ); } @@ -1630,7 +1630,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 Release::instance()->get( $plugin, $tag ); } /** @@ -1641,7 +1641,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 Release::instance()->add( $plugin, $data ); } /** @@ -1652,7 +1652,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 Release::instance()->remove( $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-release.php similarity index 86% 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-release.php index db367775c3..9ceef25503 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-release.php @@ -16,25 +16,25 @@ * * @package WordPressdotorg\Plugin_Directory */ -class Plugin_Release { +class Release { const POST_TYPE = 'plugin_release'; const DATA_META_KEY = 'release_data'; const BACKFILLED_META = '_releases_cpt_backfilled'; /** - * Fetch the instance of the Plugin_Release class. + * Fetch the instance of the Release class. * - * @return Plugin_Release + * @return Release */ public static function instance() { static $instance = null; - return ! is_null( $instance ) ? $instance : $instance = new Plugin_Release(); + return ! is_null( $instance ) ? $instance : $instance = new Release(); } /** - * Plugin_Release constructor. + * Release constructor. */ private function __construct() { add_action( 'init', array( $this, 'register_post_type' ) ); @@ -84,17 +84,17 @@ private function ensure_post_type() { * @param string|\WP_Post $plugin Plugin slug or post object. * @return array */ - public function get_releases( $plugin ) { + public function get_all( $plugin ) { $plugin = Plugin_Directory::get_plugin_post( $plugin ); if ( ! $plugin ) { return array(); } - $release_posts = $this->get_release_posts( $plugin ); + $release_posts = $this->get_posts( $plugin ); $releases = array_map( function ( $release_post ) use ( $plugin ) { - return $this->post_to_release_data( $release_post, $plugin ); + return $this->post_to_data( $release_post, $plugin ); }, $release_posts ); @@ -115,29 +115,29 @@ function ( $a, $b ) { * @param string|\WP_Post $plugin Plugin slug or post object. * @return bool */ - public function has_releases( $plugin ) { + public function has( $plugin ) { $plugin = Plugin_Directory::get_plugin_post( $plugin ); - return $plugin && (bool) $this->get_release_posts( $plugin, 1 ); + return $plugin && (bool) $this->get_posts( $plugin, 1 ); } /** * Backfill release CPTs from legacy release metadata, tags metadata, or SVN. * - * This is intended to be driven by the one-off migration script - * (bin/backfill-release-cpts.php) rather than run lazily on reads or writes. + * This is driven by the one-off migration script (bin/backfill-release-cpts.php) + * and runs lazily before writes (add() / remove()), but not on reads. * * @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 ) { + public function maybe_backfill( $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 ) ) { + if ( $this->has( $plugin ) ) { return false; } @@ -150,15 +150,16 @@ public function maybe_backfill_releases( $plugin, $force = false ) { if ( is_array( $legacy_releases ) ) { $releases = $legacy_releases; } else { - $releases = $this->get_prefill_releases( $plugin ); + $releases = $this->get_prefill_data( $plugin ); } + // Mark as migrated up-front, so a concurrent (or recursive, via add()) backfill bails early. + update_post_meta( $plugin->ID, self::BACKFILLED_META, time() ); + foreach ( $releases as $release ) { - $this->add_release( $plugin, $release ); + $this->add( $plugin, $release ); } - update_post_meta( $plugin->ID, self::BACKFILLED_META, time() ); - return $releases; } @@ -168,7 +169,7 @@ public function maybe_backfill_releases( $plugin, $force = false ) { * @param \WP_Post $plugin Plugin post object. * @return array */ - private function get_prefill_releases( $plugin ) { + private function get_prefill_data( $plugin ) { $releases = array(); $tags = get_post_meta( $plugin->ID, 'tags', true ); @@ -219,8 +220,8 @@ private function get_prefill_releases( $plugin ) { * @param string $tag Plugin version / release tag. * @return array|bool */ - public function get_release( $plugin, $tag ) { - $releases = $this->get_releases( $plugin ); + public function get( $plugin, $tag ) { + $releases = $this->get_all( $plugin ); $filtered = wp_list_filter( $releases, compact( 'tag' ) ); if ( $filtered ) { @@ -248,7 +249,7 @@ public function get_release( $plugin, $tag ) { * @param array $data Release data. * @return bool */ - public function add_release( $plugin, $data ) { + public function add( $plugin, $data ) { if ( ! isset( $data['tag'] ) ) { return false; } @@ -258,8 +259,10 @@ public function add_release( $plugin, $data ) { return false; } - $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 ); + $this->maybe_backfill( $plugin ); + + $existing_post = $this->get_post_by_tag( $plugin, $data['tag'] ); + $release = $existing_post ? $this->post_to_data( $existing_post, $plugin ) : $this->get_default_data( $plugin ); foreach ( $data as $key => $value ) { if ( isset( $release[ $key ] ) && is_array( $release[ $key ] ) ) { @@ -274,14 +277,14 @@ public function add_release( $plugin, $data ) { } unset( $release['undo-discard'] ); - $release = $this->normalize_release_data( $release, $plugin ); + $release = $this->normalize_data( $release, $plugin ); - $release_id = $this->save_release_post( $plugin, $release, $existing_post ); + $release_id = $this->save_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 ); + $this->delete_duplicate_posts( $plugin, $release['tag'], $release_id ); return true; } @@ -293,18 +296,20 @@ public function add_release( $plugin, $data ) { * @param string $tag Release tag. * @return bool */ - public function remove_release( $plugin, $tag ) { + public function remove( $plugin, $tag ) { $plugin = Plugin_Directory::get_plugin_post( $plugin ); if ( ! $plugin ) { return false; } - $release_post = $this->get_release_post_by_tag( $plugin, $tag ); + $this->maybe_backfill( $plugin ); + + $release_post = $this->get_post_by_tag( $plugin, $tag ); if ( ! $release_post ) { return false; } - $release = $this->post_to_release_data( $release_post, $plugin ); + $release = $this->post_to_data( $release_post, $plugin ); if ( ! empty( $release['confirmed'] ) ) { return false; } @@ -319,7 +324,7 @@ public function remove_release( $plugin, $tag ) { * @param int $limit Maximum number of posts. * @return \WP_Post[] */ - private function get_release_posts( $plugin, $limit = -1 ) { + private function get_posts( $plugin, $limit = -1 ) { $this->ensure_post_type(); return get_posts( @@ -342,7 +347,7 @@ private function get_release_posts( $plugin, $limit = -1 ) { * @param string $tag Release tag. * @return \WP_Post|null */ - private function get_release_post_by_tag( $plugin, $tag ) { + private function get_post_by_tag( $plugin, $tag ) { $this->ensure_post_type(); $posts = get_posts( @@ -370,7 +375,7 @@ private function get_release_post_by_tag( $plugin, $tag ) { * @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 ) { + private function save_post( $plugin, $release, $existing_post = null ) { $this->ensure_post_type(); $title = $release['version'] ? $release['version'] : $release['tag']; @@ -404,7 +409,7 @@ private function save_release_post( $plugin, $release, $existing_post = null ) { return $release_id; } - $this->update_release_meta( $release_id, $release ); + $this->update_meta( $release_id, $release ); return $release_id; } @@ -415,7 +420,7 @@ private function save_release_post( $plugin, $release, $existing_post = null ) { * @param int $release_id Release post ID. * @param array $release Release data. */ - private function update_release_meta( $release_id, $release ) { + private function update_meta( $release_id, $release ) { update_post_meta( $release_id, self::DATA_META_KEY, $release ); $mirrored_fields = array( @@ -457,7 +462,7 @@ private function update_release_meta( $release_id, $release ) { * @param string $tag Release tag. * @param int $release_id Release post that should remain. */ - private function delete_duplicate_release_posts( $plugin, $tag, $release_id ) { + private function delete_duplicate_posts( $plugin, $tag, $release_id ) { $posts = get_posts( array( 'post_type' => self::POST_TYPE, @@ -485,7 +490,7 @@ private function delete_duplicate_release_posts( $plugin, $tag, $release_id ) { * @param \WP_Post $plugin Plugin post object. * @return array */ - private function post_to_release_data( $release_post, $plugin ) { + private function post_to_data( $release_post, $plugin ) { $data = get_post_meta( $release_post->ID, self::DATA_META_KEY, true ); $data = is_array( $data ) ? $data : array(); @@ -534,7 +539,7 @@ private function post_to_release_data( $release_post, $plugin ) { $data['version'] = $version ? $version : $release_post->post_title; } - return $this->normalize_release_data( $data, $plugin ); + return $this->normalize_data( $data, $plugin ); } /** @@ -543,7 +548,7 @@ private function post_to_release_data( $release_post, $plugin ) { * @param \WP_Post $plugin Plugin post object. * @return array */ - private function get_default_release_data( $plugin ) { + private function get_default_data( $plugin ) { return array( 'date' => time(), 'tag' => '', @@ -566,8 +571,8 @@ private function get_default_release_data( $plugin ) { * @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 ) ); + private function normalize_data( $release, $plugin ) { + $release = wp_parse_args( $release, $this->get_default_data( $plugin ) ); $release['date'] = (int) $release['date']; $release['tag'] = (string) $release['tag']; 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/Release_Test.php similarity index 83% 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/Release_Test.php index ab17e166d4..681d9222fb 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/Release_Test.php @@ -7,14 +7,14 @@ use PHPUnit\Framework\TestCase; use WordPressdotorg\Plugin_Directory\Plugin_Directory; -use WordPressdotorg\Plugin_Directory\Plugin_Release; +use WordPressdotorg\Plugin_Directory\Release; /** * Release CPT storage tests. * * @group releases */ -class Plugin_Release_Test extends TestCase { +class Release_Test extends TestCase { /** * Plugin posts created by a test. @@ -30,7 +30,7 @@ protected function tearDown(): void { foreach ( $this->plugins as $plugin ) { $release_posts = get_posts( array( - 'post_type' => Plugin_Release::POST_TYPE, + 'post_type' => Release::POST_TYPE, 'post_parent' => $plugin->ID, 'post_status' => 'any', 'posts_per_page' => -1, @@ -86,7 +86,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' => Release::POST_TYPE, 'post_parent' => $plugin->ID, 'post_status' => 'any', 'posts_per_page' => -1, @@ -156,6 +156,44 @@ public function test_legacy_releases_meta_is_not_backfilled_on_read() { $this->assertCount( 0, $this->get_release_posts( $plugin ) ); } + /** + * Writes backfill legacy release metadata to release CPTs first. + */ + public function test_writes_backfill_legacy_releases_meta_first() { + $plugin = $this->create_plugin( 'write-backfill-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, + 'confirmed' => false, + 'confirmations_required' => 1, + 'release_delay' => 0, + ), + ); + update_post_meta( $plugin->ID, 'releases', $legacy ); + + // Removing the unconfirmed legacy release backfills all releases to CPTs first. + $this->assertTrue( Plugin_Directory::remove_release( $plugin, '1.1.0' ) ); + + $releases = Plugin_Directory::get_releases( $plugin ); + $this->assertCount( 1, $releases ); + $this->assertSame( '1.0.0', $releases[0]['tag'] ); + } + /** * The migration backfills legacy release metadata to release CPTs. */ @@ -185,7 +223,7 @@ public function test_migration_backfills_legacy_releases_meta_to_cpts() { ); update_post_meta( $plugin->ID, 'releases', $legacy ); - Plugin_Release::instance()->maybe_backfill_releases( $plugin ); + Release::instance()->maybe_backfill( $plugin ); $releases = Plugin_Directory::get_releases( $plugin ); @@ -196,7 +234,7 @@ public function test_migration_backfills_legacy_releases_meta_to_cpts() { $this->assertCount( 2, $this->get_release_posts( $plugin ) ); // Re-running the migration is idempotent and does not duplicate CPTs. - Plugin_Release::instance()->maybe_backfill_releases( $plugin ); + Release::instance()->maybe_backfill( $plugin ); $this->assertCount( 2, $this->get_release_posts( $plugin ), 'Backfill should not duplicate release CPTs.' ); } From 8efe956f9d349b04ffe247c1a8f3accf6c0426d0 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 11 Jun 2026 14:23:44 +1000 Subject: [PATCH 05/11] Plugin Directory: Use wp_cache_flush_runtime() in the release backfill script. Co-Authored-By: Claude Fable 5 --- .../bin/backfill-release-cpts.php | 23 ++----------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php index d05eef9fdd..7ff3704752 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php @@ -78,25 +78,6 @@ fwrite( STDOUT, sprintf( "%d/%d\t%s\t%s\n", $i + 1, $total, $slug, $message ) ); - clear_memory_caches(); -} - -/** - * Reset in-memory caches between plugins to keep memory usage flat. - */ -function clear_memory_caches() { - global $wpdb, $wp_object_cache; - - $wpdb->queries = []; - - if ( is_object( $wp_object_cache ) ) { - $wp_object_cache->cache = []; - $wp_object_cache->group_ops = []; - $wp_object_cache->memcache_debug = []; - $wp_object_cache->stats = [ - 'get' => 0, - 'delete' => 0, - 'add' => 0, - ]; - } + // Reset in-memory caches between plugins to keep memory usage flat. + wp_cache_flush_runtime(); } From 23cb9ce0b76accab95b0225d91204507df57870b Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 11 Jun 2026 14:43:42 +1000 Subject: [PATCH 06/11] Plugin Directory: Store release fields as individual postmeta. Drops the release_data array blob; each release field is now stored as its own meta key, named exactly as in the legacy release array, with META_FIELDS as the authoritative field list for both reads and writes. Co-Authored-By: Claude Fable 5 --- .../plugin-directory/class-release.php | 106 +++++++----------- .../plugin-directory/tests/Release_Test.php | 2 +- 2 files changed, 39 insertions(+), 69 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php index 9ceef25503..48509347f4 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php @@ -19,9 +19,34 @@ class Release { const POST_TYPE = 'plugin_release'; - const DATA_META_KEY = 'release_data'; const BACKFILLED_META = '_releases_cpt_backfilled'; + /** + * Release array fields stored as postmeta on the release post. + */ + const META_FIELDS = array( + 'date', + 'tag', + 'version', + 'committer', + 'zips_built', + 'zips_built_from_revision', + 'confirmations', + 'confirmed', + 'confirmations_required', + 'revision', + 'revision_final', + 'revision_prior', + 'commit_log', + 'tested', + 'requires_php', + 'requires_wp', + 'requires_plugins', + 'discarded', + 'rollout_strategy', + 'release_delay', + ); + /** * Fetch the instance of the Release class. * @@ -356,7 +381,7 @@ private function get_post_by_tag( $plugin, $tag ) { '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_key' => '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', @@ -415,42 +440,17 @@ private function save_post( $plugin, $release, $existing_post = null ) { } /** - * Update full and mirrored release postmeta. + * Update the release postmeta fields. * * @param int $release_id Release post ID. * @param array $release Release data. */ private function update_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 ) { + foreach ( self::META_FIELDS as $field ) { if ( array_key_exists( $field, $release ) ) { - update_post_meta( $release_id, $meta_key, $release[ $field ] ); + update_post_meta( $release_id, $field, $release[ $field ] ); } else { - delete_post_meta( $release_id, $meta_key ); + delete_post_meta( $release_id, $field ); } } } @@ -469,7 +469,7 @@ private function delete_duplicate_posts( $plugin, $tag, $release_id ) { '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_key' => '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, @@ -491,39 +491,11 @@ private function delete_duplicate_posts( $plugin, $tag, $release_id ) { * @return array */ private function post_to_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; - } + $data = array(); - if ( metadata_exists( 'post', $release_post->ID, $meta_key ) ) { - $data[ $field ] = get_post_meta( $release_post->ID, $meta_key, true ); + foreach ( self::META_FIELDS as $field ) { + if ( metadata_exists( 'post', $release_post->ID, $field ) ) { + $data[ $field ] = get_post_meta( $release_post->ID, $field, true ); } } @@ -531,12 +503,10 @@ private function post_to_data( $release_post, $plugin ) { $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; + $data['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; + $data['version'] = $release_post->post_title; } return $this->normalize_data( $data, $plugin ); diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Test.php index 681d9222fb..01b103c0af 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Test.php @@ -121,7 +121,7 @@ public function test_add_release_writes_cpt_and_preserves_legacy_shape() { $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 ) ); + $this->assertSame( '1.0.0', get_post_meta( $release_posts[0]->ID, 'tag', true ) ); $release = Plugin_Directory::get_release( $plugin, '1.0.0' ); $this->assertSame( '1.0.0', $release['tag'] ); From 64c8a1e127b73551f6c95dc158274da87b305416 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 11 Jun 2026 14:55:39 +1000 Subject: [PATCH 07/11] Plugin Directory: Look up releases by tag directly, with a tag => ID cache. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get() now resolves via get_post_by_tag() — exact tag first, then the trunk@{version} fallback — instead of filtering all releases in PHP. get_post_by_tag() caches plugin-slug:tag => post ID, validating cached IDs on read so deleted or re-parented posts self-correct without explicit invalidation on the write paths. Co-Authored-By: Claude Fable 5 --- .../plugin-directory/class-release.php | 53 +++++++++++++------ 1 file changed, 37 insertions(+), 16 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php index 48509347f4..2083a16f3f 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php @@ -20,6 +20,7 @@ class Release { const POST_TYPE = 'plugin_release'; const BACKFILLED_META = '_releases_cpt_backfilled'; + const CACHE_GROUP = 'plugin_release'; /** * Release array fields stored as postmeta on the release post. @@ -246,25 +247,22 @@ private function get_prefill_data( $plugin ) { * @return array|bool */ public function get( $plugin, $tag ) { - $releases = $this->get_all( $plugin ); - - $filtered = wp_list_filter( $releases, compact( 'tag' ) ); - if ( $filtered ) { - return array_shift( $filtered ); + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return false; } - $filtered = wp_list_filter( - $releases, - array( - 'tag' => "trunk@{$tag}", - 'version' => $tag, - ) - ); - if ( $filtered ) { - return array_shift( $filtered ); + $release_post = $this->get_post_by_tag( $plugin, $tag ); + + // Fall back to a trunk release of that version, recorded as trunk@{version}. + if ( ! $release_post ) { + $release_post = $this->get_post_by_tag( $plugin, "trunk@{$tag}" ); + if ( $release_post && get_post_meta( $release_post->ID, 'version', true ) !== $tag ) { + $release_post = null; + } } - return false; + return $release_post ? $this->post_to_data( $release_post, $plugin ) : false; } /** @@ -375,6 +373,23 @@ private function get_posts( $plugin, $limit = -1 ) { private function get_post_by_tag( $plugin, $tag ) { $this->ensure_post_type(); + // The cached ID is validated below, so deletions and re-tags self-correct. + $cache_key = $plugin->post_name . ':' . $tag; + $post_id = wp_cache_get( $cache_key, self::CACHE_GROUP ); + if ( $post_id ) { + $post = get_post( $post_id ); + if ( + $post && + self::POST_TYPE === $post->post_type && + $plugin->ID === $post->post_parent && + get_post_meta( $post->ID, 'tag', true ) === $tag + ) { + return $post; + } + + wp_cache_delete( $cache_key, self::CACHE_GROUP ); + } + $posts = get_posts( array( 'post_type' => self::POST_TYPE, @@ -389,7 +404,13 @@ private function get_post_by_tag( $plugin, $tag ) { ) ); - return $posts ? $posts[0] : null; + if ( ! $posts ) { + return null; + } + + wp_cache_set( $cache_key, $posts[0]->ID, self::CACHE_GROUP ); + + return $posts[0]; } /** From a66fd507419ed75d1674b7bf1dd9748260b2e585 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 11 Jun 2026 14:56:25 +1000 Subject: [PATCH 08/11] Plugin Directory: Use WP_Post magic meta getters for release tag/version checks. Co-Authored-By: Claude Fable 5 --- .../wp-content/plugins/plugin-directory/class-release.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php index 2083a16f3f..8ed208f589 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php @@ -257,7 +257,7 @@ public function get( $plugin, $tag ) { // Fall back to a trunk release of that version, recorded as trunk@{version}. if ( ! $release_post ) { $release_post = $this->get_post_by_tag( $plugin, "trunk@{$tag}" ); - if ( $release_post && get_post_meta( $release_post->ID, 'version', true ) !== $tag ) { + if ( $release_post && $release_post->version !== $tag ) { $release_post = null; } } @@ -382,7 +382,7 @@ private function get_post_by_tag( $plugin, $tag ) { $post && self::POST_TYPE === $post->post_type && $plugin->ID === $post->post_parent && - get_post_meta( $post->ID, 'tag', true ) === $tag + $post->tag === $tag ) { return $post; } From 82af31d2f1a8e1fb4b16cbf5eaa357eee6959dbb Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Thu, 11 Jun 2026 15:05:22 +1000 Subject: [PATCH 09/11] Plugin Directory: Bind Releases to a plugin via for_plugin(). Renames Release to Releases and replaces the singleton with a per-plugin factory: Releases::for_plugin( $plugin ) resolves the plugin once and returns null for unknown plugins, with callers nullsafe-chaining onto it. The CPT registration is now a static register_post_type() hooked to init. Co-Authored-By: Claude Fable 5 --- .../bin/backfill-release-cpts.php | 8 +- .../class-plugin-directory.php | 12 +- .../{class-release.php => class-releases.php} | 217 ++++++++---------- .../{Release_Test.php => Releases_Test.php} | 12 +- 4 files changed, 110 insertions(+), 139 deletions(-) rename wordpress.org/public_html/wp-content/plugins/plugin-directory/{class-release.php => class-releases.php} (67%) rename wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/{Release_Test.php => Releases_Test.php} (97%) diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php index 7ff3704752..58c3cc38ef 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php @@ -12,7 +12,7 @@ namespace WordPressdotorg\Plugin_Directory; use WordPressdotorg\Plugin_Directory\Plugin_Directory; -use WordPressdotorg\Plugin_Directory\Release; +use WordPressdotorg\Plugin_Directory\Releases; // This script should only be called in a CLI environment. if ( 'cli' != php_sapi_name() ) { @@ -62,11 +62,11 @@ die(); } -$releases = Release::instance(); -$total = count( $slugs ); +$total = count( $slugs ); foreach ( $slugs as $i => $slug ) { - $result = $releases->maybe_backfill( $slug, $force ); + $releases = Releases::for_plugin( $slug ); + $result = $releases ? $releases->maybe_backfill( $force ) : new \WP_Error( 'invalid_plugin', 'Invalid plugin' ); if ( is_wp_error( $result ) ) { $message = 'error: ' . $result->get_error_message(); 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 7ffcb4ad24..4563749144 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. - Release::instance(); + add_action( 'init', [ Releases::class, 'register_post_type' ] ); // Add upload size limit to limit plugin ZIP file uploads to 10M add_filter( 'upload_size_limit', function( $size ) { @@ -1607,7 +1607,7 @@ public function split_post_content_into_pages( $content ) { * Get a list of all Plugin Releases. */ public static function get_releases( $plugin ) { - return Release::instance()->get_all( $plugin ); + return Releases::for_plugin( $plugin )?->get_all() ?? array(); } /** @@ -1617,7 +1617,7 @@ public static function get_releases( $plugin ) { * @return array */ public static function prefill_releases_meta( $plugin ) { - Release::instance()->maybe_backfill( $plugin, true ); + Releases::for_plugin( $plugin )?->maybe_backfill( true ); return self::get_releases( $plugin ); } @@ -1630,7 +1630,7 @@ public static function prefill_releases_meta( $plugin ) { * @return array|bool */ public static function get_release( $plugin, $tag ) { - return Release::instance()->get( $plugin, $tag ); + return Releases::for_plugin( $plugin )?->get( $tag ) ?? false; } /** @@ -1641,7 +1641,7 @@ public static function get_release( $plugin, $tag ) { * @return bool */ public static function add_release( $plugin, $data ) { - return Release::instance()->add( $plugin, $data ); + return Releases::for_plugin( $plugin )?->add( $data ) ?? false; } /** @@ -1652,7 +1652,7 @@ public static function add_release( $plugin, $data ) { * @return bool */ public static function remove_release( $plugin, $tag ) { - return Release::instance()->remove( $plugin, $tag ); + return Releases::for_plugin( $plugin )?->remove( $tag ) ?? false; } /** diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php similarity index 67% rename from wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php rename to wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php index 8ed208f589..395cc31211 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-release.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php @@ -8,7 +8,7 @@ namespace WordPressdotorg\Plugin_Directory; /** - * Storage and compatibility layer for plugin release records. + * Storage and compatibility layer for the release records of a plugin. * * Releases used to be stored as one `releases` postmeta array on the plugin * post. This keeps the public Plugin_Directory::get_release(s)/add_release() @@ -16,7 +16,7 @@ * * @package WordPressdotorg\Plugin_Directory */ -class Release { +class Releases { const POST_TYPE = 'plugin_release'; const BACKFILLED_META = '_releases_cpt_backfilled'; @@ -49,27 +49,40 @@ class Release { ); /** - * Fetch the instance of the Release class. + * The plugin whose releases this instance manages. * - * @return Release + * @var \WP_Post */ - public static function instance() { - static $instance = null; + private $plugin; - return ! is_null( $instance ) ? $instance : $instance = new Release(); + /** + * Releases constructor. + * + * @param \WP_Post $plugin Plugin post object. + */ + private function __construct( $plugin ) { + $this->plugin = $plugin; } /** - * Release constructor. + * Get a Releases accessor for a plugin. + * + * @param string|\WP_Post $plugin Plugin slug or post object. + * @return Releases|null Null for unknown plugins; chain with the nullsafe operator. */ - private function __construct() { - add_action( 'init', array( $this, 'register_post_type' ) ); + public static function for_plugin( $plugin ) { + $plugin = Plugin_Directory::get_plugin_post( $plugin ); + if ( ! $plugin ) { + return null; + } + + return new Releases( $plugin ); } /** * Register the private release CPT. */ - public function register_post_type() { + public static function register_post_type() { if ( post_type_exists( self::POST_TYPE ) ) { return; } @@ -98,31 +111,23 @@ public function register_post_type() { /** * Ensure release CPT queries and writes can run before `init` in CLI contexts. */ - private function ensure_post_type() { + private static function ensure_post_type() { if ( ! post_type_exists( self::POST_TYPE ) ) { - $this->register_post_type(); + self::register_post_type(); } } /** - * Get all releases for a plugin as legacy release arrays. + * Get all releases for the plugin as legacy release arrays. * - * @param string|\WP_Post $plugin Plugin slug or post object. * @return array */ - public function get_all( $plugin ) { - $plugin = Plugin_Directory::get_plugin_post( $plugin ); - if ( ! $plugin ) { - return array(); - } - - $release_posts = $this->get_posts( $plugin ); - + public function get_all() { $releases = array_map( - function ( $release_post ) use ( $plugin ) { - return $this->post_to_data( $release_post, $plugin ); + function ( $release_post ) { + return $this->post_to_data( $release_post ); }, - $release_posts + $this->get_posts() ); uasort( @@ -136,14 +141,12 @@ function ( $a, $b ) { } /** - * Check if a plugin has any CPT release records. + * Check if the plugin has any CPT release records. * - * @param string|\WP_Post $plugin Plugin slug or post object. * @return bool */ - public function has( $plugin ) { - $plugin = Plugin_Directory::get_plugin_post( $plugin ); - return $plugin && (bool) $this->get_posts( $plugin, 1 ); + public function has() { + return (bool) $this->get_posts( 1 ); } /** @@ -152,38 +155,32 @@ public function has( $plugin ) { * This is driven by the one-off migration script (bin/backfill-release-cpts.php) * and runs lazily before writes (add() / remove()), but not on reads. * - * @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. + * @param bool $force Whether to run even if CPT releases exist. + * @return array|false Backfilled release arrays, false when skipped. */ - public function maybe_backfill( $plugin, $force = false ) { - $plugin = Plugin_Directory::get_plugin_post( $plugin ); - if ( ! $plugin ) { - return new \WP_Error( 'invalid_plugin', 'Invalid plugin' ); - } - + public function maybe_backfill( $force = false ) { if ( ! $force ) { - if ( $this->has( $plugin ) ) { + if ( $this->has() ) { return false; } - if ( get_post_meta( $plugin->ID, self::BACKFILLED_META, true ) ) { + if ( get_post_meta( $this->plugin->ID, self::BACKFILLED_META, true ) ) { return false; } } - $legacy_releases = get_post_meta( $plugin->ID, 'releases', true ); + $legacy_releases = get_post_meta( $this->plugin->ID, 'releases', true ); if ( is_array( $legacy_releases ) ) { $releases = $legacy_releases; } else { - $releases = $this->get_prefill_data( $plugin ); + $releases = $this->get_prefill_data(); } // Mark as migrated up-front, so a concurrent (or recursive, via add()) backfill bails early. - update_post_meta( $plugin->ID, self::BACKFILLED_META, time() ); + update_post_meta( $this->plugin->ID, self::BACKFILLED_META, time() ); foreach ( $releases as $release ) { - $this->add( $plugin, $release ); + $this->add( $release ); } return $releases; @@ -192,12 +189,11 @@ public function maybe_backfill( $plugin, $force = false ) { /** * Get prefill release data from old tags metadata or SVN tags. * - * @param \WP_Post $plugin Plugin post object. * @return array */ - private function get_prefill_data( $plugin ) { + private function get_prefill_data() { $releases = array(); - $tags = get_post_meta( $plugin->ID, 'tags', true ); + $tags = get_post_meta( $this->plugin->ID, 'tags', true ); if ( $tags ) { foreach ( $tags as $tag_version => $tag ) { @@ -214,7 +210,7 @@ private function get_prefill_data( $plugin ) { return $releases; } - $svn_tags = Tools\SVN::ls( "https://plugins.svn.wordpress.org/{$plugin->post_name}/tags/", true ); + $svn_tags = Tools\SVN::ls( "https://plugins.svn.wordpress.org/{$this->plugin->post_name}/tags/", true ); $svn_tags = $svn_tags ? $svn_tags : array(); foreach ( $svn_tags as $entry ) { if ( 'dir' !== $entry['kind'] ) { @@ -242,50 +238,38 @@ private function get_prefill_data( $plugin ) { /** * 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. + * @param string $tag Plugin version / release tag. * @return array|bool */ - public function get( $plugin, $tag ) { - $plugin = Plugin_Directory::get_plugin_post( $plugin ); - if ( ! $plugin ) { - return false; - } - - $release_post = $this->get_post_by_tag( $plugin, $tag ); + public function get( $tag ) { + $release_post = $this->get_post_by_tag( $tag ); // Fall back to a trunk release of that version, recorded as trunk@{version}. if ( ! $release_post ) { - $release_post = $this->get_post_by_tag( $plugin, "trunk@{$tag}" ); + $release_post = $this->get_post_by_tag( "trunk@{$tag}" ); if ( $release_post && $release_post->version !== $tag ) { $release_post = null; } } - return $release_post ? $this->post_to_data( $release_post, $plugin ) : false; + return $release_post ? $this->post_to_data( $release_post ) : false; } /** * Add or update a Plugin Release. * - * @param string|\WP_Post $plugin Plugin slug or post object. - * @param array $data Release data. + * @param array $data Release data. * @return bool */ - public function add( $plugin, $data ) { + public function add( $data ) { if ( ! isset( $data['tag'] ) ) { return false; } - $plugin = Plugin_Directory::get_plugin_post( $plugin ); - if ( ! $plugin ) { - return false; - } - - $this->maybe_backfill( $plugin ); + $this->maybe_backfill(); - $existing_post = $this->get_post_by_tag( $plugin, $data['tag'] ); - $release = $existing_post ? $this->post_to_data( $existing_post, $plugin ) : $this->get_default_data( $plugin ); + $existing_post = $this->get_post_by_tag( $data['tag'] ); + $release = $existing_post ? $this->post_to_data( $existing_post ) : $this->get_default_data(); foreach ( $data as $key => $value ) { if ( isset( $release[ $key ] ) && is_array( $release[ $key ] ) ) { @@ -300,14 +284,14 @@ public function add( $plugin, $data ) { } unset( $release['undo-discard'] ); - $release = $this->normalize_data( $release, $plugin ); + $release = $this->normalize_data( $release ); - $release_id = $this->save_post( $plugin, $release, $existing_post ); + $release_id = $this->save_post( $release, $existing_post ); if ( ! $release_id || is_wp_error( $release_id ) ) { return false; } - $this->delete_duplicate_posts( $plugin, $release['tag'], $release_id ); + $this->delete_duplicate_posts( $release['tag'], $release_id ); return true; } @@ -315,24 +299,18 @@ public function add( $plugin, $data ) { /** * Remove an unconfirmed Plugin Release. * - * @param string|\WP_Post $plugin Plugin slug or post object. - * @param string $tag Release tag. + * @param string $tag Release tag. * @return bool */ - public function remove( $plugin, $tag ) { - $plugin = Plugin_Directory::get_plugin_post( $plugin ); - if ( ! $plugin ) { - return false; - } - - $this->maybe_backfill( $plugin ); + public function remove( $tag ) { + $this->maybe_backfill(); - $release_post = $this->get_post_by_tag( $plugin, $tag ); + $release_post = $this->get_post_by_tag( $tag ); if ( ! $release_post ) { return false; } - $release = $this->post_to_data( $release_post, $plugin ); + $release = $this->post_to_data( $release_post ); if ( ! empty( $release['confirmed'] ) ) { return false; } @@ -341,20 +319,19 @@ public function remove( $plugin, $tag ) { } /** - * Query release CPT posts for a plugin. + * Query release CPT posts for the plugin. * - * @param \WP_Post $plugin Plugin post object. - * @param int $limit Maximum number of posts. + * @param int $limit Maximum number of posts. * @return \WP_Post[] */ - private function get_posts( $plugin, $limit = -1 ) { - $this->ensure_post_type(); + private function get_posts( $limit = -1 ) { + self::ensure_post_type(); return get_posts( array( 'post_type' => self::POST_TYPE, 'posts_per_page' => $limit, - 'post_parent' => $plugin->ID, + 'post_parent' => $this->plugin->ID, 'post_status' => 'any', 'orderby' => 'date', 'order' => 'DESC', @@ -366,22 +343,21 @@ private function get_posts( $plugin, $limit = -1 ) { /** * Query one release CPT post for an exact release tag. * - * @param \WP_Post $plugin Plugin post object. - * @param string $tag Release tag. + * @param string $tag Release tag. * @return \WP_Post|null */ - private function get_post_by_tag( $plugin, $tag ) { - $this->ensure_post_type(); + private function get_post_by_tag( $tag ) { + self::ensure_post_type(); // The cached ID is validated below, so deletions and re-tags self-correct. - $cache_key = $plugin->post_name . ':' . $tag; + $cache_key = $this->plugin->post_name . ':' . $tag; $post_id = wp_cache_get( $cache_key, self::CACHE_GROUP ); if ( $post_id ) { $post = get_post( $post_id ); if ( $post && self::POST_TYPE === $post->post_type && - $plugin->ID === $post->post_parent && + $this->plugin->ID === $post->post_parent && $post->tag === $tag ) { return $post; @@ -394,7 +370,7 @@ private function get_post_by_tag( $plugin, $tag ) { array( 'post_type' => self::POST_TYPE, 'posts_per_page' => 1, - 'post_parent' => $plugin->ID, + 'post_parent' => $this->plugin->ID, 'post_status' => 'any', 'meta_key' => 'tag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $tag, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value @@ -416,13 +392,12 @@ private function get_post_by_tag( $plugin, $tag ) { /** * 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_post( $plugin, $release, $existing_post = null ) { - $this->ensure_post_type(); + private function save_post( $release, $existing_post = null ) { + self::ensure_post_type(); $title = $release['version'] ? $release['version'] : $release['tag']; if ( 'trunk' === $release['tag'] ) { @@ -434,8 +409,8 @@ private function save_post( $plugin, $release, $existing_post = null ) { $post = array( 'post_type' => self::POST_TYPE, 'post_title' => $title, - 'post_name' => sanitize_title( $plugin->post_name . '-' . $release['tag'] ), - 'post_parent' => $plugin->ID, + 'post_name' => sanitize_title( $this->plugin->post_name . '-' . $release['tag'] ), + 'post_parent' => $this->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 ), @@ -479,16 +454,15 @@ private function update_meta( $release_id, $release ) { /** * 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. + * @param string $tag Release tag. + * @param int $release_id Release post that should remain. */ - private function delete_duplicate_posts( $plugin, $tag, $release_id ) { + private function delete_duplicate_posts( $tag, $release_id ) { $posts = get_posts( array( 'post_type' => self::POST_TYPE, 'posts_per_page' => -1, - 'post_parent' => $plugin->ID, + 'post_parent' => $this->plugin->ID, 'post_status' => 'any', 'meta_key' => 'tag', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key 'meta_value' => $tag, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value @@ -508,10 +482,9 @@ private function delete_duplicate_posts( $plugin, $tag, $release_id ) { * 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_data( $release_post, $plugin ) { + private function post_to_data( $release_post ) { $data = array(); foreach ( self::META_FIELDS as $field ) { @@ -530,40 +503,38 @@ private function post_to_data( $release_post, $plugin ) { $data['version'] = $release_post->post_title; } - return $this->normalize_data( $data, $plugin ); + return $this->normalize_data( $data ); } /** - * Get the default legacy release array for a plugin. + * Get the default legacy release array for the plugin. * - * @param \WP_Post $plugin Plugin post object. * @return array */ - private function get_default_data( $plugin ) { + private function get_default_data() { return array( 'date' => time(), 'tag' => '', 'version' => '', - 'zips_built' => ! $plugin->release_confirmation, + 'zips_built' => ! $this->plugin->release_confirmation, 'zips_built_from_revision' => 0, 'confirmations' => array(), - 'confirmed' => ! $plugin->release_confirmation, - 'confirmations_required' => (int) $plugin->release_confirmation, + 'confirmed' => ! $this->plugin->release_confirmation, + 'confirmations_required' => (int) $this->plugin->release_confirmation, 'committer' => array(), 'revision' => array(), - 'release_delay' => get_release_cooldown_delay( $plugin->post_name ), + 'release_delay' => get_release_cooldown_delay( $this->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. + * @param array $release Release data. * @return array */ - private function normalize_data( $release, $plugin ) { - $release = wp_parse_args( $release, $this->get_default_data( $plugin ) ); + private function normalize_data( $release ) { + $release = wp_parse_args( $release, $this->get_default_data() ); $release['date'] = (int) $release['date']; $release['tag'] = (string) $release['tag']; diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/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/Release_Test.php rename to wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php index 01b103c0af..67082f6ed1 100644 --- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Release_Test.php +++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php @@ -7,14 +7,14 @@ use PHPUnit\Framework\TestCase; use WordPressdotorg\Plugin_Directory\Plugin_Directory; -use WordPressdotorg\Plugin_Directory\Release; +use WordPressdotorg\Plugin_Directory\Releases; /** * Release CPT storage tests. * * @group releases */ -class Release_Test extends TestCase { +class Releases_Test extends TestCase { /** * Plugin posts created by a test. @@ -30,7 +30,7 @@ protected function tearDown(): void { foreach ( $this->plugins as $plugin ) { $release_posts = get_posts( array( - 'post_type' => Release::POST_TYPE, + 'post_type' => Releases::POST_TYPE, 'post_parent' => $plugin->ID, 'post_status' => 'any', 'posts_per_page' => -1, @@ -86,7 +86,7 @@ private function create_plugin( $slug = 'release-cpt-test' ) { private function get_release_posts( $plugin ) { return get_posts( array( - 'post_type' => Release::POST_TYPE, + 'post_type' => Releases::POST_TYPE, 'post_parent' => $plugin->ID, 'post_status' => 'any', 'posts_per_page' => -1, @@ -223,7 +223,7 @@ public function test_migration_backfills_legacy_releases_meta_to_cpts() { ); update_post_meta( $plugin->ID, 'releases', $legacy ); - Release::instance()->maybe_backfill( $plugin ); + Releases::for_plugin( $plugin )->maybe_backfill(); $releases = Plugin_Directory::get_releases( $plugin ); @@ -234,7 +234,7 @@ public function test_migration_backfills_legacy_releases_meta_to_cpts() { $this->assertCount( 2, $this->get_release_posts( $plugin ) ); // Re-running the migration is idempotent and does not duplicate CPTs. - Release::instance()->maybe_backfill( $plugin ); + Releases::for_plugin( $plugin )->maybe_backfill(); $this->assertCount( 2, $this->get_release_posts( $plugin ), 'Backfill should not duplicate release CPTs.' ); } From e0deb52643ce69e241e953a7112b389e8ae98980 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 12 Jun 2026 15:47:59 +1000 Subject: [PATCH 10/11] Plugin Directory: Fall back to legacy release metadata on reads pre-migration. get_all() and get() now fall back to the legacy releases postmeta when no release CPTs exist yet, and migrate (from tags metadata / SVN) only when neither exists, matching the pre-CPT read behaviour. Co-Authored-By: Claude Fable 5 --- .../plugin-directory/class-releases.php | 45 ++++++++++++++++++- .../plugin-directory/tests/Releases_Test.php | 41 +++++++++++++++-- 2 files changed, 80 insertions(+), 6 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 395cc31211..e47a5dcdba 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 @@ -130,6 +130,10 @@ function ( $release_post ) { $this->get_posts() ); + if ( ! $releases ) { + $releases = $this->get_unmigrated(); + } + uasort( $releases, function ( $a, $b ) { @@ -153,7 +157,9 @@ public function has() { * Backfill release CPTs from legacy release metadata, tags metadata, or SVN. * * This is driven by the one-off migration script (bin/backfill-release-cpts.php) - * and runs lazily before writes (add() / remove()), but not on reads. + * and runs lazily before writes (add() / remove()). Reads fall back to the + * legacy metadata without migrating, only triggering this when neither + * CPTs nor legacy metadata exist. * * @param bool $force Whether to run even if CPT releases exist. * @return array|false Backfilled release arrays, false when skipped. @@ -186,6 +192,27 @@ public function maybe_backfill( $force = false ) { return $releases; } + /** + * Get release data for a plugin that hasn't been migrated to CPTs yet. + * + * Reads the legacy `releases` postmeta directly; when that doesn't exist + * either, migrates from tags metadata / SVN via maybe_backfill(). + * + * @return array + */ + private function get_unmigrated() { + if ( get_post_meta( $this->plugin->ID, self::BACKFILLED_META, true ) ) { + return array(); + } + + $releases = get_post_meta( $this->plugin->ID, 'releases', true ); + if ( ! is_array( $releases ) ) { + $releases = $this->maybe_backfill(); + } + + return array_map( array( $this, 'normalize_data' ), $releases ? $releases : array() ); + } + /** * Get prefill release data from old tags metadata or SVN tags. * @@ -252,7 +279,21 @@ public function get( $tag ) { } } - return $release_post ? $this->post_to_data( $release_post ) : false; + if ( $release_post ) { + return $this->post_to_data( $release_post ); + } + + // Prior to migration, search the legacy metadata. + foreach ( $this->get_unmigrated() as $release ) { + if ( + $tag === $release['tag'] || + ( "trunk@{$tag}" === $release['tag'] && $tag === $release['version'] ) + ) { + return $release; + } + } + + return false; } /** 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 67082f6ed1..7a7cfe7e3d 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 @@ -133,9 +133,9 @@ public function test_add_release_writes_cpt_and_preserves_legacy_shape() { } /** - * Legacy release metadata is not backfilled on read; the migration handles it. + * Reads fall back to legacy release metadata prior to migration, without migrating. */ - public function test_legacy_releases_meta_is_not_backfilled_on_read() { + public function test_reads_fall_back_to_legacy_releases_meta_before_migration() { $plugin = $this->create_plugin( 'legacy-release-cpt-test' ); $legacy = array( array( @@ -151,11 +151,44 @@ public function test_legacy_releases_meta_is_not_backfilled_on_read() { ); update_post_meta( $plugin->ID, 'releases', $legacy ); - // Reads no longer trigger an automatic backfill. - $this->assertSame( array(), Plugin_Directory::get_releases( $plugin ) ); + $releases = Plugin_Directory::get_releases( $plugin ); + $this->assertCount( 1, $releases ); + $this->assertSame( '1.0.0', $releases[0]['tag'] ); + $this->assertSame( array( 'alice' ), $releases[0]['committer'] ); + + $release = Plugin_Directory::get_release( $plugin, '1.0.0' ); + $this->assertSame( '1.0.0', $release['version'] ); + + // The metadata fallback is read-only; the migration creates the CPTs. $this->assertCount( 0, $this->get_release_posts( $plugin ) ); } + /** + * Reads migrate from tags metadata when neither CPTs nor legacy release metadata exist. + */ + public function test_reads_migrate_when_no_legacy_releases_meta() { + $plugin = $this->create_plugin( 'prefill-release-cpt-test' ); + delete_post_meta( $plugin->ID, 'releases' ); + update_post_meta( + $plugin->ID, + 'tags', + array( + '1.0.0' => array( + 'tag' => '1.0.0', + 'date' => '2023-11-14 22:13:20', + 'author' => 'alice', + ), + ) + ); + + $releases = Plugin_Directory::get_releases( $plugin ); + + $this->assertCount( 1, $releases ); + $this->assertSame( '1.0.0', $releases[0]['tag'] ); + $this->assertSame( array( 'alice' ), $releases[0]['committer'] ); + $this->assertCount( 1, $this->get_release_posts( $plugin ) ); + } + /** * Writes backfill legacy release metadata to release CPTs first. */ From c8ce6bf6023366c0ddf478442efaf409dc7ee709 Mon Sep 17 00:00:00 2001 From: Dion Hulse Date: Fri, 12 Jun 2026 16:03:07 +1000 Subject: [PATCH 11/11] Plugin Directory: Don't apply the new-release cooldown to pre-existing releases. The cooldown delay is captured at release creation time; normalizing or backfilling an existing release without one recorded should leave it at 0 rather than stamping the current cooldown default onto it. Co-Authored-By: Claude Fable 5 --- .../plugin-directory/class-releases.php | 11 +++++++- .../plugin-directory/tests/Releases_Test.php | 25 ++++++++++++++++++- 2 files changed, 34 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 e47a5dcdba..ac3605fa2d 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 @@ -186,6 +186,9 @@ public function maybe_backfill( $force = false ) { update_post_meta( $this->plugin->ID, self::BACKFILLED_META, time() ); foreach ( $releases as $release ) { + // Don't capture the new-release cooldown onto pre-existing releases. + $release += array( 'release_delay' => 0 ); + $this->add( $release ); } @@ -575,7 +578,13 @@ private function get_default_data() { * @return array */ private function normalize_data( $release ) { - $release = wp_parse_args( $release, $this->get_default_data() ); + $defaults = $this->get_default_data(); + + // The cooldown delay is captured at release creation time (see add()); + // existing releases without one recorded never had one. + $defaults['release_delay'] = 0; + + $release = wp_parse_args( $release, $defaults ); $release['date'] = (int) $release['date']; $release['tag'] = (string) $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 7a7cfe7e3d..b41484b4c1 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 @@ -45,9 +45,24 @@ protected function tearDown(): void { } $this->plugins = array(); + remove_all_filters( 'wporg_plugins_release_cooldown_delay' ); parent::tearDown(); } + /** + * Force a non-zero release cooldown delay for the duration of a test. + * + * @param int $delay Cooldown delay in seconds. + */ + private function set_release_cooldown( $delay ) { + add_filter( + 'wporg_plugins_release_cooldown_delay', + function () use ( $delay ) { + return $delay; + } + ); + } + /** * Create a plugin post for release tests. * @@ -136,6 +151,8 @@ public function test_add_release_writes_cpt_and_preserves_legacy_shape() { * Reads fall back to legacy release metadata prior to migration, without migrating. */ public function test_reads_fall_back_to_legacy_releases_meta_before_migration() { + $this->set_release_cooldown( HOUR_IN_SECONDS ); + $plugin = $this->create_plugin( 'legacy-release-cpt-test' ); $legacy = array( array( @@ -146,7 +163,6 @@ public function test_reads_fall_back_to_legacy_releases_meta_before_migration() 'revision' => array( 100 ), 'zips_built' => true, 'confirmations_required' => 0, - 'release_delay' => 0, ), ); update_post_meta( $plugin->ID, 'releases', $legacy ); @@ -155,6 +171,7 @@ public function test_reads_fall_back_to_legacy_releases_meta_before_migration() $this->assertCount( 1, $releases ); $this->assertSame( '1.0.0', $releases[0]['tag'] ); $this->assertSame( array( 'alice' ), $releases[0]['committer'] ); + $this->assertSame( 0, $releases[0]['release_delay'], 'Pre-existing releases should not gain a cooldown delay.' ); $release = Plugin_Directory::get_release( $plugin, '1.0.0' ); $this->assertSame( '1.0.0', $release['version'] ); @@ -167,6 +184,8 @@ public function test_reads_fall_back_to_legacy_releases_meta_before_migration() * Reads migrate from tags metadata when neither CPTs nor legacy release metadata exist. */ public function test_reads_migrate_when_no_legacy_releases_meta() { + $this->set_release_cooldown( HOUR_IN_SECONDS ); + $plugin = $this->create_plugin( 'prefill-release-cpt-test' ); delete_post_meta( $plugin->ID, 'releases' ); update_post_meta( @@ -186,6 +205,7 @@ public function test_reads_migrate_when_no_legacy_releases_meta() { $this->assertCount( 1, $releases ); $this->assertSame( '1.0.0', $releases[0]['tag'] ); $this->assertSame( array( 'alice' ), $releases[0]['committer'] ); + $this->assertSame( 0, $releases[0]['release_delay'], 'Pre-existing releases should not gain a cooldown delay.' ); $this->assertCount( 1, $this->get_release_posts( $plugin ) ); } @@ -275,6 +295,8 @@ public function test_migration_backfills_legacy_releases_meta_to_cpts() { * Existing release tags are updated instead of duplicated. */ public function test_add_release_updates_existing_tag_and_merges_array_fields() { + $this->set_release_cooldown( HOUR_IN_SECONDS ); + $plugin = $this->create_plugin( 'merge-release-cpt-test' ); Plugin_Directory::add_release( @@ -301,6 +323,7 @@ public function test_add_release_updates_existing_tag_and_merges_array_fields() $this->assertSame( array( 'alice', 'bob' ), $release['committer'] ); $this->assertSame( array( 100, 101 ), $release['revision'] ); $this->assertTrue( $release['confirmed'] ); + $this->assertSame( HOUR_IN_SECONDS, $release['release_delay'], 'New releases capture the cooldown delay at creation.' ); $this->assertCount( 1, $this->get_release_posts( $plugin ) ); }