Repository: wp-graphql/wp-graphql (language: php)

Issue:
Idea: SemVer auto-update enforcement
### What problem does this address?

WordPress loves auto-updates; Headless apps that rely on a strongly-typed schema, not so much.
As we get closer to closing out the non-breaking backlog and start on a v2.0, we should consider a way to enforce SemVer on the WP backend.

### What is your proposed solution?

At a bare minimum, we should disable automatic updates for **X**.y.z releases, and place some sort of notice/confirmation dialog when trying to update the plugin manually to a major version in the background.

Ideally, we would take this a step further, and let users (either by filter or by admin setting) set a different autoupdate policy (E.g. only autoupdate x.y.**Z** versions).

As a bonus, we could even let users opt-in to a beta/next channel, as (based on the gh issue tracker), there's a lot of fundamental/breaking work we have slated for v2.

Major stretch goal would be to allow WPGraphQL extensions to provide a `tested up to` label, so we could also warn about possible incompatibilities with a new major release.

I would recommend implementing this _well before_ we start work on v2, so there can be a series of releases to ensure there's no kinks before game-day.

### What alternatives have you considered?

_No response_

### Additional Context

- The [`auto_update_{type}` hook](https://developer.wordpress.org/reference/classes/wp_automatic_updater/should_update/#hooks)
- WC's [Plugin Update logic](https://github.com/woocommerce/woocommerce/tree/2343d5201af878a394b800a5664adeb82c37bae7/plugins/woocommerce/includes/admin/plugin-updates) (used to display update warnings based on versions and tested compatibility.
- WC's [Beta Channel logic](https://github.com/woocommerce/woocommerce/blob/trunk/plugins/woocommerce-beta-tester/includes/class-wc-beta-tester.php)


Discussion / hints:
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.

@Blakewilson has done some work in the realm of semver updates for WordPress plugins and might have some ideas to contribute. (see: https://github.com/blakewilson/semantic-versioning-plugin)
Per today's triage session: we'll put together some POCs for feedback.
@jasonbahl @justlevine Last night I updated my semantic versioning plugin to be a standalone class instead that you can install via Composer. Here's more info: https://github.com/blakewilson/wp-enforce-semver#usage
Todo: wrangle this to an actionable list (@justlevine )

Fix patch:
diff --git a/build/updates.asset.php b/build/updates.asset.php
new file mode 100644
index 000000000..976e41b72
--- /dev/null
+++ b/build/updates.asset.php
@@ -0,0 +1,1 @@
+<?php return array('dependencies' => array(), 'version' => '9c227d873776bd4ba8da');
diff --git a/build/updates.css b/build/updates.css
new file mode 100644
index 000000000..d8519eedd
--- /dev/null
+++ b/build/updates.css
@@ -0,0 +1,1 @@
+#wp-graphql-update .hidden,#wp-graphql-update .updating-message .wp-graphql-update-notice{display:none}#wp-graphql-update .wp-graphql-update-notice{background:#fff8e5!important;border-left:4px solid #ffb900;border-top:1px solid #ffb900;font-weight:400;margin:0 -12px 0 -16px!important;padding:9px 0 9px 12px!important}#wp-graphql-update p:before{content:unset}#wp-graphql-update .warning:before{content:""}#wp-graphql-update table{margin:.75em 0 0}#wp-graphql-update table tr{background:transparent none;border:0}#wp-graphql-update table td,#wp-graphql-update table th{background:transparent none!important;border:0!important;box-shadow:none;font-size:1em;margin:0;padding:.75em 20px 0}#wp-graphql-update table th{font-weight:700}#wp-graphql-update-modal{display:none}.wp-graphql-update-modal__container{border-radius:4px;padding:0}.wp-graphql-update-modal__container #TB_closeAjaxWindow,.wp-graphql-update-modal__container #TB_title{display:none}.wp-graphql-update-modal__container #TB_ajaxContent{height:100%!important;margin:0;padding:0;width:100%!important}.wp-graphql-update-modal__container #TB_ajaxContent p{margin:0 0 1em}.wp-graphql-update-modal__container #TB_ajaxContent .message{padding:0 2em}.wp-graphql-update-modal__content h1{background:#e9e9e9;border-bottom:1px solid #eee;border-top-left-radius:4px;border-top-right-radius:4px;font-size:1.6em;line-height:1.5em;margin:0 0 .5em;padding:.75em 1em}.wp-graphql-update-modal__content .wp-graphql-update-notice{padding:0 2em}.wp-graphql-update-modal__content .plugin-details-table-container{max-height:40vh;overflow-y:auto}.wp-graphql-update-modal__content table{margin:0 0 1.25em}.wp-graphql-update-modal__content th{font-weight:700;margin-top:0}.wp-graphql-update-modal__content td,.wp-graphql-update-modal__content th{background:transparent none;border:0;box-shadow:none;font-size:1em;margin:0;padding:.75em 1.25em 0}.wp-graphql-update-modal__content .actions{border-top:1px solid #eee;margin:0;overflow:hidden;padding:1em 0 2em}.wp-graphql-update-modal__content .actions a.button-primary{float:right}
diff --git a/package-lock.json b/package-lock.json
index 3e543265c..5181727bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -44,7 +44,8 @@
         "dotenv": "^16.4.4",
         "husky": "^9.0.11",
         "lint-staged": "^15.2.2",
-        "sort-package-json": "^2.7.0"
+        "sort-package-json": "^2.7.0",
+        "webpack-remove-empty-scripts": "^1.0.4"
       }
     },
     "node_modules/@aashutoshrathi/word-wrap": {
@@ -6312,6 +6313,20 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
+    "node_modules/ansis": {
+      "version": "1.5.2",
+      "resolved": "https://registry.npmjs.org/ansis/-/ansis-1.5.2.tgz",
+      "integrity": "sha512-T3vUABrcgSj/HXv27P+A/JxGk5b/ydx0JjN3lgjBTC2iZUFxQGjh43zCzLSbU4C1QTgmx9oaPeWNJFM+auI8qw==",
+      "dev": true,
+      "license": "ISC",
+      "engines": {
+        "node": ">=12.13"
+      },
+      "funding": {
+        "type": "patreon",
+        "url": "https://patreon.com/biodiscus"
+      }
+    },
     "node_modules/antd": {
       "version": "5.15.4",
       "resolved": "https://registry.npmjs.org/antd/-/antd-5.15.4.tgz",
@@ -23975,6 +23990,26 @@
         "node": ">=8"
       }
     },
