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..32e45aeeab 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-plugin-directory.php
@@ -69,6 +69,9 @@ private function __construct() {
// Search
Plugin_Search::instance();
+ // Releases.
+ Releases::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 Releases::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;
- }
+ Releases::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 Releases::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 Releases::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 Releases::instance()->remove_release( $plugin, $tag );
}
/**
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..87144a8ff0
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/class-releases.php
@@ -0,0 +1,1071 @@
+ array(
+ 'name' => 'Releases',
+ 'singular_name' => 'Release',
+ ),
+ '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 ) {
+ $plugin = Plugin_Directory::get_plugin_post( $plugin );
+ if ( ! $plugin ) {
+ return false;
+ }
+
+ $release_post = $this->get_release_post_by_tag( $plugin, $tag );
+ if ( ! $release_post && ! $this->has_releases( $plugin ) && ! get_post_meta( $plugin->ID, self::BACKFILLED_META, true ) ) {
+ $this->maybe_backfill_releases( $plugin );
+ $release_post = $this->get_release_post_by_tag( $plugin, $tag );
+ }
+
+ if ( $release_post ) {
+ return $this->post_to_release_data( $release_post, $plugin );
+ }
+
+ $trunk_release_post = $this->get_release_post_by_tag( $plugin, "trunk@{$tag}" );
+ if ( $trunk_release_post ) {
+ $release = $this->post_to_release_data( $trunk_release_post, $plugin );
+ if ( $tag === $release['version'] ) {
+ return $release;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * Fetch a release CPT post by tag.
+ *
+ * @param string|\WP_Post $plugin Plugin slug or post object.
+ * @param string $tag Plugin version / release tag.
+ * @return \WP_Post|null
+ */
+ public function get_release_post( $plugin, $tag ) {
+ $plugin = Plugin_Directory::get_plugin_post( $plugin );
+ if ( ! $plugin ) {
+ return null;
+ }
+
+ return $this->get_release_post_by_tag( $plugin, $tag );
+ }
+
+ /**
+ * Add or update a Plugin Release.
+ *
+ * @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 );
+
+ $merge_list_fields = array( 'committer', 'revision' );
+ foreach ( $data as $key => $value ) {
+ if ( in_array( $key, $merge_list_fields, true ) && isset( $release[ $key ] ) ) {
+ $release[ $key ] = array_unique( array_merge( $release[ $key ], (array) $value ) );
+ } else {
+ $release[ $key ] = $value;
+ }
+ }
+
+ 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();
+
+ // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $posts = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => 1,
+ 'post_parent' => $plugin->ID,
+ 'post_status' => 'any',
+ 'meta_key' => 'release_tag',
+ 'meta_value' => $tag,
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'suppress_filters' => true,
+ )
+ );
+ // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+
+ return $posts ? $posts[0] : null;
+ }
+
+ /**
+ * 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();
+ $date_gmt = gmdate( 'Y-m-d H:i:s', $date );
+ $post_status = $release['post_status'] ?? '';
+ if ( ! $post_status && $existing_post ) {
+ $post_status = $existing_post->post_status;
+ }
+ if ( ! $post_status ) {
+ $post_status = ( 'trunk' === $release['tag'] ) ? 'draft' : 'publish';
+ }
+
+ $post = array(
+ 'post_type' => self::POST_TYPE,
+ 'post_title' => $title,
+ 'post_name' => sanitize_title( $plugin->post_name . '-' . $release['tag'] ),
+ 'post_parent' => $plugin->ID,
+ 'post_status' => $post_status,
+ 'post_date' => get_date_from_gmt( $date_gmt ),
+ 'post_date_gmt' => $date_gmt,
+ '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',
+ 'sync_status' => 'release_sync_status',
+ 'sync_after' => 'release_sync_after',
+ 'synced_at' => 'release_synced_at',
+ );
+
+ 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 ) {
+ // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $posts = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => -1,
+ 'post_parent' => $plugin->ID,
+ 'post_status' => 'any',
+ 'meta_key' => 'release_tag',
+ 'meta_value' => $tag,
+ 'fields' => 'ids',
+ 'suppress_filters' => true,
+ )
+ );
+ // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+
+ foreach ( $posts as $post_id ) {
+ if ( (int) $post_id !== (int) $release_id ) {
+ 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',
+ 'sync_status' => 'release_sync_status',
+ 'sync_after' => 'release_sync_after',
+ 'synced_at' => 'release_synced_at',
+ );
+
+ 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;
+ }
+ if ( empty( $data['post_status'] ) ) {
+ $data['post_status'] = $release_post->post_status;
+ }
+
+ return $this->normalize_release_data( $data, $plugin );
+ }
+
+ /**
+ * Sync stored release import data to the main plugin post.
+ *
+ * @param string|\WP_Post $plugin Plugin slug or post object.
+ * @param string|\WP_Post $release_ref Release tag or release post.
+ * @param array $args Optional arguments.
+ * @return bool
+ */
+ public function sync_release_to_plugin( $plugin, $release_ref, $args = array() ) {
+ $plugin = Plugin_Directory::get_plugin_post( $plugin );
+ if ( ! $plugin ) {
+ return false;
+ }
+
+ $release_post = ( $release_ref instanceof \WP_Post ) ? $release_ref : $this->get_release_post_by_tag( $plugin, $release_ref );
+ if ( ! $release_post ) {
+ return false;
+ }
+
+ $release = $this->post_to_release_data( $release_post, $plugin );
+ if ( empty( $release['plugin'] ) || ! is_array( $release['plugin'] ) ) {
+ return false;
+ }
+
+ $force = ! empty( $args['force'] );
+ $sync_after = $this->get_release_sync_after( $plugin, $release );
+ if ( ! $force && $sync_after > time() ) {
+ // Keep the main plugin post on the previous version during cooldown,
+ // so both update checks and new installs continue to see the old release.
+ $release['post_status'] = 'draft';
+ $release['sync_status'] = 'pending';
+ $release['sync_after'] = $sync_after;
+ $this->add_release( $plugin, $release );
+ Jobs\API_Update_Updater::queue_release_to_update_api( $plugin->post_name, $sync_after );
+
+ return false;
+ }
+
+ /**
+ * Filters whether an imported release is ready to sync to the plugin post.
+ *
+ * Scanner integrations can return false here while a draft release is still being
+ * verified, then call sync_pending_release() when the verification passes.
+ *
+ * @param bool $ready Whether the release can sync now.
+ * @param \WP_Post $plugin Plugin post object.
+ * @param array $release Release data.
+ * @param \WP_Post $release_post Release CPT post.
+ */
+ $ready = apply_filters( 'wporg_plugins_release_ready_for_sync', true, $plugin, $release, $release_post );
+ if ( ! $force && ! $ready ) {
+ $release['post_status'] = 'draft';
+ $release['sync_status'] = 'pending';
+ $release['sync_after'] = $sync_after;
+ $this->add_release( $plugin, $release );
+
+ return false;
+ }
+
+ if ( $force ) {
+ $release['release_delay'] = 0;
+ $this->add_release( $plugin, $release );
+ }
+
+ $result = $this->apply_release_to_plugin( $plugin, $release_post, $release );
+ if ( ! $result || is_wp_error( $result ) ) {
+ return false;
+ }
+
+ $release['post_status'] = 'publish';
+ $release['sync_status'] = 'synced';
+ $release['sync_after'] = 0;
+ $release['synced_at'] = time();
+ $this->add_release( $plugin, $release );
+ $this->supersede_pending_release_imports( $plugin, $release_post->ID );
+
+ $plugin = get_post( $plugin->ID );
+ $release_plugin = $release['plugin'];
+
+ /**
+ * Action that fires after a plugin release is synced to the plugin post.
+ *
+ * @param \WP_Post $plugin The plugin updated.
+ * @param string $stable_tag The new stable tag for the plugin.
+ * @param string $old_stable_tag The previous stable tag for the plugin.
+ * @param array $changed_tags The list of SVN tags/trunk affected to trigger the import.
+ * @param int $svn_revision The SVN revision that triggered the import.
+ * @param array $warnings The list of warnings generated during the import process.
+ */
+ do_action(
+ 'wporg_plugins_imported',
+ $plugin,
+ $release_plugin['stable_tag'] ?? $release['tag'],
+ $release_plugin['old_stable_tag'] ?? '',
+ $release_plugin['changed_tags'] ?? array(),
+ $release_plugin['svn_revision'] ?? 0,
+ $release_plugin['warnings'] ?? array()
+ );
+
+ /**
+ * Fires after a release CPT import is synced to its plugin post.
+ *
+ * @param \WP_Post $plugin The plugin updated.
+ * @param array $release Release data.
+ * @param array $release_plugin Stored plugin data.
+ */
+ do_action( 'wporg_plugins_release_synced', $plugin, $release, $release_plugin );
+
+ return true;
+ }
+
+ /**
+ * Sync the newest pending release import for a plugin.
+ *
+ * @param string|\WP_Post $plugin Plugin slug or post object.
+ * @param bool $force Whether to bypass cooldown/readiness checks.
+ * @return bool
+ */
+ public function sync_pending_release( $plugin, $force = false ) {
+ $plugin = Plugin_Directory::get_plugin_post( $plugin );
+ if ( ! $plugin ) {
+ return false;
+ }
+
+ $release_post = $this->get_pending_release_post( $plugin, $force );
+ if ( ! $release_post ) {
+ return false;
+ }
+
+ return $this->sync_release_to_plugin( $plugin, $release_post, compact( 'force' ) );
+ }
+
+ /**
+ * Get the release timestamp used for cooldown calculations.
+ *
+ * @param string|\WP_Post $plugin Plugin slug or post object.
+ * @param array $release Release data.
+ * @return int
+ */
+ public function get_release_time( $plugin, $release ) {
+ $plugin = Plugin_Directory::get_plugin_post( $plugin );
+
+ if (
+ ! empty( $release['confirmations_required'] ) &&
+ ! empty( $release['confirmations'] ) &&
+ is_array( $release['confirmations'] )
+ ) {
+ return max( array_map( 'intval', $release['confirmations'] ) );
+ }
+
+ if ( ! empty( $release['date'] ) ) {
+ return (int) $release['date'];
+ }
+
+ if ( $plugin ) {
+ $version_date = get_post_meta( $plugin->ID, 'version_date', true );
+ $release_time = strtotime( $version_date ? $version_date : $plugin->post_modified );
+ if ( $release_time ) {
+ return $release_time;
+ }
+ }
+
+ return time();
+ }
+
+ /**
+ * Get the time when a release import may sync to the plugin post.
+ *
+ * @param string|\WP_Post $plugin Plugin slug or post object.
+ * @param array $release Release data.
+ * @return int Unix timestamp, or 0 when no delayed sync is required.
+ */
+ public function get_release_sync_after( $plugin, $release ) {
+ $release_delay = (int) ( $release['release_delay'] ?? 0 );
+ if ( ! $release_delay ) {
+ return 0;
+ }
+
+ if ( isset( $release['plugin']['is_new_version'] ) && ! $release['plugin']['is_new_version'] ) {
+ return 0;
+ }
+
+ return $this->get_release_time( $plugin, $release ) + $release_delay;
+ }
+
+ /**
+ * Query the newest pending release import.
+ *
+ * @param \WP_Post $plugin Plugin post object.
+ * @param bool $force Whether sync_after should be ignored.
+ * @return \WP_Post|null
+ */
+ private function get_pending_release_post( $plugin, $force = false ) {
+ $this->ensure_post_type();
+
+ // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $posts = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => -1,
+ 'post_parent' => $plugin->ID,
+ 'post_status' => 'draft',
+ 'meta_key' => 'release_sync_status',
+ 'meta_value' => 'pending',
+ 'orderby' => 'date',
+ 'order' => 'DESC',
+ 'suppress_filters' => true,
+ )
+ );
+ // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+
+ foreach ( $posts as $post ) {
+ $release = $this->post_to_release_data( $post, $plugin );
+ if ( $force || empty( $release['sync_after'] ) || (int) $release['sync_after'] <= time() ) {
+ return $post;
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Mark older pending release imports as superseded after one release syncs.
+ *
+ * @param \WP_Post $plugin Plugin post object.
+ * @param int $synced_release_id Release post ID that just synced.
+ */
+ private function supersede_pending_release_imports( $plugin, $synced_release_id ) {
+ $this->ensure_post_type();
+
+ // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+ $posts = get_posts(
+ array(
+ 'post_type' => self::POST_TYPE,
+ 'posts_per_page' => -1,
+ 'post_parent' => $plugin->ID,
+ 'post_status' => 'draft',
+ 'meta_key' => 'release_sync_status',
+ 'meta_value' => 'pending',
+ 'suppress_filters' => true,
+ )
+ );
+ // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value
+
+ foreach ( $posts as $post ) {
+ if ( (int) $post->ID === (int) $synced_release_id ) {
+ continue;
+ }
+
+ $release = $this->post_to_release_data( $post, $plugin );
+ if ( empty( $release['plugin'] ) || ! is_array( $release['plugin'] ) ) {
+ continue;
+ }
+
+ $release['post_status'] = 'draft';
+ $release['sync_status'] = 'superseded';
+ $release['sync_after'] = 0;
+ $this->add_release( $plugin, $release );
+ }
+ }
+
+ /**
+ * Apply a release CPT import to the main plugin post.
+ *
+ * @param \WP_Post $plugin Plugin post object.
+ * @param \WP_Post $release_post Release post object.
+ * @param array $release Release data.
+ * @return bool|\WP_Error
+ */
+ private function apply_release_to_plugin( $plugin, $release_post, $release ) {
+ $release_plugin = $release['plugin'];
+
+ if ( ! empty( $release_plugin['post'] ) && is_array( $release_plugin['post'] ) ) {
+ $post = array_merge(
+ array(
+ 'ID' => $plugin->ID,
+ 'post_type' => 'plugin',
+ ),
+ $release_plugin['post']
+ );
+
+ $updated = wp_update_post( $post, true );
+ if ( ! $updated || is_wp_error( $updated ) ) {
+ return $updated;
+ }
+ }
+
+ $default_categories = wp_get_object_terms( $release_post->ID, 'plugin_category', array( 'fields' => 'ids' ) );
+ $default_categories = is_wp_error( $default_categories ) ? array() : $default_categories;
+ if ( $default_categories && ! wp_get_object_terms( $plugin->ID, 'plugin_category', array( 'fields' => 'ids' ) ) ) {
+ wp_set_object_terms( $plugin->ID, array_map( 'intval', $default_categories ), 'plugin_category' );
+ }
+
+ foreach ( array( 'plugin_tags', 'plugin_contributors' ) as $taxonomy ) {
+ $term_ids = wp_get_object_terms( $release_post->ID, $taxonomy, array( 'fields' => 'ids' ) );
+ $term_ids = is_wp_error( $term_ids ) ? array() : $term_ids;
+ wp_set_object_terms( $plugin->ID, array_map( 'intval', $term_ids ), $taxonomy );
+ }
+
+ $plugin_sections = wp_get_object_terms( $release_post->ID, 'plugin_section', array( 'fields' => 'slugs' ) );
+ $plugin_sections = is_wp_error( $plugin_sections ) ? array() : $plugin_sections;
+ $managed_plugin_sections = array(
+ 'adopt-me',
+ 'dashboard-widgets',
+ );
+
+ foreach ( $managed_plugin_sections as $term ) {
+ if ( in_array( $term, $plugin_sections, true ) ) {
+ wp_add_object_terms( $plugin->ID, $term, 'plugin_section' );
+ } else {
+ wp_remove_object_terms( $plugin->ID, $term, 'plugin_section' );
+ }
+ }
+
+ if ( defined( __NAMESPACE__ . '\PLUGINS_TABLE_PREFIX' ) || defined( 'PLUGINS_TABLE_PREFIX' ) ) {
+ Tools::sync_plugin_committers_with_taxonomy( $plugin->post_name );
+ }
+
+ $this->copy_release_import_meta_to_plugin( $release_post, $plugin );
+
+ if ( metadata_exists( 'post', $release_post->ID, 'block_files' ) && has_term( 'block', 'plugin_section', $plugin->ID ) ) {
+ update_post_meta( $plugin->ID, 'block_files', get_post_meta( $release_post->ID, 'block_files', true ) );
+ } else {
+ delete_post_meta( $plugin->ID, 'block_files' );
+ }
+
+ clean_post_cache( $plugin->ID );
+ Jobs\API_Update_Updater::update_single_plugin( $plugin->post_name );
+ Standalone\Plugins_Info_API::flush_plugin_information_cache( $plugin->post_name );
+
+ return true;
+ }
+
+ /**
+ * Copy importer-owned release postmeta to the main plugin post.
+ *
+ * @param \WP_Post $release_post Release post object.
+ * @param \WP_Post $plugin Plugin post object.
+ */
+ private function copy_release_import_meta_to_plugin( $release_post, $plugin ) {
+ $replace_meta_keys = array(
+ 'donate_link',
+ 'license',
+ 'license_uri',
+ 'upgrade_notice',
+ 'screenshots',
+ 'header_name',
+ 'header_plugin_uri',
+ 'header_author',
+ 'header_author_uri',
+ 'header_textdomain',
+ 'requires_plugins',
+ 'requires',
+ 'requires_php',
+ 'tested',
+ 'tagged_versions',
+ 'sections',
+ 'assets_screenshots',
+ 'assets_icons',
+ 'assets_banners',
+ 'last_updated',
+ 'assets_banners_color',
+ 'assets_blueprints',
+ 'stable_tag',
+ 'version',
+ 'tags',
+ );
+
+ foreach ( $replace_meta_keys as $meta_key ) {
+ if ( metadata_exists( 'post', $release_post->ID, $meta_key ) ) {
+ update_post_meta( $plugin->ID, $meta_key, wp_slash( get_post_meta( $release_post->ID, $meta_key, true ) ) );
+ } else {
+ delete_post_meta( $plugin->ID, $meta_key );
+ }
+ }
+
+ foreach ( array( 'plugin_name_history', 'last_version', 'last_stable_tag', 'last_version_date', 'version_date' ) as $meta_key ) {
+ if ( metadata_exists( 'post', $release_post->ID, $meta_key ) ) {
+ update_post_meta( $plugin->ID, $meta_key, wp_slash( get_post_meta( $release_post->ID, $meta_key, true ) ) );
+ }
+ }
+
+ if ( metadata_exists( 'post', $release_post->ID, 'all_blocks' ) ) {
+ update_post_meta( $plugin->ID, 'all_blocks', get_post_meta( $release_post->ID, 'all_blocks', true ) );
+ } else {
+ delete_post_meta( $plugin->ID, 'all_blocks' );
+ }
+
+ foreach ( array( 'block_name', 'block_title', 'dashboard_widget_name' ) as $meta_key ) {
+ delete_post_meta( $plugin->ID, $meta_key );
+ foreach ( get_post_meta( $release_post->ID, $meta_key ) as $value ) {
+ if ( '' === $value ) {
+ continue;
+ }
+ add_post_meta( $plugin->ID, $meta_key, $value, false );
+ }
+ }
+
+ if ( metadata_exists( 'post', $release_post->ID, '_missing_blueprint_notice' ) ) {
+ add_post_meta( $plugin->ID, '_missing_blueprint_notice', get_post_meta( $release_post->ID, '_missing_blueprint_notice', true ), true );
+ }
+ }
+
+ /**
+ * 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 ( isset( $release['plugin'] ) && ! is_array( $release['plugin'] ) ) {
+ $release['plugin'] = array();
+ }
+ if ( isset( $release['sync_after'] ) ) {
+ $release['sync_after'] = (int) $release['sync_after'];
+ }
+ if ( isset( $release['synced_at'] ) ) {
+ $release['synced_at'] = (int) $release['synced_at'];
+ }
+ if ( isset( $release['post_status'] ) && ! in_array( $release['post_status'], array( 'draft', 'publish' ), true ) ) {
+ unset( $release['post_status'] );
+ }
+
+ if ( ! $release['confirmations_required'] && ! $release['zips_built'] ) {
+ $release['zips_built'] = true;
+ }
+
+ return $release;
+ }
+}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php
index 3a1785c472..086527a2fe 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/cli/class-import.php
@@ -2,12 +2,11 @@
namespace WordPressdotorg\Plugin_Directory\CLI;
use Exception;
-use WordPressdotorg\Plugin_Directory\Jobs\API_Update_Updater;
use WordPressdotorg\Plugin_Directory\Block_JSON;
use WordPressdotorg\Plugin_Directory\Plugin_Directory;
use WordPressdotorg\Plugin_Directory\Email\Release_Confirmation as Release_Confirmation_Email;
use WordPressdotorg\Plugin_Directory\Readme\{ Parser as Readme_Parser, Validator as Readme_Validator };
-use WordPressdotorg\Plugin_Directory\Standalone\Plugins_Info_API;
+use WordPressdotorg\Plugin_Directory\Releases;
use WordPressdotorg\Plugin_Directory\Template;
use WordPressdotorg\Plugin_Directory\Tools;
use WordPressdotorg\Plugin_Directory\Tools\Filesystem;
@@ -229,10 +228,12 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk
Plugin_Directory::add_release(
$plugin,
[
- 'tag' => $svn_changed_tag,
- 'version' => $release_version,
- 'committer' => [ $last_committer ],
- 'revision' => [ $last_revision ]
+ 'tag' => $svn_changed_tag,
+ 'version' => $release_version,
+ 'committer' => [ $last_committer ],
+ 'revision' => [ $last_revision ],
+ 'post_status' => 'draft',
+ 'sync_status' => 'pending_confirmation',
]
);
@@ -360,19 +361,58 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk
$content = "\n{$headers->Description}";
}
+ $current_version = get_post_meta( $plugin->ID, 'version', true );
+ $current_version_date = get_post_meta( $plugin->ID, 'version_date', true );
+ $is_new_version = $version && $version !== $current_version;
+
+ $release_tag = $stable_tag;
+ if ( 'trunk' === $stable_tag && $is_new_version ) {
+ $release_tag = "trunk@{$version}";
+ }
+
+ $release_data = array(
+ 'tag' => $release_tag,
+ 'version' => $version,
+ 'committer' => array( $last_committer ),
+ 'revision' => array( $last_revision ),
+ 'post_status' => 'draft',
+ 'sync_status' => 'importing',
+ 'sync_after' => 0,
+ );
+
+ if ( ! Plugin_Directory::add_release( $plugin, $release_data ) ) {
+ throw new Exception( sprintf( 'Plugin release %s could not be created.', esc_html( $release_tag ) ) );
+ }
+
+ $release_post = Releases::instance()->get_release_post( $plugin, $release_tag );
+ if ( ! $release_post ) {
+ throw new Exception( sprintf( 'Plugin release %s not found.', esc_html( $release_tag ) ) );
+ }
+ $release_id = $release_post->ID;
+
+ $post = array(
+ 'post_title' => $plugin->post_title,
+ 'post_content' => trim( $content ) ?: $plugin->post_content,
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ 'post_excerpt' => trim( $readme->short_description ) ?: $headers->Description ?: $plugin->post_excerpt,
+ 'post_modified' => $plugin->post_modified,
+ 'post_modified_gmt' => $plugin->post_modified_gmt,
+ 'post_status' => $plugin->post_status,
+ 'post_date' => $plugin->post_date,
+ 'post_date_gmt' => $plugin->post_date_gmt,
+ );
+
// Use the Readme name, as long as it's not the plugin slug.
if (
$readme->name &&
$readme->name !== $plugin->post_name
) {
- $plugin->post_title = $readme->name;
+ $post['post_title'] = $readme->name;
} elseif ( $headers->Name ) {
- $plugin->post_title = strip_tags( $headers->Name );
+ // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
+ $post['post_title'] = strip_tags( $headers->Name );
}
- $plugin->post_content = trim( $content ) ?: $plugin->post_content;
- $plugin->post_excerpt = trim( $readme->short_description ) ?: $headers->Description ?: $plugin->post_excerpt;
-
/*
* Bump last updated if:
* - The version has changed.
@@ -380,14 +420,16 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk
* - A tag (or trunk) commit is made to the current stable. The build has changed, even if not new version.
*/
if (
- ( ! $version || $version != get_post_meta( $plugin->ID, 'version', true ) ) ||
+ ( ! $version || $version != $current_version ) ||
$plugin->post_modified == '0000-00-00 00:00:00' ||
( $svn_changed_tags && in_array( ( $stable_tag ?: 'trunk' ), $svn_changed_tags, true ) )
) {
if ( $last_modified ) {
- $plugin->post_modified = $plugin->post_modified_gmt = $last_modified;
+ $post['post_modified'] = $last_modified;
+ $post['post_modified_gmt'] = $last_modified;
} else {
- $plugin->post_modified = $plugin->post_modified_gmt = current_time( 'mysql' );
+ $post['post_modified'] = current_time( 'mysql' );
+ $post['post_modified_gmt'] = $post['post_modified'];
}
}
@@ -395,42 +437,26 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk
// `export_and_parse_plugin()` will throw an exception in the case where plugin files cannot be found,
// so by this time the plugin should be live.
if ( 'approved' === $plugin->post_status ) {
- $plugin->post_status = 'publish';
+ $post['post_status'] = 'publish';
// The post date should be set to when the plugin is first set live.
- $plugin->post_date = $plugin->post_date_gmt = current_time( 'mysql' );
- }
-
- wp_update_post( $plugin );
-
- // Set categories if there aren't any yet. wp-admin takes precedent.
- if ( ! wp_get_object_terms( $plugin->ID, 'plugin_category', array( 'fields' => 'ids' ) ) ) {
- wp_set_object_terms( $plugin->ID, Tag_To_Category::map( $readme->tags ), 'plugin_category' );
+ $post['post_date'] = current_time( 'mysql' );
+ $post['post_date_gmt'] = $post['post_date'];
}
- // Set tags from the readme
- wp_set_object_terms( $plugin->ID, $readme->tags, 'plugin_tags' );
-
- // Update the contributors list
- wp_set_object_terms( $plugin->ID, $readme->contributors, 'plugin_contributors' );
-
- // Update the committers list
- Tools::sync_plugin_committers_with_taxonomy( $plugin->post_name );
-
- if ( in_array( 'adopt-me', $readme->tags ) ) {
- wp_set_object_terms( $plugin->ID, 'adopt-me', 'plugin_section' );
- } else {
- wp_remove_object_terms( $plugin->ID, 'adopt-me', 'plugin_section' );
- }
+ wp_set_object_terms( $release_id, Tag_To_Category::map( $readme->tags ), 'plugin_category' );
+ wp_set_object_terms( $release_id, $readme->tags, 'plugin_tags' );
+ wp_set_object_terms( $release_id, $readme->contributors, 'plugin_contributors' );
// Update all readme meta
+ $meta = array();
foreach ( $this->readme_fields as $readme_field ) {
- update_post_meta( $plugin->ID, $readme_field, wp_slash( $readme->$readme_field ) );
+ $meta[ $readme_field ] = $readme->$readme_field;
}
// Store the plugin headers we need. Note that 'Version', 'RequiresWP', and 'RequiresPHP' are handled below.
foreach ( $this->plugin_headers as $plugin_header => $meta_field ) {
- update_post_meta( $plugin->ID, $meta_field, ( isset( $headers->$plugin_header ) ? wp_slash( $headers->$plugin_header ) : '' ) );
+ $meta[ $meta_field ] = ( isset( $headers->$plugin_header ) ? $headers->$plugin_header : '' );
}
// Update the Requires, Requires PHP, and Tested up to fields, prefering those from the Plugin Headers.
@@ -463,19 +489,21 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk
if ( ! isset( $plugin_names[ $headers->Name ] ) ) {
// [ 'Plugin Name' => '1.2.3', 'Plugin New Name' => '4.5.6' ]
$plugin_names[ $headers->Name ] = $headers->Version;
- update_post_meta( $plugin->ID, 'plugin_name_history', wp_slash( $plugin_names ) );
+ $meta['plugin_name_history'] = $plugin_names;
+ } else {
+ delete_post_meta( $release_id, 'plugin_name_history' );
}
- update_post_meta( $plugin->ID, 'requires_plugins', wp_slash( $requires_plugins ) );
- update_post_meta( $plugin->ID, 'requires', wp_slash( $requires ) );
- update_post_meta( $plugin->ID, 'requires_php', wp_slash( $requires_php ) );
- update_post_meta( $plugin->ID, 'tested', wp_slash( $tested ) );
- update_post_meta( $plugin->ID, 'tagged_versions', wp_slash( array_keys( $tagged_versions ) ) );
- update_post_meta( $plugin->ID, 'sections', wp_slash( array_keys( $readme->sections ) ) );
- update_post_meta( $plugin->ID, 'assets_screenshots', wp_slash( $assets['screenshot'] ) );
- update_post_meta( $plugin->ID, 'assets_icons', wp_slash( $assets['icon'] ) );
- update_post_meta( $plugin->ID, 'assets_banners', wp_slash( $assets['banner'] ) );
- update_post_meta( $plugin->ID, 'last_updated', wp_slash( $plugin->post_modified_gmt ) );
+ $meta['requires_plugins'] = $requires_plugins;
+ $meta['requires'] = $requires;
+ $meta['requires_php'] = $requires_php;
+ $meta['tested'] = $tested;
+ $meta['tagged_versions'] = array_keys( $tagged_versions );
+ $meta['sections'] = array_keys( $readme->sections );
+ $meta['assets_screenshots'] = $assets['screenshot'];
+ $meta['assets_icons'] = $assets['icon'];
+ $meta['assets_banners'] = $assets['banner'];
+ $meta['last_updated'] = $post['post_modified_gmt'];
// Calculate the 'plugin color' from the average color of the banner if provided. This is used for fallback icons.
$banner_average_color = '';
@@ -483,115 +511,113 @@ public function import_from_svn( $plugin_slug, $svn_changed_tags = array( 'trunk
// The Banners are not stored locally, which is why a URL is used here
$banner_average_color = Tools::get_image_average_color( Template::get_asset_url( $plugin, $first_banner, false /* no CDN */ ) );
}
- update_post_meta( $plugin->ID, 'assets_banners_color', wp_slash( $banner_average_color ) );
+ $meta['assets_banners_color'] = $banner_average_color;
// Store the content of blueprint files, if they're available and valid.
if ( isset( $assets['blueprint'] ) && count( $assets['blueprint'] ) > 0 ) {
- update_post_meta( $plugin->ID, 'assets_blueprints', wp_slash( $assets['blueprint'] ) );
+ $meta['assets_blueprints'] = $assets['blueprint'];
+ delete_post_meta( $release_id, '_missing_blueprint_notice' );
} else {
- delete_post_meta( $plugin->ID, 'assets_blueprints' );
+ delete_post_meta( $release_id, 'assets_blueprints' );
// TODO: maybe if ( $touches_stable_tag )?
- add_post_meta( $plugin->ID, '_missing_blueprint_notice', 1, true );
+ add_post_meta( $release_id, '_missing_blueprint_notice', 1, true );
}
// Store the block data, if known
if ( count( $blocks ) ) {
- $changed = update_post_meta( $plugin->ID, 'all_blocks', $blocks );
- if ( $changed || count ( get_post_meta( $plugin->ID, 'block_name' ) ) !== count ( $blocks ) ) {
- delete_post_meta( $plugin->ID, 'block_name' );
- delete_post_meta( $plugin->ID, 'block_title' );
-
- foreach ( $blocks as $block ) {
- add_post_meta( $plugin->ID, 'block_name', $block->name, false );
- add_post_meta( $plugin->ID, 'block_title', $block->title, false );
- }
+ update_post_meta( $release_id, 'all_blocks', $blocks );
+ delete_post_meta( $release_id, 'block_name' );
+ delete_post_meta( $release_id, 'block_title' );
+
+ foreach ( $blocks as $block ) {
+ add_post_meta( $release_id, 'block_name', $block->name, false );
+ add_post_meta( $release_id, 'block_title', $block->title, false );
}
} else {
- delete_post_meta( $plugin->ID, 'all_blocks' );
- delete_post_meta( $plugin->ID, 'block_name' );
- delete_post_meta( $plugin->ID, 'block_title' );
+ delete_post_meta( $release_id, 'all_blocks' );
+ delete_post_meta( $release_id, 'block_name' );
+ delete_post_meta( $release_id, 'block_title' );
}
- // Only store block_files for plugins in the block directory
- if ( count( $block_files ) && has_term( 'block', 'plugin_section', $plugin->ID ) ) {
- update_post_meta( $plugin->ID, 'block_files', $block_files );
- } else {
- delete_post_meta( $plugin->ID, 'block_files' );
+ $plugin_sections = array();
+ if ( in_array( 'adopt-me', $readme->tags, true ) ) {
+ $plugin_sections[] = 'adopt-me';
}
// Dashboard widgets: assign the section term and store widget names.
+ delete_post_meta( $release_id, 'dashboard_widget_name' );
if ( $dashboard_widgets ) {
- wp_add_object_terms( $plugin->ID, 'dashboard-widgets', 'plugin_section' );
-
- delete_post_meta( $plugin->ID, 'dashboard_widget_name' );
+ $plugin_sections[] = 'dashboard-widgets';
foreach ( $dashboard_widgets as $widget_name ) {
if ( '' === $widget_name ) {
continue;
}
- add_post_meta( $plugin->ID, 'dashboard_widget_name', $widget_name, false );
+ add_post_meta( $release_id, 'dashboard_widget_name', $widget_name, false );
}
- } else {
- wp_remove_object_terms( $plugin->ID, 'dashboard-widgets', 'plugin_section' );
- delete_post_meta( $plugin->ID, 'dashboard_widget_name' );
}
-
- // Add the release to storage.
- if ( 'trunk' != $stable_tag ) {
- Plugin_Directory::add_release(
- $plugin,
- [
- 'tag' => $stable_tag,
- 'version' => $version,
- 'committer' => [ $last_committer ],
- 'revision' => [ $last_revision ]
- ]
- );
- } elseif ( 'trunk' === $stable_tag && version_compare( $version, $plugin->version, '>' ) ) {
- // This is a new version, released from trunk.
- Plugin_Directory::add_release(
- $plugin,
- [
- 'tag' => "trunk@{$version}",
- 'version' => $version,
- 'committer' => [ $last_committer ],
- 'revision' => [ $last_revision ]
- ]
- );
- }
-
- $this->rebuild_affected_zips( $plugin_slug, $stable_tag, $current_stable_tag, $svn_changed_tags, $svn_revision_triggered );
+ wp_set_object_terms( $release_id, $plugin_sections, 'plugin_section' );
// If we've got a new version, store the last version in the plugin meta.
- if ( $version && $version !== $plugin->version ) {
- update_post_meta( $plugin->ID, 'last_version', wp_slash( $plugin->version ) );
- update_post_meta( $plugin->ID, 'last_stable_tag', wp_slash( $current_stable_tag ) );
- update_post_meta( $plugin->ID, 'last_version_date', wp_slash( $plugin->version_date ) );
+ if ( $is_new_version ) {
+ $meta['last_version'] = $current_version;
+ $meta['last_stable_tag'] = $current_stable_tag;
+ $meta['last_version_date'] = $current_version_date;
// Keep the date of the last version change, this often differs from the last_updated/post_modified dates.
- update_post_meta( $plugin->ID, 'version_date', wp_slash( current_time( 'mysql' ) ) );
+ $meta['version_date'] = current_time( 'mysql' );
+ } else {
+ foreach ( array( 'last_version', 'last_stable_tag', 'last_version_date', 'version_date' ) as $meta_key ) {
+ delete_post_meta( $release_id, $meta_key );
+ }
}
- // Finally, set the new version live.
- update_post_meta( $plugin->ID, 'stable_tag', wp_slash( $stable_tag ) );
- update_post_meta( $plugin->ID, 'version', wp_slash( $version ) );
+ $meta['stable_tag'] = $stable_tag;
+ $meta['version'] = $version;
// Update the list of tags last, as it controls which ZIPs are present in the 'Previous versions' section and info API.
- update_post_meta( $plugin->ID, 'tags', wp_slash( $tagged_versions ) );
+ $meta['tags'] = $tagged_versions;
+
+ foreach ( $meta as $meta_key => $value ) {
+ update_post_meta( $release_id, $meta_key, wp_slash( $value ) );
+ }
- // Ensure that the API gets the updated data
- API_Update_Updater::update_single_plugin( $plugin->post_name );
- Plugins_Info_API::flush_plugin_information_cache( $plugin->post_name );
+ if ( count( $block_files ) ) {
+ update_post_meta( $release_id, 'block_files', $block_files );
+ } else {
+ delete_post_meta( $release_id, 'block_files' );
+ }
+
+ $release_data['sync_status'] = 'pending';
+ $release_data['plugin'] = array(
+ 'stable_tag' => $stable_tag,
+ 'old_stable_tag' => $current_stable_tag,
+ 'changed_tags' => array_values( $svn_changed_tags ),
+ 'svn_revision' => (int) $svn_revision_triggered,
+ 'warnings' => $this->warnings,
+ 'is_new_version' => (bool) $is_new_version,
+ 'post' => $post,
+ );
+ if ( ! Plugin_Directory::add_release( $plugin, $release_data ) ) {
+ throw new Exception( sprintf( 'Plugin release %s could not be updated.', esc_html( $release_tag ) ) );
+ }
- /**
- * Action that fires after a plugin is imported.
- *
- * @param WP_Post $plugin The plugin updated.
- * @param string $stable_tag The new stable tag for the plugin.
- * @param string $old_stable_tag The previous stable tag for the plugin.
- * @param array $changed_tags The list of SVN tags/trunk affected to trigger the import.
- * @param int $svn_revision The SVN revision that triggered the import.
- * @param array $warnings The list of warnings generated during the import process.
- */
- do_action( 'wporg_plugins_imported', $plugin, $stable_tag, $current_stable_tag, $svn_changed_tags, $svn_revision_triggered, $this->warnings );
+ $this->rebuild_affected_zips( $plugin_slug, $stable_tag, $current_stable_tag, $svn_changed_tags, $svn_revision_triggered );
+
+ $imported_release = Plugin_Directory::get_release( $plugin, $release_tag );
+ if ( $imported_release ) {
+ /**
+ * Action that fires after a plugin import updates a draft release CPT.
+ *
+ * Scanner integrations can use this to start verification before the release
+ * import data is synced to the main plugin post.
+ *
+ * @param \WP_Post $plugin The plugin being imported.
+ * @param array $release Release data.
+ * @param array $release_plugin Stored plugin data.
+ */
+ do_action( 'wporg_plugins_release_imported', $plugin, $imported_release, $imported_release['plugin'] ?? array() );
+ }
+
+ Releases::instance()->sync_release_to_plugin( $plugin, $release_tag );
return true;
}
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
index f24821258a..edb235117a 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
@@ -2,6 +2,7 @@
namespace WordPressdotorg\Plugin_Directory\Jobs;
use WordPressdotorg\Plugin_Directory\Plugin_Directory;
+use WordPressdotorg\Plugin_Directory\Releases;
use WordPressdotorg\Plugin_Directory\Template;
use WordPressdotorg\Plugin_Directory\Tools;
@@ -220,17 +221,12 @@ public static function update_single_plugin( $plugin_slug ) {
* @return int Unix timestamp.
*/
public static function compute_release_time( $post, $release ) {
- $release_time = strtotime( $post->version_date ? $post->version_date : $post->post_modified );
-
- if (
- $release &&
- $release['confirmations_required'] &&
- $release['confirmations']
- ) {
- $release_time = max( $release['confirmations'] );
+ if ( $release ) {
+ return Releases::instance()->get_release_time( $post, $release );
}
- return $release_time;
+ $version_date = get_post_meta( $post->ID, 'version_date', true );
+ return strtotime( $version_date ? $version_date : $post->post_modified );
}
/**
@@ -247,11 +243,17 @@ public static function queue_release_to_update_api( $plugin_slug, $cooldown_unti
/**
* Cron handler for `release_to_update_api:{slug}`. Fires when the cooldown
- * expires; writes the new version to `update_source` immediately. The slug
- * is recovered from the dynamic hook name so no args need flow through cron.
+ * expires; first syncs any pending release CPT snapshot to the plugin post,
+ * then writes the new version to `update_source`. The slug is recovered from
+ * the dynamic hook name so no args need flow through cron.
*/
public static function cron_trigger_release() {
list( , $plugin_slug ) = explode( ':', current_filter(), 2 );
+
+ if ( Releases::instance()->sync_pending_release( $plugin_slug ) ) {
+ return;
+ }
+
self::update_single_plugin( $plugin_slug );
}
@@ -276,6 +278,18 @@ public static function force_release( $plugin_slug, $reason, $user = null ) {
return false;
}
+ if ( Releases::instance()->sync_pending_release( $post, true ) ) {
+ Tools::audit_log(
+ sprintf(
+ 'Force-released a pending release, bypassing the release cooldown. Reason: %s',
+ $reason
+ ),
+ $post
+ );
+
+ return true;
+ }
+
$version = get_post_meta( $post->ID, 'version', true );
$release = Plugin_Directory::get_release( $post, $version );
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php
new file mode 100644
index 0000000000..6349f42507
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Releases_Test.php
@@ -0,0 +1,387 @@
+plugins as $plugin ) {
+ wp_clear_scheduled_hook( "release_to_update_api:{$plugin->post_name}" );
+
+ $release_posts = get_posts(
+ array(
+ 'post_type' => Releases::POST_TYPE,
+ 'post_parent' => $plugin->ID,
+ 'post_status' => 'any',
+ 'posts_per_page' => -1,
+ )
+ );
+
+ foreach ( $release_posts as $release_post ) {
+ wp_delete_post( $release_post->ID, true );
+ }
+
+ wp_delete_post( $plugin->ID, true );
+ }
+
+ $this->plugins = array();
+ parent::tearDown();
+ }
+
+ /**
+ * Create a plugin post for release tests.
+ *
+ * @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, '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'] );
+ }
+
+ /**
+ * Delayed imports remain draft releases and do not update the plugin post.
+ */
+ public function test_cooldown_keeps_release_import_draft_until_release_time() {
+ $plugin = $this->create_plugin( 'cooldown-release-cpt-test' );
+ update_post_meta( $plugin->ID, 'version', '1.0.0' );
+
+ Plugin_Directory::add_release(
+ $plugin,
+ array(
+ 'date' => time(),
+ 'tag' => '2.0.0',
+ 'version' => '2.0.0',
+ 'release_delay' => HOUR_IN_SECONDS,
+ 'post_status' => 'draft',
+ 'sync_status' => 'pending',
+ 'plugin' => array(
+ 'is_new_version' => true,
+ ),
+ )
+ );
+
+ $this->assertFalse( Releases::instance()->sync_release_to_plugin( $plugin, '2.0.0' ) );
+ $this->assertSame( '1.0.0', get_post_meta( $plugin->ID, 'version', true ) );
+
+ $release = Plugin_Directory::get_release( $plugin, '2.0.0' );
+ $this->assertSame( 'draft', $release['post_status'] );
+ $this->assertSame( 'pending', $release['sync_status'] );
+ $this->assertGreaterThan( time(), $release['sync_after'] );
+ }
+
+ /**
+ * Release post import data is synced to the main plugin post.
+ */
+ public function test_release_import_data_syncs_to_plugin_post() {
+ $plugin = $this->create_plugin( 'release-import-sync-test' );
+ update_post_meta( $plugin->ID, 'version', '1.0.0' );
+
+ Plugin_Directory::add_release(
+ $plugin,
+ array(
+ 'date' => time() - HOUR_IN_SECONDS,
+ 'tag' => '2.0.0',
+ 'version' => '2.0.0',
+ 'release_delay' => 0,
+ 'post_status' => 'draft',
+ 'sync_status' => 'pending',
+ 'plugin' => array(
+ 'is_new_version' => true,
+ 'stable_tag' => '2.0.0',
+ 'old_stable_tag' => '1.0.0',
+ 'changed_tags' => array( '2.0.0' ),
+ 'svn_revision' => 123,
+ 'warnings' => array(),
+ 'post' => array(
+ 'post_title' => 'Release Import Sync Test',
+ 'post_content' => 'Imported release content.',
+ 'post_excerpt' => 'Imported release excerpt.',
+ ),
+ ),
+ )
+ );
+
+ $release_post = Releases::instance()->get_release_post( $plugin, '2.0.0' );
+ $this->assertInstanceOf( WP_Post::class, $release_post );
+
+ update_post_meta( $release_post->ID, 'stable_tag', '2.0.0' );
+ update_post_meta( $release_post->ID, 'version', '2.0.0' );
+ update_post_meta( $release_post->ID, 'requires', '6.5' );
+ add_post_meta( $release_post->ID, 'dashboard_widget_name', 'Release Widget', false );
+ wp_set_object_terms( $release_post->ID, array( 'dashboard-widgets' ), 'plugin_section' );
+
+ $this->assertTrue( Releases::instance()->sync_release_to_plugin( $plugin, '2.0.0' ) );
+
+ $plugin = get_post( $plugin->ID );
+ $this->assertSame( 'Release Import Sync Test', $plugin->post_title );
+ $this->assertSame( 'Imported release content.', $plugin->post_content );
+ $this->assertSame( '2.0.0', get_post_meta( $plugin->ID, 'stable_tag', true ) );
+ $this->assertSame( '2.0.0', get_post_meta( $plugin->ID, 'version', true ) );
+ $this->assertSame( '6.5', get_post_meta( $plugin->ID, 'requires', true ) );
+ $this->assertSame( array( 'Release Widget' ), get_post_meta( $plugin->ID, 'dashboard_widget_name' ) );
+ $this->assertTrue( has_term( 'dashboard-widgets', 'plugin_section', $plugin ) );
+ }
+
+ /**
+ * Syncing one release import supersedes older pending imports.
+ */
+ public function test_sync_supersedes_other_pending_release_imports() {
+ $plugin = $this->create_plugin( 'superseded-release-import-test' );
+ update_post_meta( $plugin->ID, 'version', '1.0.0' );
+
+ foreach ( array( '2.0.0', '3.0.0' ) as $version ) {
+ Plugin_Directory::add_release(
+ $plugin,
+ array(
+ 'date' => time() - HOUR_IN_SECONDS,
+ 'tag' => $version,
+ 'version' => $version,
+ 'release_delay' => 0,
+ 'post_status' => 'draft',
+ 'sync_status' => 'pending',
+ 'plugin' => array(
+ 'is_new_version' => true,
+ 'stable_tag' => $version,
+ 'old_stable_tag' => '1.0.0',
+ 'post' => array(
+ 'post_title' => "Release {$version}",
+ ),
+ ),
+ )
+ );
+
+ $release_post = Releases::instance()->get_release_post( $plugin, $version );
+ update_post_meta( $release_post->ID, 'stable_tag', $version );
+ update_post_meta( $release_post->ID, 'version', $version );
+ }
+
+ $this->assertTrue( Releases::instance()->sync_release_to_plugin( $plugin, '3.0.0' ) );
+
+ $superseded_release = Plugin_Directory::get_release( $plugin, '2.0.0' );
+ $this->assertSame( 'superseded', $superseded_release['sync_status'] );
+ $this->assertSame( '3.0.0', get_post_meta( $plugin->ID, 'version', true ) );
+ $this->assertFalse( Releases::instance()->sync_pending_release( $plugin, true ) );
+ $this->assertSame( '3.0.0', get_post_meta( $plugin->ID, 'version', true ) );
+ }
+}