diff --git a/.changeset/nine-geese-double.md b/.changeset/nine-geese-double.md new file mode 100644 index 00000000..5b35e0e9 --- /dev/null +++ b/.changeset/nine-geese-double.md @@ -0,0 +1,5 @@ +--- +"@wpengine/wp-graphql-content-blocks": minor +--- + +Added support for automatic updates hosted from WP Engine infrastructure. Includes warnings when major versions with potential breaking changes are released. diff --git a/.circleci/config.yml b/.circleci/config.yml new file mode 100644 index 00000000..f6de05c2 --- /dev/null +++ b/.circleci/config.yml @@ -0,0 +1,163 @@ +# Notes: +# - Jobs initially start from /home/circleci/project +# - Plugin located at /home/circleci/project/wp-graphql-content-blocks + +version: 2.1 + +orbs: + php: circleci/php@1.1.0 + wp-product-orb: wpengine/wp-product-orb@2.0.0 + node: circleci/node@5.2.0 + +jobs: + plugin-unzip: + executor: wp-product-orb/default + environment: + WPE_SESSION_DIR: ./.wpe + parameters: + slug: + type: string + filename: + type: string + steps: + - attach_workspace: + at: . + - wp-product-orb/variable_load + - run: + name: "Unzip plugin files" + command: | + cd ~/project + mkdir build + curl -sL https://github.com/wpengine/wp-graphql-content-blocks/releases/latest/download/wp-graphql-content-blocks.zip > <>.zip + unzip -o -d <> <>.zip + ls -laR + - wp-product-orb/get_version_from_php: + filename: <>/<> + return_var: BUILD_VERSION + - wp-product-orb/variable: + var: BUILD_VERSION + value: $BUILD_VERSION + - run: + name: "Move zip file to build directory" + command: | + mv <>.zip build/<>.$BUILD_VERSION.zip + - run: + name: "DEBUG" + command: | + ls -laR + - persist_to_workspace: + root: . + paths: + - . + + plugin-build-json: + executor: wp-product-orb/parser + environment: + WPE_SESSION_DIR: ./.wpe + parameters: + slug: + type: string + steps: + - attach_workspace: + at: . + - run: + command: | + cd ~/project + - wp-product-orb/variable_load + - wp-product-orb/parse_wp_readme: + infile: <>/readme.txt + outfile: build/<>.$BUILD_VERSION.json + - store_artifacts: + path: build + - run: + name: "DEBUG" + command: | + pwd + ls -laR + - persist_to_workspace: + root: . + paths: + - build + + plugin-deploy: + executor: wp-product-orb/authenticate + environment: + WPE_SESSION_DIR: ./.wpe + parameters: + auth_url: + type: string + upload_url: + type: string + slug: + type: string + steps: + - attach_workspace: + at: . + - wp-product-orb/variable_load + - wp-product-orb/authenticate: + user: WPE_LDAP_USER + pass: WPE_LDAP_PASS + url: <> + - run: + name: "DEBUG" + command: | + pwd + ls -laR + - wp-product-orb/post_zip: + url: <>/<> + zip: build/<>.$BUILD_VERSION.zip + json: build/<>.$BUILD_VERSION.json + version: $BUILD_VERSION + +workflows: + deploy: + jobs: + - plugin-unzip: + slug: wpgraphql-content-blocks + filename: wp-graphql-content-blocks.php + # Run this job when a tag is published. + filters: + branches: + ignore: /.*/ + tags: + only: /^v.*/ + - plugin-build-json: + slug: wpgraphql-content-blocks + requires: + - plugin-unzip + # Run this job when a tag is published. + filters: + branches: + ignore: /.*/ + tags: + only: /^v.*/ + - plugin-deploy: + name: "plugin-deploy-staging" + slug: wpgraphql-content-blocks + requires: + - plugin-unzip + - plugin-build-json + filters: + branches: + only: + - main + - canary + tags: + only: /^v.*/ + context: wpe-ldap-creds + auth_url: https://auth-staging.wpengine.io/v1/tokens + upload_url: https://wp-product-info-staging.wpesvc.net/v1/plugins + - plugin-deploy: + name: "plugin-deploy-production" + slug: wp-graphql-content-blocks + requires: + - "plugin-deploy-staging" + filters: + branches: + ignore: /.*/ + tags: + # Run this job when a tag is published. + only: /^v.*/ + context: wpe-ldap-creds + auth_url: https://auth.wpengine.io/v1/tokens + upload_url: https://wp-product-info.wpesvc.net/v1/plugins \ No newline at end of file diff --git a/.phpcs.xml.dist b/.phpcs.xml.dist index 66e3b06a..192f47fe 100644 --- a/.phpcs.xml.dist +++ b/.phpcs.xml.dist @@ -54,6 +54,7 @@ + diff --git a/composer.json b/composer.json index 6929b4b6..56af64b1 100644 --- a/composer.json +++ b/composer.json @@ -6,7 +6,8 @@ "minimum-stability": "dev", "require": { "php": ">=7.4", - "imangazaliev/didom": "^2.0" + "imangazaliev/didom": "^2.0", + "blakewilson/wp-enforce-semver": "^2.0" }, "require-dev": { "brain/monkey": "^2.6", diff --git a/composer.lock b/composer.lock index 0eb7448c..026562c3 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,53 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b45898422af097c51889e5dad4ac25b0", + "content-hash": "f13b8ebc87b833928dcb8ff3475f18b6", "packages": [ + { + "name": "blakewilson/wp-enforce-semver", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/blakewilson/wp-enforce-semver.git", + "reference": "d595e75ffadd7993975dfb124072b0664c853c49" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/blakewilson/wp-enforce-semver/zipball/d595e75ffadd7993975dfb124072b0664c853c49", + "reference": "d595e75ffadd7993975dfb124072b0664c853c49", + "shasum": "" + }, + "require": { + "phlak/semver": "^4.1" + }, + "require-dev": { + "dealerdirect/phpcodesniffer-composer-installer": "^1.0", + "phpcompatibility/phpcompatibility-wp": "^2.1", + "wp-coding-standards/wpcs": "^2.3" + }, + "type": "library", + "autoload": { + "psr-4": { + "EnforceSemVer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Blake Wilson", + "email": "blake@blake.id" + } + ], + "description": "A class to enforce SemVer in your WordPress plugins.", + "support": { + "issues": "https://github.com/blakewilson/wp-enforce-semver/issues", + "source": "https://github.com/blakewilson/wp-enforce-semver/tree/2.0.1" + }, + "time": "2023-10-20T05:29:32+00:00" + }, { "name": "imangazaliev/didom", "version": "2.0.1", @@ -57,6 +102,65 @@ "source": "https://github.com/Imangazaliev/DiDOM/tree/2.0.1" }, "time": "2023-03-05T03:23:48+00:00" + }, + { + "name": "phlak/semver", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/PHLAK/SemVer.git", + "reference": "decdb385f26f2f8da2748289534fa3e61347917e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/PHLAK/SemVer/zipball/decdb385f26f2f8da2748289534fa3e61347917e", + "reference": "decdb385f26f2f8da2748289534fa3e61347917e", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "phlak/coding-standards": "^2.0", + "psy/psysh": "^0.11.1", + "vimeo/psalm": "^4.3", + "yoast/phpunit-polyfills": "^1.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/Support/helpers.php" + ], + "psr-4": { + "PHLAK\\SemVer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Kankiewicz", + "email": "Chris@ChrisKankiewicz.com" + } + ], + "description": "Semantic versioning helper library", + "support": { + "issues": "https://github.com/PHLAK/SemVer/issues", + "source": "https://github.com/PHLAK/SemVer/tree/4.1.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/PHLAK", + "type": "github" + }, + { + "url": "https://paypal.me/ChrisKankiewicz", + "type": "paypal" + } + ], + "time": "2022-12-23T20:28:04+00:00" } ], "packages-dev": [ @@ -4021,5 +4125,5 @@ "platform-overrides": { "php": "7.4" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/includes/WPGraphQLContentBlocks.php b/includes/WPGraphQLContentBlocks.php index bcbf3536..824a7da4 100644 --- a/includes/WPGraphQLContentBlocks.php +++ b/includes/WPGraphQLContentBlocks.php @@ -147,6 +147,12 @@ static function () { }//end if }//end if + require_once WPGRAPHQL_CONTENT_BLOCKS_PLUGIN_DIR . 'includes/updates/update-functions.php'; + require_once WPGRAPHQL_CONTENT_BLOCKS_PLUGIN_DIR . 'includes/updates/update-callbacks.php'; + + // phpcs:ignore SlevomatCodingStandard.Variables.UnusedVariable.UnusedVariable -- Library bootstraps itself, hence variable is unused. + $semver = new \EnforceSemVer\EnforceSemVer( WPGRAPHQL_CONTENT_BLOCKS_PATH ); + return true; } diff --git a/includes/updates/update-callbacks.php b/includes/updates/update-callbacks.php new file mode 100644 index 00000000..e02f4cd1 --- /dev/null +++ b/includes/updates/update-callbacks.php @@ -0,0 +1,178 @@ +requires_at_least ) || empty( $response->version ) ) { + return $data; + } + + $current_plugin_data = \get_plugin_data( WPGRAPHQL_CONTENT_BLOCKS_FILE ); + $meets_wp_req = version_compare( get_bloginfo( 'version' ), $response->requires_at_least, '>=' ); + + // Only update the response if there's a newer version, otherwise WP shows an update notice for the same version. + if ( $meets_wp_req && version_compare( $current_plugin_data['Version'], $response->version, '<' ) ) { + $response->plugin = plugin_basename( WPGRAPHQL_CONTENT_BLOCKS_FILE ); + $data->response[ WPGRAPHQL_CONTENT_BLOCKS_PATH ] = $response; + } + + return $data; +} + +add_filter( 'plugins_api', __NAMESPACE__ . '\custom_plugin_api_request', 10, 3 ); +/** + * Callback for WordPress 'plugins_api' filter. + * + * Return a custom response for this plugin from the custom endpoint. + * + * @link https://developer.wordpress.org/reference/hooks/plugins_api/ + * + * @param false|object|array $api The result object or array. Default false. + * @param string $action The type of information being requested from the Plugin Installation API. + * @param object $args Plugin API arguments. + * + * @return false|\WPGraphQL\ContentBlocks\PluginUpdater\stdClass $response Plugin API arguments. + */ +function custom_plugin_api_request( $api, $action, $args ) { + if ( empty( $args->slug ) || WPGRAPHQL_CONTENT_BLOCKS_SLUG !== $args->slug ) { + return $api; + } + + $response = get_plugin_data_from_wpe( $args ); + if ( empty( $response ) || is_wp_error( $response ) ) { + return $api; + } + + return $response; +} + +add_action( 'admin_notices', __NAMESPACE__ . '\delegate_plugin_row_notice' ); +/** + * Callback for WordPress 'admin_notices' action. + * + * Delegate actions to display an error message on the plugin table row if present. + * + * @link https://developer.wordpress.org/reference/hooks/admin_notices/ + * + * @return void + */ +function delegate_plugin_row_notice() { + $screen = get_current_screen(); + if ( 'plugins' !== $screen->id ) { + return; + } + + $error = get_plugin_api_error(); + if ( ! $error ) { + return; + } + + $plugin_basename = plugin_basename( WPGRAPHQL_CONTENT_BLOCKS_FILE ); + + remove_action( "after_plugin_row_{$plugin_basename}", 'wp_plugin_update_row' ); + add_action( "after_plugin_row_{$plugin_basename}", __NAMESPACE__ . '\display_plugin_row_notice', 10 ); +} + +/** + * Callback for WordPress 'after_plugin_row_{plugin_basename}' action. + * + * Callback added in add_plugin_page_notices(). + * + * Show a notice in the plugin table row when there is an error present. + * + * @return void + */ +function display_plugin_row_notice() { + $error = get_plugin_api_error(); + + ?> + + +
+