+    "node_modules/webpack-remove-empty-scripts": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/webpack-remove-empty-scripts/-/webpack-remove-empty-scripts-1.0.4.tgz",
+      "integrity": "sha512-W/Vd94oNXMsQam+W9G+aAzGgFlX1aItcJpkG3byuHGDaxyK3H17oD/b5RcqS/ZHzStIKepksdLDznejDhDUs+Q==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "ansis": "1.5.2"
+      },
+      "engines": {
+        "node": ">=12.14"
+      },
+      "funding": {
+        "type": "patreon",
+        "url": "https://patreon.com/biodiscus"
+      },
+      "peerDependencies": {
+        "webpack": ">=5.32.0"
+      }
+    },
     "node_modules/webpack-sources": {
       "version": "3.2.3",
       "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz",
diff --git a/package.json b/package.json
index 1b6606c2f..943865e17 100644
--- a/package.json
+++ b/package.json
@@ -74,7 +74,8 @@
     "dotenv": "^16.4.4",
     "husky": "^9.0.11",
     "lint-staged": "^15.2.2",
-    "sort-package-json": "^2.7.0"
+    "sort-package-json": "^2.7.0",
+    "webpack-remove-empty-scripts": "^1.0.4"
   },
   "lint-staged": {
     "package.json": "sort-package-json"
diff --git a/packages/updates/index.scss b/packages/updates/index.scss
new file mode 100644
index 000000000..4e14696bf
--- /dev/null
+++ b/packages/updates/index.scss
@@ -0,0 +1,144 @@
+/**
+ * Modified from WooCommerce
+ *
+ * @see https://github.com/woocommerce/woocommerce/blob/5f04212f8188e0f7b09f6375d1a6c610fac8a631/plugins/woocommerce/client/legacy/css/menu.scss#L1
+ */
+
+#wp-graphql-update {
+
+	.updating-message {
+		.wp-graphql-update-notice {
+			display: none;
+		}
+	}
+
+	.hidden {
+		display: none;
+	}
+
+	.wp-graphql-update-notice {
+		font-weight: normal;
+		background: #fff8e5 !important;
+		border-left: 4px solid #ffb900;
+		border-top: 1px solid #ffb900;
+		padding: 9px 0 9px 12px !important;
+		margin: 0 -12px 0 -16px !important;
+	}
+
+	p:before {
+		content: unset;
+	}
+
+	.warning:before{
+		// https://developer.wordpress.org/resource/dashicons/#warning
+		content: "\f534"
+	}
+
+	table {
+		margin: 0.75em 0 0;
+
+		tr {
+			background: transparent none;
+			border: 0;
+		}
+
+		th,
+		td {
+			background: transparent none !important;
+			margin: 0;
+			padding: 0.75em 20px 0;
+			border: 0 !important;
+			font-size: 1em;
+			box-shadow: none;
+		}
+
+		th {
+			font-weight: bold;
+		}
+	}
+}
+
+#wp-graphql-update-modal {
+	display:none;
+}
+
+.wp-graphql-update-modal__container {
+	border-radius: 4px;
+	padding: 0;
+
+	#TB_closeAjaxWindow {
+		display: none;
+	}
+
+	#TB_title {
+		display: none;
+	}
+
+	#TB_ajaxContent {
+		height: 100% !important;
+		padding: 0;
+		margin: 0;
+		width: 100% !important;
+
+		p {
+			margin: 0 0 1em;
+		}
+
+		.message {
+			padding: 0 2em;
+		}
+	}
+}
+
+.wp-graphql-update-modal__content {
+	h1 {
+		padding: 0.75em 1em;
+		margin: 0 0 0.5em;
+		line-height: 1.5em;
+		font-size: 1.6em;
+		border-bottom: 1px solid #eee;
+		background: #e9e9e9;
+		border-top-left-radius: 4px;
+		border-top-right-radius: 4px;
+	}
+
+	.wp-graphql-update-notice {
+		padding: 0 2em;
+
+	}
+
+	.plugin-details-table-container {
+		max-height: 40vh;
+		overflow-y: auto;
+	}
+
+	table {
+		margin: 0 0 1.25em;
+	}
+
+	th {
+		font-weight: bold;
+		margin-top: 0;
+		}
+
+	th,
+	td {
+		background: transparent none;
+		margin: 0;
+		padding: 0.75em 1.25em 0;
+		border: 0;
+		font-size: 1em;
+		box-shadow: none;
+	}
+
+	.actions {
+		border-top: 1px solid #eee;
+		margin: 0;
+		padding: 1em 0 2em 0;
+		overflow: hidden;
+
+		a.button-primary {
+			float: right;
+		}
+	}
+}
diff --git a/src/Admin/Updates/PluginsScreenLoader.php b/src/Admin/Updates/PluginsScreenLoader.php
new file mode 100644
index 000000000..cf426fcaa
--- /dev/null
+++ b/src/Admin/Updates/PluginsScreenLoader.php
@@ -0,0 +1,130 @@
+<?php
+/**
+ * Handles plugin update checks and notifications on the plugins screen.
+ *
+ * Code is inspired by and adapted from WooCommerce's WC_Plugins_Screen_Updates class.
+ *
+ * @see https://github.com/woocommerce/woocommerce/blob/5f04212f8188e0f7b09f6375d1a6c610fac8a631/plugins/woocommerce/includes/admin/plugin-updates/class-wc-plugins-screen-updates.php
+ * *
+ * @package WPGraphQL\Admin\Updates
+ */
+
+namespace WPGraphQL\Admin\Updates;
+
+/**
+ * Class PluginsScreenLoader
+ */
+class PluginsScreenLoader {
+	/**
+	 * The UpdateChecker instance.
+	 *
+	 * @var \WPGraphQL\Admin\Updates\UpdateChecker
+	 */
+	private $update_checker;
+
+	/**
+	 * The class constructor.
+	 *
+	 * Class properties are set inside the action.
+	 */
+	public function __construct() {
+		add_action( 'in_plugin_update_message-wp-graphql/wp-graphql.php', [ $this, 'in_plugin_update_message' ], 10, 2 );
+	}
+
+	/**
+	 * Injects a warning message into the plugin update message.
+	 *
+	 * @param array<string,mixed> $args The plugin update message arguments.
+	 * @param object              $response The plugin update response.
+	 */
+	public function in_plugin_update_message( $args, $response ): void {
+		$this->update_checker = new UpdateChecker( $response );
+
+		if ( $this->update_checker->should_autoupdate( true ) ) {
+			return;
+		}
+
+		// @todo - maybe show upgrade notice?
+		$update_message = '';
+
+		$untested_plugins = $this->update_checker->get_untested_plugins( 'major' );
+
+		if ( ! empty( $untested_plugins ) ) {
+			$update_message .= $this->get_untested_plugins_message( $untested_plugins );
+			$update_message .= $this->update_checker->get_untested_plugins_modal( $untested_plugins );
+		}
+
+		// Handle dangling <p> tags from the default update message.
+		$update_message = sprintf( '</p>%s<p class="hidden">', $update_message );
+
+		// Output the JS for the modal.
+		add_action( 'admin_print_footer_scripts', [ $this, 'modal_js' ] );
+
+		echo wp_kses_post( $update_message );
+	}
+
+	/**
+	 * Gets the untested plugins warning message.
+	 *
+	 * @param array<string,array<string,mixed>> $untested_plugins The untested plugins.
+	 */
+	private function get_untested_plugins_message( array $untested_plugins ): string {
+		$plugins = array_map(
+			static function ( $plugin ) {
+				return $plugin['Name'];
+			},
+			$untested_plugins
+		);
+
+		if ( empty( $plugins ) ) {
+			return '';
+		}
+
+		ob_start();
+		?>
+		<div class="wp-graphql-update-notice">
+			<p class="warning"><strong><?php echo esc_html__( 'Untested Plugins:', 'wp-graphql' ); ?></strong></p>
+			<?php echo wp_kses_post( $this->update_checker->get_compatibility_warning_message( $untested_plugins ) ); ?>
+		</div>
+
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * The modal JS for the plugin update message.
+	 */
+	public function modal_js(): void {
+		?>
+		<script>
+			( function( $ ) {
+				var $update_box = $( '#wp-graphql-update' );
+				var $update_link = $update_box.find('a.update-link').first();
+				var update_url = $update_link.attr( 'href' );
+
+				// Set up thickbox.
+				$update_link.removeClass( 'update-link' );
+				$update_link.addClass( 'wp-graphql-thickbox' );
+				$update_link.attr( 'href', '#TB_inline?height=600&width=550&inlineId=wp-graphql-update-modal' );
+
+				// Trigger the update if the user accepts the modal's warning.
+				$( '#wp-graphql-update-modal .accept' ).on( 'click', function( evt ) {
+					evt.preventDefault();
+					tb_remove();
+					$update_link.removeClass( 'wc-thickbox open-plugin-details-modal' );
+					$update_link.addClass( 'update-link' );
+					$update_link.attr( 'href', update_url );
+					$update_link.trigger( 'click' );
+				});
+
+				$( '#wp-graphql-update-modal .cancel' ).on( 'click', function( evt ) {
+					evt.preventDefault();
+					tb_remove();
+				});
+			})( jQuery );
+		</script>
+
+		<?php
+		$this->update_checker->modal_js();
+	}
+}
diff --git a/src/Admin/Updates/SemVer.php b/src/Admin/Updates/SemVer.php
new file mode 100644
index 000000000..b7659fa10
--- /dev/null
+++ b/src/Admin/Updates/SemVer.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * Basic SemVer implementation.
+ *
+ * We don't need to do anything fancy here, just enough to compare versions.
+ *
+ * @package WPGraphQL\Admin\Updates
+ * @since @todo
+ */
+
+namespace WPGraphQL\Admin\Updates;
+
+/**
+ * Class SemVer
+ */
+final class SemVer {
+	/**
+	 * Get the release type of the current version of WPGraphQL.
+	 *
+	 * @param string $old_version The old version of WPGraphQL.
+	 * @param string $new_version The new version of WPGraphQL.
+	 *
+	 * @return 'major'|'minor'|'patch'|'prerelease'|'unknown' The release type.
+	 */
+	public static function get_release_type( string $old_version, string $new_version ): string {
+		$old_version = self::parse( $old_version );
+		$new_version = self::parse( $new_version );
+
+		if ( null === $old_version || null === $new_version ) {
+			return 'unknown';
+		}
+
+		if ( $old_version['major'] < $new_version['major'] ) {
+			return 'major';
+		}
+
+		if ( $old_version['minor'] < $new_version['minor'] ) {
+			return 'minor';
+		}
+
+		if ( $old_version['patch'] < $new_version['patch'] ) {
+			return 'patch';
+		}
+
+		return 'prerelease';
+	}
+
+	/**
+	 * Parse the version string.
+	 *
+	 * @param string $version The version string.
+	 *
+	 * @return ?array{major:int,minor:int,patch:int,prerelease:?string,buildmetadata:?string,version:string}
+	 */
+	public static function parse( string $version ): ?array {
+		/**
+		 * Semantic Versioning regex.
+		 *
+		 * @see https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string
+		 */
+		$regex = '/^(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/';
+
+		if ( ! preg_match( $regex, $version, $matches ) ) {
+			return null;
+		}
+
+		$prerelease = ! empty( $matches['prerelease'] ) ? self::parse_prerelease_version( $matches['prerelease'] ) : null;
+
+		return [
+			'major'         => (int) $matches['major'],
+			'minor'         => (int) $matches['minor'],
+			'patch'         => (int) $matches['patch'],
+			'prerelease'    => $prerelease,
+			'buildmetadata' => ! empty( $matches['buildmetadata'] ) ? $matches['buildmetadata'] : null,
+			'version'       => sprintf( '%d.%d.%d', $matches['major'], $matches['minor'], $matches['patch'] ),
+		];
+	}
+
+	/**
+	 * Parse the prerelease version string.
+	 *
+	 * @param string $version The version string.
+	 */
+	protected static function parse_prerelease_version( string $version ): ?string {
+		$separator      = '';
+		$release_string = '';
+
+		$version_parts = explode( '.', $version );
+
+		if ( empty( $version_parts ) ) {
+			return null;
+		}
+
+		foreach ( $version_parts as $part ) {
+			// Leading zero is invalid.
+			if ( ctype_digit( $part ) ) {
+				$part = (int) $part;
+			}
+
+			$release_string .= $separator . (string) $part;
+			// If this isnt the first round, the separator is a dot.
+			$separator = '.';
+		}
+
+		return $release_string;
+	}
+}
diff --git a/src/Admin/Updates/UpdateChecker.php b/src/Admin/Updates/UpdateChecker.php
new file mode 100644
index 000000000..f9cc78b52
--- /dev/null
+++ b/src/Admin/Updates/UpdateChecker.php
@@ -0,0 +1,628 @@
+<?php
+/**
+ * Handle the plugin update checks and notifications.
+ *
+ * @internal This class is for internal use only. It may change in the future without warning.
+ *
+ * Code is inspired by and adapted from WooCommerce's WC_Plugin_Updates class.
+ * @see https://github.com/woocommerce/woocommerce/blob/5f04212f8188e0f7b09f6375d1a6c610fac8a631/plugins/woocommerce/includes/admin/plugin-updates/class-wc-plugin-updates.php
+ *
+ * @package WPGraphQL\Admin\Updates
+ */
+
+namespace WPGraphQL\Admin\Updates;
+
+/**
+ * Class UpdateChecker
+ *
+ * @internal This class is for internal use only. It may change in the future without warning.
+ */
+class UpdateChecker {
+	/**
+	 * The version header to check for in the plugin file.
+	 */
+	public const VERSION_HEADER = 'Requires WPGraphQL';
+
+	/**
+	 * The tested up to header to check for in the plugin file.
+	 */
+	public const TESTED_UP_TO_HEADER = 'WPGraphQL tested up to';
+
+	/**
+	 * The current version of the plugin.
+	 *
+	 * @var string
+	 */
+	public $current_version = WPGRAPHQL_VERSION;
+
+	/**
+	 * The new version of the available.
+	 *
+	 * @var string
+	 */
+	public $new_version;
+
+	/**
+	 * The local cache of _all_ plugins.
+	 *
+	 * @var ?array<string,array<string,mixed>>
+	 */
+	private $all_plugins;
+
+	/**
+	 * The array of plugins that use WPGraphQL as a dependency.
+	 *
+	 * @var ?array<string,array<string,mixed>>
+	 */
+	private $dependents;
+
+	/**
+	 * The array of plugins that *maybe* use WPGraphQL as a dependency.
+	 *
+	 * @var ?array<string,array<string,mixed>>
+	 */
+	private $possible_dependents;
+
+	/**
+	 * The WPGraphQL plugin data object.
+	 *
+	 * @var object
+	 */
+	private $plugin_data;
+
+
+	/**
+	 * The release type of the new version of WPGraphQL.
+	 *
+	 * @var 'major'|'minor'|'patch'|'prerelease'|'unknown'
+	 */
+	private $release_type;
+
+	/**
+	 * UpdateChecker constructor.
+	 *
+	 * @param object $plugin_data The plugin data object from the update check.
+	 */
+	public function __construct( $plugin_data ) {
+		$this->plugin_data  = $plugin_data;
+		$this->new_version  = property_exists( $plugin_data, 'new_version' ) ? $plugin_data->new_version : '';
+		$this->release_type = SemVer::get_release_type( $this->current_version, $this->new_version );
+	}
+
+	/**
+	 * Checks whether any untested or incompatible WPGraphQL extensions should prevent an autoupdate.
+	 *
+	 * @param bool $default_value Whether to allow the update by default.
+	 */
+	public function should_autoupdate( bool $default_value ): bool {
+		// If this is a major release, and we have those disabled, don't allow the autoupdate.
+		if ( 'major' === $this->release_type && ! $this->should_allow_major_autoupdates() ) {
+			return false;
+		}
+
+		// If there are active incompatible plugins, don't allow the update.
+		$incompatible_plugins = $this->get_incompatible_plugins( $this->new_version, true );
+
+		if ( ! empty( $incompatible_plugins ) ) {
+			return false;
+		}
+
+		// If allow untested autoupdates enabled, allow the update.
+		if ( $this->should_allow_untested_autoupdates() ) {
+			return $default_value;
+		}
+
+		$untested_release_type = $this->get_untested_release_type();
+		$untested_plugins      = $this->get_untested_plugins( $untested_release_type );
+
+		if ( ! empty( $untested_plugins ) ) {
+			return false;
+		}
+
+		return $default_value;
+	}
+	/**
+	 * Gets a list of plugins that use WPGraphQL as a dependency and are not tested with the current version of WPGraphQL.
+	 *
+	 * @param string $release_type The release type of the current version of WPGraphQL.
+	 *
+	 * @return array<string,array<string,mixed>> The array of untested plugin data.
+	 * @throws \InvalidArgumentException If the WPGraphQL version is invalid.
+	 */
+	public function get_untested_plugins( string $release_type ): array {
+		$version = SemVer::parse( $this->new_version );
+
+		if ( null === $version ) {
+			throw new \InvalidArgumentException( esc_html__( 'Invalid WPGraphQL version', 'wp-graphql' ) );
+		}
+
+		$dependents = array_merge(
+			$this->get_dependents(),
+			$this->get_possible_dependents()
+		);
+
+		$untested_plugins = [];
+		foreach ( $dependents as $file => $plugin ) {
+			// If the plugin doesn't have a version header, it's compatibility is unknown.
+			if ( empty( $plugin[ self::TESTED_UP_TO_HEADER ] ) ) {
+				$plugin[ self::TESTED_UP_TO_HEADER ] = __( 'Unknown', 'wp-graphql' );
+
+				$untested_plugins[ $file ] = $plugin;
+				continue;
+			}
+
+			// Parse the tested version.
+			$tested_version = SemVer::parse( $plugin[ self::TESTED_UP_TO_HEADER ] );
+			if ( null === $tested_version ) {
+				continue;
+			}
+
+			// If the major version is greater, the plugin is untested.
+			if ( $version['major'] > $tested_version['major'] ) {
+				$untested_plugins[ $file ] = $plugin;
+				continue;
+			}
+
+			// If the minor version is greater, the plugin is untested.
+			if ( 'major' !== $release_type && $version['minor'] > $tested_version['minor'] ) {
+				$untested_plugins[ $file ] = $plugin;
+				continue;
+			}
+
+			// If the patch version is greater, the plugin is untested.
+			if ( 'major' !== $release_type && 'minor' !== $release_type && $version['patch'] > $tested_version['patch'] ) {
+				$untested_plugins[ $file ] = $plugin;
+				continue;
+			}
+		}
+
+		return $untested_plugins;
+	}
+
+	/**
+	 * Get incompatible plugins.
+	 *
+	 * @param string $version The current plugin version.
+	 * @param bool   $active_only Whether to only return active plugins. Default false.
+	 *
+	 * @return array<string,array<string,mixed>> The array of incompatible plugins.
+	 */
+	public function get_incompatible_plugins( string $version = WPGRAPHQL_VERSION, bool $active_only = false ): array {
+		$dependents = $this->get_dependents();
+		$plugins    = [];
+
+		foreach ( $dependents as $file => $plugin ) {
+			// Skip if the plugin is not active or is not incompatible.
+			if ( ! $this->is_incompatible_dependent( $file, $version ) ) {
+				continue;
+			}
+
+			// If we only want active plugins, skip if the plugin is not active.
+			if ( $active_only && ! is_plugin_active( $file ) ) {
+				continue;
+			}
+
+			$plugins[ $file ] = $plugin;
+		}
+
+		return $plugins;
+	}
+
+	/**
+	 * Get the shared modal HTML for the update checkers.
+	 *
+	 * @param array<string,array<string,mixed>> $untested_plugins The untested plugins.
+	 */
+	public function get_untested_plugins_modal( array $untested_plugins ): string {
+		$plugins = array_map(
+			static function ( $plugin ) {
+				return $plugin['Name'];
+			},
+			$untested_plugins
+		);
+
+		if ( empty( $plugins ) ) {
+			return '';
+		}
+
+		ob_start();
+		?>
+
+		<div id="wp-graphql-update-modal">
+			<div class="wp-graphql-update-modal__content">
+
+				<h1><?php esc_html_e( 'Are you sure you\'re ready to update?', 'wp-graphql' ); ?></h1>
+
+				<div class="wp-graphql-update-notice">
+					<?php echo wp_kses_post( $this->get_compatibility_warning_message( $untested_plugins ) ); ?>
+
+					<?php if ( current_user_can( 'update_plugins' ) ) : ?>
+						<div class="actions">
+							<a href="#" class="button button-secondary cancel"><?php esc_html_e( 'Cancel', 'wp-graphql' ); ?></a>
+							<a class="button button-primary accept" href="#"><?php esc_html_e( 'Update now', 'wp-graphql' ); ?></a>
+						</div>
+					<?php endif ?>
+				</div>
+			</div>
+		</div>
+
+		<?php
+		return (string) ob_get_clean();
+	}
+
+	/**
+	 * Outputs the shared modal JS for the update checkers.
+	 *
+	 * @todo WIP.
+	 */
+	public function modal_js(): void {
+		?>
+		<script>
+			( function( $ ) {
+				// Initialize thickbox.
+				tb_init( '.wp-graphql-thickbox' );
+
+				var old_tb_position = false;
+
+				// Make the WC thickboxes look good when opened.
+				$( '.wp-graphql-thickbox' ).on( 'click', function( evt ) {
+					var $overlay = $( '#TB_overlay' );
+					if ( ! $overlay.length ) {
+						$( 'body' ).append( '<div id="TB_overlay"></div><div id="TB_window" class="wp-graphql-update-modal__container"></div>' );
+					} else {
+						$( '#TB_window' ).removeClass( 'thickbox-loading' ).addClass( 'wp-graphql-update-modal__container' );
+					}
+
+					// WP overrides the tb_position function. We need to use a different tb_position function than that one.
+					// This is based on the original tb_position.
+					if ( ! old_tb_position ) {
+						old_tb_position = tb_position;
+					}
+					tb_position = function() {
+						$( '#TB_window' ).css( { marginLeft: '-' + parseInt( ( TB_WIDTH / 2 ), 10 ) + 'px', width: TB_WIDTH + 'px' } );
+						$( '#TB_window' ).css( { marginTop: '-' + parseInt( ( TB_HEIGHT / 2 ), 10 ) + 'px' } );
+					};
+				});
+
+				// Reset tb_position to WP default when modal is closed.
+				$( 'body' ).on( 'thickbox:removed', function() {
+					if ( old_tb_position ) {
+						tb_position = old_tb_position;
+					}
+				});
+			}
+		) ( jQuery );
+		</script>
+		<?php
+	}
+
+	/**
+	 * Returns whether to allow major plugin autoupdates.
+	 *
+	 * Defaults to false.
+	 *
+	 * @uses 'wpgraphql_enable_major_autoupdates' filter.
+	 */
+	protected function should_allow_major_autoupdates(): bool {
+		/**
+		 * Filter whether to allow major autoupdates.
+		 *
+		 * @param bool   $should_allow    Whether to allow major autoupdates. Defaults to false.
+		 * @param string $new_version     The new WPGraphQL version number.
+		 * @param string $current_version The current WPGraphQL version number.
+		 * @param object $plugin_data     The plugin data object.
+		 */
+		return apply_filters( 'wpgraphql_enable_major_autoupdates', false, $this->new_version, $this->current_version, $this->plugin_data );
+	}
+
+	/**
+	 * Returns whether to allow plugin autoupdates when plugin dependencies are untested and might be incompatible.
+	 *
+	 * @uses `wpgraphql_untested_release_type` filter to determine the release type to use when checking for untested plugins.
+	 *
+	 * @uses 'wpgraphql_enable_untested_autoupdates' filter.
+	 */
+	protected function should_allow_untested_autoupdates(): bool {
+		$should_allow = $this->get_untested_release_type() !== $this->release_type;
+
+		/**
+		 * Filter whether to allow autoupdates with untested plugins.
+		 *
+		 * @param bool   $should_allow    Whether to allow autoupdates with untested plugins.
+		 * @param string $release_type    The release type of the current version of WPGraphQL. Either 'major', 'minor', 'patch', or 'prerelease'.
+		 * @param string $new_version     The new WPGraphQL version number.
+		 * @param string $current_version The current WPGraphQL version number.
+		 * @param object $plugin_data     The plugin data object.
+		 */
+		return apply_filters( 'wpgraphql_enable_untested_autoupdates', $should_allow, $this->release_type, $this->new_version, $this->current_version, $this->plugin_data );
+	}
+
+	/**
+	 * Gets the release type to use when checking for untested plugins.
+	 *
+	 * @return 'major'|'minor'|'patch'|'prerelease' The release type to use when checking for untested plugins.
+	 */
+	protected function get_untested_release_type(): string {
+		/**
+		 * Filter the release type to use when checking for untested plugins.
+		 * This is used to prevent autoupdates when a plugin is untested with the specified channel. I.e. major > minor > patch > prerelease.
+		 *
+		 * @param 'major'|'minor'|'patch'|'prerelease' $release_type The release type to use when checking for untested plugins. Defaults to 'major'.
+		 */
+		$release_type = (string) apply_filters( 'wpgraphql_untested_release_type', 'major' );
+
+		if ( ! in_array( $release_type, [ 'major', 'minor', 'patch', 'prerelease' ], true ) ) {
+			$release_type = 'major';
+		}
+
+		return $release_type;
+	}
+	/**
+	 * Gets the plugins that use WPGraphQL as a dependency.
+	 *
+	 * @return array<string,array<string,mixed>> The array of plugins that use WPGraphQL as a dependency, keyed by plugin path.
+	 */
+	protected function get_dependents(): array {
+		if ( isset( $this->dependents ) ) {
+			return $this->dependents;
+		}
+
+		$all_plugins = $this->get_all_plugins();
+		$plugins     = [];
+
+		foreach ( $all_plugins as $plugin_path => $plugin ) {
+			// If they're explicitly using a header, it's a dependent.
+			if ( ! $this->is_versioned_dependent( $plugin_path ) && ! $this->is_wpapi_dependent( $plugin_path ) ) {
+				continue;
+			}
+
+			$plugins[ $plugin_path ] = $plugin;
+		}
+
+		/**
+		 * Filters the list of plugins that use WPGraphQL as a dependency.
+		 *
+		 * @param array<string,array<string,mixed>> $plugins The array of plugins that use WPGraphQL as a dependency.
+		 * @param array<string,array<string,mixed>> $all_plugins The array of all plugins.
+		 */
+		$this->dependents = apply_filters( 'graphql_get_dependents', $plugins, $all_plugins );
+
+		return $this->dependents;
+	}
+
+	/**
+	 * Gets the plugins that *maybe* use WPGraphQL as a dependency.
+	 *
+	 * @return array<string,array<string,mixed>> The array of plugins that maybe use WPGraphQL as a dependency, keyed by plugin path.
+	 */
+	protected function get_possible_dependents(): array {
+		// Bail early if we've already fetched the possible plugins.
+		if ( isset( $this->possible_dependents ) ) {
+			return $this->possible_dependents;
+		}
+
+		$all_plugins = $this->get_all_plugins();
+		$plugins     = [];
+
+		foreach ( $all_plugins as $plugin_path => $plugin ) {
+			// Skip the WPGraphQL plugin.
+			if ( 'WPGraphQL' === $plugin['Name'] ) {
+				continue;
+			}
+
+			if ( ! $this->is_possible_dependent( $plugin_path ) ) {
+				continue;
+			}
+
+			$plugins[ $plugin_path ] = $plugin;
+		}
+
+		/**
+		 * Filters the list of plugins that use WPGraphQL as a dependency.
+		 *
+		 * Can be used to hide false positives or to add additional plugins that may use WPGraphQL as a dependency.
+		 *
+		 * @param array<string,array<string,mixed>> $plugins The array of plugins that maybe use WPGraphQL as a dependency.
+		 * @param array<string,array<string,mixed>> $all_plugins The array of all plugins.
+		 */
+		$this->possible_dependents = apply_filters( 'graphql_get_possible_dependents', $plugins, $all_plugins );
+
+		return $this->possible_dependents;
+	}
+
+	/**
+	 * Gets all plugins, priming the cache if necessary.
+	 *
+	 * @return array<string,array<string,mixed>> The array of all plugins, keyed by plugin path.
+	 */
+	private function get_all_plugins(): array {
+		if ( ! isset( $this->all_plugins ) ) {
+			$this->all_plugins = get_plugins();
+		}
+
+		return $this->all_plugins;
+	}
+
+	/**
+	 * Checks whether a dependency is incompatible with a specific version of WPGraphQL.
+	 *
+	 * @param string $plugin_path The plugin path used as the key in the plugins array.
+	 * @param string $version     The current version to check against.
+	 */
+	private function is_incompatible_dependent( string $plugin_path, string $version = WPGRAPHQL_VERSION ): bool {
+		$current_version = SemVer::parse( $version );
+
+		if ( null === $current_version ) {
+			return false;
+		}
+
+		$all_plugins = $this->get_all_plugins();
+		$plugin_data = $all_plugins[ $plugin_path ] ?? null;
+
+		// If the plugin doesn't have a version header, it's compatibility is unknown.
+		if ( empty( $plugin_data[ self::VERSION_HEADER ] ) ) {
+			return false;
+		}
+
+		// Parse the version.
+		$minimum_version = SemVer::parse( $plugin_data[ self::VERSION_HEADER ] );
+
+		if ( null === $minimum_version ) {
+			return false;
+		}
+
+		// Check if the plugin is incompatible.
+		if ( $minimum_version['major'] > $current_version['major'] ) {
+			return true;
+		}
+
+		if ( $minimum_version['minor'] > $current_version['minor'] ) {
+			return true;
+		}
+
+		if ( $minimum_version['patch'] > $current_version['patch'] ) {
+			return true;
+		}
+
+		// The plugin is compatible.
+		return false;
+	}
+
+	/**
+	 * Checks whether the plugin is "possibly" using WPGraphQL as a dependency.
+	 *
+	 * I.e if it's in the plugin name or description.
+	 *
+	 * @param string $plugin_path The plugin path used as the key in the plugins array.
+	 */
+	private function is_possible_dependent( string $plugin_path ): bool {
+		$all_plugins = $this->get_all_plugins();
+		$plugin_data = $all_plugins[ $plugin_path ] ?? null;
+
+		// Bail early if the plugin doesn't exist.
+		if ( empty( $plugin_data ) ) {
+			return false;
+		}
+
+		return stristr( $plugin_data['Name'], 'WPGraphQL' ) || stristr( $plugin_data['Description'], 'WPGraphQL' );
+	}
+
+	/**
+	 * Checks whether the plugin uses our version headers.
+	 *
+	 * @param string $plugin_path The plugin path used as the key in the plugins array.
+	 */
+	private function is_versioned_dependent( string $plugin_path ): bool {
+		$all_plugins = $this->get_all_plugins();
+		$plugin_data = $all_plugins[ $plugin_path ] ?? null;
+
+		// Bail early if the plugin doesn't exist.
+		if ( empty( $plugin_data ) ) {
+			return false;
+		}
+
+		return ! empty( $plugin_data[ self::VERSION_HEADER ] ) || ! empty( $plugin_data[ self::TESTED_UP_TO_HEADER ] );
+	}
+
+	/**
+	 * Whether the plugin lists WPGraphQL in its `Requires Plugins` header.
+	 *
+	 * @param string $plugin_path The plugin path used as the key in the plugins array.
+	 */
+	private function is_wpapi_dependent( string $plugin_path ): bool {
+		$all_plugins = $this->get_all_plugins();
+
+		$plugin_data = $all_plugins[ $plugin_path ] ?? null;
+
+		// Bail early if the plugin doesn't exist.
+		if ( empty( $plugin_data ) || empty( $plugin_data['RequiresPlugins'] ) ) {
+			return false;
+		}
+
+		// Normalize the required plugins.
+		$required_plugins = array_map(
+			static function ( $slug ) {
+				return strtolower( trim( $slug ) );
+			},
+			explode( ',', $plugin_data['RequiresPlugins'] ) ?: []
+		);
+
+		return in_array( 'wp-graphql', $required_plugins, true );
+	}
+
+	/**
+	 * Gets the complete compatibility warning message including the plugins table and follow-up text.
+	 *
+	 * @param array<string,array<string,mixed>> $untested_plugins The untested plugins.
+	 * @return string The formatted HTML message.
+	 */
+	public function get_compatibility_warning_message( array $untested_plugins ): string {
+		ob_start();
+		?>
+		<p>
+		<?php
+		echo wp_kses_post(
+			sprintf(
+			// translators: %s: The WPGraphQL version wrapped in a strong tag.
+				__(
+					'The following active plugin(s) require WPGraphQL to function but have not yet declared compatibility with %s. Before updating WPGraphQL, please:',
+					'wp-graphql'
+				),
+				// translators: %s: The WPGraphQL version.
+				sprintf( '<strong>WPGraphQL v%s</strong>', $this->new_version )
+			)
+		);
+		?>
+		</p>
+
+		<ol>
+			<li>
+			<?php
+			echo wp_kses_post(
+				sprintf(
+				// translators: %s: The WPGraphQL version wrapped in a strong tag.
+					__( 'Update these plugins to their latest versions that declare compatibility with %s, OR', 'wp-graphql' ),
+					sprintf( '<strong>WPGraphQL v%s</strong>', $this->new_version )
+				)
+			);
+			?>
+			</li>
+			<li>
+			<?php
+			echo wp_kses_post(
+				sprintf(
+				// translators: %s: The WPGraphQL version wrapped in a strong tag.
+					__( 'Confirm their compatibility with %s on your staging environment.', 'wp-graphql' ),
+					sprintf( '<strong>WPGraphQL v%s</strong>', $this->new_version )
+				)
+			);
+			?>
+			</li>
+		</ol>
+
+		<div class="plugin-details-table-container">
+			<table class="plugin-details-table" cellspacing="0">
+				<thead>
+					<tr>
+						<th><?php esc_html_e( 'Plugin', 'wp-graphql' ); ?></th>
+						<th><?php esc_html_e( 'WPGraphQL Tested Up To', 'wp-graphql' ); ?></th>
+					</tr>
+				</thead>
+				<tbody>
+					<?php foreach ( $untested_plugins as $plugin ) : ?>
+						<tr>
+							<td><?php echo esc_html( $plugin['Name'] ); ?></td>
+							<td><?php echo esc_html( $plugin[ self::TESTED_UP_TO_HEADER ] ); ?></td>
+						</tr>
+					<?php endforeach; ?>
+				</tbody>
+			</table>
+		</div>
+
+		<p><?php esc_html_e( 'For more information, review each plugin\'s changelogs or contact the plugin\'s developers.', 'wp-graphql' ); ?></p>
+
+		<p><strong><?php esc_html_e( 'We strongly recommend creating a backup of your site before updating.', 'wp-graphql' ); ?></strong></p>
+		<?php
+		return (string) ob_get_clean();
+	}
+}
diff --git a/src/Admin/Updates/Updates.php b/src/Admin/Updates/Updates.php
new file mode 100644
index 000000000..f73ceee95
--- /dev/null
+++ b/src/Admin/Updates/Updates.php
@@ -0,0 +1,191 @@
+<?php
+/**
+ * Initializes Plugin Update functionality.
+ *
+ * @package WPGraphQL\Admin\Updates
+ * @since @todo
+ */
+
+namespace WPGraphQL\Admin\Updates;
+
+/**
+ * Class Updates
+ */
+final class Updates {
+	/**
+	 * Initialize the Updates functionality.
+	 */
+	public function init(): void {
+		// Expose the plugin headers.
+		add_filter( 'extra_plugin_headers', [ $this, 'enable_plugin_headers' ] );
+		add_filter( 'extra_theme_headers', [ $this, 'enable_plugin_headers' ] );
+
+		// Prevent autoupdates for untested WPGraphQL Extensions.
+		add_filter( 'auto_update_plugin', [ $this, 'maybe_allow_autoupdates' ], 10, 2 );
+
+		// Load the Update Checker for the current screen.
+		add_action( 'current_screen', [ $this, 'load_screen_checker' ] );
+
+		// Disable incompatible plugins.
+		add_action( 'admin_init', [ $this, 'disable_incompatible_plugins' ] );
+		add_action( 'graphql_activate', [ $this, 'disable_incompatible_plugins' ] );
+		add_action( 'admin_notices', [ $this, 'disable_incompatible_plugins_notice' ] );
+
+		// Register admin assets.
+		add_action( 'admin_enqueue_scripts', [ $this, 'register_assets' ] );
+	}
+
+	/**
+	 * Enable the plugin headers.
+	 *
+	 * @param string[] $headers The headers.
+	 * @return string[]
+	 */
+	public function enable_plugin_headers( $headers ) {
+		$headers[] = UpdateChecker::VERSION_HEADER;
+		$headers[] = UpdateChecker::TESTED_UP_TO_HEADER;
+
+		return $headers;
+	}
+
+	/**
+	 * Prevent autoupdates when there is an untested WPGraphQL Extension.
+	 *
+	 * @param bool   $should_update Whether the plugin should autoupdate.
+	 * @param object $plugin The plugin data object.
+	 *
+	 * @return bool Whether the plugin should autoupdate.
+	 */
+	public function maybe_allow_autoupdates( $should_update, $plugin ) {
+		// Bail if it's not our plugin.
+		if ( ! isset( $plugin->plugin ) || ! isset( $plugin->new_version ) || 'wp-graphql/wp-graphql.php' !== $plugin->plugin ) {
+			return $should_update;
+		}
+
+		// If autoupdates are already disabled we don't need to check further.
+		if ( false === $should_update ) {
+			return $should_update;
+		}
+
+		$new_version = sanitize_text_field( $plugin->new_version );
+
+		if ( '' === $new_version ) {
+			return $should_update;
+		}
+
+		// Store the sanitized version in the plugin object.
+		$plugin->new_version = $new_version;
+
+		$plugin_updates = new UpdateChecker( $plugin );
+
+		return $plugin_updates->should_autoupdate( (bool) $should_update );
+	}
+
+	/**
+	 * Maybe loads the Update Checker for the current admin screen.
+	 */
+	public function load_screen_checker(): void {
+		$screen = get_current_screen();
+
+		// Bail if we're not on a screen.
+		if ( ! $screen ) {
+			return;
+		}
+
+		// Loaders for the different WPAdmin Screens.
+		$loaders = [
+			'plugins'     => PluginsScreenLoader::class,
+			'update-core' => UpdatesScreenLoader::class,
+		];
+
+		// Bail if the current screen doesn't need an update checker.
+		if ( ! in_array( $screen->id, array_keys( $loaders ), true ) ) {
+			return;
+		}
+
+		// Load the update checker for the current screen.
+		new $loaders[ $screen->id ]();
+	}
+
+	/**
+	 * Registers the admin assets.
+	 */
+	public function register_assets(): void {
+		$screen          = get_current_screen();
+		$allowed_screens = [
+			'plugins',
+			'update-core',
+		];
+
+		// Bail if we're not on a screen.
+		if ( ! $screen || ! in_array( $screen->id, $allowed_screens, true ) ) {
+			return;
+		}
+
+		$asset_file = include WPGRAPHQL_PLUGIN_DIR . 'build/updates.asset.php';
+
+		wp_enqueue_style(
+			'wp-graphql-admin-updates',
+			WPGRAPHQL_PLUGIN_URL . 'build/updates.css',
+			$asset_file['dependencies'],
+			$asset_file['version']
+		);
+	}
+
+	/**
+	 * Disables plugins that don't meet the minimum `Requires WPGraphQL` version.
+	 */
+	public function disable_incompatible_plugins(): void {
+		// Initialize the Update Checker.
+		$update_checker = new UpdateChecker( (object) get_plugins()['wp-graphql/wp-graphql.php'] );
+
+		// Get the incompatible plugins.
+		$incompatible_plugins = $update_checker->get_incompatible_plugins( WPGRAPHQL_VERSION, true );
+
+		// Deactivate the incompatible plugins.
+		$notice_data = [];
+		foreach ( $incompatible_plugins as $file => $plugin ) {
+			$notice_data[] = [
+				'name'    => $plugin['Name'],
+				'version' => $plugin[ UpdateChecker::VERSION_HEADER ],
+			];
+			deactivate_plugins( $file );
+		}
+
+		// Display a notice to the user.
+		if ( ! empty( $notice_data ) ) {
+			set_transient( 'wpgraphql_incompatible_plugins', $notice_data );
+		}
+	}
+
+	/**
+	 * Displays a one-time notice to the user if incompatible plugins were deactivated.
+	 */
+	public function disable_incompatible_plugins_notice(): void {
+		$incompatible_plugins = get_transient( 'wpgraphql_incompatible_plugins' );
+
+		if ( empty( $incompatible_plugins ) ) {
+			return;
+		}
+
+		$notice = sprintf(
+			'<p>%s</p>',
+			__( 'The following plugins were deactivated because they require a newer version of WPGraphQL. Please update WPGraphQL to a newer version to reactivate these plugins.', 'wp-graphql' )
+		);
+
+		$notice .= '<ul class="ul-disc">';
+		foreach ( $incompatible_plugins as $plugin ) {
+			$notice .= sprintf(
+				'<li><strong>%s</strong> (requires at least WPGraphQL: v%s)</li>',
+				esc_html( $plugin['name'] ),
+				esc_html( $plugin['version'] )
+			);
+		}
+		$notice .= '</ul>';
+
+		echo wp_kses_post( sprintf( '<div class="notice notice-error is-dismissable">%s</div>', $notice ) );
+
+		// Delete once the notice is displayed.
+		delete_transient( 'wpgraphql_incompatible_plugins' );
+	}
+}
diff --git a/src/Admin/Updates/UpdatesScreenLoader.php b/src/Admin/Updates/UpdatesScreenLoader.php
new file mode 100644
index 000000000..284f742a6
--- /dev/null
+++ b/src/Admin/Updates/UpdatesScreenLoader.php
@@ -0,0 +1,109 @@
+<?php
+/**
+ * Handles plugin update checks and notifications on the plugins screen.
+ *
+ * Code is inspired by and adapted from WooCommerce's WC_Updates_Screen_Updates class.
+ *
+ * @see https://github.com/woocommerce/woocommerce/blob/5f04212f8188e0f7b09f6375d1a6c610fac8a631/plugins/woocommerce/includes/admin/plugin-updates/class-wc-updates-screen-updates.php
+ *
+ * @package WPGraphQL\Admin\Updates
+ */
+
+namespace WPGraphQL\Admin\Updates;
+
+/**
+ * Class UpdatesScreenLoader
+ */
+class UpdatesScreenLoader {
+	/**
+	 * The UpdateChecker instance.
+	 *
+	 * @var \WPGraphQL\Admin\Updates\UpdateChecker
+	 */
+	private $update_checker;
+
+	/**
+	 * The class constructor.
+	 *
+	 * Class properties are set inside the action.
+	 */
+	public function __construct() {
+		add_action( 'admin_print_footer_scripts', [ $this, 'update_screen_modal' ] );
+	}
+
+	/**
+	 * Show a warning message on the upgrades screen if the user tries to upgrade and has untested plugins.
+	 */
+	public function update_screen_modal(): void {
+		// Bail if the plugin is not on the update list.
+		$updateable_plugins = get_plugin_updates();
+
+		if ( empty( $updateable_plugins['wp-graphql/wp-graphql.php']->update->new_version ) ) {
+			return;
+		}
+
+		$this->update_checker = new UpdateChecker( $updateable_plugins['wp-graphql/wp-graphql.php']->update );
+
+		if ( $this->update_checker->should_autoupdate( true ) ) {
+			return;
+		}
+
+		$untested_plugins = $this->update_checker->get_untested_plugins( 'major' );
+
+		if ( empty( $untested_plugins ) ) {
+			return;
+		}
+
+		// Output the modal.
+		echo wp_kses_post( $this->update_checker->get_untested_plugins_modal( $untested_plugins ) );
+
+		$this->modal_js();
+	}
+
+	/**
+	 * The modal JS for the plugin update message.
+	 */
+	public function modal_js(): void {
+		?>
+		<script>
+			( function( $ ) {
+				var modal_dismissed = false;
+
+				// Show the modal if the WC upgrade checkbox is checked.
+				var show_modal_if_checked = function() {
+					if ( modal_dismissed ) {
+						return;
+					}
+					var $checkbox = $( 'input[value="wp-graphql/wp-graphql.php"]' );
+					if ( $checkbox.prop( 'checked' ) ) {
+						$( '#wp-graphql-upgrade-warning' ).trigger( 'click' );
+					}
+				}
+
+				$( '#plugins-select-all, input[value="wp-graphql/wp-graphql.php"]' ).on( 'change', function() {
+					show_modal_if_checked();
+				} );
+
+				// Add a hidden thickbox link to use for bringing up the modal.
+				$('body').append( '<a href="#TB_inline?height=600&width=550&inlineId=wp-graphql-update-modal" class="wp-graphql-thickbox" id="wp-graphql-upgrade-warning" style="display:none"></a>' );
+
+				// Don't show the modal again once it's been accepted.
+				$( '#wp-graphql-update-modal .accept' ).on( 'click', function( evt ) {
+					evt.preventDefault();
+					modal_dismissed = true;
+					tb_remove();
+				});
+
+				// Uncheck the WC update checkbox if the modal is canceled.
+				$( '#wp-graphql-update-modal .cancel' ).on( 'click', function( evt ) {
+					evt.preventDefault();
+					$( 'input[value="wp-graphql/wp-graphql.php"]' ).prop( 'checked', false );
+					tb_remove();
+				});
+			})( jQuery );
+		</script>
+
+		<?php
+		$this->update_checker->modal_js();
+	}
+}
diff --git a/src/WPGraphQL.php b/src/WPGraphQL.php
index a3c6783de..2bca54504 100644
--- a/src/WPGraphQL.php
+++ b/src/WPGraphQL.php
@@ -243,6 +243,9 @@ static function () {
 				$query_log->init();
 			}
 		);
