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..58c3cc38ef
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/bin/backfill-release-cpts.php
@@ -0,0 +1,83 @@
+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();
+}
+
+$total = count( $slugs );
+
+foreach ( $slugs as $i => $slug ) {
+ $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();
+ } 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 ) );
+
+ // Reset in-memory caches between plugins to keep memory usage flat.
+ wp_cache_flush_runtime();
+}
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 105c678dfc..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
@@ -69,6 +69,9 @@ private function __construct() {
// Search
Plugin_Search::instance();
+ // Releases.
+ 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 ) {
return 10 * MB_IN_BYTES;
@@ -1604,80 +1607,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 Releases::for_plugin( $plugin )?->get_all() ?? array();
}
/**
- * 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;
- }
+ Releases::for_plugin( $plugin )?->maybe_backfill( 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 );
}
/**
@@ -1688,21 +1630,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 Releases::for_plugin( $plugin )?->get( $tag ) ?? false;
}
/**
@@ -1713,66 +1641,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 Releases::for_plugin( $plugin )?->add( $data ) ?? false;
}
/**
@@ -1783,20 +1652,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 Releases::for_plugin( $plugin )?->remove( $tag ) ?? false;
}
/**
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
new file mode 100644
index 0000000000..ac3605fa2d
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php
@@ -0,0 +1,607 @@
+plugin = $plugin;
+ }
+
+ /**
+ * 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.
+ */
+ 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 static function register_post_type() {
+ if ( post_type_exists( self::POST_TYPE ) ) {
+ return;
+ }
+
+ register_post_type(
+ self::POST_TYPE,
+ array(
+ 'labels' => 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 static function ensure_post_type() {
+ if ( ! post_type_exists( self::POST_TYPE ) ) {
+ self::register_post_type();
+ }
+ }
+
+ /**
+ * Get all releases for the plugin as legacy release arrays.
+ *
+ * @return array
+ */
+ public function get_all() {
+ $releases = array_map(
+ function ( $release_post ) {
+ return $this->post_to_data( $release_post );
+ },
+ $this->get_posts()
+ );
+
+ if ( ! $releases ) {
+ $releases = $this->get_unmigrated();
+ }
+
+ uasort(
+ $releases,
+ function ( $a, $b ) {
+ return $b['date'] <=> $a['date'];
+ }
+ );
+
+ return array_values( $releases );
+ }
+
+ /**
+ * Check if the plugin has any CPT release records.
+ *
+ * @return bool
+ */
+ public function has() {
+ return (bool) $this->get_posts( 1 );
+ }
+
+ /**
+ * 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()). 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.
+ */
+ public function maybe_backfill( $force = false ) {
+ if ( ! $force ) {
+ if ( $this->has() ) {
+ return false;
+ }
+
+ if ( get_post_meta( $this->plugin->ID, self::BACKFILLED_META, true ) ) {
+ return false;
+ }
+ }
+
+ $legacy_releases = get_post_meta( $this->plugin->ID, 'releases', true );
+ if ( is_array( $legacy_releases ) ) {
+ $releases = $legacy_releases;
+ } else {
+ $releases = $this->get_prefill_data();
+ }
+
+ // Mark as migrated up-front, so a concurrent (or recursive, via add()) backfill bails early.
+ 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 );
+ }
+
+ 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.
+ *
+ * @return array
+ */
+ private function get_prefill_data() {
+ $releases = array();
+ $tags = get_post_meta( $this->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/{$this->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 $tag Plugin version / release tag.
+ * @return array|bool
+ */
+ 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( "trunk@{$tag}" );
+ if ( $release_post && $release_post->version !== $tag ) {
+ $release_post = null;
+ }
+ }
+
+ 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;
+ }
+
+ /**
+ * Add or update a Plugin Release.
+ *
+ * @param array $data Release data.
+ * @return bool
+ */
+ public function add( $data ) {
+ if ( ! isset( $data['tag'] ) ) {
+ return false;
+ }
+
+ $this->maybe_backfill();
+
+ $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 ] ) ) {
+ $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_data( $release );
+
+ $release_id = $this->save_post( $release, $existing_post );
+ if ( ! $release_id || is_wp_error( $release_id ) ) {
+ return false;
+ }
+
+ $this->delete_duplicate_posts( $release['tag'], $release_id );
+
+ return true;
+ }
+
+ /**
+ * Remove an unconfirmed Plugin Release.
+ *
+ * @param string $tag Release tag.
+ * @return bool
+ */
+ public function remove( $tag ) {
+ $this->maybe_backfill();
+
+ $release_post = $this->get_post_by_tag( $tag );
+ if ( ! $release_post ) {
+ return false;
+ }
+
+ $release = $this->post_to_data( $release_post );
+ if ( ! empty( $release['confirmed'] ) ) {
+ return false;
+ }
+
+ return (bool) wp_delete_post( $release_post->ID, true );
+ }
+
+ /**
+ * Query release CPT posts for the plugin.
+ *
+ * @param int $limit Maximum number of posts.
+ * @return \WP_Post[]
+ */
+ 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' => $this->plugin->ID,
+ 'post_status' => 'any',
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'suppress_filters' => true,
+ )
+ );
+ }
+
+ /**
+ * Query one release CPT post for an exact release tag.
+ *
+ * @param string $tag Release tag.
+ * @return \WP_Post|null
+ */
+ 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 = $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 &&
+ $this->plugin->ID === $post->post_parent &&
+ $post->tag === $tag
+ ) {
+ return $post;
+ }
+
+ wp_cache_delete( $cache_key, self::CACHE_GROUP );
+ }
+
+ $posts = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => 1,
+ '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
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'suppress_filters' => true,
+ )
+ );
+
+ if ( ! $posts ) {
+ return null;
+ }
+
+ wp_cache_set( $cache_key, $posts[0]->ID, self::CACHE_GROUP );
+
+ return $posts[0];
+ }
+
+ /**
+ * Save a release array as a CPT post.
+ *
+ * @param array $release Release data.
+ * @param \WP_Post|null $existing_post Existing release post, if any.
+ * @return int|\WP_Error
+ */
+ private function save_post( $release, $existing_post = null ) {
+ self::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( $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 ),
+ '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_meta( $release_id, $release );
+
+ return $release_id;
+ }
+
+ /**
+ * Update the release postmeta fields.
+ *
+ * @param int $release_id Release post ID.
+ * @param array $release Release data.
+ */
+ private function update_meta( $release_id, $release ) {
+ foreach ( self::META_FIELDS as $field ) {
+ if ( array_key_exists( $field, $release ) ) {
+ update_post_meta( $release_id, $field, $release[ $field ] );
+ } else {
+ delete_post_meta( $release_id, $field );
+ }
+ }
+ }
+
+ /**
+ * Delete duplicate release posts for a tag after an upsert.
+ *
+ * @param string $tag Release tag.
+ * @param int $release_id Release post that should remain.
+ */
+ private function delete_duplicate_posts( $tag, $release_id ) {
+ $posts = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => -1,
+ '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
+ '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.
+ * @return array
+ */
+ private function post_to_data( $release_post ) {
+ $data = array();
+
+ foreach ( self::META_FIELDS as $field ) {
+ if ( metadata_exists( 'post', $release_post->ID, $field ) ) {
+ $data[ $field ] = get_post_meta( $release_post->ID, $field, 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'] ) ) {
+ $data['tag'] = $release_post->post_title;
+ }
+ if ( empty( $data['version'] ) ) {
+ $data['version'] = $release_post->post_title;
+ }
+
+ return $this->normalize_data( $data );
+ }
+
+ /**
+ * Get the default legacy release array for the plugin.
+ *
+ * @return array
+ */
+ private function get_default_data() {
+ return array(
+ 'date' => time(),
+ 'tag' => '',
+ 'version' => '',
+ 'zips_built' => ! $this->plugin->release_confirmation,
+ 'zips_built_from_revision' => 0,
+ 'confirmations' => array(),
+ 'confirmed' => ! $this->plugin->release_confirmation,
+ 'confirmations_required' => (int) $this->plugin->release_confirmation,
+ 'committer' => array(),
+ 'revision' => array(),
+ '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.
+ * @return array
+ */
+ private function normalize_data( $release ) {
+ $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'];
+ $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/Releases_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php
new file mode 100644
index 0000000000..b41484b4c1
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php
@@ -0,0 +1,380 @@
+plugins as $plugin ) {
+ $release_posts = get_posts(
+ array(
+ 'post_type' => Releases::POST_TYPE,
+ 'post_parent' => $plugin->ID,
+ 'post_status' => 'any',
+ 'posts_per_page' => -1,
+ )
+ );
+
+ foreach ( $release_posts as $release_post ) {
+ wp_delete_post( $release_post->ID, true );
+ }
+
+ wp_delete_post( $plugin->ID, true );
+ }
+
+ $this->plugins = array();
+ 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.
+ *
+ * @param string $slug Plugin slug.
+ * @return WP_Post
+ */
+ private function create_plugin( $slug = 'release-cpt-test' ) {
+ $now = current_time( 'mysql' );
+ $post_id = wp_insert_post(
+ array(
+ 'post_type' => 'plugin',
+ 'post_name' => $slug,
+ 'post_title' => 'Release CPT Test',
+ 'post_status' => 'publish',
+ 'post_date' => $now,
+ 'post_date_gmt' => $now,
+ 'post_modified' => $now,
+ 'post_modified_gmt' => $now,
+ )
+ );
+
+ update_post_meta( $post_id, 'releases', array() );
+
+ $plugin = get_post( $post_id );
+ $this->plugins[] = $plugin;
+
+ return $plugin;
+ }
+
+ /**
+ * 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' => Releases::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, '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'] );
+ }
+
+ /**
+ * 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(
+ 'date' => 1700000000,
+ 'tag' => '1.0.0',
+ 'version' => '1.0.0',
+ 'committer' => array( 'alice' ),
+ 'revision' => array( 100 ),
+ 'zips_built' => true,
+ 'confirmations_required' => 0,
+ ),
+ );
+ update_post_meta( $plugin->ID, 'releases', $legacy );
+
+ $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->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'] );
+
+ // 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() {
+ $this->set_release_cooldown( HOUR_IN_SECONDS );
+
+ $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->assertSame( 0, $releases[0]['release_delay'], 'Pre-existing releases should not gain a cooldown delay.' );
+ $this->assertCount( 1, $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.
+ */
+ public function test_migration_backfills_legacy_releases_meta_to_cpts() {
+ $plugin = $this->create_plugin( 'migrate-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::for_plugin( $plugin )->maybe_backfill();
+
+ $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 ) );
+
+ // Re-running the migration is idempotent and does not duplicate CPTs.
+ Releases::for_plugin( $plugin )->maybe_backfill();
+ $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() {
+ $this->set_release_cooldown( HOUR_IN_SECONDS );
+
+ $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->assertSame( HOUR_IN_SECONDS, $release['release_delay'], 'New releases capture the cooldown delay at creation.' );
+ $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'] );
+ }
+}