diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index d7b7982665a9822bfaa504146d4a48744bd4c55e..88a9249f5c67ca98e6f0307aea58e103eac93b58 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -76,6 +76,11 @@ services:
     arguments: ['%app.root%', '%site.path%', '@file_system', '@stream_wrapper_manager']
     tags:
       - { name: event_subscriber }
+  automatic_updates.update_version_subscriber:
+    class: Drupal\automatic_updates\Event\UpdateVersionSubscriber
+    arguments: ['@module_handler']
+    tags:
+      - { name: event_subscriber }
   automatic_updates.composer_executable_validator:
     class: Drupal\automatic_updates\Validation\ComposerExecutableValidator
     arguments: ['@automatic_updates.exec_finder']
diff --git a/src/Event/PreStartEvent.php b/src/Event/PreStartEvent.php
index e8fecadb02c07647debaecdfce62fd0d2b556dfc..f5b32b9f4de72152295ccc06a7fe9e8be246dfc5 100644
--- a/src/Event/PreStartEvent.php
+++ b/src/Event/PreStartEvent.php
@@ -9,4 +9,32 @@ class PreStartEvent extends UpdateEvent {
 
   use ExcludedPathsTrait;
 
+  /**
+   * The desired package versions to update to, keyed by package name.
+   *
+   * @var string[]
+   */
+  protected $packageVersions;
+
+  /**
+   * Constructs a PreStartEvent.
+   *
+   * @param string[] $package_versions
+   *   (optional) The desired package versions to update to, keyed by package
+   *   name.
+   */
+  public function __construct(array $package_versions = []) {
+    $this->packageVersions = $package_versions;
+  }
+
+  /**
+   * Returns the desired package versions to update to.
+   *
+   * @return string[]
+   *   The desired package versions to update to, keyed by package name.
+   */
+  public function getPackageVersions(): array {
+    return $this->packageVersions;
+  }
+
 }
diff --git a/src/Event/UpdateVersionSubscriber.php b/src/Event/UpdateVersionSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..5029b66d2f942e6f7626281a5076342acd2f5c44
--- /dev/null
+++ b/src/Event/UpdateVersionSubscriber.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\automatic_updates\Event;
+
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates\Validation\ValidationResult;
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates that core updates are within a supported version range.
+ */
+class UpdateVersionSubscriber implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Constructs an UpdateVersionSubscriber.
+   *
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler service.
+   */
+  public function __construct(ModuleHandlerInterface $module_handler) {
+    // Load procedural functions needed for ::getCoreVersion().
+    $module_handler->loadInclude('update', 'inc', 'update.compare');
+  }
+
+  /**
+   * Returns the running core version, according to the Update module.
+   *
+   * @return string
+   *   The running core version as known to the Update module.
+   */
+  protected function getCoreVersion(): string {
+    $available_updates = update_calculate_project_data(update_get_available());
+    return $available_updates['drupal']['existing_version'];
+  }
+
+  /**
+   * Validates that core is not being updated to another minor or major version.
+   *
+   * @param \Drupal\automatic_updates\Event\PreStartEvent $event
+   *   The event object.
+   */
+  public function checkUpdateVersion(PreStartEvent $event): void {
+    $from_version = ExtensionVersion::createFromVersionString($this->getCoreVersion());
+    $to_version = ExtensionVersion::createFromVersionString($event->getPackageVersions()['drupal/core']);
+
+    if ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) {
+      $error = ValidationResult::createError([
+        $this->t('Updating from one major version to another is not supported.'),
+      ]);
+      $event->addValidationResult($error);
+    }
+    elseif ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
+      $error = ValidationResult::createError([
+        $this->t('Updating from one minor version to another is not supported.'),
+      ]);
+      $event->addValidationResult($error);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      AutomaticUpdatesEvents::PRE_START => 'checkUpdateVersion',
+    ];
+  }
+
+}
diff --git a/src/Updater.php b/src/Updater.php
index 57588de8b298fad4eab6196bf348e9332ffc2ed8..843b5d6fe7f7b0782dbb8809ae295aa0c063b708 100644
--- a/src/Updater.php
+++ b/src/Updater.php
@@ -166,10 +166,12 @@ class Updater {
     if (count($project_versions) !== 1 || !array_key_exists('drupal', $project_versions)) {
       throw new \InvalidArgumentException("Currently only updates to Drupal core are supported.");
     }
-    $packages[] = 'drupal/core:' . $project_versions['drupal'];
+    $packages = [
+      'drupal/core' => $project_versions['drupal'],
+    ];
     $stage_key = $this->createActiveStage($packages);
     /** @var \Drupal\automatic_updates\Event\PreStartEvent $event */
-    $event = $this->dispatchUpdateEvent(new PreStartEvent(), AutomaticUpdatesEvents::PRE_START);
+    $event = $this->dispatchUpdateEvent(new PreStartEvent($packages), AutomaticUpdatesEvents::PRE_START);
     $this->beginner->begin(static::getActiveDirectory(), static::getStageDirectory(), $this->getExclusions($event));
     return $stage_key;
   }