+
+		// Initialize Update functionality.
+		( new \WPGraphQL\Admin\Updates\Updates() )->init();
 	}
 
 	/**
diff --git a/webpack.config.js b/webpack.config.js
index 557389006..6d790ad00 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -1,32 +1,53 @@
-const defaults = require("@wordpress/scripts/config/webpack.config");
-const path = require("path");
+const defaults = require( '@wordpress/scripts/config/webpack.config' );
+const path = require( 'path' );
+const RemoveEmptyScriptsPlugin = require( 'webpack-remove-empty-scripts' );
 
+// The plugin configuration overrides.
+const plugins = [
+	new RemoveEmptyScriptsPlugin( {
+		stage: RemoveEmptyScriptsPlugin.STAGE_AFTER_PROCESS_PLUGINS,
+	} ),
+];
 
 module.exports = {
-    ...defaults,
-    entry: {
-        index: path.resolve(process.cwd(), "packages/wpgraphiql", "index.js"),
-        app: path.resolve(process.cwd(), "packages/wpgraphiql", "app.js"),
-        graphiqlQueryComposer: path.resolve(
-            process.cwd(),
-            "packages/graphiql-query-composer",
-            "index.js"
-        ),
-        graphiqlAuthSwitch: path.resolve(
-            process.cwd(),
-            "packages/graphiql-auth-switch",
-            "index.js"
-        ),
-        graphiqlFullscreenToggle: path.resolve(
-            process.cwd(),
-            "packages/graphiql-fullscreen-toggle",
-            "index.js"
-        ),
-    },
-    externals: {
-        react: "React",
-        "react-dom": "ReactDOM",
-        wpGraphiQL: "wpGraphiQL",
-        graphql: "wpGraphiQL.GraphQL",
-    },
+	...defaults,
+	entry: {
+		index: path.resolve( process.cwd(), 'packages/wpgraphiql', 'index.js' ),
+		app: path.resolve( process.cwd(), 'packages/wpgraphiql', 'app.js' ),
+		graphiqlQueryComposer: path.resolve(
+			process.cwd(),
+			'packages/graphiql-query-composer',
+			'index.js'
+		),
+		graphiqlAuthSwitch: path.resolve(
+			process.cwd(),
+			'packages/graphiql-auth-switch',
+			'index.js'
+		),
+		graphiqlFullscreenToggle: path.resolve(
+			process.cwd(),
+			'packages/graphiql-fullscreen-toggle',
+			'index.js'
+		),
+		updates: path.resolve(
+			process.cwd(),
+			'packages/updates',
+			'index.scss'
+		),
+	},
+	plugins: [
+		...defaults.plugins.filter(
+			( plugin ) =>
+				! plugins.some(
+					( p ) => p.constructor.name === plugin.constructor.name
+				)
+		),
+		...plugins,
+	],
+	externals: {
+		react: 'React',
+		'react-dom': 'ReactDOM',
+		wpGraphiQL: 'wpGraphiQL',
+		graphql: 'wpGraphiQL.GraphQL',
+	},
 };


Test patch:
diff --git a/tests/_data/plugins/plugin-incompatible-version.php b/tests/_data/plugins/plugin-incompatible-version.php
new file mode 100644
index 000000000..f3d651536
--- /dev/null
+++ b/tests/_data/plugins/plugin-incompatible-version.php
@@ -0,0 +1,13 @@
+<?php
+/**
+ * Plugin Name: Incompatible Version
+ * Version: 1.0.0
+ * Description: A plugin with headers.
+ * Requires WPGraphQL: 99.2.1
+ * WPGraphQL Tested Up To: 1000.0.0
+ */
+
+// Exit if accessed directly.
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
diff --git a/tests/_data/plugins/plugin-with-headers.php b/tests/_data/plugins/plugin-with-headers.php
new file mode 100644
index 000000000..a58be03dd
--- /dev/null
+++ b/tests/_data/plugins/plugin-with-headers.php
@@ -0,0 +1,13 @@
+<?php
+/**
+ * Plugin Name: Test Plugin With Headers
+ * Version: 1.0.0
+ * Description: A plugin with headers.
+ * Requires WPGraphQL: 1.0.0
+ * WPGraphQL Tested Up To: 1.0.0
+ */
+
+// Exit if accessed directly.
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
diff --git a/tests/_data/plugins/plugin-with-meta.php b/tests/_data/plugins/plugin-with-meta.php
new file mode 100644
index 000000000..aaaf0ddba
--- /dev/null
+++ b/tests/_data/plugins/plugin-with-meta.php
@@ -0,0 +1,11 @@
+<?php
+/**
+ * Plugin Name: Test Plugin for WPGraphQL
+ * Version: 1.0.0
+ * Description: A plugin with WPGraphQL in the meta.
+ */
+
+// Exit if accessed directly.
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
diff --git a/tests/_data/plugins/plugin-with-requires.php b/tests/_data/plugins/plugin-with-requires.php
new file mode 100644
index 000000000..605f4964f
--- /dev/null
+++ b/tests/_data/plugins/plugin-with-requires.php
@@ -0,0 +1,12 @@
+<?php
+/**
+ * Plugin Name: Test Plugin With Requires Header
+ * Version: 1.0.0
+ * Description: A plugin with headers.
+ * Requires Plugins: wp-graphql
+ */
+
+// Exit if accessed directly.
+if ( ! defined( 'ABSPATH' ) ) {
+	exit;
+}
diff --git a/tests/wpunit/MediaItemMutationsTest.php b/tests/wpunit/MediaItemMutationsTest.php
index 500cd915e..97c182e99 100644
--- a/tests/wpunit/MediaItemMutationsTest.php
+++ b/tests/wpunit/MediaItemMutationsTest.php
@@ -82,7 +82,7 @@ public function setUp(): void {
 		$this->date          = '2017-08-01T15:00:00';
 		$this->dateGmt       = '2017-08-01T21:00:00';
 		$this->description   = 'This is a magic description.';
-		$this->filePath      = 'https://raw.githubusercontent.com/wp-graphql/wp-graphql/refs/heads/release/v1.29.1/tests/_data/images/mgc.gif';
+		$this->filePath      = 'https://raw.githubusercontent.com/wp-graphql/wp-graphql/refs/heads/master/tests/_data/images/mgc.gif';
 		$this->fileType      = 'IMAGE_GIF';
 		$this->slug          = 'magic-shia';
 		$this->status        = 'INHERIT';
diff --git a/tests/wpunit/SemVerTest.php b/tests/wpunit/SemVerTest.php
new file mode 100644
index 000000000..3172c170d
--- /dev/null
+++ b/tests/wpunit/SemVerTest.php
@@ -0,0 +1,63 @@
+<?php
+
+use WPGraphQL\Admin\Updates\SemVer;
+
+class SemVerTest extends \Tests\WPGraphQL\TestCase\WPGraphQLTestCase {
+
+	/**
+	 * Tests the get_release_type method.
+	 */
+	public function testGetReleaseType() {
+
+		// Test from 1.x.y
+		$this->assertEquals( 'major', SemVer::get_release_type( '1.2.3', '2.0.0' ) );
+		$this->assertEquals( 'minor', SemVer::get_release_type( '1.2.3', '1.3.0' ) );
+		$this->assertEquals( 'patch', SemVer::get_release_type( '1.2.3', '1.2.4' ) );
+		$this->assertEquals( 'prerelease', SemVer::get_release_type( '1.2.3', '1.2.3-alpha.1' ) );
+		$this->assertEquals( 'unknown', SemVer::get_release_type( '1.2.3', 'invalid-version' ) );
+
+		// Test from 0.1.x
+		$this->assertEquals( 'major', SemVer::get_release_type( '0.1.2', '1.0.0' ) );
+		$this->assertEquals( 'minor', SemVer::get_release_type( '0.1.2', '0.2.0' ) );
+		$this->assertEquals( 'patch', SemVer::get_release_type( '0.1.2', '0.1.3' ) );
+		$this->assertEquals( 'prerelease', SemVer::get_release_type( '0.1.2', '0.1.2-alpha.1' ) );
+		$this->assertEquals( 'unknown', SemVer::get_release_type( '0.1.2', 'invalid-version' ) );
+
+		// Test from 0.0.1
+		$this->assertEquals( 'major', SemVer::get_release_type( '0.0.1', '1.0.0' ) );
+		$this->assertEquals( 'minor', SemVer::get_release_type( '0.0.1', '0.1.0' ) );
+		$this->assertEquals( 'patch', SemVer::get_release_type( '0.0.1', '0.0.2' ) );
+		$this->assertEquals( 'prerelease', SemVer::get_release_type( '0.0.1', '0.0.1-alpha.1' ) );
+		$this->assertEquals( 'unknown', SemVer::get_release_type( '0.0.1', 'invalid-version' ) );
+	}
+
+	/**
+	 * Tests the parse method.
+	 */
+	public function testParse() {
+		// Test invalid version.
+		$this->assertNull( SemVer::parse( 'invalid-version' ) );
+
+		// Test valid version.
+		$version = SemVer::parse( '1.2.3' );
+		$this->assertEquals( 1, $version['major'] );
+		$this->assertEquals( 2, $version['minor'] );
+		$this->assertEquals( 3, $version['patch'] );
+		$this->assertNull( $version['prerelease'] );
+		$this->assertNull( $version['buildmetadata'] );
+
+		// Test version with prerelease
+		$version = SemVer::parse( '1.2.3-alpha' );
+		$this->assertEquals( 1, $version['major'] );
+		$this->assertEquals( 2, $version['minor'] );
+		$this->assertEquals( 3, $version['patch'] );
+
+		// Test version with prerelease and buildmetadata
+		$version = SemVer::parse( '1.2.3-alpha.1+buildmetadata' );
+		$this->assertEquals( 1, $version['major'] );
+		$this->assertEquals( 2, $version['minor'] );
+		$this->assertEquals( 3, $version['patch'] );
+		$this->assertEquals( 'alpha.1', $version['prerelease'] );
+		$this->assertEquals( 'buildmetadata', $version['buildmetadata'] );
+	}
+}
diff --git a/tests/wpunit/UpdatesTest.php b/tests/wpunit/UpdatesTest.php
new file mode 100644
index 000000000..a3413cc00
--- /dev/null
+++ b/tests/wpunit/UpdatesTest.php
@@ -0,0 +1,572 @@
+<?php
+
+use WPGraphQL\Admin\Updates\Updates;
+
+class UpdatesTest extends \Tests\WPGraphQL\TestCase\WPGraphQLTestCase {
+	private $test_plugins = [];
+	private $old_current_screen;
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function setUp(): void {
+		global $current_screen;
+		$this->old_current_screen = $current_screen;
+
+		$this->test_plugins = [
+			'plugin-with-headers',
+			'plugin-with-requires',
+			'plugin-with-meta',
+			'plugin-incompatible-version',
+		];
+
+		// Cleanup any test plugins that may have been left behind.
+		foreach ( $this->test_plugins as $plugin ) {
+			$this->cleanup_test_plugin( $plugin );
+		}
+
+		parent::setUp();
+
+		wp_cache_delete( 'plugins', 'plugins' );
+	}
+
+	/**
+	 * {@inheritdoc}
+	 */
+	public function tearDown(): void {
+		// Remove the test plugin from the plugins directory.
+		foreach ( $this->test_plugins as $plugin ) {
+			$this->cleanup_test_plugin( $plugin );
+		}
+
+		wp_cache_delete( 'plugins', 'plugins' );
+
+		$this->reset_current_screen();
+
+		parent::tearDown();
+	}
+
+	private function install_test_plugin( string $plugin ) {
+		$plugin_dir = WP_PLUGIN_DIR . '/' . $plugin;
+		mkdir( $plugin_dir );
+
+		$plugin_file = $plugin_dir . '/' . $plugin . '.php';
+		copy( codecept_data_dir() . 'plugins/' . $plugin . '.php', $plugin_file );
+	}
+
+	private function cleanup_test_plugin( string $plugin ) {
+		$plugin_dir = WP_PLUGIN_DIR . '/' . $plugin;
+
+		@unlink( $plugin_dir . '/' . $plugin . '.php' );
+		@rmdir( $plugin_dir );
+
+		$this->deactivate_plugin( $plugin . '/' . $plugin . '.php' );
+	}
+
+
+	/**
+	 * Test plugin headers.
+	 */
+	public function testPluginHeaders(): void {
+		$actual = get_plugins();
+
+		$this->assertArrayHasKey( 'wp-graphql/wp-graphql.php', $actual, 'WPGraphQL Plugin not found.' );
+
+		$actual_plugin_data = $actual['wp-graphql/wp-graphql.php'];
+		$this->assertArrayHasKey( 'Requires WPGraphQL', $actual_plugin_data, 'Requires WPGraphQL header not found.' );
+		$this->assertArrayHasKey( 'WPGraphQL tested up to', $actual_plugin_data, 'WPGraphQL tested up to header not found.' );
+	}
+
+	/**
+	 * Test load_screen_checker does not throw an error.
+	 */
+	public function testLoadScreenChecker(): void {
+		$updates = new Updates();
+		$updates->init();
+
+		// Test against a non-plugin screen.
+		set_current_screen( 'test' );
+
+		$updates->load_screen_checker();
+
+		// Test against the plugins screen.
+		set_current_screen( 'plugins' );
+		$updates->load_screen_checker();
+
+		// Test against the updates screen.
+		set_current_screen( 'update-core' );
+		$updates->load_screen_checker();
+
+		$this->assertTrue( true, 'load_screen_checker did not throw an error.' );
+	}
+
+	/**
+	 * Test register_assets enqueues the correct styles.
+	 */
+	public function testRegisterAssets(): void {
+		$updates = new Updates();
+		$updates->init();
+
+		// On a non-allowed screen.
+		$this->reset_current_screen();
+		$updates->register_assets();
+
+		$this->assertArrayNotHasKey( 'wp-graphql-admin-updates', wp_styles()->registered, 'wp-graphql-admin-updates style should not be registered.' );
+
+		// On the plugins screen.
+		set_current_screen( 'plugins' );
+		$updates->register_assets();
+
+		$actual = wp_styles()->registered['wp-graphql-admin-updates'];
+
+		$this->assertArrayHasKey( 'wp-graphql-admin-updates', wp_styles()->registered, 'wp-graphql-admin-updates style not registered.' );
+		$this->assertStringContainsString( 'wp-graphql/build/updates.css', $actual->src, 'wp-graphql-admin-updates style not enqueued.' );
+
+		// On the updates screen.
+		set_current_screen( 'update-core' );
+		$updates->register_assets();
+
+		$actual = wp_styles()->registered['wp-graphql-admin-updates'];
+
+		$this->assertArrayHasKey( 'wp-graphql-admin-updates', wp_styles()->registered, 'wp-graphql-admin-updates style not registered.' );
+		$this->assertStringContainsString( 'wp-graphql/build/updates.css', $actual->src, 'wp-graphql-admin-updates style not enqueued.' );
+	}
+
+	/**
+	 * Test whether maybe_allow_autoupdates returns the correct value.
+	 */
+	public function testMaybeAllowAutoupdates(): void {
+		$updates = new Updates();
+		$updates->init();
+
+		// Test Against a non-WPGraphQL plugin.
+		$plugin_data = (object) [
+			'plugin'      => 'test-plugin/test-plugin.php',
+			'new_version' => '1.0.0',
+		];
+
+		$default_autoupdate = false;
+		$actual             = $updates->maybe_allow_autoupdates( $default_autoupdate, $plugin_data );
+
+		$this->assertEquals( $default_autoupdate, $actual, 'maybe_allow_autoupdates did not return the the default value.' );
+
+		$default_autoupdate = true;
+		$actual             = $updates->maybe_allow_autoupdates( $default_autoupdate, $plugin_data );
+
+		$this->assertEquals( $default_autoupdate, $actual, 'maybe_allow_autoupdates did not return the default value.' );
+
+		// Test Against WPGraphQL with no new version.
+		$plugin_data = (object) [
+			'plugin'      => 'wp-graphql/wp-graphql.php',
+			'new_version' => '',
+		];
+
+		$default_autoupdate = false;
+		$actual             = $updates->maybe_allow_autoupdates( $default_autoupdate, $plugin_data );
+
+		$this->assertEquals( $default_autoupdate, $actual, 'maybe_allow_autoupdates did not return the the default value.' );
+
+		$default_autoupdate = true;
+		$actual             = $updates->maybe_allow_autoupdates( $default_autoupdate, $plugin_data );
+
+		$this->assertEquals( $default_autoupdate, $actual, 'maybe_allow_autoupdates did not return the default value.' );
+
+		// Test with a minor update.
+		$current_version = explode( '.', WPGRAPHQL_VERSION );
+		$plugin_data     = $this->get_plugin_update_data( $current_version[0] . '.99.0' );
+		$actual          = $updates->maybe_allow_autoupdates( $default_autoupdate, $plugin_data );
+
+		// Test with a major update.
+		$plugin_data = $this->get_plugin_update_data( '99.0.0' );
+
+		$default_autoupdate = true;
+		$actual             = $updates->maybe_allow_autoupdates( $default_autoupdate, $plugin_data );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false for a major update.' );
+
+		// Test with major updates enabled.
+		add_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+
+		$actual = $updates->maybe_allow_autoupdates( $default_autoupdate, $plugin_data );
+
+		$this->assertTrue( $actual, 'maybe_allow_autoupdates should return true.' );
+
+		// Cleanup.
+		remove_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+	}
+
+	/**
+	 * Tests disabling incompatible plugins.
+	 */
+	public function testDisableIncompatiblePlugins(): void {
+		// Codeception doesn't deactivate plugins so we check if the hook was triggered.
+		$deactivated_plugin = false;
+		add_action(
+			'deactivate_plugin',
+			static function ( $plugin ) use ( &$deactivated_plugin ) {
+				if ( 'plugin-incompatible-version/plugin-incompatible-version.php' === $plugin ) {
+					$deactivated_plugin = true;
+				}
+			}
+		);
+
+		$updates = new Updates();
+		$updates->init();
+
+		// Test with no plugins installed.
+		$updates->disable_incompatible_plugins();
+
+		$this->assertFalse( $deactivated_plugin, 'Plugin should not exist.' );
+
+		// Test with an installed plugin.
+		$this->install_test_plugin( 'plugin-incompatible-version' );
+		wp_cache_delete( 'plugins', 'plugins' );
+
+		$updates->disable_incompatible_plugins();
+
+		$this->assertFalse( $deactivated_plugin, 'Plugin should already be deactivated.' );
+
+		// Test wih an active plugin.
+		$this->activate_plugin( 'plugin-incompatible-version/plugin-incompatible-version.php' );
+
+		// Confirm the plugin is active.
+		$this->assertTrue( is_plugin_active( 'plugin-incompatible-version/plugin-incompatible-version.php' ), 'Plugin is not active.' );
+
+		$updates->disable_incompatible_plugins();
+
+		$this->assertTrue( $deactivated_plugin, 'Plugin was not deactivated.' );
+
+		$actual_data = get_transient( 'wpgraphql_incompatible_plugins' );
+
+		$this->assertNotEmpty( $actual_data, 'Transient was not set.' );
+
+		ob_start();
+		do_action( 'admin_notices' );
+		$message = ob_get_clean();
+
+		$this->assertStringContainsString( 'The following plugins were deactivated', $message, 'Deactivation message not found.' );
+		$this->assertStringContainsString( 'Incompatible Version', $message, 'Deactivated plugin not found.' );
+
+		// Ensure transient is cleared.
+		$actual_data = get_transient( 'wpgraphql_incompatible_plugins' );
+
+		$this->assertEmpty( $actual_data, 'Transient was not cleared.' );
+
+		// Cleanup.
+		$this->cleanup_test_plugin( 'plugin-incompatible-version' );
+		remove_all_actions( 'deactivate_plugin' );
+	}
+
+
+	/**
+	 * Test updating with a `Requires Plugin` dependency.
+	 */
+	public function testUpdateWithRequiresPluginDep(): void {
+		// Only test on WP 6.5+.
+		if ( ! is_wp_version_compatible( '6.5' ) ) {
+			$this->markTestSkipped( 'Requires Plugin header does not exist prior to < 6.5.' );
+		}
+
+		$this->install_test_plugin( 'plugin-with-requires' );
+		wp_cache_delete( 'plugins', 'plugins' );
+
+		$updates = new Updates();
+		$updates->init();
+
+		add_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+
+		$plugin_date = $this->get_plugin_update_data( '99.0.0' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_date );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false for a plugin with a `Requires Plugin` dependency.' );
+
+		// Cleanup.
+		$this->cleanup_test_plugin( 'plugin-with-requires' );
+		remove_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+	}
+
+	/**
+	 * Test updating with a `Tested up to` dependency.
+	 */
+	public function testUpdateWithHeadersDep(): void {
+		$this->install_test_plugin( 'plugin-with-headers' );
+		wp_cache_delete( 'plugins', 'plugins' );
+
+		$updates = new Updates();
+		$updates->init();
+
+		add_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+
+		// Test with a major update.
+		$plugin_date = $this->get_plugin_update_data( '99.0.0' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_date );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false for a plugin with a `Tested up to` dependency.' );
+
+		// Test with a minor update.
+		$current_version = explode( '.', WPGRAPHQL_VERSION );
+		$plugin_data     = $this->get_plugin_update_data( $current_version[0] . '.99.0' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertTrue( $actual, 'maybe_allow_autoupdates should return true for a minor update.' );
+
+		// Test with untested autoupdates disabled.
+		add_filter(
+			'wpgraphql_untested_release_type',
+			static function () {
+				return 'minor';
+			}
+		);
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false for a minor update if disallowed.' );
+
+		// Test with a patch update.
+		remove_all_filters( 'wpgraphql_untested_release_type' );
+		$plugin_data = $this->get_plugin_update_data( $current_version[0] . '.0.99' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertTrue( $actual, 'maybe_allow_autoupdates should return true for a patch.' );
+
+		// Test with autoupdates disabled.
+		add_filter(
+			'wpgraphql_untested_release_type',
+			static function () {
+				return 'patch';
+			}
+		);
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false for a patch if disallowed.' );
+
+		// Cleanup.
+		$this->cleanup_test_plugin( 'plugin-with-headers' );
+		remove_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+		remove_all_filters( 'wpgraphql_untested_release_type' );
+	}
+
+	/**
+	 * Test updating with a possible dependency.
+	 */
+	public function testUpdateWithPossibleDep(): void {
+		$this->install_test_plugin( 'plugin-with-meta' );
+		wp_cache_delete( 'plugins', 'plugins' );
+
+		$updates = new Updates();
+		$updates->init();
+
+		add_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+
+		// Test with a major update.
+		$plugin_date = $this->get_plugin_update_data( '99.0.0' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_date );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false for a plugin with a possible dependency.' );
+
+		// Cleanup.
+		$this->cleanup_test_plugin( 'plugin-with-meta' );
+		remove_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+	}
+
+	/**
+	 * Test the Plugin Screen inline messsage and modal.
+	 */
+	public function testPluginScreenLoader(): void {
+		$this->install_test_plugin( 'plugin-with-headers' );
+		$this->activate_plugin( 'plugin-with-headers/plugin-with-headers.php' );
+
+		$loader = new \WPGraphQL\Admin\Updates\PluginsScreenLoader();
+
+		ob_start();
+		$loader->in_plugin_update_message( [], (object) [ 'new_version' => '99.0.0' ] );
+		$message = ob_get_clean();
+
+		// Test the modal message.
+		$this->assertStringContainsString( 'The following active plugin(s) require WPGraphQL to function but have not yet declared compatibility with <strong>WPGraphQL v99.0.0</strong>', $message, 'Plugin screen message not found.' );
+		// Test the plugin name.
+		$this->assertStringContainsString( 'Test Plugin With Headers', $message, 'Plugin name not found.' );
+
+		// Ensure the modal js is output.
+		ob_start();
+		$loader->modal_js();
+		$expected = ob_get_clean();
+
+		ob_start();
+		do_action( 'admin_print_footer_scripts' );
+		$actual = ob_get_clean();
+
+		$this->assertStringContainsString( $expected, $actual, 'Modal JS not output.' );
+
+		// Cleanup.
+		$this->cleanup_test_plugin( 'plugin-with-headers' );
+	}
+
+	/**
+	 * Test the update screen modal.
+	 */
+	public function testUpdateScreenLoader(): void {
+		$this->install_test_plugin( 'plugin-with-headers' );
+		$this->activate_plugin( 'plugin-with-headers/plugin-with-headers.php' );
+
+		$loader = new \WPGraphQL\Admin\Updates\UpdatesScreenLoader();
+
+		// Test with no plugin update.
+		ob_start();
+		$loader->update_screen_modal();
+		$message = ob_get_clean();
+
+		$this->assertEmpty( $message, 'Plugin screen message should be empty.' );
+
+		// Stub a plugin update.
+		add_filter(
+			'pre_site_transient_update_plugins',
+			function () {
+				return (object) [
+					'response' => [
+						'wp-graphql/wp-graphql.php' => $this->get_plugin_update_data( '99.0.0' ),
+					],
+				];
+			},
+			PHP_INT_MAX
+		);
+
+		ob_start();
+		$loader->update_screen_modal();
+		$message = ob_get_clean();
+
+		$this->assertStringContainsString( 'The following active plugin(s) require WPGraphQL to function but have not yet declared compatibility with <strong>WPGraphQL v99.0.0</strong', $message, 'Plugin screen message not found.' );
+		$this->assertStringContainsString( 'Test Plugin With Headers', $message, 'Plugin name not found.' );
+
+		// Cleanup.
+		$this->cleanup_test_plugin( 'plugin-with-headers' );
+		remove_all_filters( 'pre_site_transient_update_plugins' );
+	}
+
+	/**
+	 * Test updating with an incompatible plugin.
+	 */
+	public function testUpdateWithIncompatiblePlugin(): void {
+		$this->install_test_plugin( 'plugin-incompatible-version' );
+
+		add_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+
+		// Test with a deactivated incompatible version.
+		$updates = new Updates();
+		$updates->init();
+
+		$plugin_data = $this->get_plugin_update_data( '90.0.0' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertTrue( $actual, 'maybe_allow_autoupdates should return true if the incompatible plugin is deactivated.' );
+
+		// Test with an active incompatible version.
+		$this->activate_plugin( 'plugin-incompatible-version/plugin-incompatible-version.php' );
+
+		$updates = new Updates();
+		$updates->init();
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false if the incompatible plugin is active.' );
+
+		// Test with an incompatible minor update.
+		$updates = new Updates();
+		$updates->init();
+
+		$plugin_data = $this->get_plugin_update_data( '99.0.0' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false if the incompatible plugin is active.' );
+
+		// Test with an incompatible patch update.
+		$plugin_data = $this->get_plugin_update_data( '99.2.0' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertFalse( $actual, 'maybe_allow_autoupdates should return false if the incompatible plugin is active.' );
+
+		// Test with compatible minor update.
+		$updates = new Updates();
+		$updates->init();
+
+		$plugin_data = $this->get_plugin_update_data( '99.99.99' );
+
+		$actual = $updates->maybe_allow_autoupdates( true, $plugin_data );
+
+		$this->assertTrue( $actual, 'maybe_allow_autoupdates should return true since the plugin is compatible.' );
+
+		// Cleanup.
+		$this->cleanup_test_plugin( 'plugin-incompatible-version' );
+		remove_filter( 'wpgraphql_enable_major_autoupdates', '__return_true' );
+	}
+
+	/**
+	 * Set plugin update data.
+	 *
+	 * @var string $version The plugin version.
+	 */
+	private function get_plugin_update_data( string $version ) {
+		return (object) [
+			'slug'        => 'wp-graphql',
+			'plugin'      => 'wp-graphql/wp-graphql.php',
+			'new_version' => $version,
+		];
+	}
+
+	/**
+	 * Activates a plugin based on its dir/filename
+	 *
+	 * @param string $plugin
+	 */
+	private function activate_plugin( $plugin ): void {
+		$current = get_option( 'active_plugins' );
+		if ( ! in_array( $plugin, $current, true ) ) {
+			$current[] = $plugin;
+			sort( $current );
+			$success = update_option( 'active_plugins', $current );
+
+			$GLOBALS['wp_tests_options']['active_plugins'] = $current;
+
+			if ( ! $success ) {
+				codecept_debug( 'Failed to activate plugin.' );
+			}
+		}
+	}
+
+	/**
+	 * Deactivates a plugin based on its dir/filename
+	 *
+	 * @param string $plugin
+	 */
+	private function deactivate_plugin( $plugin ): void {
+		$current = get_option( 'active_plugins' );
+		if ( in_array( $plugin, $current, true ) ) {
+			$current = array_diff( $current, [ $plugin ] );
+			sort( $current );
+			$success = update_option( 'active_plugins', $current );
+
+			$GLOBALS['wp_tests_options']['active_plugins'] = $current;
+
+			if ( ! $success ) {
+				codecept_debug( 'Failed to deactivate plugin.' );
+			}
+		}
+	}
+
+	/**
+	 * Reset the current screen.
+	 */
+	private function reset_current_screen(): void {
+		global $current_screen;
+		$current_screen = $this->old_current_screen;
+	}
+}