+ +

+
+ + + id ) { + return; + } + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only used to avoid displaying messages when inappropriate. + if ( ! empty( $_GET['action'] ) && 'do-theme-upgrade' === $_GET['action'] ) { + return; + } + + $error = get_plugin_api_error(); + if ( ! $error ) { + return; + } + + ?> +
+

+ +

+
+
' . __( 'THIS UPDATE MAY CONTAIN BREAKING CHANGES: This plugin uses Semantic Versioning, and this new version is a major release. Please review the changelog before updating.', 'wp-graphql-content-blocks' ); +} \ No newline at end of file diff --git a/includes/updates/update-functions.php b/includes/updates/update-functions.php new file mode 100644 index 00000000..a3ba01a9 --- /dev/null +++ b/includes/updates/update-functions.php @@ -0,0 +1,152 @@ +requires_at_least, '>=' ); + + $api = new stdClass(); + $api->author = 'WP Engine'; + $api->homepage = 'https://wpengine.com'; + $api->name = $product_info->name; + $api->requires = isset( $product_info->requires_at_least ) ? $product_info->requires_at_least : $current_plugin_data['RequiresWP']; + $api->sections['changelog'] = isset( $product_info->sections->changelog ) ? $product_info->sections->changelog : '

1.0

  • Initial release.