@@ -216,9 +218,6 @@ class Updater {
   public function commit(): void {
     /** @var \Drupal\automatic_updates\Event\PreCommitEvent $event */
     $event = $this->dispatchUpdateEvent(new PreCommitEvent(), AutomaticUpdatesEvents::PRE_COMMIT);
-    // @todo Pass excluded paths into the committer once
-    // https://github.com/php-tuf/composer-stager/pull/14 is in a tagged release
-    // of Composer Stager.
     $this->committer->commit($this->getStageDirectory(), static::getActiveDirectory(), $this->getExclusions($event));
   }
 
@@ -255,12 +254,17 @@ class Updater {
    *   The active update ID.
    */
   private function createActiveStage(array $package_versions): string {
+    $requirements = [];
+    foreach ($package_versions as $package_name => $version) {
+      $requirements[] = "$package_name:$version";
+    }
+
     $value = static::STATE_KEY . microtime();
     $this->state->set(
       static::STATE_KEY,
       [
         'id' => $value,
-        'package_versions' => $package_versions,
+        'package_versions' => $requirements,
       ]
     );
     return $value;
diff --git a/tests/src/Functional/ExclusionsTest.php b/tests/src/Functional/ExclusionsTest.php
index 26d8a26cdaad47b236c9bc4ceaacf8921ea8e686..cf0a77990f850901f346bc6debfab259ec5b0b9e 100644
--- a/tests/src/Functional/ExclusionsTest.php
+++ b/tests/src/Functional/ExclusionsTest.php
@@ -15,7 +15,7 @@ class ExclusionsTest extends BrowserTestBase {
   /**
    * {@inheritdoc}
    */
-  protected static $modules = ['automatic_updates_test'];
+  protected static $modules = ['automatic_updates_test', 'update_test'];
 
   /**
    * {@inheritdoc}
@@ -40,6 +40,22 @@ class ExclusionsTest extends BrowserTestBase {
     $settings['file_private_path'] = 'files/private';
     new Settings($settings);
 
+    // Updater::begin() will trigger update validators, such as
+    // \Drupal\automatic_updates\Event\UpdateVersionSubscriber, that need to
+    // fetch release metadata. We need to ensure that those HTTP request(s)
+    // succeed, so set them up to point to our fake release metadata.
+    $this->config('update_test.settings')
+      ->set('xml_map', [
+        'drupal' => '0.0',
+      ])
+      ->save();
+    $this->config('update.settings')
+      ->set('fetch.url', $this->baseUrl . '/automatic-update-test')
+      ->save();
+    $this->config('update_test.settings')
+      ->set('system_info.#all.version', '9.8.0')
+      ->save();
+
     $updater->begin(['drupal' => '9.8.1']);
     $this->assertFileDoesNotExist("$stage_dir/sites/default/settings.php");
     $this->assertFileDoesNotExist("$stage_dir/sites/default/settings.local.php");
diff --git a/tests/src/Kernel/UpdaterTest.php b/tests/src/Kernel/UpdaterTest.php
index 8f8ab0ecb28e461e8cdda72a7feefd6c3220a207..110eb4bf94aba13e6900e274b2f7ae1ec0695f5a 100644
--- a/tests/src/Kernel/UpdaterTest.php
+++ b/tests/src/Kernel/UpdaterTest.php
@@ -3,6 +3,10 @@
 namespace Drupal\Tests\automatic_updates\Kernel;
 
 use Drupal\KernelTests\KernelTestBase;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\Utils;
 use Prophecy\Argument;
 
 /**
@@ -17,14 +21,28 @@ class UpdaterTest extends KernelTestBase {
    */
   protected static $modules = [
     'automatic_updates',
-    'update',
+    'automatic_updates_test',
     'composer_stager_bypass',
+    'update',
+    'update_test',
   ];
 
   /**
    * Tests that correct versions are staged after calling ::begin().
    */
   public function testCorrectVersionsStaged() {
+    // Ensure that the HTTP client will fetch our fake release metadata.
+    $release_data = Utils::tryFopen(__DIR__ . '/../../fixtures/release-history/drupal.0.0.xml', 'r');
+    $response = new Response(200, [], Utils::streamFor($release_data));
+    $handler = new MockHandler([$response]);
+    $client = new Client(['handler' => $handler]);
+    $this->container->set('http_client', $client);
+
+    // Set the running core version to 9.8.0.
+    $this->config('update_test.settings')
+      ->set('system_info.#all.version', '9.8.0')
+      ->save();
+
     $this->container->get('automatic_updates.updater')->begin([
       'drupal' => '9.8.1',
     ]);