'; + $api->slug = $args->slug; + + // Only pass along the update info if the requirements are met and there's actually a newer version. + if ( $meets_wp_req && version_compare( $current_plugin_data['Version'], $product_info->version, '<' ) ) { + $api->version = $product_info->version; + $api->download_link = $product_info->download_link; + } + + return $api; +} + +/** + * Fetches and returns the plugin info api error. + * + * @return mixed|false The plugin api error or false. + */ +function get_plugin_api_error() { + return get_option( 'wpgraphql_content_blocks_product_info_api_error', false ); +} + +/** + * Retrieve remote plugin information from the custom endpoint. + * + * @return \stdClass + */ +function get_remote_plugin_info() { + $current_plugin_data = \get_plugin_data( WPGRAPHQL_CONTENT_BLOCKS_FILE ); + $response = get_transient( 'wpgraphql_content_blocks_product_info' ); + + if ( false === $response ) { + $request_args = [ + 'timeout' => ( ( defined( 'DOING_CRON' ) && DOING_CRON ) ? 30 : 3 ), + 'user-agent' => 'WordPress/' . get_bloginfo( 'version' ) . '; ' . get_bloginfo( 'url' ), + 'body' => [ + 'version' => $current_plugin_data['Version'], + ], + ]; + + $response = request_plugin_updates( $request_args ); + if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { + if ( is_wp_error( $response ) ) { + update_option( 'wpgraphql_content_blocks_product_info_api_error', $response->get_error_code(), false ); + } else { + $response_body = json_decode( wp_remote_retrieve_body( $response ), false ); + $error_code = ! empty( $response_body->error_code ) ? $response_body->error_code : 'unknown'; + update_option( 'wpgraphql_content_blocks_product_info_api_error', $error_code, false ); + } + + $response = new stdClass(); + + set_transient( 'wpgraphql_content_blocks_product_info', $response, MINUTE_IN_SECONDS * 5 ); + + return $response; + } + + delete_option( 'wpgraphql_content_blocks_product_info_api_error' ); + + $response = json_decode( + wp_remote_retrieve_body( $response ) + ); + + if ( ! property_exists( $response, 'icons' ) || empty( $response->icons['default'] ) ) { + $response->icons['default'] = WPGRAPHQL_CONTENT_BLOCKS_URL . 'includes/updates/images/wpe-logo-stacked-inverse.svg'; + } + + set_transient( 'wpgraphql_content_blocks_product_info', $response, HOUR_IN_SECONDS * 12 ); + } + + return $response; +} + +/** + * Get the remote plugin api error message. + * + * @param string $reason The reason/error code received the API. + * + * @return string The error message. + */ +function get_api_error_text( string $reason ): string { + switch ( $reason ) { + case 'key-unknown': + return __( 'The product you requested information for is unknown. Please contact support.', 'wp-graphql-content-blocks' ); + + default: + return sprintf( + /* translators: %1$s: Link to GitHub issues. %2$s: The text that is linked. */ + __( + 'WPGraphQL Content Blocks encountered an unknown error connecting to the update service. This issue could be temporary. Please %2$s if this error persists.', + 'wp-graphql-content-blocks' + ), + 'https://github.com/wpengine/wp-graphql-content-blocks/issues', + esc_html__( 'contact support', 'wp-graphql-content-blocks' ) + ); + } +} + +/** + * Retrieve plugin update information via http GET request. + * + * @param array $args Array of request args. + * + * @return array|\WP_Error A response as an array or WP_Error. + * @uses wp_remote_get() + * @link https://developer.wordpress.org/reference/functions/wp_remote_get/ + */ +function request_plugin_updates( array $args = [] ) { + return wp_remote_get( + 'https://wp-product-info.wpesvc.net/v1/plugins/wpgraphql-content-blocks', + $args + ); +} diff --git a/tests/e2e/example.spec.js b/tests/e2e/example.spec.js index 8c5ab9e3..3bf205ea 100644 --- a/tests/e2e/example.spec.js +++ b/tests/e2e/example.spec.js @@ -16,7 +16,7 @@ describe('example test', () => { await visitAdminPage('plugins.php'); // Select the plugin based on slug and active class - const activePlugin = await page.$x('//tr[contains(@class, "active") and contains(@data-slug, "wpgraphql-content-blocks")]'); + const activePlugin = await page.$x('//tr[contains(@class, "active") and not(contains(@class, "plugin-update-tr")) and contains(@data-slug, "wpgraphql-content-blocks")]'); // assert that our plugin is active by checking the HTML expect(activePlugin?.length).toBe(1); diff --git a/tests/unit/updates/test-update-callbacks.php b/tests/unit/updates/test-update-callbacks.php new file mode 100644 index 00000000..5dd9095d --- /dev/null +++ b/tests/unit/updates/test-update-callbacks.php @@ -0,0 +1,22 @@ +