From 783be1d6211cfacc9caaf6d80f9bc9c19135d197 Mon Sep 17 00:00:00 2001
From: Ted Bowman <ted@tedbow.com>
Date: Thu, 31 Mar 2022 12:56:56 -0400
Subject: [PATCH] Contrib: cspell fix -
 https://git.drupalcode.org/project/automatic_updates/-/commit/6109421c063e54f211ea615efde62c81fec34bfd

---
 core/misc/cspell/dictionary.txt               |   2 +-
 .../modules/auto_updates/auto_updates.install |   7 +
 core/modules/auto_updates/auto_updates.module |  33 ++-
 .../auto_updates/auto_updates.routing.yml     |  12 +
 .../auto_updates/auto_updates.services.yml    |  31 ++-
 .../auto_updates/src/BatchProcessor.php       |  62 ++++-
 .../src/Controller/UpdateController.php       |  21 +-
 core/modules/auto_updates/src/CronUpdater.php |  80 +++---
 .../src/Event/ReadinessCheckEvent.php         |  12 +-
 .../auto_updates/src/Form/UpdateReady.php     |  86 ++++--
 .../auto_updates/src/Form/UpdaterForm.php     | 122 +++++---
 core/modules/auto_updates/src/ProjectInfo.php | 106 +++++++
 .../auto_updates/src/ReleaseChooser.php       | 132 +++++++++
 .../src/Routing/RouteSubscriber.php           |  39 +++
 .../auto_updates/src/UpdateRecommender.php    |  69 -----
 core/modules/auto_updates/src/Updater.php     |   4 +-
 .../src/Validation/AdminReadinessMessages.php |  42 +--
 .../Validation/ReadinessValidationManager.php |  12 +-
 .../Validator/CronUpdateVersionValidator.php  | 100 +++++++
 .../src/Validator/UpdateVersionValidator.php  | 204 ++++++++------
 .../auto_updates/src/VersionParsingTrait.php  |  37 +++
 .../drupal.9.8.2-older-sec-release.xml        | 102 +++++++
 .../fixtures/release-history/drupal.9.8.2.xml |  38 ++-
 .../release-history/semver_test.1.1.xml       | 184 ++++++++++++
 .../tests/fixtures/staged/9.8.1/composer.json |   7 +
 .../9.8.1/vendor/composer/installed.json      |   9 +
 .../auto_updates_test.install                 |  17 ++
 .../auto_updates_test.routing.yml             |   4 +
 .../auto_updates_test.services.yml            |   4 -
 .../src/AutoUpdatesTestServiceProvider.php    |  24 --
 .../src/Form/TestUpdateReady.php              |  19 --
 .../src/Routing/RouteSubscriber.php           |  22 --
 .../auto_updates_test/src/TestController.php  |  17 +-
 .../Validator/TestPendingUpdatesValidator.php |  30 --
 .../tests/src/Build/CoreUpdateTest.php        |   9 +-
 .../AutoUpdatesFunctionalTestBase.php         |  35 ++-
 .../Functional/ReadinessValidationTest.php    |  24 +-
 .../tests/src/Functional/UpdateLockTest.php   |  11 +-
 .../tests/src/Functional/UpdaterFormTest.php  | 261 +++++++++++++++---
 .../src/Kernel/AutoUpdatesKernelTestBase.php  |  19 +-
 .../tests/src/Kernel/CronUpdaterTest.php      |  16 +-
 .../PackageManagerReadinessChecksTest.php     |   1 +
 .../ReadinessValidationManagerTest.php        |   4 +-
 .../StagedDatabaseUpdateValidatorTest.php     |   4 +
 .../UpdateVersionValidatorTest.php            |  86 +-----
 .../tests/src/Kernel/ReleaseChooserTest.php   | 171 ++++++++++++
 .../src/Kernel/UpdateRecommenderTest.php      |  47 ----
 .../tests/src/Kernel/UpdaterTest.php          |   2 +-
 .../tests/src/Unit/ProjectInfoTest.php        | 194 +++++++++++++
 .../package_manager/package_manager.module    |  15 +-
 .../package_manager.services.yml              |  15 +
 .../src/Event/PreOperationStageEvent.php      |   2 +-
 .../src/PackageManagerUninstallValidator.php  |  42 +++
 .../PackageManagerUninstallValidator.php      |  88 ++++++
 core/modules/package_manager/src/Stage.php    | 139 ++++++++--
 .../src/Validator/LockFileValidator.php       |  28 +-
 .../src/Validator/MultisiteValidator.php      |  74 +++++
 .../tests/fixtures/alpha/1.0.0/composer.json  |   5 +
 .../tests/fixtures/alpha/1.1.0/composer.json  |   5 +
 .../updated_module/1.0.0/composer.json        |   5 +
 .../1.0.0/updated_module.info.yml             |   4 +
 .../1.0.0/updated_module.module               |  27 ++
 .../1.0.0/updated_module.permissions.yml      |   4 +
 .../1.0.0/updated_module.routing.yml          |  12 +
 .../updated_module/1.1.0/composer.json        |   5 +
 .../1.1.0/src/PostApplySubscriber.php         |  52 ++++
 .../1.1.0/updated_module.info.yml             |   4 +
 .../1.1.0/updated_module.module               |  33 +++
 .../1.1.0/updated_module.permissions.yml      |   4 +
 .../1.1.0/updated_module.routing.yml          |  12 +
 .../1.1.0/updated_module.services.yml         |   7 +
 .../src/InvocationRecorderBase.php            |   2 +-
 .../package_manager_test_api.routing.yml      |   6 +-
 .../package_manager_test_api.services.yml     |  10 +
 .../src/ApiController.php                     |  45 ++-
 .../src/SystemChangeRecorder.php              | 129 +++++++++
 .../package_manager_test_fixture.info.yml     |   6 +
 .../package_manager_test_fixture.services.yml |   8 +
 .../src/EventSubscriber/FixtureStager.php     |  88 ++++++
 .../src/EventSubscriber/TestSubscriber.php    |  19 ++
 .../tests/src/Build/PackageUpdateTest.php     | 125 +++++++++
 .../tests/src/Build/StagedUpdateTest.php      |  87 ------
 .../tests/src/Kernel/ExcludedPathsTest.php    |  11 +
 .../src/Kernel/LockFileValidatorTest.php      |  16 ++
 .../src/Kernel/MultisiteValidatorTest.php     |  62 +++++
 .../Kernel/PackageManagerKernelTestBase.php   |  10 +-
 .../tests/src/Kernel/StageEventsTest.php      |  13 +
 .../tests/src/Kernel/StageTest.php            | 204 +++++++++++++-
 88 files changed, 3228 insertions(+), 769 deletions(-)
 create mode 100644 core/modules/auto_updates/src/ProjectInfo.php
 create mode 100644 core/modules/auto_updates/src/ReleaseChooser.php
 create mode 100644 core/modules/auto_updates/src/Routing/RouteSubscriber.php
 delete mode 100644 core/modules/auto_updates/src/UpdateRecommender.php
 create mode 100644 core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php
 create mode 100644 core/modules/auto_updates/src/VersionParsingTrait.php
 create mode 100644 core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
 create mode 100644 core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml
 create mode 100644 core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json
 create mode 100644 core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json
 create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install
 delete mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/AutoUpdatesTestServiceProvider.php
 delete mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/Form/TestUpdateReady.php
 delete mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/Routing/RouteSubscriber.php
 delete mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/Validator/TestPendingUpdatesValidator.php
 create mode 100644 core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php
 delete mode 100644 core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php
 create mode 100644 core/modules/package_manager/src/PackageManagerUninstallValidator.php
 create mode 100644 core/modules/package_manager/src/ProxyClass/PackageManagerUninstallValidator.php
 create mode 100644 core/modules/package_manager/src/Validator/MultisiteValidator.php
 create mode 100644 core/modules/package_manager/tests/fixtures/alpha/1.0.0/composer.json
 create mode 100644 core/modules/package_manager/tests/fixtures/alpha/1.1.0/composer.json
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/composer.json
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/composer.json
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/src/PostApplySubscriber.php
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.services.yml
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php
 create mode 100644 core/modules/package_manager/tests/src/Build/PackageUpdateTest.php
 delete mode 100644 core/modules/package_manager/tests/src/Build/StagedUpdateTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php

diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
index 95836d0313a5..bd36df7b19a3 100644
--- a/core/misc/cspell/dictionary.txt
+++ b/core/misc/cspell/dictionary.txt
@@ -1700,4 +1700,4 @@ zzgroup
 Ãœbersetzung
 åwesome
 über
-Ȅchȏ
+Ȅchȏ
\ No newline at end of file
diff --git a/core/modules/auto_updates/auto_updates.install b/core/modules/auto_updates/auto_updates.install
index b7ab9425fef6..961829e7a15e 100644
--- a/core/modules/auto_updates/auto_updates.install
+++ b/core/modules/auto_updates/auto_updates.install
@@ -7,6 +7,13 @@
 
 use Drupal\auto_updates\Validation\ReadinessRequirements;
 
+/**
+ * Implements hook_uninstall().
+ */
+function auto_updates_uninstall() {
+  \Drupal::service('auto_updates.updater')->destroy(TRUE);
+}
+
 /**
  * Implements hook_requirements().
  */
diff --git a/core/modules/auto_updates/auto_updates.module b/core/modules/auto_updates/auto_updates.module
index 09d33000f6d8..b7580c9a3bf7 100644
--- a/core/modules/auto_updates/auto_updates.module
+++ b/core/modules/auto_updates/auto_updates.module
@@ -5,25 +5,34 @@
  * Contains hook implementations for Automatic Updates.
  */
 
+use Drupal\auto_updates\BatchProcessor;
+use Drupal\auto_updates\ProjectInfo;
 use Drupal\Core\Routing\RouteMatchInterface;
 use Drupal\auto_updates\CronUpdater;
-use Drupal\auto_updates\UpdateRecommender;
 use Drupal\auto_updates\Validation\AdminReadinessMessages;
 use Drupal\Core\Extension\ExtensionVersion;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Url;
+use Drupal\system\Controller\DbUpdateController;
 use Drupal\update\ProjectSecurityData;
 
 /**
  * Implements hook_help().
  */
 function auto_updates_help($route_name, RouteMatchInterface $route_match) {
-  // @todo Fully document all the modules features in
-  //   https://www.drupal.org/i/3253591.
   switch ($route_name) {
     case 'help.page.auto_updates':
       $output = '<h3>' . t('About') . '</h3>';
       $output .= '<p>' . t('Automatic Updates lets you update Drupal core.') . '</p>';
+      $output .= '<p>';
+      $output .= t('Automatic Updates will keep Drupal secure and up-to-date by automatically installing new patch-level updates, if available, when cron runs. It also provides a user interface to check if any updates are available and install them. You can <a href=":configure-form">configure Automatic Updates</a> to install all patch-level updates, only security updates, or no updates at all, during cron. By default, only security updates are installed during cron; this requires that you <a href=":update-form">install non-security updates through the user interface</a>.', [
+        ':configure-form' => Url::fromRoute('update.settings')->toString(),
+        ':update-form' => Url::fromRoute('auto_updates.report_update')->toString(),
+      ]);
+      $output .= '</p>';
+      $output .= '<p>' . t('Additionally, Automatic Updates periodically runs checks to ensure that updates can be installed, and will warn site administrators if problems are detected.') . '</p>';
+      $output .= '<h3>' . t('Requirements') . '</h3>';
+      $output .= '<p>' . t('Automatic Updates requires Composer @version or later available as an executable, and PHP must have permission to run it. The path to the executable may be set in the <code>package_manager.settings:executables.composer</code> config setting, or it will be automatically detected.', ['@version' => ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION]) . '</p>';
       $output .= '<p>' . t('For more information, see the <a href=":automatic-updates-documentation">online documentation for the Automatic Updates module</a>.', [':automatic-updates-documentation' => 'https://www.drupal.org/docs/8/update/automatic-updates']) . '</p>';
       return $output;
   }
@@ -139,9 +148,8 @@ function auto_updates_form_update_manager_update_form_alter(&$form, FormStateInt
  * Implements hook_form_FORM_ID_alter() for 'update_settings' form.
  */
 function auto_updates_form_update_settings_alter(array &$form, FormStateInterface $form_state, string $form_id) {
-  $recommender = new UpdateRecommender();
-  $drupal_project = $recommender->getProjectInfo();
-  $version = ExtensionVersion::createFromVersionString($drupal_project['existing_version']);
+  $project_info = new ProjectInfo();
+  $version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion());
   $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion();
   // @todo In https://www.drupal.org/node/2998285 use the update XML to
   //   determine when the installed of core will become unsupported.
@@ -200,3 +208,16 @@ function auto_updates_local_tasks_alter(array &$local_tasks) {
     }
   }
 }
+
+/**
+ * Implements hook_batch_alter().
+ *
+ * @todo Remove this in https://www.drupal.org/i/3267817.
+ */
+function auto_updates_batch_alter(array &$batch): void {
+  foreach ($batch['sets'] as &$batch_set) {
+    if (!empty($batch_set['finished']) && $batch_set['finished'] === [DbUpdateController::class, 'batchFinished']) {
+      $batch_set['finished'] = [BatchProcessor::class, 'dbUpdateBatchFinished'];
+    }
+  }
+}
diff --git a/core/modules/auto_updates/auto_updates.routing.yml b/core/modules/auto_updates/auto_updates.routing.yml
index 42225ddebb92..c4e32b7106df 100644
--- a/core/modules/auto_updates/auto_updates.routing.yml
+++ b/core/modules/auto_updates/auto_updates.routing.yml
@@ -5,6 +5,8 @@ auto_updates.update_readiness:
     _title: 'Update readiness checking'
   requirements:
     _permission: 'administer software updates'
+  options:
+    _maintenance_access: TRUE
 auto_updates.confirmation_page:
   path: '/admin/automatic-update-ready/{stage_id}'
   defaults:
@@ -13,12 +15,16 @@ auto_updates.confirmation_page:
   requirements:
     _permission: 'administer software updates'
     _access_update_manager: 'TRUE'
+  options:
+    _maintenance_access: TRUE
 auto_updates.finish:
   path: '/automatic-update/finish'
   defaults:
     _controller: '\Drupal\auto_updates\Controller\UpdateController::onFinish'
   requirements:
     _permission: 'administer software updates'
+  options:
+    _maintenance_access: TRUE
 # Links to our updater form appear in three different sets of local tasks. To ensure the breadcrumbs and paths are
 # consistent with the other local tasks in each set, we need two separate routes to the same form.
 auto_updates.report_update:
@@ -30,6 +36,8 @@ auto_updates.report_update:
     _permission: 'administer software updates'
   options:
     _admin_route: TRUE
+    _maintenance_access: TRUE
+    _auto_updates_readiness_messages: skip
 auto_updates.module_update:
   path: '/admin/modules/automatic-update'
   defaults:
@@ -39,6 +47,8 @@ auto_updates.module_update:
     _permission: 'administer software updates'
   options:
     _admin_route: TRUE
+    _maintenance_access: TRUE
+    _auto_updates_readiness_messages: skip
 auto_updates.theme_update:
   path: '/admin/theme/automatic-update'
   defaults:
@@ -48,3 +58,5 @@ auto_updates.theme_update:
     _permission: 'administer software updates'
   options:
     _admin_route: TRUE
+    _maintenance_access: TRUE
+    _auto_updates_readiness_messages: skip
diff --git a/core/modules/auto_updates/auto_updates.services.yml b/core/modules/auto_updates/auto_updates.services.yml
index e405c39b16c4..579fbbbaac2f 100644
--- a/core/modules/auto_updates/auto_updates.services.yml
+++ b/core/modules/auto_updates/auto_updates.services.yml
@@ -1,4 +1,8 @@
 services:
+  auto_updates.route_subscriber:
+    class: Drupal\auto_updates\Routing\RouteSubscriber
+    tags:
+      - { name: event_subscriber }
   auto_updates.readiness_validation_manager:
     class: Drupal\auto_updates\Validation\ReadinessValidationManager
     arguments:
@@ -14,6 +18,7 @@ services:
   auto_updates.updater:
     class: Drupal\auto_updates\Updater
     arguments:
+      - '@config.factory'
       - '@package_manager.path_locator'
       - '@package_manager.beginner'
       - '@package_manager.stager'
@@ -21,11 +26,13 @@ services:
       - '@file_system'
       - '@event_dispatcher'
       - '@tempstore.shared'
+      - '@datetime.time'
   auto_updates.cron_updater:
     class: Drupal\auto_updates\CronUpdater
     arguments:
-      - '@config.factory'
+      - '@auto_updates.cron_release_chooser'
       - '@logger.factory'
+      - '@config.factory'
       - '@package_manager.path_locator'
       - '@package_manager.beginner'
       - '@package_manager.stager'
@@ -33,6 +40,7 @@ services:
       - '@file_system'
       - '@event_dispatcher'
       - '@tempstore.shared'
+      - '@datetime.time'
   auto_updates.staged_projects_validator:
     class: Drupal\auto_updates\Validator\StagedProjectsValidator
     arguments:
@@ -52,6 +60,21 @@ services:
       - '@config.factory'
     tags:
       - { name: event_subscriber }
+  auto_updates.cron_update_version_validator:
+    class: Drupal\auto_updates\Validator\CronUpdateVersionValidator
+    arguments:
+      - '@string_translation'
+      - '@config.factory'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.release_chooser:
+    class: Drupal\auto_updates\ReleaseChooser
+    arguments:
+      - '@auto_updates.update_version_validator'
+  auto_updates.cron_release_chooser:
+    class: Drupal\auto_updates\ReleaseChooser
+    arguments:
+      - '@auto_updates.cron_update_version_validator'
   auto_updates.composer_executable_validator:
     class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
     arguments:
@@ -82,6 +105,12 @@ services:
       - '@package_manager.validator.file_system'
     tags:
       - { name: event_subscriber }
+  auto_updates.validator.multisite:
+    class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+    arguments:
+      - '@package_manager.validator.multisite'
+    tags:
+      - { name: event_subscriber }
   auto_updates.cron_frequency_validator:
     class: Drupal\auto_updates\Validator\CronFrequencyValidator
     arguments:
diff --git a/core/modules/auto_updates/src/BatchProcessor.php b/core/modules/auto_updates/src/BatchProcessor.php
index 120aa5904d63..ffacbfd6ae94 100644
--- a/core/modules/auto_updates/src/BatchProcessor.php
+++ b/core/modules/auto_updates/src/BatchProcessor.php
@@ -4,6 +4,7 @@
 
 use Drupal\Core\Url;
 use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\system\Controller\DbUpdateController;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 
 /**
@@ -11,6 +12,20 @@
  */
 class BatchProcessor {
 
+  /**
+   * The session key under which the stage ID is stored.
+   *
+   * @var string
+   */
+  public const STAGE_ID_SESSION_KEY = '_auto_updates_stage_id';
+
+  /**
+   * The session key which indicates if the update is done in maintenance mode.
+   *
+   * @var string
+   */
+  public const MAINTENANCE_MODE_SESSION_KEY = '_auto_updates_maintenance_mode';
+
   /**
    * Gets the updater service.
    *
@@ -66,8 +81,8 @@ protected static function handleException(\Throwable $error, array &$context): v
    */
   public static function begin(array $project_versions, array &$context): void {
     try {
-      $stage_unique = static::getUpdater()->begin($project_versions);
-      $context['results']['stage_id'] = $stage_unique;
+      $stage_id = static::getUpdater()->begin($project_versions);
+      \Drupal::service('session')->set(static::STAGE_ID_SESSION_KEY, $stage_id);
     }
     catch (\Throwable $e) {
       static::handleException($e, $context);
@@ -84,10 +99,11 @@ public static function begin(array $project_versions, array &$context): void {
    */
   public static function stage(array &$context): void {
     try {
-      $stage_id = $context['results']['stage_id'];
+      $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY);
       static::getUpdater()->claim($stage_id)->stage();
     }
     catch (\Throwable $e) {
+      static::clean($stage_id, $context);
       static::handleException($e, $context);
     }
   }
@@ -142,8 +158,9 @@ public static function clean(string $stage_id, array &$context): void {
    */
   public static function finishStage(bool $success, array $results, array $operations): ?RedirectResponse {
     if ($success) {
+      $stage_id = \Drupal::service('session')->get(static::STAGE_ID_SESSION_KEY);
       $url = Url::fromRoute('auto_updates.confirmation_page', [
-        'stage_id' => $results['stage_id'],
+        'stage_id' => $stage_id,
       ]);
       return new RedirectResponse($url->setAbsolute()->toString());
     }
@@ -162,6 +179,8 @@ public static function finishStage(bool $success, array $results, array $operati
    *   A list of the operations that had not been completed by the batch API.
    */
   public static function finishCommit(bool $success, array $results, array $operations): ?RedirectResponse {
+    \Drupal::service('session')->remove(static::STAGE_ID_SESSION_KEY);
+
     if ($success) {
       $url = Url::fromRoute('auto_updates.finish')
         ->setAbsolute()
@@ -189,4 +208,39 @@ protected static function handleBatchError(array $results): void {
     }
   }
 
+  /**
+   * Reset maintenance mode after update.php.
+   *
+   * This wraps \Drupal\system\Controller\DbUpdateController::batchFinished()
+   * because that function would leave the site in maintenance mode if we
+   * redirected the user to update.php already in maintenance mode. We need to
+   * take the site out of maintenance mode, if it was not enabled before they
+   * submitted our confirmation form.
+   *
+   * @param bool $success
+   *   Whether the batch API tasks were all completed successfully.
+   * @param array $results
+   *   An array of all the results.
+   * @param array $operations
+   *   A list of the operations that had not been completed by the batch API.
+   *
+   * @todo Remove the need for this workaround in
+   *    https://www.drupal.org/i/3267817.
+   *
+   * @see \Drupal\update\Form\UpdateReady::submitForm()
+   * @see auto_updates_batch_alter()
+   */
+  public static function dbUpdateBatchFinished(bool $success, array $results, array $operations) {
+    DbUpdateController::batchFinished($success, $results, $operations);
+    // Now that the update is done, we can put the site back online if it was
+    // previously not in maintenance mode.
+    // \Drupal\system\Controller\DbUpdateController::batchFinished() will not
+    // unset maintenance mode if the site was in maintenance mode when the user
+    // was redirected to update.php by
+    // \Drupal\auto_updates\Controller\UpdateController::onFinish().
+    if (!\Drupal::request()->getSession()->remove(static::MAINTENANCE_MODE_SESSION_KEY)) {
+      \Drupal::state()->set('system.maintenance_mode', FALSE);
+    }
+  }
+
 }
diff --git a/core/modules/auto_updates/src/Controller/UpdateController.php b/core/modules/auto_updates/src/Controller/UpdateController.php
index fe840ef2c70f..42a2a536abe9 100644
--- a/core/modules/auto_updates/src/Controller/UpdateController.php
+++ b/core/modules/auto_updates/src/Controller/UpdateController.php
@@ -2,11 +2,14 @@
 
 namespace Drupal\auto_updates\Controller;
 
+use Drupal\auto_updates\BatchProcessor;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\State\StateInterface;
 use Drupal\Core\Url;
 use Drupal\package_manager\Validator\PendingUpdatesValidator;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\RedirectResponse;
+use Symfony\Component\HttpFoundation\Request;
 
 /**
  * Defines a controller to handle various stages of an automatic update.
@@ -28,9 +31,12 @@ class UpdateController extends ControllerBase {
    *
    * @param \Drupal\package_manager\Validator\PendingUpdatesValidator $pending_updates_validator
    *   The pending updates validator.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
    */
-  public function __construct(PendingUpdatesValidator $pending_updates_validator) {
+  public function __construct(PendingUpdatesValidator $pending_updates_validator, StateInterface $state) {
     $this->pendingUpdatesValidator = $pending_updates_validator;
+    $this->stateService = $state;
   }
 
   /**
@@ -38,7 +44,8 @@ public function __construct(PendingUpdatesValidator $pending_updates_validator)
    */
   public static function create(ContainerInterface $container) {
     return new static(
-      $container->get('package_manager.validator.pending_updates')
+      $container->get('package_manager.validator.pending_updates'),
+      $container->get('state')
     );
   }
 
@@ -49,10 +56,13 @@ public static function create(ContainerInterface $container) {
    * update.php to run those. Otherwise, they are redirected to the status
    * report.
    *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The current request object.
+   *
    * @return \Symfony\Component\HttpFoundation\RedirectResponse
    *   A redirect to the appropriate destination.
    */
-  public function onFinish(): RedirectResponse {
+  public function onFinish(Request $request): RedirectResponse {
     if ($this->pendingUpdatesValidator->updatesExist()) {
       $message = $this->t('Please apply database updates to complete the update process.');
       $url = Url::fromRoute('system.db_update');
@@ -60,6 +70,11 @@ public function onFinish(): RedirectResponse {
     else {
       $message = $this->t('Update complete!');
       $url = Url::fromRoute('update.status');
+      // Now that the update is done, we can put the site back online if it was
+      // previously not in maintenance mode.
+      if (!$request->getSession()->remove(BatchProcessor::MAINTENANCE_MODE_SESSION_KEY)) {
+        $this->state()->set('system.maintenance_mode', FALSE);
+      }
     }
     $this->messenger()->addStatus($message);
     return new RedirectResponse($url->setAbsolute()->toString());
diff --git a/core/modules/auto_updates/src/CronUpdater.php b/core/modules/auto_updates/src/CronUpdater.php
index 38317a8608b7..0b400a1e43ab 100644
--- a/core/modules/auto_updates/src/CronUpdater.php
+++ b/core/modules/auto_updates/src/CronUpdater.php
@@ -2,7 +2,6 @@
 
 namespace Drupal\auto_updates;
 
-use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Logger\LoggerChannelFactoryInterface;
 use Drupal\package_manager\Exception\StageValidationException;
 
@@ -37,32 +36,32 @@ class CronUpdater extends Updater {
   public const ALL = 'patch';
 
   /**
-   * The config factory service.
+   * The logger.
    *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   * @var \Psr\Log\LoggerInterface
    */
-  protected $configFactory;
+  protected $logger;
 
   /**
-   * The logger.
+   * The cron release chooser service.
    *
-   * @var \Psr\Log\LoggerInterface
+   * @var \Drupal\auto_updates\ReleaseChooser
    */
-  protected $logger;
+  protected $releaseChooser;
 
   /**
    * Constructs a CronUpdater object.
    *
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory service.
+   * @param \Drupal\auto_updates\ReleaseChooser $release_chooser
+   *   The cron release chooser service.
    * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
    *   The logger channel factory.
    * @param mixed ...$arguments
    *   Additional arguments to pass to the parent constructor.
    */
-  public function __construct(ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, ...$arguments) {
+  public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, ...$arguments) {
     parent::__construct(...$arguments);
-    $this->configFactory = $config_factory;
+    $this->releaseChooser = $release_chooser;
     $this->logger = $logger_factory->get('auto_updates');
   }
 
@@ -70,50 +69,35 @@ public function __construct(ConfigFactoryInterface $config_factory, LoggerChanne
    * Handles updates during cron.
    */
   public function handleCron(): void {
-    $level = $this->configFactory->get('auto_updates.settings')
-      ->get('cron');
-
-    // If automatic updates are disabled, bail out.
-    if ($level === static::DISABLED) {
+    if ($this->isDisabled()) {
       return;
     }
 
-    $recommender = new UpdateRecommender();
-    try {
-      $recommended_release = $recommender->getRecommendedRelease(TRUE);
-    }
-    catch (\Throwable $e) {
-      $this->logger->error($e->getMessage());
-      return;
-    }
-
-    // If we're already up-to-date, there's nothing else we need to do.
-    if ($recommended_release === NULL) {
-      return;
+    $next_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor();
+    if ($next_release) {
+      $this->performUpdate($next_release->getVersion());
     }
+  }
 
-    $project = $recommender->getProjectInfo();
-    if (empty($project['existing_version'])) {
+  /**
+   * Performs the update.
+   *
+   * @param string $update_version
+   *   The version to which to update.
+   */
+  private function performUpdate(string $update_version): void {
+    $installed_version = (new ProjectInfo())->getInstalledVersion();
+    if (empty($installed_version)) {
       $this->logger->error('Unable to determine the current version of Drupal core.');
       return;
     }
 
-    // If automatic updates are only enabled for security releases, bail out if
-    // the recommended release is not a security release.
-    if ($level === static::SECURITY && !$recommended_release->isSecurityRelease()) {
-      return;
-    }
-
-    // @todo Use the queue to add update jobs allowing jobs to span multiple
-    //   cron runs.
-    $recommended_version = $recommended_release->getVersion();
-
     // Do the bulk of the update in its own try-catch structure, so that we can
     // handle any exceptions or validation errors consistently, and destroy the
     // stage regardless of whether the update succeeds.
     try {
       $this->begin([
-        'drupal' => $recommended_version,
+        'drupal' => $update_version,
       ]);
       $this->stage();
       $this->apply();
@@ -121,8 +105,8 @@ public function handleCron(): void {
       $this->logger->info(
         'Drupal core has been updated from %previous_version to %update_version',
         [
-          '%previous_version' => $project['existing_version'],
-          '%update_version' => $recommended_version,
+          '%previous_version' => $installed_version,
+          '%update_version' => $update_version,
         ]
       );
     }
@@ -149,6 +133,16 @@ public function handleCron(): void {
     }
   }
 
+  /**
+   * Determines if cron updates are disabled.
+   *
+   * @return bool
+   *   TRUE if cron updates are disabled, otherwise FALSE.
+   */
+  private function isDisabled(): bool {
+    return $this->configFactory->get('auto_updates.settings')->get('cron') === static::DISABLED;
+  }
+
   /**
    * Generates a log message from a stage validation exception.
    *
diff --git a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
index 4fa4e3fc5c84..53467824924c 100644
--- a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
+++ b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
@@ -2,9 +2,9 @@
 
 namespace Drupal\auto_updates\Event;
 
-use Drupal\auto_updates\Updater;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Stage;
 use Drupal\package_manager\ValidationResult;
 
 /**
@@ -31,14 +31,14 @@ class ReadinessCheckEvent extends PreOperationStageEvent {
   /**
    * Constructs a ReadinessCheckEvent object.
    *
-   * @param \Drupal\auto_updates\Updater $updater
-   *   The updater service.
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage service.
    * @param string[] $project_versions
    *   (optional) The versions of the packages to update to, keyed by package
    *   name.
    */
-  public function __construct(Updater $updater, array $project_versions = []) {
-    parent::__construct($updater);
+  public function __construct(Stage $stage, array $project_versions = []) {
+    parent::__construct($stage);
     if ($project_versions) {
       if (count($project_versions) !== 1 || !array_key_exists('drupal', $project_versions)) {
         throw new \InvalidArgumentException("Currently only updates to Drupal core are supported.");
@@ -66,7 +66,7 @@ public function getPackageVersions(): array {
   /**
    * Adds warning information to the event.
    */
-  public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL) {
+  public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL): void {
     $this->results[] = ValidationResult::createWarning($messages, $summary);
   }
 
diff --git a/core/modules/auto_updates/src/Form/UpdateReady.php b/core/modules/auto_updates/src/Form/UpdateReady.php
index ad8915a19b3a..7765a16f82b5 100644
--- a/core/modules/auto_updates/src/Form/UpdateReady.php
+++ b/core/modules/auto_updates/src/Form/UpdateReady.php
@@ -11,6 +11,7 @@
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\Exception\StageOwnershipException;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 
@@ -104,46 +105,71 @@ public function buildForm(array $form, FormStateInterface $form_state, string $s
       return $form;
     }
 
-    // Don't check for pending database updates if the form has been submitted,
-    // because we don't want to store the warning in the messenger during form
-    // submit.
+    $messages = [];
+
+    // If there are any installed modules with database updates in the staging
+    // area, warn the user that they might be sent to update.php once the
+    // staged changes have been applied.
+    $pending_updates = $this->getModulesWithStagedDatabaseUpdates();
+    if ($pending_updates) {
+      $messages[MessengerInterface::TYPE_WARNING][] = $this->t('Possible database updates were detected in the following modules; you may be redirected to the database update page in order to complete the update process.');
+      foreach ($pending_updates as $info) {
+        $messages[MessengerInterface::TYPE_WARNING][] = $info['name'];
+      }
+    }
+
+    try {
+      $staged_core_packages = $this->updater->getStageComposer()
+        ->getCorePackages();
+    }
+    catch (\Throwable $exception) {
+      $messages[MessengerInterface::TYPE_ERROR][] = $this->t('There was an error loading the pending update. Press the <em>Cancel update</em> button to start over.');
+    }
+
+    // Don't set any messages if the form has been submitted, because we don't
+    // want them to be set during form submit.
     if (!$form_state->getUserInput()) {
-      // If there are any installed modules with database updates in the staging
-      // area, warn the user that they might be sent to update.php once the
-      // staged changes have been applied.
-      $pending_updates = $this->getModulesWithStagedDatabaseUpdates();
-
-      if ($pending_updates) {
-        $this->messenger()->addWarning($this->t('Possible database updates were detected in the following modules; you may be redirected to the database update page in order to complete the update process.'));
-        foreach ($pending_updates as $info) {
-          $this->messenger()->addWarning($info['name']);
+      foreach ($messages as $type => $messages_of_type) {
+        foreach ($messages_of_type as $message) {
+          $this->messenger()->addMessage($message, $type);
         }
       }
     }
 
+    $form['actions'] = [
+      'cancel' => [
+        '#type' => 'submit',
+        '#value' => $this->t('Cancel update'),
+        '#submit' => ['::cancel'],
+      ],
+      '#type' => 'actions',
+    ];
     $form['stage_id'] = [
       '#type' => 'value',
       '#value' => $stage_id,
     ];
 
+    if (empty($staged_core_packages)) {
+      return $form;
+    }
+
+    $form['update_version'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'p',
+      '#value' => $this->t('Drupal core will be updated to %version', [
+        '%version' => reset($staged_core_packages)->getPrettyVersion(),
+      ]),
+    ];
     $form['backup'] = [
       '#prefix' => '<strong>',
       '#markup' => $this->t('Back up your database and site before you continue. <a href=":backup_url">Learn how</a>.', [':backup_url' => 'https://www.drupal.org/node/22281']),
       '#suffix' => '</strong>',
     ];
-
     $form['maintenance_mode'] = [
       '#title' => $this->t('Perform updates with site in maintenance mode (strongly recommended)'),
       '#type' => 'checkbox',
       '#default_value' => TRUE,
     ];
-
-    $form['actions'] = ['#type' => 'actions'];
-    $form['actions']['cancel'] = [
-      '#type' => 'submit',
-      '#value' => $this->t('Cancel update'),
-      '#submit' => ['::cancel'],
-    ];
     $form['actions']['submit'] = [
       '#type' => 'submit',
       '#value' => $this->t('Continue'),
@@ -170,12 +196,13 @@ protected function getModulesWithStagedDatabaseUpdates(): array {
    * {@inheritdoc}
    */
   public function submitForm(array &$form, FormStateInterface $form_state) {
-    $session = $this->getRequest()->getSession();
     // Store maintenance_mode setting so we can restore it when done.
-    $session->set('maintenance_mode', $this->state->get('system.maintenance_mode'));
-    if ($form_state->getValue('maintenance_mode') === TRUE) {
+    $this->getRequest()
+      ->getSession()
+      ->set(BatchProcessor::MAINTENANCE_MODE_SESSION_KEY, $this->state->get('system.maintenance_mode'));
+
+    if ($form_state->getValue('maintenance_mode')) {
       $this->state->set('system.maintenance_mode', TRUE);
-      // @todo unset after updater. After db update?
     }
     $stage_id = $form_state->getValue('stage_id');
     $batch = (new BatchBuilder())
@@ -193,9 +220,14 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
    * Cancels the in-progress update.
    */
   public function cancel(array &$form, FormStateInterface $form_state): void {
-    $this->updater->destroy();
-    $this->messenger()->addStatus($this->t('The update was successfully cancelled.'));
-    $form_state->setRedirect('auto_updates.report_update');
+    try {
+      $this->updater->destroy();
+      $this->messenger()->addStatus($this->t('The update was successfully cancelled.'));
+      $form_state->setRedirect('auto_updates.report_update');
+    }
+    catch (StageException $e) {
+      $this->messenger()->addError($e->getMessage());
+    }
   }
 
 }
diff --git a/core/modules/auto_updates/src/Form/UpdaterForm.php b/core/modules/auto_updates/src/Form/UpdaterForm.php
index 7d66c169a67d..90074c6c5de3 100644
--- a/core/modules/auto_updates/src/Form/UpdaterForm.php
+++ b/core/modules/auto_updates/src/Form/UpdaterForm.php
@@ -4,16 +4,18 @@
 
 use Drupal\auto_updates\BatchProcessor;
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\ProjectInfo;
+use Drupal\auto_updates\ReleaseChooser;
 use Drupal\auto_updates\Updater;
-use Drupal\auto_updates\UpdateRecommender;
 use Drupal\auto_updates\Validation\ReadinessTrait;
-use Drupal\auto_updates\Validation\ReadinessValidationManager;
 use Drupal\Core\Batch\BatchBuilder;
 use Drupal\Core\Form\FormBase;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Link;
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\Url;
+use Drupal\package_manager\Exception\StageException;
+use Drupal\package_manager\Exception\StageOwnershipException;
 use Drupal\system\SystemManager;
 use Drupal\update\UpdateManagerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -44,18 +46,18 @@ class UpdaterForm extends FormBase {
   protected $state;
 
   /**
-   * The readiness validation manager service.
+   * The event dispatcher service.
    *
-   * @var \Drupal\auto_updates\Validation\ReadinessValidationManager
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
    */
-  protected $readinessValidationManager;
+  protected $eventDispatcher;
 
   /**
-   * The event dispatcher service.
+   * The release chooser service.
    *
-   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   * @var \Drupal\auto_updates\ReleaseChooser
    */
-  protected $eventDispatcher;
+  protected $releaseChooser;
 
   /**
    * Constructs a new UpdaterForm object.
@@ -64,16 +66,16 @@ class UpdaterForm extends FormBase {
    *   The state service.
    * @param \Drupal\auto_updates\Updater $updater
    *   The updater service.
-   * @param \Drupal\auto_updates\Validation\ReadinessValidationManager $readiness_validation_manager
-   *   The readiness validation manager service.
    * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
    *   The event dispatcher service.
+   * @param \Drupal\auto_updates\ReleaseChooser $release_chooser
+   *   The release chooser service.
    */
-  public function __construct(StateInterface $state, Updater $updater, ReadinessValidationManager $readiness_validation_manager, EventDispatcherInterface $event_dispatcher) {
+  public function __construct(StateInterface $state, Updater $updater, EventDispatcherInterface $event_dispatcher, ReleaseChooser $release_chooser) {
     $this->updater = $updater;
     $this->state = $state;
-    $this->readinessValidationManager = $readiness_validation_manager;
     $this->eventDispatcher = $event_dispatcher;
+    $this->releaseChooser = $release_chooser;
   }
 
   /**
@@ -90,8 +92,8 @@ public static function create(ContainerInterface $container) {
     return new static(
       $container->get('state'),
       $container->get('auto_updates.updater'),
-      $container->get('auto_updates.readiness_validation_manager'),
-      $container->get('event_dispatcher')
+      $container->get('event_dispatcher'),
+      $container->get('auto_updates.release_chooser')
     );
   }
 
@@ -101,14 +103,52 @@ public static function create(ContainerInterface $container) {
   public function buildForm(array $form, FormStateInterface $form_state) {
     $this->messenger()->addWarning($this->t('This is an experimental Automatic Updater using Composer. Use at your own risk.'));
 
+    if ($this->updater->isAvailable()) {
+      $stage_exists = FALSE;
+    }
+    else {
+      $stage_exists = TRUE;
+
+      // If there's a stage ID stored in the session, try to claim the stage
+      // with it. If we succeed, then an update is already in progress, and the
+      // current session started it, so redirect them to the confirmation form.
+      $stage_id = $this->getRequest()->getSession()->get(BatchProcessor::STAGE_ID_SESSION_KEY);
+      if ($stage_id) {
+        try {
+          $this->updater->claim($stage_id);
+          return $this->redirect('auto_updates.confirmation_page', [
+            'stage_id' => $stage_id,
+          ]);
+        }
+        catch (StageOwnershipException $e) {
+          // We already know a stage exists, even if it's not ours, so we don't
+          // have to do anything else here.
+        }
+      }
+    }
+
     $form['last_check'] = [
       '#theme' => 'update_last_check',
       '#last' => $this->state->get('update.last_check', 0),
     ];
+    $project_info = new ProjectInfo();
 
-    $recommender = new UpdateRecommender();
     try {
-      $recommended_release = $recommender->getRecommendedRelease(TRUE);
+      // @todo Until https://www.drupal.org/i/3264849 is fixed, we can only show
+      //   one release on the form. First, try to show the latest release in the
+      //   currently installed minor. Failing that, try to show the latest
+      //   release in the next minor. If neither of those are available, just
+      //   show the first available release.
+      $recommended_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor();
+      if (!$recommended_release) {
+        $recommended_release = $this->releaseChooser->getLatestInNextMinor();
+        if (!$recommended_release) {
+          // @todo Do not list an update that can't be validated in
+          //   https://www.drupal.org/i/3271235.
+          $updates = $project_info->getInstallableReleases();
+          $recommended_release = array_pop($updates);
+        }
+      }
     }
     catch (\RuntimeException $e) {
       $form['message'] = [
@@ -122,6 +162,9 @@ public function buildForm(array $form, FormStateInterface $form_state) {
 
     // If we're already up-to-date, there's nothing else we need to do.
     if ($recommended_release === NULL) {
+      // @todo Link to the Available Updates report if there are other updates
+      //   that are not supported by this module in
+      //   https://www.drupal.org/i/3271235.
       $this->messenger()->addMessage('No update available');
       return $form;
     }
@@ -133,7 +176,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       ],
     ];
 
-    $project = $recommender->getProjectInfo();
+    $project = $project_info->getProjectInfo();
     if (empty($project['title']) || empty($project['link'])) {
       throw new \UnexpectedValueException('Expected project data to have a title and link.');
     }
@@ -161,7 +204,7 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       'title' => [
         'data' => $title,
       ],
-      'installed_version' => $project['existing_version'],
+      'installed_version' => $project_info->getInstalledVersion(),
       'recommended_version' => [
         'data' => [
           // @todo Is an inline template the right tool here? Is there an Update
@@ -210,53 +253,42 @@ public function buildForm(array $form, FormStateInterface $form_state) {
     }
     $this->displayResults($results, $this->messenger());
 
-    // If there were no errors, allow the user to proceed with the update.
-    if ($this->getOverallSeverity($results) !== SystemManager::REQUIREMENT_ERROR) {
-      $form['actions'] = $this->actions($form_state);
-    }
-    return $form;
-  }
-
-  /**
-   * Builds the form actions.
-   *
-   * @param \Drupal\Core\Form\FormStateInterface $form_state
-   *   The form state.
-   *
-   * @return mixed[][]
-   *   The form's actions elements.
-   */
-  protected function actions(FormStateInterface $form_state): array {
-    $actions = ['#type' => 'actions'];
-
-    if (!$this->updater->isAvailable()) {
-      // If the form has been submitted do not display this error message
+    if ($stage_exists) {
+      // If the form has been submitted, do not display this error message
       // because ::deleteExistingUpdate() may run on submit. The message will
       // still be displayed on form build if needed.
       if (!$form_state->getUserInput()) {
         $this->messenger()->addError($this->t('Cannot begin an update because another Composer operation is currently in progress.'));
       }
-      $actions['delete'] = [
+      $form['actions']['delete'] = [
         '#type' => 'submit',
         '#value' => $this->t('Delete existing update'),
         '#submit' => ['::deleteExistingUpdate'],
       ];
     }
-    else {
-      $actions['submit'] = [
+    // If there were no errors, allow the user to proceed with the update.
+    elseif ($this->getOverallSeverity($results) !== SystemManager::REQUIREMENT_ERROR) {
+      $form['actions']['submit'] = [
         '#type' => 'submit',
         '#value' => $this->t('Update'),
       ];
     }
-    return $actions;
+    $form['actions']['#type'] = 'actions';
+
+    return $form;
   }
 
   /**
    * Submit function to delete an existing in-progress update.
    */
   public function deleteExistingUpdate(): void {
-    $this->updater->destroy(TRUE);
-    $this->messenger()->addMessage($this->t("Staged update deleted"));
+    try {
+      $this->updater->destroy(TRUE);
+      $this->messenger()->addMessage($this->t("Staged update deleted"));
+    }
+    catch (StageException $e) {
+      $this->messenger()->addError($e->getMessage());
+    }
   }
 
   /**
diff --git a/core/modules/auto_updates/src/ProjectInfo.php b/core/modules/auto_updates/src/ProjectInfo.php
new file mode 100644
index 000000000000..df802a80d906
--- /dev/null
+++ b/core/modules/auto_updates/src/ProjectInfo.php
@@ -0,0 +1,106 @@
+<?php
+
+namespace Drupal\auto_updates;
+
+use Composer\Semver\Comparator;
+use Composer\Semver\Semver;
+use Drupal\update\ProjectRelease;
+use Drupal\update\UpdateManagerInterface;
+
+/**
+ * Defines a class for retrieving project information from Update module.
+ *
+ * @todo Allow passing a project name to handle more than Drupal core in
+ *    https://www.drupal.org/i/3271240.
+ */
+class ProjectInfo {
+
+  /**
+   * Returns up-to-date project information for Drupal core.
+   *
+   * @param bool $refresh
+   *   (optional) Whether to fetch the latest information about available
+   *   updates from drupal.org. This can be an expensive operation, so defaults
+   *   to FALSE.
+   *
+   * @return array
+   *   The retrieved project information for Drupal core.
+   *
+   * @throws \RuntimeException
+   *   If data about available updates cannot be retrieved.
+   */
+  public function getProjectInfo(bool $refresh = FALSE): array {
+    $available_updates = update_get_available($refresh);
+    $project_data = update_calculate_project_data($available_updates);
+    return $project_data['drupal'];
+  }
+
+  /**
+   * Gets all releases of Drupal core to which the site can update.
+   *
+   * @param bool $refresh
+   *   (optional) Whether to fetch the latest information about available
+   *   updates from drupal.org. This can be an expensive operation, so defaults
+   *   to FALSE.
+   *
+   * @return \Drupal\update\ProjectRelease[]
+   *   An array of possible update releases with release versions as keys. The
+   *   releases are in descending order by version number (i.e., higher versions
+   *   are listed first).
+   *
+   * @throws \RuntimeException
+   *   Thrown if $refresh is TRUE and there are no available releases.
+   *
+   * @todo Remove or simplify this function in https://www.drupal.org/i/3252190.
+   */
+  public function getInstallableReleases(bool $refresh = FALSE): array {
+    $project = $this->getProjectInfo($refresh);
+    $installed_version = $this->getInstalledVersion();
+    // If we refreshed and we were able to get available releases we should
+    // always have at least have the current release stored.
+    if ($refresh && empty($project['releases'])) {
+      throw new \RuntimeException('There was a problem getting update information. Try again later.');
+    }
+    // If we're already up-to-date, there's nothing else we need to do.
+    if ($project['status'] === UpdateManagerInterface::CURRENT) {
+      return [];
+    }
+    elseif (empty($project['recommended'])) {
+      // If we don't know what to recommend they update to, time to freak out.
+      throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.');
+    }
+    $installable_releases = [];
+    if (Comparator::greaterThan($project['recommended'], $installed_version)) {
+      $release = ProjectRelease::createFromArray($project['releases'][$project['recommended']]);
+      $installable_releases[$release->getVersion()] = $release;
+    }
+    if (!empty($project['security updates'])) {
+      foreach ($project['security updates'] as $security_update) {
+        $release = ProjectRelease::createFromArray($security_update);
+        $version = $release->getVersion();
+        if (Comparator::greaterThan($version, $installed_version)) {
+          $installable_releases[$version] = $release;
+        }
+      }
+    }
+    $sorted_versions = Semver::rsort(array_keys($installable_releases));
+    return array_replace(array_flip($sorted_versions), $installable_releases);
+  }
+
+  /**
+   * Returns the installed project version, according to the Update module.
+   *
+   * @param bool $refresh
+   *   (optional) Whether to fetch the latest information about available
+   *   updates from drupal.org. This can be an expensive operation, so defaults
+   *   to FALSE.
+   *
+   * @return string
+   *   The installed project version as known to the Update module.
+   */
+  public function getInstalledVersion(bool $refresh = FALSE): string {
+    $project_data = $this->getProjectInfo($refresh);
+    return $project_data['existing_version'];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/ReleaseChooser.php b/core/modules/auto_updates/src/ReleaseChooser.php
new file mode 100644
index 000000000000..c72ec3a572e9
--- /dev/null
+++ b/core/modules/auto_updates/src/ReleaseChooser.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\auto_updates;
+
+use Composer\Semver\Semver;
+use Drupal\auto_updates\Validator\UpdateVersionValidator;
+use Drupal\update\ProjectRelease;
+use Drupal\Core\Extension\ExtensionVersion;
+
+/**
+ * Defines a class to choose a release of Drupal core to update to.
+ */
+class ReleaseChooser {
+
+  use VersionParsingTrait;
+
+  /**
+   * The version validator service.
+   *
+   * @var \Drupal\auto_updates\Validator\UpdateVersionValidator
+   */
+  protected $versionValidator;
+
+  /**
+   * The project information fetcher.
+   *
+   * @var \Drupal\auto_updates\ProjectInfo
+   */
+  protected $projectInfo;
+
+  /**
+   * Constructs an ReleaseChooser object.
+   *
+   * @param \Drupal\auto_updates\Validator\UpdateVersionValidator $version_validator
+   *   The version validator.
+   */
+  public function __construct(UpdateVersionValidator $version_validator) {
+    $this->versionValidator = $version_validator;
+    $this->projectInfo = new ProjectInfo();
+  }
+
+  /**
+   * Refreshes the project information through the Update module.
+   *
+   * @return $this
+   *   The called object.
+   */
+  public function refresh(): self {
+    $this->projectInfo->getProjectInfo(TRUE);
+    return $this;
+  }
+
+  /**
+   * Returns the releases that are installable.
+   *
+   * @return \Drupal\update\ProjectRelease[]
+   *   The releases that are installable according to the version validator
+   *   service.
+   */
+  protected function getInstallableReleases(): array {
+    return array_filter(
+      $this->projectInfo->getInstallableReleases(),
+      [$this->versionValidator, 'isValidVersion'],
+      ARRAY_FILTER_USE_KEY
+    );
+  }
+
+  /**
+   * Gets the most recent release in the same minor as a specified version.
+   *
+   * @param string $version
+   *   The full semantic version number, which must include a patch version.
+   *
+   * @return \Drupal\update\ProjectRelease|null
+   *   The most recent release in the minor if available, otherwise NULL.
+   *
+   * @throws \InvalidArgumentException
+   *   If the given semantic version number does not contain a patch version.
+   */
+  protected function getMostRecentReleaseInMinor(string $version): ?ProjectRelease {
+    if (static::getPatchVersion($version) === NULL) {
+      throw new \InvalidArgumentException("The version number $version does not contain a patch version");
+    }
+    $releases = $this->getInstallableReleases();
+    foreach ($releases as $release) {
+      if (Semver::satisfies($release->getVersion(), "~$version")) {
+        return $release;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Gets the installed version of Drupal core.
+   *
+   * @return string
+   *   The installed version of Drupal core.
+   */
+  protected function getInstalledVersion(): string {
+    return $this->projectInfo->getInstalledVersion();
+  }
+
+  /**
+   * Gets the latest release in the currently installed minor.
+   *
+   * This will only return a release if it passes the ::isValidVersion() method
+   * of the version validator service injected into this class.
+   *
+   * @return \Drupal\update\ProjectRelease|null
+   *   The latest release in the currently installed minor, if any, otherwise
+   *   NULL.
+   */
+  public function getLatestInInstalledMinor(): ?ProjectRelease {
+    return $this->getMostRecentReleaseInMinor($this->getInstalledVersion());
+  }
+
+  /**
+   * Gets the latest release in the next minor.
+   *
+   * This will only return a release if it passes the ::isValidVersion() method
+   * of the version validator service injected into this class.
+   *
+   * @return \Drupal\update\ProjectRelease|null
+   *   The latest release in the next minor, if any, otherwise NULL.
+   */
+  public function getLatestInNextMinor(): ?ProjectRelease {
+    $installed_version = ExtensionVersion::createFromVersionString($this->getInstalledVersion());
+    $next_minor = $installed_version->getMajorVersion() . '.' . (((int) $installed_version->getMinorVersion()) + 1) . '.0';
+    return $this->getMostRecentReleaseInMinor($next_minor);
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Routing/RouteSubscriber.php b/core/modules/auto_updates/src/Routing/RouteSubscriber.php
new file mode 100644
index 000000000000..bbcad03e9443
--- /dev/null
+++ b/core/modules/auto_updates/src/Routing/RouteSubscriber.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\auto_updates\Routing;
+
+use Drupal\Core\Routing\RouteSubscriberBase;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Modifies route definitions.
+ *
+ * @internal
+ */
+class RouteSubscriber extends RouteSubscriberBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function alterRoutes(RouteCollection $collection) {
+    $disabled_routes = [
+      'update.theme_update',
+      'system.theme_install',
+      'update.module_update',
+      'update.module_install',
+      'update.status',
+      'update.report_update',
+      'update.report_install',
+      'update.settings',
+      'system.status',
+      'update.confirmation_page',
+    ];
+    foreach ($disabled_routes as $route) {
+      $route = $collection->get($route);
+      if ($route) {
+        $route->setOption('_auto_updates_readiness_messages', 'skip');
+      }
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/src/UpdateRecommender.php b/core/modules/auto_updates/src/UpdateRecommender.php
deleted file mode 100644
index adf09b2ee1f1..000000000000
--- a/core/modules/auto_updates/src/UpdateRecommender.php
+++ /dev/null
@@ -1,69 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates;
-
-use Drupal\update\ProjectRelease;
-use Drupal\update\UpdateManagerInterface;
-
-/**
- * Determines the recommended release of Drupal core to update to.
- */
-class UpdateRecommender {
-
-  /**
-   * Returns up-to-date project information for Drupal core.
-   *
-   * @param bool $refresh
-   *   (optional) Whether to fetch the latest information about available
-   *   updates from drupal.org. This can be an expensive operation, so defaults
-   *   to FALSE.
-   *
-   * @return array
-   *   The retrieved project information for Drupal core.
-   *
-   * @throws \RuntimeException
-   *   If data about available updates cannot be retrieved.
-   */
-  public function getProjectInfo(bool $refresh = FALSE): array {
-    $available_updates = update_get_available($refresh);
-    if (empty($available_updates)) {
-      throw new \RuntimeException('There was a problem getting update information. Try again later.');
-    }
-
-    $project_data = update_calculate_project_data($available_updates);
-    return $project_data['drupal'];
-  }
-
-  /**
-   * Returns the recommended release of Drupal core.
-   *
-   * @param bool $refresh
-   *   (optional) Whether to fetch the latest information about available
-   *   updates from drupal.org. This can be an expensive operation, so defaults
-   *   to FALSE.
-   *
-   * @return \Drupal\update\ProjectRelease|null
-   *   A value object with information about the recommended release, or NULL
-   *   if Drupal core is already up-to-date.
-   *
-   * @throws \LogicException
-   *   If Drupal core is out of date and the recommended version of cannot be
-   *   determined.
-   */
-  public function getRecommendedRelease(bool $refresh = FALSE): ?ProjectRelease {
-    $project = $this->getProjectInfo($refresh);
-
-    // If we're already up-to-date, there's nothing else we need to do.
-    if ($project['status'] === UpdateManagerInterface::CURRENT) {
-      return NULL;
-    }
-    // If we don't know what to recommend they update to, time to freak out.
-    elseif (empty($project['recommended'])) {
-      throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.');
-    }
-
-    $recommended_version = $project['recommended'];
-    return ProjectRelease::createFromArray($project['releases'][$recommended_version]);
-  }
-
-}
diff --git a/core/modules/auto_updates/src/Updater.php b/core/modules/auto_updates/src/Updater.php
index b9c964a0ec0a..68b3aec8564e 100644
--- a/core/modules/auto_updates/src/Updater.php
+++ b/core/modules/auto_updates/src/Updater.php
@@ -91,9 +91,9 @@ public function stage(): void {
   /**
    * {@inheritdoc}
    */
-  protected function dispatch(StageEvent $event): void {
+  protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
     try {
-      parent::dispatch($event);
+      parent::dispatch($event, $on_error);
     }
     catch (StageValidationException $e) {
       throw new UpdateException($e->getResults(), $e->getMessage() ?: "Unable to complete the update because of errors.", $e->getCode(), $e);
diff --git a/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php b/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php
index b644d39a668c..b8d4cb767145 100644
--- a/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php
+++ b/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\auto_updates\Validation;
 
+use Drupal\auto_updates\CronUpdater;
+use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
 use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\Messenger\MessengerTrait;
@@ -57,6 +59,13 @@ final class AdminReadinessMessages implements ContainerInjectionInterface {
    */
   protected $currentRouteMatch;
 
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $config;
+
   /**
    * Constructs a ReadinessRequirement object.
    *
@@ -72,14 +81,17 @@ final class AdminReadinessMessages implements ContainerInjectionInterface {
    *   The translation service.
    * @param \Drupal\Core\Routing\CurrentRouteMatch $current_route_match
    *   The current route match.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config
+   *   The config factory service.
    */
-  public function __construct(ReadinessValidationManager $readiness_checker_manager, MessengerInterface $messenger, AdminContext $admin_context, AccountProxyInterface $current_user, TranslationInterface $translation, CurrentRouteMatch $current_route_match) {
+  public function __construct(ReadinessValidationManager $readiness_checker_manager, MessengerInterface $messenger, AdminContext $admin_context, AccountProxyInterface $current_user, TranslationInterface $translation, CurrentRouteMatch $current_route_match, ConfigFactoryInterface $config) {
     $this->readinessCheckerManager = $readiness_checker_manager;
     $this->setMessenger($messenger);
     $this->adminContext = $admin_context;
     $this->currentUser = $current_user;
     $this->setStringTranslation($translation);
     $this->currentRouteMatch = $current_route_match;
+    $this->config = $config;
   }
 
   /**
@@ -92,7 +104,8 @@ public static function create(ContainerInterface $container): self {
       $container->get('router.admin_context'),
       $container->get('current_user'),
       $container->get('string_translation'),
-      $container->get('current_route_match')
+      $container->get('current_route_match'),
+      $container->get('config.factory')
     );
   }
 
@@ -127,24 +140,15 @@ public function displayAdminPageMessages(): void {
    *   Whether the messages should be displayed on the current page.
    */
   protected function displayResultsOnCurrentPage(): bool {
+    // If updates will not run during cron then we don't need to show the
+    // readiness checks on admin pages.
+    if ($this->config->get('auto_updates.settings')->get('cron') === CronUpdater::DISABLED) {
+      return FALSE;
+    }
+
     if ($this->adminContext->isAdminRoute() && $this->currentUser->hasPermission('administer site configuration')) {
-      // These routes don't need additional nagging.
-      $disabled_routes = [
-        'update.theme_update',
-        'system.theme_install',
-        'update.module_update',
-        'update.module_install',
-        'update.status',
-        'update.report_update',
-        'update.report_install',
-        'update.settings',
-        'system.status',
-        'update.confirmation_page',
-        'auto_updates.report_update',
-        'auto_updates.module_update',
-        'auto_updates.theme_update',
-      ];
-      return !in_array($this->currentRouteMatch->getRouteName(), $disabled_routes, TRUE);
+      $route = $this->currentRouteMatch->getRouteObject();
+      return $route && $route->getOption('_auto_updates_readiness_messages') !== 'skip';
     }
     return FALSE;
   }
diff --git a/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php
index 8c71360b9da9..c513af498f8f 100644
--- a/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php
+++ b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php
@@ -4,8 +4,8 @@
 
 use Drupal\auto_updates\CronUpdater;
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\ProjectInfo;
 use Drupal\auto_updates\Updater;
-use Drupal\auto_updates\UpdateRecommender;
 use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
@@ -102,20 +102,18 @@ public function __construct(KeyValueExpirableFactoryInterface $key_value_expirab
    * @return $this
    */
   public function run(): self {
-    $recommender = new UpdateRecommender();
-    $release = $recommender->getRecommendedRelease(TRUE);
     // If updates will run during cron, use the cron updater service provided by
     // this module. This will allow subscribers to ReadinessCheckEvent to run
     // specific validation for conditions that only affect cron updates.
-    if ($this->config->get('auto_updates.settings')->get('cron') == CronUpdater::DISABLED) {
+    if ($this->config->get('auto_updates.settings')->get('cron') === CronUpdater::DISABLED) {
       $stage = $this->updater;
     }
     else {
       $stage = $this->cronUpdater;
     }
-
-    $project_versions = $release ? ['drupal' => $release->getVersion()] : [];
-    $event = new ReadinessCheckEvent($stage, $project_versions);
+    $event = new ReadinessCheckEvent($stage);
+    // Version validators will need up-to-date project info.
+    (new ProjectInfo())->getProjectInfo(TRUE);
     $this->eventDispatcher->dispatch($event);
     $results = $event->getResults();
     $this->keyValueExpirable->setWithExpire(
diff --git a/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php b/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php
new file mode 100644
index 000000000000..de52a4ad0fa2
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Drupal\auto_updates\CronUpdater;
+use Drupal\auto_updates\ProjectInfo;
+use Drupal\auto_updates\VersionParsingTrait;
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\package_manager\Stage;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * Validates the target version of Drupal core before a cron update.
+ *
+ * @internal
+ *   This class is an internal part of the module's cron update handling and
+ *   should not be used by external code.
+ */
+final class CronUpdateVersionValidator extends UpdateVersionValidator {
+
+  use VersionParsingTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function isStageSupported(Stage $stage): bool {
+    return $stage instanceof CronUpdater;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getValidationResult(string $to_version_string): ?ValidationResult {
+    if ($result = parent::getValidationResult($to_version_string)) {
+      return $result;
+    }
+    $from_version_string = $this->getCoreVersion();
+    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
+    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
+    $variables = [
+      '@to_version' => $to_version_string,
+      '@from_version' => $from_version_string,
+    ];
+    // @todo Return multiple validation messages and summary in
+    //   https://www.drupal.org/project/auto_updates/issues/3272068.
+    // Validate that both the from and to versions are stable releases.
+    if ($from_version->getVersionExtra()) {
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables),
+      ]);
+    }
+    if ($to_version->getVersionExtra()) {
+      // Because we do not support updating to a new minor version during
+      // cron it is probably impossible to update from a stable version to
+      // a unstable/pre-release version, but we should check this condition
+      // just in case.
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables),
+      ]);
+    }
+
+    if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables),
+      ]);
+    }
+
+    // Only updating to the next patch release is supported during cron.
+    $supported_patch_version = $from_version->getMajorVersion() . '.' . $from_version->getMinorVersion() . '.' . (((int) static::getPatchVersion($from_version_string)) + 1);
+    if ($to_version_string !== $supported_patch_version) {
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables),
+      ]);
+    }
+
+    // If both the from and to version numbers are valid check if the current
+    // settings only allow security updates during cron and if so ensure the
+    // update release is a security release.
+    $level = $this->configFactory->get('auto_updates.settings')->get('cron');
+    if ($level === CronUpdater::SECURITY) {
+      $releases = (new ProjectInfo())->getInstallableReleases();
+      // @todo Remove this check and add validation to
+      //   \Drupal\auto_updates\Validator\UpdateVersionValidator::getValidationResult()
+      //   to ensure the update release is always secure and supported in
+      //   https://www.drupal.org/i/3271468.
+      if (!isset($releases[$to_version_string])) {
+        return ValidationResult::createError([
+          $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a valid release.', $variables),
+        ]);
+      }
+      if (!$releases[$to_version_string]->isSecurityRelease()) {
+        return ValidationResult::createError([
+          $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a security release.', $variables),
+        ]);
+      }
+    }
+    return NULL;
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php
index f7d5751df259..9ff9ea6e7f79 100644
--- a/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php
+++ b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php
@@ -2,20 +2,28 @@
 
 namespace Drupal\auto_updates\Validator;
 
-use Composer\Semver\Semver;
+use Composer\Semver\Comparator;
 use Drupal\auto_updates\CronUpdater;
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\ProjectInfo;
 use Drupal\auto_updates\Updater;
 use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\PreOperationStageEvent;
 use Drupal\Core\Extension\ExtensionVersion;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Stage;
+use Drupal\package_manager\ValidationResult;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
  * Validates that core updates are within a supported version range.
+ *
+ * @internal
+ *   This class is an internal part of the module's update handling and
+ *   should not be used by external code.
  */
 class UpdateVersionValidator implements EventSubscriberInterface {
 
@@ -48,12 +56,7 @@ public function __construct(TranslationInterface $translation, ConfigFactoryInte
    *   The running core version as known to the Update module.
    */
   protected function getCoreVersion(): string {
-    // We need to call these functions separately, because
-    // update_get_available() will include the file that contains
-    // update_calculate_project_data().
-    $available_updates = update_get_available();
-    $available_updates = update_calculate_project_data($available_updates);
-    return $available_updates['drupal']['existing_version'];
+    return (new ProjectInfo())->getInstalledVersion();
   }
 
   /**
@@ -63,122 +66,141 @@ protected function getCoreVersion(): string {
    *   The event object.
    */
   public function checkUpdateVersion(PreOperationStageEvent $event): void {
-    $stage = $event->getStage();
-    // We only want to do this check if the stage belongs to Automatic Updates.
-    if (!$stage instanceof Updater) {
+    if (!static::isStageSupported($event->getStage())) {
       return;
     }
+    if ($to_version = $this->getUpdateVersion($event)) {
+      if ($result = $this->getValidationResult($to_version)) {
+        $event->addError($result->getMessages(), $result->getSummary());
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'checkUpdateVersion',
+      ReadinessCheckEvent::class => 'checkUpdateVersion',
+    ];
+  }
 
+  /**
+   * Gets the update version.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event.
+   *
+   * @return string|null
+   *   The version that the site will update to if any, otherwise NULL.
+   */
+  protected function getUpdateVersion(StageEvent $event): ?string {
+    /** @var \Drupal\auto_updates\Updater $updater */
+    $updater = $event->getStage();
     if ($event instanceof ReadinessCheckEvent) {
       $package_versions = $event->getPackageVersions();
-      // During readiness checks, we might not know the desired package
-      // versions, which means there's nothing to validate.
-      if (empty($package_versions)) {
-        return;
-      }
     }
     else {
       // If the stage has begun its life cycle, we expect it knows the desired
       // package versions.
-      $package_versions = $stage->getPackageVersions()['production'];
+      $package_versions = $updater->getPackageVersions()['production'];
+    }
+    if ($package_versions) {
+      // All the core packages will be updated to the same version, so it
+      // doesn't matter which specific package we're looking at.
+      $core_package_name = key($updater->getActiveComposer()->getCorePackages());
+      return $package_versions[$core_package_name];
+    }
+    else {
+      // During readiness checks we might not have a version to update to. Check
+      // if there are any possible updates and add a message about why we cannot
+      // update to that version.
+      // @todo Remove this code in https://www.drupal.org/i/3272326 when we add
+      //   add a validator that will warn if cron updates will no longer work
+      //   because the site is more than 1 patch release behind.
+      $project_info = new ProjectInfo();
+      if ($possible_releases = $project_info->getInstallableReleases()) {
+        $possible_release = array_pop($possible_releases);
+        return $possible_release->getVersion();
+      }
     }
+    return NULL;
+  }
 
+  /**
+   * Determines if a version is valid.
+   *
+   * @param string $version
+   *   The version string.
+   *
+   * @return bool
+   *   TRUE if the version is valid (i.e., the site can update to it), otherwise
+   *   FALSE.
+   */
+  public function isValidVersion(string $version): bool {
+    return empty($this->getValidationResult($version));
+  }
+
+  /**
+   * Validates if an update to a specific version is allowed.
+   *
+   * @param string $to_version_string
+   *   The version to update to.
+   *
+   * @return \Drupal\package_manager\ValidationResult|null
+   *   NULL if the update is allowed, otherwise returns a validation result with
+   *   the reason why the update is not allowed.
+   */
+  protected function getValidationResult(string $to_version_string): ?ValidationResult {
     $from_version_string = $this->getCoreVersion();
-    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
-    // All the core packages will be updated to the same version, so it doesn't
-    // matter which specific package we're looking at.
-    $core_package_name = key($stage->getActiveComposer()->getCorePackages());
-    $to_version_string = $package_versions[$core_package_name];
-    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
     $variables = [
       '@to_version' => $to_version_string,
       '@from_version' => $from_version_string,
     ];
-    $from_version_extra = $from_version->getVersionExtra();
-    $to_version_extra = $to_version->getVersionExtra();
-    if (Semver::satisfies($to_version_string, "< $from_version_string")) {
-      $event->addError([
-        $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables),
+    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
+    // @todo Return multiple validation messages and summary in
+    //   https://www.drupal.org/project/auto_updates/issues/3272068.
+    if ($from_version->getVersionExtra() === 'dev') {
+      return ValidationResult::createError([
+        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables),
       ]);
     }
-    elseif ($from_version_extra === 'dev') {
-      $event->addError([
-        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables),
+    if (Comparator::lessThan($to_version_string, $from_version_string)) {
+      return ValidationResult::createError([
+        $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables),
       ]);
     }
-    elseif ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) {
-      $event->addError([
+    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
+    if ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) {
+      return ValidationResult::createError([
         $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one major version to another are not supported.', $variables),
       ]);
     }
-    elseif ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
+    if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
       if (!$this->configFactory->get('auto_updates.settings')->get('allow_core_minor_updates')) {
-        $event->addError([
+        return ValidationResult::createError([
           $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported.', $variables),
         ]);
       }
-      elseif ($stage instanceof CronUpdater) {
-        $event->addError([
-          $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables),
-        ]);
-      }
-    }
-    elseif ($stage instanceof CronUpdater) {
-      if ($from_version_extra || $to_version_extra) {
-        if ($from_version_extra) {
-          $messages[] = $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables);
-          $event->addError($messages);
-        }
-        if ($to_version_extra) {
-          // Because we do not support updating to a new minor version during
-          // cron it is probably impossible to update from a stable version to
-          // a unstable/pre-release version, but we should check this condition
-          // just in case.
-          $messages[] = $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables);
-          $event->addError($messages);
-        }
-      }
-      else {
-        $to_patch_version = (int) $this->getPatchVersion($to_version_string);
-        $from_patch_version = (int) $this->getPatchVersion($from_version_string);
-        if ($from_patch_version + 1 !== $to_patch_version) {
-          $messages[] = $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables);
-          $event->addError($messages);
-        }
-      }
     }
+    return NULL;
   }
 
   /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PreCreateEvent::class => 'checkUpdateVersion',
-      ReadinessCheckEvent::class => 'checkUpdateVersion',
-    ];
-  }
-
-  /**
-   * Gets the patch number for a version string.
+   * Determines if a stage is supported by this validator.
    *
-   * @todo Move this method to \Drupal\Core\Extension\ExtensionVersion in
-   *   https://www.drupal.org/i/3261744.
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage to check.
    *
-   * @param string $version_string
-   *   The version string.
-   *
-   * @return string
-   *   The patch number.
+   * @return bool
+   *   TRUE if the stage is supported by this validator, otherwise FALSE.
    */
-  private function getPatchVersion(string $version_string): string {
-    $version_extra = ExtensionVersion::createFromVersionString($version_string)
-      ->getVersionExtra();
-    if ($version_extra) {
-      $version_string = str_replace("-$version_extra", '', $version_string);
-    }
-    $version_parts = explode('.', $version_string);
-    return $version_parts[2];
+  protected static function isStageSupported(Stage $stage): bool {
+    // We only want to do this check if the stage belongs to Automatic Updates,
+    // and it is not a cron update.
+    // @see \Drupal\auto_updates\Validator\CronUpdateVersionValidator
+    return $stage instanceof Updater && !$stage instanceof CronUpdater;
   }
 
 }
diff --git a/core/modules/auto_updates/src/VersionParsingTrait.php b/core/modules/auto_updates/src/VersionParsingTrait.php
new file mode 100644
index 000000000000..c94eb113043e
--- /dev/null
+++ b/core/modules/auto_updates/src/VersionParsingTrait.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\auto_updates;
+
+use Drupal\Core\Extension\ExtensionVersion;
+
+/**
+ * Common function for parsing version traits.
+ *
+ * @internal
+ *   This trait may be removed in patch or minor versions.
+ */
+trait VersionParsingTrait {
+
+  /**
+   * Gets the patch number from a version string.
+   *
+   * @todo Move this method to \Drupal\Core\Extension\ExtensionVersion in
+   *   https://www.drupal.org/i/3261744.
+   *
+   * @param string $version_string
+   *   The version string.
+   *
+   * @return string|null
+   *   The patch number if available, otherwise NULL.
+   */
+  protected static function getPatchVersion(string $version_string): ?string {
+    $version_extra = ExtensionVersion::createFromVersionString($version_string)
+      ->getVersionExtra();
+    if ($version_extra) {
+      $version_string = str_replace("-$version_extra", '', $version_string);
+    }
+    $version_parts = explode('.', $version_string);
+    return count($version_parts) === 3 ? $version_parts[2] : NULL;
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
new file mode 100644
index 000000000000..cc65b78a690d
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
@@ -0,0 +1,102 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Drupal</title>
+<short_name>drupal</short_name>
+<dc:creator>Drupal</dc:creator>
+<supported_branches>9.7.,9.8.</supported_branches>
+<project_status>published</project_status>
+<link>http://example.com/project/drupal</link>
+  <terms>
+   <term><name>Projects</name><value>Drupal project</value></term>
+  </terms>
+<releases>
+  <release>
+    <name>Drupal 9.8.2</name>
+    <version>9.8.2</version>
+    <status>published</status>
+    <release_link>http://example.com/drupal-9-8-2-release</release_link>
+    <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link>
+    <date>1250425521</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+ <release>
+  <name>Drupal 9.8.1</name>
+  <version>9.8.1</version>
+  <status>published</status>
+  <release_link>http://example.com/drupal-9-8-1-release</release_link>
+  <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
+  <date>1250425521</date>
+  <terms>
+    <term><name>Release type</name><value>New features</value></term>
+    <term><name>Release type</name><value>Bug fixes</value></term>
+    <term><name>Release type</name><value>Security update</value></term>
+  </terms>
+ </release>
+ <release>
+   <name>Drupal 9.8.0</name>
+   <version>9.8.0</version>
+   <status>published</status>
+   <release_link>http://example.com/drupal-9-8-0-release</release_link>
+   <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link>
+   <date>1250424521</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+     <term><name>Release type</name><value>Insecure</value></term>
+   </terms>
+ </release>
+  <release>
+    <name>Drupal 9.8.0-alpha1</name>
+    <version>9.8.0-alpha1</version>
+    <status>published</status>
+    <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
+    <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link>
+    <date>1250424521</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+    <release>
+        <name>Drupal 9.7.1</name>
+        <version>9.7.1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-1-release</release_link>
+        <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
+        <date>1250425521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+            <term><name>Release type</name><value>Security update</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>Drupal 9.7.0</name>
+        <version>9.7.0</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-0-release</release_link>
+        <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+            <term><name>Release type</name><value>Insecure</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>Drupal 9.7.0-alpha1</name>
+        <version>9.7.0-alpha1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
+        <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
+</releases>
+</project>
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml
index 7bb2c1a1128a..2de1077e5e2d 100644
--- a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml
+++ b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml
@@ -3,7 +3,7 @@
 <title>Drupal</title>
 <short_name>drupal</short_name>
 <dc:creator>Drupal</dc:creator>
-<supported_branches>9.8.</supported_branches>
+<supported_branches>9.7.,9.8.</supported_branches>
 <project_status>published</project_status>
 <link>http://example.com/project/drupal</link>
   <terms>
@@ -58,5 +58,41 @@
       <term><name>Release type</name><value>Bug fixes</value></term>
     </terms>
   </release>
+    <release>
+        <name>Drupal 9.7.1</name>
+        <version>9.7.1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-1-release</release_link>
+        <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
+        <date>1250425521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>Drupal 9.7.0</name>
+        <version>9.7.0</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-0-release</release_link>
+        <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>Drupal 9.7.0-alpha1</name>
+        <version>9.7.0-alpha1</version>
+        <status>published</status>
+        <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
+        <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
 </releases>
 </project>
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml b/core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml
new file mode 100644
index 000000000000..cdb353fd4208
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml
@@ -0,0 +1,184 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Semver Test</title>
+<short_name>semver_test</short_name>
+<dc:creator>Drupal</dc:creator>
+<supported_branches>8.0.,8.1.</supported_branches>
+<project_status>published</project_status>
+<link>http://example.com/project/semver_test</link>
+  <terms>
+   <term><name>Projects</name><value>Semver Test project</value></term>
+  </terms>
+<releases>
+  <release>
+    <!-- This release is not in a supported branch; therefore it should not be recommended. -->
+    <name>Semver Test 8.2.0</name>
+    <version>8.2.0</version>
+    <tag>8.2.0</tag>
+    <status>published</status>
+    <release_link>http://example.com/semver_test-8-2-0-release</release_link>
+    <download_link>http://example.com/semver_test-8-2-0.tar.gz</download_link>
+    <date>1584195300</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+ <release>
+   <name>Semver Test 8.1.1</name>
+   <version>8.1.1</version>
+   <tag>8.1.1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-1-1-release</release_link>
+   <download_link>http://example.com/semver_test-8-1-1.tar.gz</download_link>
+   <date>1581603300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.1.1-beta1</name>
+   <version>8.1.1-beta1</version>
+   <tag>8.1.1-beta1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-1-1-beta1-release</release_link>
+   <download_link>http://example.com/semver_test-8-1-1-beta1.tar.gz</download_link>
+   <date>1579011300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.1.1-alpha1</name>
+   <version>8.1.1-alpha1</version>
+   <tag>8.1.1-alpha1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-1-1-alpha1-release</release_link>
+   <download_link>http://example.com/semver_test-8-1-1-alpha1.tar.gz</download_link>
+   <date>1576419300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.1.0</name>
+   <version>8.1.0</version>
+   <tag>8.1.0</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-1-0-release</release_link>
+   <download_link>http://example.com/semver_test-8-1-0.tar.gz</download_link>
+   <date>1573827300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.1.0-beta1</name>
+   <version>8.1.0-beta1</version>
+   <tag>8.1.0-beta1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-1-0-beta1-release</release_link>
+   <download_link>http://example.com/semver_test-8-1-0-beta1.tar.gz</download_link>
+   <date>1571235300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.1.0-alpha1</name>
+   <version>8.1.0-alpha1</version>
+   <tag>8.1.0-alpha1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-1-0-alpha1-release</release_link>
+   <download_link>http://example.com/semver_test-8-1-0-alpha1.tar.gz</download_link>
+   <date>1568643300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.0.1</name>
+   <version>8.0.1</version>
+   <tag>8.0.1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-0-1-release</release_link>
+   <download_link>http://example.com/semver_test-8-0-1.tar.gz</download_link>
+   <date>1566051300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.0.1-beta1</name>
+   <version>8.0.1-beta1</version>
+   <tag>8.0.1-beta1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-0-1-beta1-release</release_link>
+   <download_link>http://example.com/semver_test-8-0-1-beta1.tar.gz</download_link>
+   <date>1563459300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.0.1-alpha1</name>
+   <version>8.0.1-alpha1</version>
+   <tag>8.0.1-alpha1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-0-1-alpha1-release</release_link>
+   <download_link>http://example.com/semver_test-8-0-1-alpha1.tar.gz</download_link>
+   <date>1560867300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.0.0</name>
+   <version>8.0.0</version>
+   <tag>8.0.0</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-0-0-release</release_link>
+   <download_link>http://example.com/semver_test-8-0-0.tar.gz</download_link>
+   <date>1558275300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.0.0-beta1</name>
+   <version>8.0.0-beta1</version>
+   <tag>8.0.0-beta1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-0-0-beta1-release</release_link>
+   <download_link>http://example.com/semver_test-8-0-0-beta1.tar.gz</download_link>
+   <date>1555683300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>Semver Test 8.0.0-alpha1</name>
+   <version>8.0.0-alpha1</version>
+   <tag>8.0.0-alpha1</tag>
+   <status>published</status>
+   <release_link>http://example.com/semver_test-8-0-0-alpha1-release</release_link>
+   <download_link>http://example.com/semver_test-8-0-0-alpha1.tar.gz</download_link>
+   <date>1553091300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json
new file mode 100644
index 000000000000..6a9ed719d00a
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json
@@ -0,0 +1,7 @@
+{
+  "extra": {
+    "_readme": [
+      "This fixture simulates a staging area in which, according to Composer, Drupal core 9.8.1 is installed."
+    ]
+  }
+}
diff --git a/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json
new file mode 100644
index 000000000000..87b5a18421ce
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json
@@ -0,0 +1,9 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.1",
+      "type": "drupal-core"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install
new file mode 100644
index 000000000000..e0792445e68d
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.install
@@ -0,0 +1,17 @@
+<?php
+
+/**
+ * @file
+ * Contains install and update hooks.
+ */
+
+if (\Drupal::state()->get('auto_updates_test.new_update')) {
+
+  /**
+   * Dynamic auto_updates_update_9001.
+   */
+  function auto_updates_update_9001(&$sandbox) {
+
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml
index 8c5c2a6988f0..f22ab077fb3b 100644
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml
@@ -5,9 +5,13 @@ auto_updates_test.metadata:
     _controller: '\Drupal\auto_updates_test\TestController::metadata'
   requirements:
     _access: 'TRUE'
+  options:
+    _maintenance_access: TRUE
 auto_updates_test.update:
   path: '/automatic-update-test/update/{to_version}'
   defaults:
     _controller: '\Drupal\auto_updates_test\TestController::update'
   requirements:
     _access: 'TRUE'
+  options:
+    _maintenance_access: TRUE
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml
index ec89173182d4..c3c46dc8d051 100644
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml
@@ -1,8 +1,4 @@
 services:
-  auto_updates_test.route_subscriber:
-    class: \Drupal\auto_updates_test\Routing\RouteSubscriber
-    tags:
-      - { name: event_subscriber }
   auto_updates_test.checker:
     class: Drupal\auto_updates_test\EventSubscriber\TestSubscriber1
     tags:
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/AutoUpdatesTestServiceProvider.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/AutoUpdatesTestServiceProvider.php
deleted file mode 100644
index 9be0b1e42242..000000000000
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/AutoUpdatesTestServiceProvider.php
+++ /dev/null
@@ -1,24 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates_test;
-
-use Drupal\auto_updates_test\Validator\TestPendingUpdatesValidator;
-use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\Core\DependencyInjection\ServiceProviderBase;
-
-/**
- * Modifies container services for testing purposes.
- */
-class AutoUpdatesTestServiceProvider extends ServiceProviderBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function alter(ContainerBuilder $container) {
-    parent::alter($container);
-
-    $container->getDefinition('package_manager.validator.pending_updates')
-      ->setClass(TestPendingUpdatesValidator::class);
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Form/TestUpdateReady.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Form/TestUpdateReady.php
deleted file mode 100644
index 7953980da788..000000000000
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Form/TestUpdateReady.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates_test\Form;
-
-use Drupal\auto_updates\Form\UpdateReady;
-
-/**
- * A test-only version of the form displayed before applying an update.
- */
-class TestUpdateReady extends UpdateReady {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getModulesWithStagedDatabaseUpdates(): array {
-    return $this->state->get('auto_updates_test.staged_database_updates', parent::getModulesWithStagedDatabaseUpdates());
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Routing/RouteSubscriber.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Routing/RouteSubscriber.php
deleted file mode 100644
index f12aad83749a..000000000000
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Routing/RouteSubscriber.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates_test\Routing;
-
-use Drupal\auto_updates_test\Form\TestUpdateReady;
-use Drupal\Core\Routing\RouteSubscriberBase;
-use Symfony\Component\Routing\RouteCollection;
-
-/**
- * Alters route definitions for testing purposes.
- */
-class RouteSubscriber extends RouteSubscriberBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function alterRoutes(RouteCollection $collection) {
-    $collection->get('auto_updates.confirmation_page')
-      ->setDefault('_form', TestUpdateReady::class);
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php
index 3ad87828be6f..7342479e95bd 100644
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php
@@ -3,7 +3,6 @@
 namespace Drupal\auto_updates_test;
 
 use Drupal\auto_updates\Exception\UpdateException;
-use Drupal\auto_updates\UpdateRecommender;
 use Drupal\Component\Utility\Environment;
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Render\HtmlResponse;
@@ -36,8 +35,17 @@ public function update(string $to_version): Response {
       $updater->apply();
       $updater->destroy();
 
-      $project = (new UpdateRecommender())->getProjectInfo();
-      $content = $project['existing_version'];
+      // The code base has been updated, but as far as the PHP runtime is
+      // concerned, \Drupal::VERSION refers to the old version, until the next
+      // request. So check if the updated version is in Drupal.php and return
+      // a clear indication of whether it's there or not.
+      $drupal_php = file_get_contents(\Drupal::root() . '/core/lib/Drupal.php');
+      if (str_contains($drupal_php, "const VERSION = '$to_version';")) {
+        $content = "$to_version found in Drupal.php";
+      }
+      else {
+        $content = "$to_version not found in Drupal.php";
+      }
       $status = 200;
     }
     catch (UpdateException $e) {
@@ -63,9 +71,6 @@ public function update(string $to_version): Response {
    * directory of mock XML files.
    */
   public function metadata($project_name = 'drupal', $version = NULL): Response {
-    if ($project_name !== 'drupal') {
-      return new Response();
-    }
     $xml_map = $this->config('update_test.settings')->get('xml_map');
     if (isset($xml_map[$project_name])) {
       $availability_scenario = $xml_map[$project_name];
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Validator/TestPendingUpdatesValidator.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Validator/TestPendingUpdatesValidator.php
deleted file mode 100644
index 8f735f95d4cb..000000000000
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Validator/TestPendingUpdatesValidator.php
+++ /dev/null
@@ -1,30 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates_test\Validator;
-
-use Drupal\package_manager\Validator\PendingUpdatesValidator;
-
-/**
- * Defines a test-only implementation of the pending updates validator.
- */
-class TestPendingUpdatesValidator extends PendingUpdatesValidator {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function updatesExist(): bool {
-    $pending_updates = \Drupal::state()
-      ->get('auto_updates_test.staged_database_updates', []);
-
-    // If the System module should expose a pending update, create one that will
-    // be detected by the update hook registry. We only do this for System so
-    // that there is NO way we could possibly evaluate any user input (i.e.,
-    // if malicious code were somehow injected into state).
-    if (array_key_exists('system', $pending_updates)) {
-      // @codingStandardsIgnoreLine
-      eval('function system_update_4294967294() {}');
-    }
-    return parent::updatesExist();
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
index be3070d61b21..06f149e3b437 100644
--- a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
+++ b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
@@ -71,8 +71,13 @@ public function testApi(string $template): void {
     // directories are not writable.
     $this->assertReadOnlyFileSystemError('/automatic-update-test/update/9.8.1');
 
-    $mink->getSession()->reload();
-    $assert_session->pageTextContains('9.8.1');
+    $session = $mink->getSession();
+    $session->reload();
+    $this->assertSame('9.8.1 found in Drupal.php', trim($session->getPage()->getContent()));
+    // Even though the response is what we expect, assert the status code as
+    // well, to be extra-certain that there was no kind of server-side error.
+    $assert_session->statusCodeEquals(200);
+    $this->assertUpdateSuccessful('9.8.1');
   }
 
   /**
diff --git a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php
index db8d2575555a..8f1a0e316faf 100644
--- a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php
+++ b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php
@@ -15,6 +15,7 @@ abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase {
    */
   protected static $modules = [
     'auto_updates_test_disable_validators',
+    'package_manager_bypass',
     'update',
     'update_test',
   ];
@@ -32,6 +33,17 @@ abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase {
     // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
     'auto_updates.validator.file_system_permissions',
     'package_manager.validator.file_system',
+    // Disable the Composer executable validator, since it may cause the tests
+    // to fail if a supported version of Composer is unavailable to the web
+    // server. This should be okay in most situations because, apart from the
+    // validator, only Composer Stager needs run Composer, and
+    // package_manager_bypass is disabling those operations.
+    'auto_updates.composer_executable_validator',
+    'package_manager.validator.composer_executable',
+    // Disable the lock file validator, because it may cause the tests to fail
+    // if either the active and stage directories don't have a composer.lock
+    // file, which is the case with some of our fixtures.
+    'package_manager.validator.lock_file',
   ];
 
   /**
@@ -42,6 +54,19 @@ protected function setUp() {
     $this->disableValidators($this->disableValidators);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    // If auto_updates is installed, ensure any staging area created during
+    // the test is cleaned up.
+    $service_id = 'auto_updates.updater';
+    if ($this->container->has($service_id)) {
+      $this->container->get($service_id)->destroy(TRUE);
+    }
+    parent::tearDown();
+  }
+
   /**
    * Disables validators in the test site's settings.
    *
@@ -116,10 +141,14 @@ protected function checkForUpdates(): void {
 
   /**
    * Asserts that we are on the "update ready" form.
+   *
+   * @param string $update_version
+   *   The version of Drupal core that we are updating to.
    */
-  protected function assertUpdateReady(): void {
-    $this->assertSession()
-      ->addressMatches('/\/admin\/automatic-update-ready\/[a-zA-Z0-9_\-]+$/');
+  protected function assertUpdateReady(string $update_version): void {
+    $assert_session = $this->assertSession();
+    $assert_session->addressMatches('/\/admin\/automatic-update-ready\/[a-zA-Z0-9_\-]+$/');
+    $assert_session->pageTextContainsOnce('Drupal core will be updated to ' . $update_version);
   }
 
 }
diff --git a/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php
index ad364fdac6ab..67e4c83ecb0c 100644
--- a/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php
+++ b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php
@@ -7,6 +7,8 @@
 use Drupal\auto_updates_test\Datetime\TestTime;
 use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1;
 use Drupal\auto_updates_test2\EventSubscriber\TestSubscriber2;
+use Drupal\Core\Url;
+use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager;
 use Drupal\system\SystemManager;
 use Drupal\Tests\auto_updates\Traits\ValidationTestTrait;
 use Drupal\Tests\Traits\Core\CronRunTrait;
@@ -52,7 +54,7 @@ class ReadinessValidationTest extends AutoUpdatesFunctionalTestBase {
    */
   protected function setUp(): void {
     parent::setUp();
-    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml');
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml');
     $this->setCoreVersion('9.8.1');
 
     $this->reportViewerUser = $this->createUser([
@@ -63,6 +65,7 @@ protected function setUp(): void {
       'administer site configuration',
       'administer software updates',
       'access administration pages',
+      'access site in maintenance mode',
     ]);
     $this->createTestValidationResults();
     $this->drupalLogin($this->reportViewerUser);
@@ -291,6 +294,15 @@ public function testReadinessChecksAdminPages(): void {
     $assert->pageTextContainsOnce(static::$warningsExplanation);
     $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]);
     $assert->pageTextNotContains($expected_results[0]->getSummary());
+
+    // Confirm readiness messages are not displayed when cron updates are
+    // disabled.
+    $this->drupalGet(Url::fromRoute('update.settings'));
+    $edit['auto_updates_cron'] = 'disable';
+    $this->submitForm($edit, 'Save configuration');
+    $this->drupalGet('admin/structure');
+    $assert->pageTextNotContains(static::$warningsExplanation);
+    $assert->pageTextNotContains($expected_results[0]->getMessages()[0]);
   }
 
   /**
@@ -387,11 +399,12 @@ public function testStoredResultsClearedAfterUpdate(): void {
     $this->container->get('module_installer')->install([
       'auto_updates',
       'auto_updates_test',
-      'package_manager_bypass',
+      'package_manager_test_fixture',
     ]);
     // Because all actual staging operations are bypassed by
-    // package_manager_bypass, disable this validator because it will complain
-    // if there's no actual Composer data to inspect.
+    // package_manager_bypass (enabled by the parent class), disable this
+    // validator because it will complain if there's no actual Composer data to
+    // inspect.
     $this->disableValidators(['auto_updates.staged_projects_validator']);
 
     // The error should be persistently visible, even after the checker stops
@@ -407,6 +420,7 @@ public function testStoredResultsClearedAfterUpdate(): void {
     // readiness check (without storing the results), and the checker is no
     // longer raising an error.
     $this->drupalGet('/admin/modules/automatic-update');
+    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
     $assert_session->buttonExists('Update');
     // Ensure that the previous results are still displayed on another admin
     // page, to confirm that the updater form is not discarding the previous
@@ -417,7 +431,7 @@ public function testStoredResultsClearedAfterUpdate(): void {
     $this->drupalGet('/admin/modules/automatic-update');
     $page->pressButton('Update');
     $this->checkForMetaRefresh();
-    $this->assertUpdateReady();
+    $this->assertUpdateReady('9.8.1');
     $page->pressButton('Continue');
     $this->checkForMetaRefresh();
     $assert_session->pageTextContains('Update complete!');
diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php
index f7b19ffc0f36..f540f47f4493 100644
--- a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php
+++ b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php
@@ -2,6 +2,8 @@
 
 namespace Drupal\Tests\auto_updates\Functional;
 
+use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager;
+
 /**
  * Tests that only one Automatic Update operation can be performed at a time.
  *
@@ -20,7 +22,7 @@ class UpdateLockTest extends AutoUpdatesFunctionalTestBase {
   protected static $modules = [
     'auto_updates',
     'auto_updates_test',
-    'package_manager_bypass',
+    'package_manager_test_fixture',
   ];
 
   /**
@@ -37,7 +39,7 @@ protected function setUp(): void {
   /**
    * Tests that only user who started an update can continue through it.
    */
-  public function testLock() {
+  public function testLock(): void {
     $page = $this->getSession()->getPage();
     $assert_session = $this->assertSession();
     $this->setCoreVersion('9.8.0');
@@ -49,11 +51,12 @@ public function testLock() {
     // We should be able to get partway through an update without issue.
     $this->drupalLogin($user_1);
     $this->drupalGet('/admin/modules/automatic-update');
+    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
     $page->pressButton('Update');
     $this->checkForMetaRefresh();
-    $this->assertUpdateReady();
+    $this->assertUpdateReady('9.8.1');
     $assert_session->buttonExists('Continue');
-    $url = parse_url($this->getSession()->getCurrentUrl(), PHP_URL_PATH);
+    $url = $this->getSession()->getCurrentUrl();
 
     // Another user cannot show up and try to start an update, since the other
     // user already started one.
diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
index fc892ee15cc6..599f0471ecbc 100644
--- a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
+++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
@@ -3,9 +3,14 @@
 namespace Drupal\Tests\auto_updates\Functional;
 
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates_test\Datetime\TestTime;
+use Drupal\Component\FileSystem\FileSystem;
+use Drupal\package_manager\Event\PostRequireEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\ValidationResult;
 use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1;
+use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager;
 use Drupal\Tests\auto_updates\Traits\ValidationTestTrait;
 use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
 
@@ -31,7 +36,7 @@ class UpdaterFormTest extends AutoUpdatesFunctionalTestBase {
     'block',
     'auto_updates',
     'auto_updates_test',
-    'package_manager_bypass',
+    'package_manager_test_fixture',
   ];
 
   /**
@@ -237,58 +242,137 @@ public function testMinorVersionUpdateNotSupported(string $update_form_url): voi
 
   /**
    * Tests deleting an existing update.
-   *
-   * @todo Add test coverage for differences between stage owner and other users
-   *   in https://www.drupal.org/i/3248928.
    */
-  public function testDeleteExistingUpdate() {
+  public function testDeleteExistingUpdate(): void {
+    $conflict_message = 'Cannot begin an update because another Composer operation is currently in progress.';
+    $cancelled_message = 'The update was successfully cancelled.';
+
     $assert_session = $this->assertSession();
     $page = $this->getSession()->getPage();
     $this->setCoreVersion('9.8.0');
     $this->checkForUpdates();
 
     $this->drupalGet('/admin/modules/automatic-update');
+    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
     $page->pressButton('Update');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
 
     // Confirm we are on the confirmation page.
-    $this->assertUpdateReady();
+    $this->assertUpdateReady('9.8.1');
     $assert_session->buttonExists('Continue');
 
-    // Return to the start page.
+    // If we try to return to the start page, we should be redirected back to
+    // the confirmation page.
     $this->drupalGet('/admin/modules/automatic-update');
-    $assert_session->pageTextContainsOnce('Cannot begin an update because another Composer operation is currently in progress.');
-    $assert_session->buttonNotExists('Update');
+    $this->assertUpdateReady('9.8.1');
 
     // Delete the existing update.
-    $page->pressButton('Delete existing update');
-    $assert_session->pageTextContains('Staged update deleted');
-    $assert_session->pageTextNotContains('Cannot begin an update because another Composer operation is currently in progress.');
-
+    $page->pressButton('Cancel update');
+    $assert_session->addressEquals('/admin/reports/updates/automatic-update');
+    $assert_session->pageTextContains($cancelled_message);
+    $assert_session->pageTextNotContains($conflict_message);
     // Ensure we can start another update after deleting the existing one.
     $page->pressButton('Update');
     $this->checkForMetaRefresh();
 
     // Confirm we are on the confirmation page.
-    $this->assertUpdateReady();
+    $this->assertUpdateReady('9.8.1');
     $this->assertUpdateStagedTimes(2);
     $assert_session->buttonExists('Continue');
-    // Cancel the update, then ensure that we are bounced back to the start
-    // page, and that it will allow us to begin the update anew.
+
+    // Log in as another administrative user and ensure that we cannot begin an
+    // update because the previous session already started one.
+    $account = $this->createUser([], NULL, TRUE);
+    $this->drupalLogin($account);
+    $this->drupalGet('/admin/reports/updates/automatic-update');
+    $assert_session->pageTextContains($conflict_message);
+    $assert_session->buttonNotExists('Update');
+    // We should be able to delete the previous update, then start a new one.
+    $page->pressButton('Delete existing update');
+    $assert_session->pageTextContains('Staged update deleted');
+    $assert_session->pageTextNotContains($conflict_message);
+    $page->pressButton('Update');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateReady('9.8.1');
+
+    // Stop execution during pre-apply. This should make Package Manager think
+    // the staged changes are being applied and raise an error if we try to
+    // cancel the update.
+    TestSubscriber1::setExit(PreApplyEvent::class);
+    $page->pressButton('Continue');
+    $this->checkForMetaRefresh();
+    $page->clickLink('the error page');
     $page->pressButton('Cancel update');
-    $assert_session->addressEquals('/admin/reports/updates/automatic-update');
-    $assert_session->pageTextContains('The update was successfully cancelled.');
-    $assert_session->buttonExists('Update');
+    // The exception should have been caught and displayed in the messages area.
+    $assert_session->statusCodeEquals(200);
+    $destroy_error = 'Cannot destroy the staging area while it is being applied to the active directory.';
+    $assert_session->pageTextContains($destroy_error);
+    $assert_session->pageTextNotContains($cancelled_message);
+
+    // We should get the same error if we log in as another user and try to
+    // delete the staged update.
+    $this->drupalLogin($this->rootUser);
+    $this->drupalGet('/admin/reports/updates/automatic-update');
+    $assert_session->pageTextContains($conflict_message);
+    $page->pressButton('Delete existing update');
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains($destroy_error);
+    $assert_session->pageTextNotContains('Staged update deleted');
+
+    // Two hours later, Package Manager should consider the stage to be stale,
+    // allowing the staged update to be deleted.
+    TestTime::setFakeTimeByOffset('+2 hours');
+    $this->getSession()->reload();
+    $assert_session->pageTextContains($conflict_message);
+    $page->pressButton('Delete existing update');
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains('Staged update deleted');
+
+    // If a legitimate error is raised during pre-apply, we should be able to
+    // delete the staged update right away.
+    $this->createTestValidationResults();
+    $results = $this->testResults['checker_1']['1 error'];
+    TestSubscriber1::setTestResult($results, PreApplyEvent::class);
+    $page->pressButton('Update');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateReady('9.8.1');
+    $page->pressButton('Continue');
+    $this->checkForMetaRefresh();
+    $page->clickLink('the error page');
+    $page->pressButton('Cancel update');
+    $assert_session->pageTextContains($cancelled_message);
+  }
+
+  /**
+   * Data provider for testStagedDatabaseUpdates().
+   *
+   * @return bool[][]
+   *   The test cases.
+   */
+  public function providerStagedDatabaseUpdates() {
+    return [
+      'maintenance mode on' => [TRUE],
+      'maintenance mode off' => [FALSE],
+    ];
   }
 
   /**
    * Tests the update form when staged modules have database updates.
+   *
+   * @param bool $maintenance_mode_on
+   *   Whether the site should be in maintenance mode at the beginning of the
+   *   update process.
+   *
+   * @dataProvider providerStagedDatabaseUpdates
    */
-  public function testStagedDatabaseUpdates(): void {
+  public function testStagedDatabaseUpdates(bool $maintenance_mode_on): void {
     $this->setCoreVersion('9.8.0');
     $this->checkForUpdates();
 
+    $state = $this->container->get('state');
+    $state->set('system.maintenance_mode', $maintenance_mode_on);
+
     // Flag a warning, which will not block the update but should be displayed
     // on the updater form.
     $this->createTestValidationResults();
@@ -298,22 +382,18 @@ public function testStagedDatabaseUpdates(): void {
 
     $page = $this->getSession()->getPage();
     $this->drupalGet('/admin/modules/automatic-update');
+    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
     // The warning should be visible.
     $assert_session = $this->assertSession();
     $assert_session->pageTextContains(reset($messages));
     $page->pressButton('Update');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
-    $this->assertUpdateReady();
-    // Simulate a staged database update in the System module. We must do this
-    // after the update has started, because the pending updates validator
-    // will prevent an update from starting.
-    $this->container->get('state')
-      ->set('auto_updates_test.staged_database_updates', [
-        'system' => [
-          'name' => 'System',
-        ],
-      ]);
+    $this->assertUpdateReady('9.8.1');
+    // Simulate a staged database update in the auto_updates_test module.
+    // We must do this after the update has started, because the pending updates
+    // validator will prevent an update from starting.
+    $state->set('auto_updates_test.new_update', TRUE);
     // The warning from the updater form should be not be repeated, but we
     // should see a warning about pending database updates, and once the staged
     // changes have been applied, we should be redirected to update.php, where
@@ -322,12 +402,55 @@ public function testStagedDatabaseUpdates(): void {
     $possible_update_message = 'Possible database updates were detected in the following modules; you may be redirected to the database update page in order to complete the update process.';
     $assert_session->pageTextContains($possible_update_message);
     $assert_session->pageTextContains('System');
+    $assert_session->checkboxChecked('maintenance_mode');
     $page->pressButton('Continue');
     $this->checkForMetaRefresh();
+    // Confirm that the site was in maintenance before the update was applied.
+    // @see \Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber::handleEvent()
+    $this->assertTrue($state->get(PreApplyEvent::class . '.system.maintenance_mode'));
+    // Confirm the site remains in maintenance more when redirected to
+    // update.php.
+    $this->assertTrue($state->get('system.maintenance_mode'));
     $assert_session->addressEquals('/update.php');
     $assert_session->pageTextNotContains(reset($messages));
     $assert_session->pageTextNotContains($possible_update_message);
     $assert_session->pageTextContainsOnce('Please apply database updates to complete the update process.');
+    $this->assertTrue($state->get('system.maintenance_mode'));
+    $page->clickLink('Continue');
+    // @see auto_updates_update_9001()
+    $assert_session->pageTextContains('Dynamic auto_updates_update_9001');
+    $page->clickLink('Apply pending updates');
+    $this->checkForMetaRefresh();
+    $assert_session->pageTextContains('Updates were attempted.');
+    // Confirm the site was returned to the original maintenance module state.
+    $this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on);
+  }
+
+  /**
+   * Data provider for testSuccessfulUpdate().
+   *
+   * @return string[][]
+   *   Test case parameters.
+   */
+  public function providerSuccessfulUpdate(): array {
+    return [
+      'Modules page, maintenance mode on' => [
+        '/admin/modules/automatic-update',
+        TRUE,
+      ],
+      'Modules page, maintenance mode off' => [
+        '/admin/modules/automatic-update',
+        FALSE,
+      ],
+      'Reports page, maintenance mode on' => [
+        '/admin/reports/updates/automatic-update',
+        TRUE,
+      ],
+      'Reports page, maintenance mode off' => [
+        '/admin/reports/updates/automatic-update',
+        FALSE,
+      ],
+    ];
   }
 
   /**
@@ -335,24 +458,96 @@ public function testStagedDatabaseUpdates(): void {
    *
    * @param string $update_form_url
    *   The URL of the update form to visit.
+   * @param bool $maintenance_mode_on
+   *   Whether maintenance should be on at the beginning of the update.
    *
-   * @dataProvider providerUpdateFormReferringUrl
+   * @dataProvider providerSuccessfulUpdate
    */
-  public function testSuccessfulUpdate(string $update_form_url): void {
+  public function testSuccessfulUpdate(string $update_form_url, bool $maintenance_mode_on): void {
     $this->setCoreVersion('9.8.0');
     $this->checkForUpdates();
+    $state = $this->container->get('state');
+    $state->set('system.maintenance_mode', $maintenance_mode_on);
 
     $page = $this->getSession()->getPage();
     $this->drupalGet($update_form_url);
+    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
     $page->pressButton('Update');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
-    $this->assertUpdateReady();
+    $this->assertUpdateReady('9.8.1');
+    // Confirm that the site was put into maintenance mode if needed.
+    $this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on);
     $page->pressButton('Continue');
     $this->checkForMetaRefresh();
     $assert_session = $this->assertSession();
     $assert_session->addressEquals('/admin/reports/updates');
+    // Confirm that the site was in maintenance before the update was applied.
+    // @see \Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber::handleEvent()
+    $this->assertTrue($state->get(PreApplyEvent::class . '.system.maintenance_mode'));
     $assert_session->pageTextContainsOnce('Update complete!');
+    // Confirm the site was returned to the original maintenance mode state.
+    $this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on);
+  }
+
+  /**
+   * Tests what happens when a staged update is deleted without being destroyed.
+   */
+  public function testStagedUpdateDeletedImproperly(): void {
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+
+    $page = $this->getSession()->getPage();
+    $this->drupalGet('/admin/modules/automatic-update');
+    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
+    $page->pressButton('Update');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateStagedTimes(1);
+    $this->assertUpdateReady('9.8.1');
+    // Confirm if the staged directory is deleted without using destroy(), then
+    // an error message will be displayed on the page.
+    // @see \Drupal\package_manager\Stage::getStagingRoot()
+    $dir = FileSystem::getOsTemporaryDirectory() . '/.package_manager' . $this->config('system.site')->get('uuid');
+    $this->assertDirectoryExists($dir);
+    $this->container->get('file_system')->deleteRecursive($dir);
+    $this->getSession()->reload();
+    $assert_session = $this->assertSession();
+    $error_message = 'There was an error loading the pending update. Press the Cancel update button to start over.';
+    $assert_session->pageTextContainsOnce($error_message);
+    // We should be able to start over without any problems, and the error
+    // message should not be seen on the updater form.
+    $page->pressButton('Cancel update');
+    $assert_session->addressEquals('/admin/reports/updates/automatic-update');
+    $assert_session->pageTextNotContains($error_message);
+    $assert_session->pageTextContains('The update was successfully cancelled.');
+    $assert_session->buttonExists('Update');
+  }
+
+  /**
+   * Tests that the update stage is destroyed if an error occurs during require.
+   */
+  public function testStageDestroyedOnError(): void {
+    $session = $this->getSession();
+    $assert_session = $this->assertSession();
+    $page = $session->getPage();
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+
+    $this->drupalGet('/admin/modules/automatic-update');
+    $error = new \Exception('Some Exception');
+    TestSubscriber1::setException($error, PostRequireEvent::class);
+    $assert_session->pageTextNotContains(static::$errorsExplanation);
+    $assert_session->pageTextNotContains(static::$warningsExplanation);
+    $page->pressButton('Update');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateStagedTimes(1);
+    $assert_session->pageTextContainsOnce('An error has occurred.');
+    $page->clickLink('the error page');
+    $assert_session->addressEquals('/admin/modules/automatic-update');
+    $assert_session->pageTextNotContains('Cannot begin an update because another Composer operation is currently in progress.');
+    $assert_session->buttonNotExists('Delete existing update');
+    $assert_session->pageTextContains('Some Exception');
+    $assert_session->buttonExists('Update');
   }
 
 }
diff --git a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php
index 1194677000cc..f6c0efbaaf8c 100644
--- a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php
+++ b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php
@@ -55,15 +55,8 @@ protected function setUp(): void {
     $this->installConfig('update');
 
     // Make the update system think that all of System's post-update functions
-    // have run. Since kernel tests don't normally install modules and register
-    // their updates, we need to do this so that all validators are tested from
-    // a clean, fully up-to-date state.
-    $updates = $this->container->get('update.post_update_registry')
-      ->getPendingUpdateFunctions();
-
-    $this->container->get('keyvalue')
-      ->get('post_update')
-      ->set('existing_updates', $updates);
+    // have run.
+    $this->registerPostUpdateFunctions();
 
     // By default, pretend we're running Drupal core 9.8.0 and a non-security
     // update to 9.8.1 is available.
@@ -138,8 +131,8 @@ class TestUpdater extends Updater {
   /**
    * {@inheritdoc}
    */
-  protected static function getStagingRoot(): string {
-    return TestStage::getStagingRoot();
+  public function getStagingRoot(): string {
+    return TestStage::$stagingRoot ?: parent::getStagingRoot();
   }
 
 }
@@ -152,8 +145,8 @@ class TestCronUpdater extends CronUpdater {
   /**
    * {@inheritdoc}
    */
-  protected static function getStagingRoot(): string {
-    return TestStage::getStagingRoot();
+  public function getStagingRoot(): string {
+    return TestStage::$stagingRoot ?: parent::getStagingRoot();
   }
 
   /**
diff --git a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
index 50b28eb70981..97a3a2939fd4 100644
--- a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
@@ -4,6 +4,7 @@
 
 use Drupal\auto_updates\CronUpdater;
 use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\Form\FormState;
 use Drupal\Core\Logger\RfcLogLevel;
 use Drupal\package_manager\Event\PostApplyEvent;
@@ -63,6 +64,19 @@ protected function setUp(): void {
       ->addLogger($this->logger);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    // Since this test dynamically adds additional loggers to certain channels,
+    // we need to ensure they will persist even if the container is rebuilt when
+    // staged changes are applied.
+    // @see ::testStageDestroyedOnError()
+    $container->getDefinition('logger.factory')->addTag('persist');
+  }
+
   /**
    * Data provider for ::testUpdaterCalled().
    *
@@ -96,7 +110,7 @@ public function providerUpdaterCalled(): array {
       'enabled, normal release' => [
         CronUpdater::ALL,
         "$fixture_dir/drupal.9.8.2.xml",
-        TRUE,
+        FALSE,
       ],
       'enabled, security release' => [
         CronUpdater::ALL,
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
index 547471dbe5a3..a915c6b65d4c 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
@@ -50,6 +50,7 @@ public function providerValidatorInvoked(): array {
       'Pending updates validator' => ['package_manager.validator.pending_updates'],
       'File system validator' => ['package_manager.validator.file_system'],
       'Composer settings validator' => ['package_manager.validator.composer_settings'],
+      'Multisite validator' => ['package_manager.validator.multisite'],
     ];
   }
 
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
index 5e14c677ff91..8ff50db1a451 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
@@ -30,6 +30,7 @@ class ReadinessValidationManagerTest extends AutoUpdatesKernelTestBase {
    */
   protected function setUp(): void {
     parent::setUp();
+    $this->setCoreVersion('9.8.2');
     $this->installEntitySchema('user');
     $this->installSchema('user', ['users_data']);
     $this->createTestValidationResults();
@@ -222,6 +223,7 @@ public function testStoredResultsDeletedPostApply(): void {
       ->install(['auto_updates']);
 
     // Ensure there's a simulated core release to update to.
+    $this->setCoreVersion('9.8.1');
     $this->setReleaseMetadata(__DIR__ . '/../../../fixtures/release-history/drupal.9.8.2.xml');
 
     // The readiness checker should raise a warning, so that the update is not
@@ -246,7 +248,7 @@ public function testStoredResultsDeletedPostApply(): void {
 
     /** @var \Drupal\auto_updates\Updater $updater */
     $updater = $this->container->get('auto_updates.updater');
-    $updater->begin(['drupal' => '9.8.1']);
+    $updater->begin(['drupal' => '9.8.2']);
     $updater->stage();
     $updater->apply();
     $updater->destroy();
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php
index 410e2b8d2ce6..0118612e65c4 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php
@@ -30,6 +30,10 @@ class StagedDatabaseUpdateValidatorTest extends AutoUpdatesKernelTestBase {
    * {@inheritdoc}
    */
   protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
     parent::setUp();
 
     TestStage::$stagingRoot = $this->vfsRoot->url();
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
index d9188a6fd888..7162e24aad70 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
@@ -3,11 +3,8 @@
 namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
 
 use Drupal\auto_updates\CronUpdater;
-use Drupal\Core\Logger\RfcLogLevel;
-use Drupal\package_manager\Exception\StageValidationException;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
-use Drupal\Tests\auto_updates\Kernel\TestCronUpdater;
 use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
 use Psr\Log\Test\TestLogger;
 
@@ -167,11 +164,6 @@ public function testMinorUpdates(bool $allow_minor_updates, string $cron_setting
     // the update shouldn't have been started.
     elseif ($expected_results) {
       $this->assertUpdateStagedTimes(0);
-
-      // An exception exactly like this one should have been thrown by
-      // CronUpdater::dispatch(), and subsequently caught, formatted as HTML,
-      // and logged.
-      $this->assertErrorsWereLogged($expected_results);
     }
     // If cron updates are enabled and no validation errors were expected, the
     // update should have started and nothing should have been logged.
@@ -197,8 +189,6 @@ public function providerCronUpdateTwoPatchReleasesAhead(): array {
         CronUpdater::DISABLED,
         [],
       ],
-      // The latest release is two patch releases ahead, so the update should be
-      // blocked even though the cron configuration allows it.
       'security only' => [
         CronUpdater::SECURITY,
         [$update_disallowed],
@@ -230,15 +220,6 @@ public function testCronUpdateTwoPatchReleasesAhead(string $cron_setting, array
     $this->assertCheckerResultsFromManager($expected_results, TRUE);
     $this->container->get('cron')->run();
     $this->assertUpdateStagedTimes(0);
-
-    // If cron updates are enabled for all patch releases, the error should have
-    // been raised and logged.
-    if ($cron_setting === CronUpdater::ALL) {
-      $this->assertErrorsWereLogged($expected_results);
-    }
-    else {
-      $this->assertArrayNotHasKey(RfcLogLevel::ERROR, $this->logger->recordsByLevel);
-    }
   }
 
   /**
@@ -280,7 +261,13 @@ public function testCronUpdateOnePatchReleaseAhead(string $cron_setting, bool $w
     $this->config('auto_updates.settings')
       ->set('cron', $cron_setting)
       ->save();
-    $this->assertCheckerResultsFromManager([], TRUE);
+    if ($cron_setting === CronUpdater::SECURITY) {
+      $expected_result = ValidationResult::createError(['Drupal cannot be automatically updated during cron from its current version, 9.8.1, to the recommended version, 9.8.2, because 9.8.2 is not a security release.']);
+      $this->assertCheckerResultsFromManager([$expected_result], TRUE);
+    }
+    else {
+      $this->assertCheckerResultsFromManager([], TRUE);
+    }
     $this->container->get('cron')->run();
     $this->assertUpdateStagedTimes((int) $will_update);
   }
@@ -298,9 +285,6 @@ public function providerInvalidCronUpdate(): array {
     $dev_current_version = ValidationResult::createError([
       'Drupal cannot be automatically updated from its current version, 9.8.0-dev, to the recommended version, 9.8.2, because automatic updates from a dev version to any other version are not supported.',
     ]);
-    $newer_current_version = ValidationResult::createError([
-      'Update version 9.8.2 is lower than 9.8.3, downgrading is not supported.',
-    ]);
     $different_major_version = ValidationResult::createError([
       'Drupal cannot be automatically updated from its current version, 8.9.1, to the recommended version, 9.8.2, because automatic updates from one major version to another are not supported.',
     ]);
@@ -313,81 +297,46 @@ public function providerInvalidCronUpdate(): array {
         // the validation will be run with the regular updater, not the cron
         // updater.
         [],
-        [],
       ],
       'unstable current version, security updates allowed' => [
         CronUpdater::SECURITY,
         '9.8.0-alpha1',
         [$unstable_current_version],
-        // The update will not run because the latest release is not a security
-        // release, so nothing should be logged.
-        [],
       ],
       'unstable current version, all updates allowed' => [
         CronUpdater::ALL,
         '9.8.0-alpha1',
         [$unstable_current_version],
-        [$unstable_current_version],
       ],
       'dev current version, cron disabled' => [
         CronUpdater::DISABLED,
         '9.8.0-dev',
         [$dev_current_version],
-        [],
       ],
       'dev current version, security updates allowed' => [
         CronUpdater::SECURITY,
         '9.8.0-dev',
         [$dev_current_version],
-        // The update will not run because the latest release is not a security
-        // release, so nothing should be logged.
-        [],
       ],
       'dev current version, all updates allowed' => [
         CronUpdater::ALL,
         '9.8.0-dev',
         [$dev_current_version],
-        [$dev_current_version],
-      ],
-      'newer current version, cron disabled' => [
-        CronUpdater::DISABLED,
-        '9.8.3',
-        [$newer_current_version],
-        [],
-      ],
-      'newer current version, security updates allowed' => [
-        CronUpdater::SECURITY,
-        '9.8.3',
-        [$newer_current_version],
-        // The update will not run because the latest release is not a security
-        // release, so nothing should be logged.
-        [],
-      ],
-      'newer current version, all updates allowed' => [
-        CronUpdater::ALL,
-        '9.8.3',
-        [$newer_current_version],
-        [$newer_current_version],
       ],
       'different current major, cron disabled' => [
         CronUpdater::DISABLED,
         '8.9.1',
         [$different_major_version],
-        [],
       ],
       'different current major, security updates allowed' => [
         CronUpdater::SECURITY,
         '8.9.1',
         [$different_major_version],
-        // The update will not run because the latest release is not a security
-        // release, so nothing should be logged.
-        [],
       ],
       'different current major, all updates allowed' => [
         CronUpdater::ALL,
         '8.9.1',
         [$different_major_version],
-        [$different_major_version],
       ],
     ];
   }
@@ -402,12 +351,10 @@ public function providerInvalidCronUpdate(): array {
    * @param \Drupal\package_manager\ValidationResult[] $expected_results
    *   The validation results, if any, that should be flagged during readiness
    *   checks.
-   * @param \Drupal\package_manager\ValidationResult[] $logged_results
-   *   The validation results, if any, that should be logged when cron is run.
    *
    * @dataProvider providerInvalidCronUpdate
    */
-  public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results, array $logged_results): void {
+  public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results): void {
     $this->setCoreVersion($current_core_version);
     $this->config('auto_updates.settings')
       ->set('cron', $cron_setting)
@@ -423,23 +370,6 @@ public function testInvalidCronUpdate(string $cron_setting, string $current_core
     // created (in which case, we expect the errors to be logged).
     $this->container->get('cron')->run();
     $this->assertUpdateStagedTimes(0);
-    if ($logged_results) {
-      $this->assertErrorsWereLogged($logged_results);
-    }
-  }
-
-  /**
-   * Asserts that validation errors were logged during a cron update.
-   *
-   * @param \Drupal\package_manager\ValidationResult[] $results
-   *   The validation errors should have been logged.
-   */
-  private function assertErrorsWereLogged(array $results): void {
-    $exception = new StageValidationException($results, 'Unable to complete the update because of errors.');
-    // The exception will be formatted in a specific, predictable way.
-    // @see \Drupal\Tests\auto_updates\Kernel\CronUpdaterTest::testErrors()
-    $message = TestCronUpdater::formatValidationException($exception);
-    $this->assertTrue($this->logger->hasRecord($message, RfcLogLevel::ERROR));
   }
 
 }
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php b/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php
new file mode 100644
index 000000000000..bb76a2cdf330
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php
@@ -0,0 +1,171 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel;
+
+use Drupal\update\ProjectRelease;
+
+/**
+ * @coversDefaultClass \Drupal\auto_updates\ReleaseChooser
+ *
+ * @group auto_updates
+ */
+class ReleaseChooserTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['auto_updates'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2-older-sec-release.xml');
+
+  }
+
+  /**
+   * Data provider for testReleases().
+   *
+   * @return array[]
+   *   The test cases.
+   */
+  public function providerReleases(): array {
+    return [
+      'installed 9.8.0, no minor support' => [
+        'chooser' => 'auto_updates.release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.8.0',
+        'current_minor' => '9.8.2',
+        'next_minor' => NULL,
+      ],
+      'installed 9.8.0, minor support' => [
+        'chooser' => 'auto_updates.release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.8.0',
+        'current_minor' => '9.8.2',
+        'next_minor' => NULL,
+      ],
+      'installed 9.7.0, no minor support' => [
+        'chooser' => 'auto_updates.release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.7.0',
+        'current_minor' => '9.7.1',
+        'next_minor' => NULL,
+      ],
+      'installed 9.7.0, minor support' => [
+        'chooser' => 'auto_updates.release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.7.0',
+        'current_minor' => '9.7.1',
+        'next_minor' => '9.8.2',
+      ],
+      'installed 9.7.2, no minor support' => [
+        'chooser' => 'auto_updates.release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.7.2',
+        'current_minor' => NULL,
+        'next_minor' => NULL,
+      ],
+      'installed 9.7.2, minor support' => [
+        'chooser' => 'auto_updates.release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.7.2',
+        'current_minor' => NULL,
+        'next_minor' => '9.8.2',
+      ],
+      'cron, installed 9.8.0, no minor support' => [
+        'chooser' => 'auto_updates.cron_release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.8.0',
+        'current_minor' => '9.8.1',
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.8.0, minor support' => [
+        'chooser' => 'auto_updates.cron_release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.8.0',
+        'current_minor' => '9.8.1',
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.7.0, no minor support' => [
+        'chooser' => 'auto_updates.cron_release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.7.0',
+        'current_minor' => '9.7.1',
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.7.0, minor support' => [
+        'chooser' => 'auto_updates.cron_release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.7.0',
+        'current_minor' => '9.7.1',
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.7.2, no minor support' => [
+        'chooser' => 'auto_updates.cron_release_chooser',
+        'minor_support' => FALSE,
+        'installed_version' => '9.7.2',
+        'current_minor' => NULL,
+        'next_minor' => NULL,
+      ],
+      'cron, installed 9.7.2, minor support' => [
+        'chooser' => 'auto_updates.cron_release_chooser',
+        'minor_support' => TRUE,
+        'installed_version' => '9.7.2',
+        'current_minor' => NULL,
+        'next_minor' => NULL,
+      ],
+    ];
+  }
+
+  /**
+   * Tests fetching the recommended release when an update is available.
+   *
+   * @param string $chooser_service
+   *   The ID of release chooser service to use.
+   * @param bool $minor_support
+   *   Whether updates to the next minor will be allowed.
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $current_minor
+   *   The expected release in the currently installed minor or NULL if none is
+   *   available.
+   * @param string|null $next_minor
+   *   The expected release in the next minor or NULL if none is available.
+   *
+   * @dataProvider providerReleases
+   *
+   * @covers ::getLatestInInstalledMinor
+   * @covers ::getLatestInNextMinor
+   */
+  public function testReleases(string $chooser_service, bool $minor_support, string $installed_version, ?string $current_minor, ?string $next_minor): void {
+    $this->setCoreVersion($installed_version);
+    $this->config('auto_updates.settings')->set('allow_core_minor_updates', $minor_support)->save();
+    /** @var \Drupal\auto_updates\ReleaseChooser $chooser */
+    $chooser = $this->container->get($chooser_service);
+    $chooser->refresh();
+    $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor());
+    $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor());
+  }
+
+  /**
+   * Asserts that a project release matches a version number.
+   *
+   * @param string|null $version
+   *   The version to check, or NULL if no version expected.
+   * @param \Drupal\update\ProjectRelease|null $release
+   *   The release to check, or NULL if no release is expected.
+   */
+  private function assertReleaseVersion(?string $version, ?ProjectRelease $release) {
+    if (is_null($version)) {
+      $this->assertNull($release);
+    }
+    else {
+      $this->assertNotEmpty($release);
+      $this->assertSame($version, $release->getVersion());
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php
deleted file mode 100644
index b22029bfbce2..000000000000
--- a/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php
+++ /dev/null
@@ -1,47 +0,0 @@
-<?php
-
-namespace Drupal\Tests\auto_updates\Kernel;
-
-use Drupal\auto_updates\UpdateRecommender;
-
-/**
- * @covers \Drupal\auto_updates\UpdateRecommender
- *
- * @group auto_updates
- */
-class UpdateRecommenderTest extends AutoUpdatesKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'auto_updates',
-    'package_manager',
-  ];
-
-  /**
-   * Tests fetching the recommended release when an update is available.
-   */
-  public function testUpdateAvailable(): void {
-    $recommender = new UpdateRecommender();
-    $recommended_release = $recommender->getRecommendedRelease(TRUE);
-    $this->assertNotEmpty($recommended_release);
-    $this->assertSame('9.8.2', $recommended_release->getVersion());
-    // Getting the recommended release again should not trigger another request.
-    $this->assertNotEmpty($recommender->getRecommendedRelease());
-  }
-
-  /**
-   * Tests fetching the recommended release when there is no update available.
-   */
-  public function testNoUpdateAvailable(): void {
-    $this->setCoreVersion('9.8.2');
-
-    $recommender = new UpdateRecommender();
-    $recommended_release = $recommender->getRecommendedRelease(TRUE);
-    $this->assertNull($recommended_release);
-    // Getting the recommended release again should not trigger another request.
-    $this->assertNull($recommender->getRecommendedRelease());
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
index 2453e8e38e44..d4fe45537c65 100644
--- a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
@@ -33,7 +33,7 @@ protected function setUp(): void {
   /**
    * Tests that correct versions are staged after calling ::begin().
    */
-  public function testCorrectVersionsStaged() {
+  public function testCorrectVersionsStaged(): void {
     $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml');
 
     // Create a user who will own the stage even after the container is rebuilt.
diff --git a/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php b/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php
new file mode 100644
index 000000000000..0c1c4e82ef23
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php
@@ -0,0 +1,194 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit;
+
+use Drupal\auto_updates\ProjectInfo;
+use Drupal\update\ProjectRelease;
+use Drupal\Tests\UnitTestCase;
+use Drupal\update\UpdateManagerInterface;
+
+/**
+ * @coversDefaultClass \Drupal\auto_updates\ProjectInfo
+ *
+ * @group auto_updates
+ */
+class ProjectInfoTest extends UnitTestCase {
+
+  /**
+   * Creates release data for testing.
+   *
+   * @return string[][]
+   *   The release information.
+   */
+  private static function createTestReleases(): array {
+    $versions = ['8.2.5', '8.2.4', '8.2.3', '8.2.3-alpha'];
+    foreach ($versions as $version) {
+      $release_arrays[$version] = [
+        'status' => 'published',
+        'version' => $version,
+        'release_link' => "https://example.drupal.org/project/drupal/releases/$version",
+      ];
+    }
+    return $release_arrays;
+  }
+
+  /**
+   * Data provider for testGetInstallableReleases().
+   *
+   * @return array[][]
+   *   The test cases.
+   */
+  public function providerGetInstallableReleases(): array {
+    $release_arrays = static::createTestReleases();
+    foreach ($release_arrays as $version => $release_array) {
+      $release_objects[$version] = ProjectRelease::createFromArray($release_array);
+    }
+    return [
+      'current' => [
+        [
+          'status' => UpdateManagerInterface::CURRENT,
+          'existing_version' => '1.2.3',
+        ],
+        [],
+      ],
+      '1 release' => [
+        [
+          'status' => UpdateManagerInterface::NOT_CURRENT,
+          'existing_version' => '8.2.4',
+          'recommended' => '8.2.5',
+          'releases' => [
+            '8.2.5' => $release_arrays['8.2.5'],
+          ],
+        ],
+        [
+          '8.2.5' => $release_objects['8.2.5'],
+        ],
+      ],
+      '1 releases, also security' => [
+        [
+          'status' => UpdateManagerInterface::NOT_CURRENT,
+          'existing_version' => '8.2.4',
+          'recommended' => '8.2.5',
+          'releases' => [
+            '8.2.5' => $release_arrays['8.2.5'],
+          ],
+          'security updates' => [
+            $release_arrays['8.2.5'],
+          ],
+        ],
+        [
+          '8.2.5' => $release_objects['8.2.5'],
+        ],
+      ],
+      '1 release, other security' => [
+        [
+          'status' => UpdateManagerInterface::NOT_CURRENT,
+          'existing_version' => '8.2.2',
+          'recommended' => '8.2.5',
+          'releases' => [
+            '8.2.5' => $release_arrays['8.2.5'],
+          ],
+          'security updates' => [
+            // Set out of order security releases to ensure results are sorted.
+            $release_arrays['8.2.3-alpha'],
+            $release_arrays['8.2.3'],
+            $release_arrays['8.2.4'],
+          ],
+        ],
+        [
+          '8.2.5' => $release_objects['8.2.5'],
+          '8.2.4' => $release_objects['8.2.4'],
+          '8.2.3' => $release_objects['8.2.3'],
+          '8.2.3-alpha' => $release_objects['8.2.3-alpha'],
+        ],
+      ],
+      '1 releases, other security lower than current version' => [
+        [
+          'status' => UpdateManagerInterface::NOT_CURRENT,
+          'existing_version' => '8.2.3',
+          'recommended' => '8.2.5',
+          'releases' => [
+            '8.2.5' => $release_arrays['8.2.5'],
+          ],
+          'security updates' => [
+            // Set out of order security releases to ensure results are sorted.
+            $release_arrays['8.2.3-alpha'],
+            $release_arrays['8.2.3'],
+            $release_arrays['8.2.4'],
+          ],
+        ],
+        [
+          '8.2.5' => $release_objects['8.2.5'],
+          '8.2.4' => $release_objects['8.2.4'],
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::getInstallableReleases
+   *
+   * @param array $project_data
+   *   The project data to return from ::getProjectInfo().
+   * @param \Drupal\update\ProjectRelease[] $expected_releases
+   *   The expected releases.
+   *
+   * @dataProvider providerGetInstallableReleases
+   */
+  public function testGetInstallableReleases(array $project_data, array $expected_releases): void {
+    $project_info = $this->getMockedProjectInfo($project_data);
+    $this->assertEqualsCanonicalizing($expected_releases, $project_info->getInstallableReleases());
+  }
+
+  /**
+   * @covers ::getInstallableReleases
+   */
+  public function testInvalidProjectData(): void {
+    $release_arrays = static::createTestReleases();
+    $project_data = [
+      'status' => UpdateManagerInterface::NOT_CURRENT,
+      'existing_version' => '1.2.3',
+      'releases' => [
+        '8.2.5' => $release_arrays['8.2.5'],
+      ],
+      'security updates' => [
+        $release_arrays['8.2.4'],
+        $release_arrays['8.2.3'],
+        $release_arrays['8.2.3-alpha'],
+      ],
+    ];
+    $project_info = $this->getMockedProjectInfo($project_data);
+    $this->expectException('LogicException');
+    $this->expectExceptionMessage('Drupal core is out of date, but the recommended version could not be determined.');
+    $project_info->getInstallableReleases();
+  }
+
+  /**
+   * @covers ::getInstalledVersion
+   */
+  public function testGetInstalledVersion(): void {
+    $project_info = $this->getMockedProjectInfo(['existing_version' => '1.2.3']);
+    $this->assertSame('1.2.3', $project_info->getInstalledVersion());
+  }
+
+  /**
+   * Mocks a ProjectInfo object.
+   *
+   * @param array $project_data
+   *   The project info that should be returned by the mock's ::getProjectInfo()
+   *   method.
+   *
+   * @return \Drupal\auto_updates\ProjectInfo
+   *   The mocked object.
+   */
+  private function getMockedProjectInfo(array $project_data): ProjectInfo {
+    $project_info = $this->getMockBuilder(ProjectInfo::class)
+      ->onlyMethods(['getProjectInfo'])
+      ->getMock();
+    $project_info->expects($this->any())
+      ->method('getProjectInfo')
+      ->willReturn($project_data);
+    return $project_info;
+  }
+
+}
diff --git a/core/modules/package_manager/package_manager.module b/core/modules/package_manager/package_manager.module
index 8560fc55ab99..c54269edfcc9 100644
--- a/core/modules/package_manager/package_manager.module
+++ b/core/modules/package_manager/package_manager.module
@@ -6,17 +6,26 @@
  */
 
 use Drupal\Core\Routing\RouteMatchInterface;
+use Drupal\package_manager\Validator\ComposerExecutableValidator;
 
 /**
  * Implements hook_help().
  */
 function package_manager_help($route_name, RouteMatchInterface $route_match) {
-  // @todo Fully document all the modules features in
-  //   https://www.drupal.org/i/3253591.
   switch ($route_name) {
     case 'help.page.package_manager':
       $output = '<h3>' . t('About') . '</h3>';
-      $output .= '<p>' . t('Package Manager is an API for installing and updating Drupal core and contributed modules.') . '</p>';
+      $output .= '<p>' . t('Package Manager is a framework for updating Drupal core and installing contributed modules and themes via Composer. It has no user interface, but it provides an API for creating a temporary copy of the current site, making changes to the copy, and then syncing those changes back into the live site.') . '</p>';
+      $output .= '<p>' . t('Package Manager dispatches events before and after various operations, and external code can integrate with it by subscribing to those events. For more information, see <code>package_manager.api.php</code>.') . '</p>';
+      $output .= '<h3>' . t('Requirements') . '</h3>';
+      $output .= '<p>' . t('Package Manager requires Composer @version or later available as an executable, and PHP must have permission to run it. The path to the executable may be set in the <code>package_manager.settings:executables.composer</code> config setting, or it will be automatically detected.', ['@version' => ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION]) . '</p>';
+      $output .= '<h3>' . t('Limitations') . '</h3>';
+      $output .= '<p>' . t("Because Package Manager modifies the current site's code base, it is intentionally limited in certain ways to prevent unexpected changes from being made to the live site:") . '</p>';
+      $output .= '<ul>';
+      $output .= '<li>' . t('Package Manager can only maintain one copy of the site at any given time. If a copy of the site already exists, another one cannot be created until the existing copy is destroyed.') . '</li>';
+      $output .= '<li>' . t('The temporary copy of the site is associated with the user or session that originally created it, and only that user or session can make changes to it.') . '</li>';
+      $output .= '<li>' . t('Modules cannot be uninstalled while Package Manager is syncing changes into live site.') . '<li>';
+      $output .= '</ul>';
       $output .= '<p>' . t('For more information, see the <a href=":package-manager-documentation">online documentation for the Package Manager module</a>.', [':package-manager-documentation' => 'https://www.drupal.org/docs/8/core/modules/package-manager']) . '</p>';
       return $output;
   }
diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml
index 7480aaae798c..75ae35d2ece1 100644
--- a/core/modules/package_manager/package_manager.services.yml
+++ b/core/modules/package_manager/package_manager.services.yml
@@ -120,6 +120,13 @@ services:
       - '@string_translation'
     tags:
       - { name: event_subscriber }
+  package_manager.validator.multisite:
+    class: Drupal\package_manager\Validator\MultisiteValidator
+    arguments:
+      - '@package_manager.path_locator'
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
   package_manager.excluded_paths_subscriber:
     class: Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
     arguments:
@@ -130,3 +137,11 @@ services:
       - '@package_manager.path_locator'
     tags:
       - { name: event_subscriber }
+  package_manager.uninstall_validator:
+    class: Drupal\package_manager\PackageManagerUninstallValidator
+    tags:
+      - { name: module_install.uninstall_validator }
+    parent: container.trait
+    calls:
+      - ['setContainer', ['@service_container']]
+    lazy: true
diff --git a/core/modules/package_manager/src/Event/PreOperationStageEvent.php b/core/modules/package_manager/src/Event/PreOperationStageEvent.php
index e60ef7b28ceb..226af627bd11 100644
--- a/core/modules/package_manager/src/Event/PreOperationStageEvent.php
+++ b/core/modules/package_manager/src/Event/PreOperationStageEvent.php
@@ -39,7 +39,7 @@ public function getResults(?int $severity = NULL): array {
   /**
    * Adds error information to the event.
    */
-  public function addError(array $messages, ?TranslatableMarkup $summary = NULL) {
+  public function addError(array $messages, ?TranslatableMarkup $summary = NULL): void {
     $this->results[] = ValidationResult::createError($messages, $summary);
   }
 
diff --git a/core/modules/package_manager/src/PackageManagerUninstallValidator.php b/core/modules/package_manager/src/PackageManagerUninstallValidator.php
new file mode 100644
index 000000000000..dd9ab876071e
--- /dev/null
+++ b/core/modules/package_manager/src/PackageManagerUninstallValidator.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerAwareInterface;
+use Symfony\Component\DependencyInjection\ContainerAwareTrait;
+
+/**
+ * Prevents any module from being uninstalled if update is in process.
+ */
+class PackageManagerUninstallValidator implements ModuleUninstallValidatorInterface, ContainerAwareInterface {
+
+  use ContainerAwareTrait;
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validate($module) {
+    $stage = new Stage(
+      $this->container->get('config.factory'),
+      $this->container->get('package_manager.path_locator'),
+      $this->container->get('package_manager.beginner'),
+      $this->container->get('package_manager.stager'),
+      $this->container->get('package_manager.committer'),
+      $this->container->get('file_system'),
+      $this->container->get('event_dispatcher'),
+      $this->container->get('tempstore.shared'),
+      $this->container->get('datetime.time')
+    );
+    if ($stage->isAvailable() || !$stage->isApplying()) {
+      return [];
+    }
+    if ($stage->isApplying()) {
+      $reasons[] = $this->t('Modules cannot be uninstalled while Package Manager is applying staged changes to the active code base.');
+    }
+    return $reasons;
+  }
+
+}
diff --git a/core/modules/package_manager/src/ProxyClass/PackageManagerUninstallValidator.php b/core/modules/package_manager/src/ProxyClass/PackageManagerUninstallValidator.php
new file mode 100644
index 000000000000..73cd935eda33
--- /dev/null
+++ b/core/modules/package_manager/src/ProxyClass/PackageManagerUninstallValidator.php
@@ -0,0 +1,88 @@
+<?php
+// phpcs:ignoreFile
+
+/**
+ * This file was generated via php core/scripts/generate-proxy-class.php 'Drupal\package_manager\PackageManagerUninstallValidator' "modules/contrib/auto_updates/package_manager/src".
+ */
+
+namespace Drupal\package_manager\ProxyClass {
+
+    /**
+     * Provides a proxy class for \Drupal\package_manager\PackageManagerUninstallValidator.
+     *
+     * @see \Drupal\Component\ProxyBuilder
+     */
+    class PackageManagerUninstallValidator implements \Drupal\Core\Extension\ModuleUninstallValidatorInterface
+    {
+
+        use \Drupal\Core\DependencyInjection\DependencySerializationTrait;
+
+        /**
+         * The id of the original proxied service.
+         *
+         * @var string
+         */
+        protected $drupalProxyOriginalServiceId;
+
+        /**
+         * The real proxied service, after it was lazy loaded.
+         *
+         * @var \Drupal\package_manager\PackageManagerUninstallValidator
+         */
+        protected $service;
+
+        /**
+         * The service container.
+         *
+         * @var \Symfony\Component\DependencyInjection\ContainerInterface
+         */
+        protected $container;
+
+        /**
+         * Constructs a ProxyClass Drupal proxy object.
+         *
+         * @param \Symfony\Component\DependencyInjection\ContainerInterface $container
+         *   The container.
+         * @param string $drupal_proxy_original_service_id
+         *   The service ID of the original service.
+         */
+        public function __construct(\Symfony\Component\DependencyInjection\ContainerInterface $container, $drupal_proxy_original_service_id)
+        {
+            $this->container = $container;
+            $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id;
+        }
+
+        /**
+         * Lazy loads the real service from the container.
+         *
+         * @return object
+         *   Returns the constructed real service.
+         */
+        protected function lazyLoadItself()
+        {
+            if (!isset($this->service)) {
+                $this->service = $this->container->get($this->drupalProxyOriginalServiceId);
+            }
+
+            return $this->service;
+        }
+
+        /**
+         * {@inheritdoc}
+         */
+        public function validate($module)
+        {
+            return $this->lazyLoadItself()->validate($module);
+        }
+
+        /**
+         * {@inheritdoc}
+         */
+        public function setStringTranslation(\Drupal\Core\StringTranslation\TranslationInterface $translation)
+        {
+            return $this->lazyLoadItself()->setStringTranslation($translation);
+        }
+
+    }
+
+}
diff --git a/core/modules/package_manager/src/Stage.php b/core/modules/package_manager/src/Stage.php
index 4c7121075303..810806fe743a 100644
--- a/core/modules/package_manager/src/Stage.php
+++ b/core/modules/package_manager/src/Stage.php
@@ -2,8 +2,10 @@
 
 namespace Drupal\package_manager;
 
+use Drupal\Component\Datetime\TimeInterface;
 use Drupal\Component\FileSystem\FileSystem;
 use Drupal\Component\Utility\Crypt;
+use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\File\Exception\FileException;
 use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\TempStore\SharedTempStoreFactory;
@@ -38,6 +40,16 @@
  * operations on the staging area, and the stage must be "claimed" by its owner
  * before any such operations are done. A stage is claimed by presenting a
  * unique token that is generated when the stage is created.
+ *
+ * Although a site can only have one staging area, it is possible for privileged
+ * users to destroy a stage created by another user. To prevent such actions
+ * from putting the file system into an uncertain state (for example, if a stage
+ * is destroyed by another user while it is still being created), the staging
+ * directory has a randomly generated name. For additional cleanliness, all
+ * staging directories created by a specific site live in a single directory,
+ * called the "staging root" and identified by the UUID of the current site
+ * (e.g. `/tmp/.package_managerSITE_UUID`), which is deleted when any stage
+ * created by that site is destroyed.
  */
 class Stage {
 
@@ -55,6 +67,23 @@ class Stage {
    */
   protected const TEMPSTORE_METADATA_KEY = 'metadata';
 
+  /**
+   * The tempstore key under which to store the time that ::apply() was called.
+   *
+   * @var string
+   *
+   * @see ::apply()
+   * @see ::destroy()
+   */
+  private const TEMPSTORE_APPLY_TIME_KEY = 'apply_time';
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
   /**
    * The path locator service.
    *
@@ -104,6 +133,13 @@ class Stage {
    */
   protected $tempStore;
 
+  /**
+   * The time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
   /**
    * The lock info for the stage.
    *
@@ -116,6 +152,8 @@ class Stage {
   /**
    * Constructs a new Stage object.
    *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
    * @param \Drupal\package_manager\PathLocator $path_locator
    *   The path locator service.
    * @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner
@@ -130,14 +168,18 @@ class Stage {
    *   The event dispatcher service.
    * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore
    *   The shared tempstore factory.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
    */
-  public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore) {
+  public function __construct(ConfigFactoryInterface $config_factory, PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore, TimeInterface $time) {
+    $this->configFactory = $config_factory;
     $this->pathLocator = $path_locator;
     $this->beginner = $beginner;
     $this->stager = $stager;
     $this->committer = $committer;
     $this->fileSystem = $file_system;
     $this->eventDispatcher = $event_dispatcher;
+    $this->time = $time;
     $this->tempStore = $shared_tempstore->get('package_manager_stage');
   }
 
@@ -224,7 +266,9 @@ public function create(): string {
     $stage_dir = $this->getStageDirectory();
 
     $event = new PreCreateEvent($this);
-    $this->dispatch($event);
+    // If an error occurs and we won't be able to create the stage, mark it as
+    // available.
+    $this->dispatch($event, [$this, 'markAsAvailable']);
 
     $this->beginner->begin($active_dir, $stage_dir, $event->getExcludedPaths());
     $this->dispatch(new PostCreateEvent($this));
@@ -276,11 +320,29 @@ public function apply(): void {
     $active_dir = $this->pathLocator->getProjectRoot();
     $stage_dir = $this->getStageDirectory();
 
+    // If an error occurs while dispatching the events, ensure that ::destroy()
+    // doesn't think we're in the middle of applying the staged changes to the
+    // active directory.
+    $release_apply = function (): void {
+      $this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY);
+    };
+
     $event = new PreApplyEvent($this);
-    $this->dispatch($event);
+    $this->tempStore->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time->getRequestTime());
+    $this->dispatch($event, $release_apply);
 
     $this->committer->commit($stage_dir, $active_dir, $event->getExcludedPaths());
-    $this->dispatch(new PostApplyEvent($this));
+
+    // Rebuild the container and clear all caches, to ensure that new services
+    // are picked up.
+    drupal_flush_all_caches();
+    // Refresh the event dispatcher so that new or changed event subscribers
+    // will be called. The other services we depend on are either stateless or
+    // unlikely to call newly added code during the current request.
+    $this->eventDispatcher = \Drupal::service('event_dispatcher');
+
+    $this->dispatch(new PostApplyEvent($this), $release_apply);
+    $release_apply();
   }
 
   /**
@@ -290,29 +352,29 @@ public function apply(): void {
    *   (optional) If TRUE, the staging area will be destroyed even if it is not
    *   owned by the current user or session. Defaults to FALSE.
    *
-   * @todo Do not allow the stage to be destroyed while it's being applied to
-   *   the active directory in https://www.drupal.org/i/3248909.
+   * @throws \Drupal\package_manager\Exception\StageException
+   *   If the staged changes are being applied to the active directory.
    */
   public function destroy(bool $force = FALSE): void {
     if (!$force) {
       $this->checkOwnership();
     }
+    if ($this->isApplying()) {
+      throw new StageException('Cannot destroy the staging area while it is being applied to the active directory.');
+    }
 
     $this->dispatch(new PreDestroyEvent($this));
-    // Delete all directories in parent staging directory.
-    $parent_stage_dir = static::getStagingRoot();
-    if (is_dir($parent_stage_dir)) {
-      try {
-        $this->fileSystem->deleteRecursive($parent_stage_dir, function (string $path): void {
-          $this->fileSystem->chmod($path, 0777);
-        });
-      }
-      catch (FileException $e) {
-        // Deliberately swallow the exception so that the stage will be marked
-        // as available and the post-destroy event will be fired, even if the
-        // staging area can't actually be deleted. The file system service logs
-        // the exception, so we don't need to do anything else here.
-      }
+    // Delete the staging root and everything in it.
+    try {
+      $this->fileSystem->deleteRecursive($this->getStagingRoot(), function (string $path): void {
+        $this->fileSystem->chmod($path, 0777);
+      });
+    }
+    catch (FileException $e) {
+      // Deliberately swallow the exception so that the stage will be marked
+      // as available and the post-destroy event will be fired, even if the
+      // staging area can't actually be deleted. The file system service logs
+      // the exception, so we don't need to do anything else here.
     }
     $this->markAsAvailable();
     $this->dispatch(new PostDestroyEvent($this));
@@ -332,13 +394,16 @@ protected function markAsAvailable(): void {
    *
    * @param \Drupal\package_manager\Event\StageEvent $event
    *   The event object.
+   * @param callable $on_error
+   *   (optional) A callback function to call if an error occurs, before any
+   *   exceptions are thrown.
    *
    * @throws \Drupal\package_manager\Exception\StageValidationException
    *   If the event collects any validation errors.
    * @throws \Drupal\package_manager\Exception\StageException
    *   If any other sort of error occurs.
    */
-  protected function dispatch(StageEvent $event): void {
+  protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
     try {
       $this->eventDispatcher->dispatch($event);
 
@@ -354,10 +419,8 @@ protected function dispatch(StageEvent $event): void {
     }
 
     if (isset($error)) {
-      // If we won't be able to create the staging area, mark it as available.
-      // @see ::create()
-      if ($event instanceof PreCreateEvent) {
-        $this->markAsAvailable();
+      if ($on_error) {
+        $on_error();
       }
       throw $error;
     }
@@ -459,14 +522,12 @@ final protected function checkOwnership(): void {
    *
    * @throws \LogicException
    *   If this method is called before the stage has been created or claimed.
-   *
-   * @todo Make this method public in https://www.drupal.org/i/3251972.
    */
   public function getStageDirectory(): string {
     if (!$this->lock) {
       throw new \LogicException(__METHOD__ . '() cannot be called because the stage has not been created or claimed.');
     }
-    return static::getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0];
+    return $this->getStagingRoot() . DIRECTORY_SEPARATOR . $this->lock[0];
   }
 
   /**
@@ -476,8 +537,26 @@ public function getStageDirectory(): string {
    *   The absolute path of the directory containing the staging areas managed
    *   by this class.
    */
-  protected static function getStagingRoot(): string {
-    return FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . '.package_manager';
+  protected function getStagingRoot(): string {
+    $site_id = $this->configFactory->get('system.site')->get('uuid');
+    return FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . '.package_manager' . $site_id;
+  }
+
+  /**
+   * Checks if staged changes are being applied to the active directory.
+   *
+   * @return bool
+   *   TRUE if the staged changes are being applied to the active directory, and
+   *   it has been less than an hour since that operation began. If more than an
+   *   hour has elapsed since the changes started to be applied, FALSE is
+   *   returned even if the stage internally thinks that changes are still being
+   *   applied.
+   *
+   * @see ::apply()
+   */
+  final public function isApplying(): bool {
+    $apply_time = $this->tempStore->get(self::TEMPSTORE_APPLY_TIME_KEY);
+    return isset($apply_time) && $this->time->getRequestTime() - $apply_time < 3600;
   }
 
 }
diff --git a/core/modules/package_manager/src/Validator/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php
index 7f29b5e1cc67..a1676f3e74b2 100644
--- a/core/modules/package_manager/src/Validator/LockFileValidator.php
+++ b/core/modules/package_manager/src/Validator/LockFileValidator.php
@@ -57,14 +57,17 @@ public function __construct(StateInterface $state, PathLocator $path_locator, Tr
   }
 
   /**
-   * Returns the current hash of the active directory's lock file.
+   * Returns the current hash of the given directory's lock file.
+   *
+   * @param string $directory
+   *   Path of a directory containing a composer.lock file.
    *
    * @return string|false
-   *   The hash of the active directory's lock file, or FALSE if the lock file
+   *   The hash of the given directory's lock file, or FALSE if the lock file
    *   does not exist.
    */
-  protected function getHash() {
-    $file = $this->pathLocator->getProjectRoot() . DIRECTORY_SEPARATOR . 'composer.lock';
+  protected function getLockFileHash(string $directory) {
+    $file = $directory . DIRECTORY_SEPARATOR . 'composer.lock';
     // We want to directly hash the lock file itself, rather than look at its
     // content-hash value, which is actually a hash of the relevant parts of
     // composer.json. We're trying to verify that the actual installed packages
@@ -81,7 +84,7 @@ protected function getHash() {
    * Stores the current lock file hash.
    */
   public function storeHash(PreCreateEvent $event): void {
-    $hash = $this->getHash();
+    $hash = $this->getLockFileHash($this->pathLocator->getProjectRoot());
     if ($hash) {
       $this->state->set(static::STATE_KEY, $hash);
     }
@@ -97,8 +100,8 @@ public function storeHash(PreCreateEvent $event): void {
    */
   public function validateStagePreOperation(PreOperationStageEvent $event): void {
     // Ensure we can get a current hash of the lock file.
-    $hash = $this->getHash();
-    if (empty($hash)) {
+    $active_hash = $this->getLockFileHash($this->pathLocator->getProjectRoot());
+    if (empty($active_hash)) {
       $error = $this->t('Could not hash the active lock file.');
     }
 
@@ -109,10 +112,19 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
     }
 
     // If we have both hashes, ensure they match.
-    if ($hash && $stored_hash && !hash_equals($stored_hash, $hash)) {
+    if ($active_hash && $stored_hash && !hash_equals($stored_hash, $active_hash)) {
       $error = $this->t('Stored lock file hash does not match the active lock file.');
     }
 
+    // Don't allow staged changes to be applied if the staged lock file has no
+    // apparent changes.
+    if (empty($error) && $event instanceof PreApplyEvent) {
+      $stage_hash = $this->getLockFileHash($event->getStage()->getStageDirectory());
+      if ($stage_hash && hash_equals($active_hash, $stage_hash)) {
+        $error = $this->t('There are no pending Composer operations.');
+      }
+    }
+
     // @todo Let the validation result carry all the relevant messages in
     //   https://www.drupal.org/i/3247479.
     if (isset($error)) {
diff --git a/core/modules/package_manager/src/Validator/MultisiteValidator.php b/core/modules/package_manager/src/Validator/MultisiteValidator.php
new file mode 100644
index 000000000000..8a8ebb4c8be3
--- /dev/null
+++ b/core/modules/package_manager/src/Validator/MultisiteValidator.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace Drupal\package_manager\Validator;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\PathLocator;
+
+/**
+ * Checks that the current site is not part of a multisite.
+ */
+class MultisiteValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a new MultisiteValidator.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The string translation service.
+   */
+  public function __construct(PathLocator $path_locator, TranslationInterface $translation) {
+    $this->pathLocator = $path_locator;
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    if ($this->isMultisite()) {
+      $event->addError([
+        $this->t('Multisites are not supported by Package Manager.'),
+      ]);
+    }
+  }
+
+  /**
+   * Detects if the current site is part of a multisite.
+   *
+   * @return bool
+   *   TRUE if the current site is part of a multisite, otherwise FALSE.
+   *
+   * @todo Make this smarter in https://www.drupal.org/node/3267646.
+   */
+  protected function isMultisite(): bool {
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $web_root .= '/';
+    }
+    return file_exists($this->pathLocator->getProjectRoot() . '/' . $web_root . 'sites/sites.php');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/tests/fixtures/alpha/1.0.0/composer.json b/core/modules/package_manager/tests/fixtures/alpha/1.0.0/composer.json
new file mode 100644
index 000000000000..35db7d858c4e
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/alpha/1.0.0/composer.json
@@ -0,0 +1,5 @@
+{
+  "name": "drupal/alpha",
+  "type": "drupal-module",
+  "version": "1.0.0"
+}
diff --git a/core/modules/package_manager/tests/fixtures/alpha/1.1.0/composer.json b/core/modules/package_manager/tests/fixtures/alpha/1.1.0/composer.json
new file mode 100644
index 000000000000..f21a204a76df
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/alpha/1.1.0/composer.json
@@ -0,0 +1,5 @@
+{
+  "name": "drupal/alpha",
+  "type": "drupal-module",
+  "version": "1.1.0"
+}
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/composer.json b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/composer.json
new file mode 100644
index 000000000000..777cd741d249
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/composer.json
@@ -0,0 +1,5 @@
+{
+  "name": "drupal/updated_module",
+  "type": "drupal-module",
+  "version": "1.0.0"
+}
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml
new file mode 100644
index 000000000000..ebf1452ec9c8
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml
@@ -0,0 +1,4 @@
+name: 'Updated module'
+description: 'A module which will change during an update, to ensure that the changes are picked up.'
+type: module
+package: Testing
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module
new file mode 100644
index 000000000000..6f61c457881c
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module
@@ -0,0 +1,27 @@
+<?php
+
+/**
+ * @file
+ * Contains global functions for testing updates to a .module file.
+ */
+
+/**
+ * Page controller that says hello.
+ *
+ * @return array
+ *   A renderable array of the page content.
+ */
+function updated_module_hello(): array {
+  return [
+    '#markup' => 'Hello!',
+  ];
+}
+
+/**
+ * A test function to test if global functions are reloaded during an update.
+ *
+ * @return string
+ */
+function _updated_module_global1(): string {
+  return "pre-update-value";
+}
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml
new file mode 100644
index 000000000000..c0236005ef27
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml
@@ -0,0 +1,4 @@
+changed permission:
+  title: 'permission'
+deleted permission:
+  title: 'deleted permission'
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml
new file mode 100644
index 000000000000..03a269bb8357
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml
@@ -0,0 +1,12 @@
+updated_module.changed:
+  path: '/updated-module/changed/pre'
+  defaults:
+    _controller: 'updated_module_hello'
+  requirements:
+    _access: 'TRUE'
+updated_module.deleted:
+  path: '/updated-module/deleted'
+  defaults:
+    _controller: 'updated_module_hello'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/composer.json b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/composer.json
new file mode 100644
index 000000000000..6f997dad4cf4
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/composer.json
@@ -0,0 +1,5 @@
+{
+  "name": "drupal/updated_module",
+  "type": "drupal-module",
+  "version": "1.1.0"
+}
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/src/PostApplySubscriber.php b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/src/PostApplySubscriber.php
new file mode 100644
index 000000000000..2e7e988a3b77
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/src/PostApplySubscriber.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\updated_module;
+
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Writes a file after staged changes are applied to the active directory.
+ *
+ * This event subscriber doesn't exist in version 1.0.0 of this module, so we
+ * use it to test that new event subscribers are picked up after staged changes
+ * have been applied.
+ */
+class PostApplySubscriber implements EventSubscriberInterface {
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  private $pathLocator;
+
+  /**
+   * Constructs a PostApplySubscriber.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * Writes a file when staged changes are applied to the active directory.
+   */
+  public function postApply(): void {
+    $dir = $this->pathLocator->getProjectRoot();
+    file_put_contents("$dir/bravo.txt", 'Bravo!');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PostApplyEvent::class => 'postApply',
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml
new file mode 100644
index 000000000000..ebf1452ec9c8
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml
@@ -0,0 +1,4 @@
+name: 'Updated module'
+description: 'A module which will change during an update, to ensure that the changes are picked up.'
+type: module
+package: Testing
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module
new file mode 100644
index 000000000000..34d5dce9bb1f
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module
@@ -0,0 +1,33 @@
+<?php
+
+/**
+ * @file
+ * Contains global functions for testing updates to a .module file.
+ */
+
+/**
+ * Page controller that says hello.
+ *
+ * @return array
+ *   A renderable array of the page content.
+ */
+function updated_module_hello(): array {
+  return [
+    '#markup' => 'Hello!',
+  ];
+}
+
+/**
+ * A test function to test if global functions are reloaded during an update.
+ *
+ * @return string
+ */
+function _updated_module_global1(): string {
+  return "post-update-value";
+}
+
+/**
+ * A test function to test if a new global function will be available after an update.
+ */
+function _updated_module_global2(): void {
+}
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml
new file mode 100644
index 000000000000..926a17a41238
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml
@@ -0,0 +1,4 @@
+changed permission:
+  title: 'changed permission'
+added permission:
+  title: 'added permission'
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml
new file mode 100644
index 000000000000..525acd210ccb
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml
@@ -0,0 +1,12 @@
+updated_module.changed:
+  path: '/updated-module/changed/post'
+  defaults:
+    _controller: 'updated_module_hello'
+  requirements:
+    _access: 'TRUE'
+updated_module.added:
+  path: '/updated-module/added'
+  defaults:
+    _controller: 'updated_module_hello'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.services.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.services.yml
new file mode 100644
index 000000000000..1179d2fb06f2
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.services.yml
@@ -0,0 +1,7 @@
+services:
+  updated_module.post_apply_subscriber:
+    class: Drupal\updated_module\PostApplySubscriber
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php
index feb4dc92f688..edc1075a9aaf 100644
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php
@@ -27,7 +27,7 @@ public function getInvocationArguments(): array {
    * @param mixed ...$arguments
    *   The arguments that the main class method was called with.
    */
-  protected function saveInvocationArguments(...$arguments) {
+  protected function saveInvocationArguments(...$arguments): void {
     $invocations = $this->getInvocationArguments();
     $invocations[] = $arguments;
     \Drupal::state()->set(static::class, $invocations);
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
index 4b5cf58ca1f5..f400c0f4fcd7 100644
--- a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
@@ -1,6 +1,6 @@
-package_manager_test_api.require:
-  path: '/package-manager-test-api/require'
+package_manager_test_api:
+  path: '/package-manager-test-api'
   defaults:
-    _controller: 'Drupal\package_manager_test_api\ApiController::require'
+    _controller: 'Drupal\package_manager_test_api\ApiController::run'
   requirements:
     _access: 'TRUE'
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml
new file mode 100644
index 000000000000..6bf6af37d16d
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml
@@ -0,0 +1,10 @@
+services:
+  package_manager_test_api.system_change_recorder:
+    class: Drupal\package_manager_test_api\SystemChangeRecorder
+    arguments:
+      - '@package_manager.path_locator'
+      - '@state'
+      - '@router.no_access_checks'
+      - '@user.permissions'
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
index a9f116ff2bc8..45eb27f0f8d1 100644
--- a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
@@ -3,6 +3,7 @@
 namespace Drupal\package_manager_test_api;
 
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\package_manager\PathLocator;
 use Drupal\package_manager\Stage;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
@@ -20,14 +21,24 @@ class ApiController extends ControllerBase {
    */
   private $stage;
 
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  private $pathLocator;
+
   /**
    * Constructs an ApiController object.
    *
    * @param \Drupal\package_manager\Stage $stage
    *   The stage.
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
    */
-  public function __construct(Stage $stage) {
+  public function __construct(Stage $stage, PathLocator $path_locator) {
     $this->stage = $stage;
+    $this->pathLocator = $path_locator;
   }
 
   /**
@@ -35,6 +46,7 @@ public function __construct(Stage $stage) {
    */
   public static function create(ContainerInterface $container) {
     $stage = new Stage(
+      $container->get('config.factory'),
       $container->get('package_manager.path_locator'),
       $container->get('package_manager.beginner'),
       $container->get('package_manager.stager'),
@@ -42,40 +54,47 @@ public static function create(ContainerInterface $container) {
       $container->get('file_system'),
       $container->get('event_dispatcher'),
       $container->get('tempstore.shared'),
+      $container->get('datetime.time')
+    );
+    return new static(
+      $stage,
+      $container->get('package_manager.path_locator')
     );
-    return new static($stage);
   }
 
   /**
-   * Creates a staging area and requires packages into it.
+   * Runs a complete stage life cycle.
+   *
+   * Creates a staging area, requires packages into it, applies changes to the
+   * active directory, and destroys the stage.
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The request. The runtime and dev dependencies are expected to be in
    *   either the query string or request body, under the 'runtime' and 'dev'
    *   keys, respectively. There may also be a 'files_to_return' key, which
-   *   contains an array of file paths, relative to the stage directory, whose
+   *   contains an array of file paths, relative to the project root, whose
    *   contents should be returned in the response.
    *
    * @return \Symfony\Component\HttpFoundation\JsonResponse
    *   A JSON response containing an associative array of the contents of the
-   *   staged files listed in the 'files_to_return' request key. The array will
-   *   be keyed by path, relative to the stage directory.
+   *   files listed in the 'files_to_return' request key. The array will be
+   *   keyed by path, relative to the project root.
    */
-  public function require(Request $request): JsonResponse {
+  public function run(Request $request): JsonResponse {
     $this->stage->create();
     $this->stage->require(
       $request->get('runtime', []),
       $request->get('dev', [])
     );
+    $this->stage->apply();
+    $this->stage->destroy();
 
-    $stage_dir = $this->stage->getStageDirectory();
-    $staged_file_contents = [];
+    $dir = $this->pathLocator->getProjectRoot();
+    $file_contents = [];
     foreach ($request->get('files_to_return', []) as $path) {
-      $staged_file_contents[$path] = file_get_contents($stage_dir . '/' . $path);
+      $file_contents[$path] = file_get_contents($dir . '/' . $path);
     }
-    $this->stage->destroy();
-
-    return new JsonResponse($staged_file_contents);
+    return new JsonResponse($file_contents);
   }
 
 }
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php
new file mode 100644
index 000000000000..4738a3157c36
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php
@@ -0,0 +1,129 @@
+<?php
+
+namespace Drupal\package_manager_test_api;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\Event\PostDestroyEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Drupal\user\PermissionHandlerInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Routing\RouterInterface;
+
+/**
+ * Defines a service for checking system changes during an update.
+ */
+class SystemChangeRecorder implements EventSubscriberInterface {
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  private $pathLocator;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  private $state;
+
+  /**
+   * The router service.
+   *
+   * @var \Symfony\Component\Routing\RouterInterface
+   */
+  private $router;
+
+  /**
+   * The permission handler service.
+   *
+   * @var \Drupal\user\PermissionHandlerInterface
+   */
+  private $permissionHandler;
+
+  /**
+   * Constructs a SystemChangeRecorder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Symfony\Component\Routing\RouterInterface $router
+   *   The router service.
+   * @param \Drupal\user\PermissionHandlerInterface $permissionHandler
+   *   The permission handler service.
+   */
+  public function __construct(PathLocator $path_locator, StateInterface $state, RouterInterface $router, PermissionHandlerInterface $permissionHandler) {
+    $this->pathLocator = $path_locator;
+    $this->state = $state;
+    $this->router = $router;
+    $this->permissionHandler = $permissionHandler;
+  }
+
+  /**
+   * Records aspects of system state at various points during an update.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The stage event.
+   */
+  public function recordSystemState(StageEvent $event): void {
+    $results = [];
+
+    // Call a function in a loaded file to ensure it doesn't get reloaded after
+    // changes are applied.
+    $results['return value of existing global function'] = _updated_module_global1();
+
+    // Check if a new global function exists after changes are applied.
+    $results['new global function exists'] = function_exists('_updated_module_global2') ? "exists" : "not exists";
+
+    $route_collection = $this->router->getRouteCollection();
+    // Check if changes to an existing route are picked up.
+    $results['path of changed route'] = $route_collection->get('updated_module.changed')
+      ->getPath();
+    // Check if a route removed from the updated module is no longer available.
+    $results['deleted route exists'] = $route_collection->get('updated_module.deleted') ? 'exists' : 'not exists';
+    // Check if a route added in the updated module is available.
+    $results['new route exists'] = $route_collection->get('updated_module.added') ? 'exists' : 'not exists';
+
+    $permissions = $this->permissionHandler->getPermissions();
+    // Check if changes to an existing permission are picked up.
+    $results['title of changed permission'] = $permissions['changed permission']['title'];
+    // Check if a permission removed from the updated module is not available.
+    $results['deleted permission exists'] = array_key_exists('deleted permission', $permissions) ? 'exists' : 'not exists';
+    // Check if a permission added in the updated module is available.
+    $results['new permission exists'] = array_key_exists('added permission', $permissions) ? 'exists' : 'not exists';
+    $phase = $event instanceof PreApplyEvent ? 'pre' : 'post';
+    $this->state->set("system_changes:$phase", $results);
+  }
+
+  /**
+   * Writes the results of ::recordSystemState() to file.
+   *
+   * Build tests do not have access to the Drupal API, so write the results to
+   * a file so the build test can check them.
+   */
+  public function writeResultsToFile(): void {
+    $results = [
+      'pre' => $this->state->get('system_changes:pre'),
+      'post' => $this->state->get('system_changes:post'),
+    ];
+    $dir = $this->pathLocator->getProjectRoot();
+    file_put_contents("$dir/system_changes.json", json_encode($results));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreApplyEvent::class => 'recordSystemState',
+      PostApplyEvent::class => 'recordSystemState',
+      PostDestroyEvent::class => 'writeResultsToFile',
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml
new file mode 100644
index 000000000000..cf1308922d4f
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml
@@ -0,0 +1,6 @@
+name: 'Package Manager Test Fixture'
+description: 'Provides a mechanism for functional tests to stage fixture files.'
+type: module
+package: Testing
+dependencies:
+  - auto_updates:package_manager
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml
new file mode 100644
index 000000000000..aa44e5741e2b
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml
@@ -0,0 +1,8 @@
+services:
+  package_manager_test_fixture.stager:
+    class: Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager
+    arguments:
+      - '@state'
+      - '@package_manager.symfony_file_system'
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php b/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php
new file mode 100644
index 000000000000..a83d92799805
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace Drupal\package_manager_test_fixture\EventSubscriber;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Event\PostRequireEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * Defines an event subscriber which copies certain files into the staging area.
+ *
+ * This is most useful in conjunction with package_manager_bypass, which quietly
+ * turns all Composer Stager operations into no-ops. In such cases, no staging
+ * area will be physically created, but if a test needs to simulate certain
+ * conditions in a staging area without actually staging the active code base,
+ * this event subscriber is the way to do it.
+ */
+class FixtureStager implements EventSubscriberInterface {
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The Symfony file system service.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * Constructs a FixtureStager.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Symfony\Component\Filesystem\Filesystem $file_system
+   *   The Symfony file system service.
+   */
+  public function __construct(StateInterface $state, Filesystem $file_system) {
+    $this->state = $state;
+    $this->fileSystem = $file_system;
+  }
+
+  /**
+   * Copies files from a fixture into the staging area.
+   *
+   * Tests which use this functionality are responsible for cleaning up the
+   * staging area.
+   *
+   * @param \Drupal\package_manager\Event\PostRequireEvent $event
+   *   The event object.
+   *
+   * @see \Drupal\Tests\auto_updates\Functional\AutoUpdatesFunctionalTestBase::tearDown()
+   */
+  public function copyFilesFromFixture(PostRequireEvent $event): void {
+    $fixturePath = $this->state->get(static::class);
+    if ($fixturePath && is_dir($fixturePath)) {
+      $this->fileSystem->mirror($fixturePath, $event->getStage()->getStageDirectory(), NULL, [
+        'override' => TRUE,
+        'delete' => TRUE,
+      ]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PostRequireEvent::class => 'copyFilesFromFixture',
+    ];
+  }
+
+  /**
+   * Sets the path of the fixture to copy into the staging area.
+   *
+   * @param string $path
+   *   The path of the fixture to copy into the staging area.
+   */
+  public static function setFixturePath(string $path): void {
+    \Drupal::state()->set(static::class, $path);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php
index e58267f1bb0d..f88eb4f9a4de 100644
--- a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php
+++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php
@@ -44,6 +44,19 @@ public function __construct(StateInterface $state) {
     $this->state = $state;
   }
 
+  /**
+   * Sets whether a specific event will call exit().
+   *
+   * This is useful for simulating an unrecoverable (fatal) error when handling
+   * the given event.
+   *
+   * @param string $event
+   *   The event class.
+   */
+  public static function setExit(string $event): void {
+    \Drupal::state()->set(static::STATE_KEY . ".$event", 'exit');
+  }
+
   /**
    * Sets validation results for a specific event.
    *
@@ -99,9 +112,15 @@ public static function setException(?\Throwable $error, string $event): void {
   public function handleEvent(StageEvent $event): void {
     $results = $this->state->get(static::STATE_KEY . '.' . get_class($event), []);
 
+    // Record that value of maintenance mode for each event.
+    $this->state->set(get_class($event) . '.' . 'system.maintenance_mode', $this->state->get('system.maintenance_mode'));
+
     if ($results instanceof \Throwable) {
       throw $results;
     }
+    elseif ($results === 'exit') {
+      exit();
+    }
     /** @var \Drupal\package_manager\ValidationResult $result */
     foreach ($results as $result) {
       if ($result->getSeverity() === SystemManager::REQUIREMENT_ERROR) {
diff --git a/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php
new file mode 100644
index 000000000000..75c03353b95c
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Build;
+
+/**
+ * Tests updating packages in a staging area.
+ *
+ * @group package_manager
+ */
+class PackageUpdateTest extends TemplateProjectTestBase {
+
+  /**
+   * Tests updating packages in a staging area.
+   */
+  public function testPackageUpdate(): void {
+    $this->createTestProject('RecommendedProject');
+
+    $this->addRepository('alpha', __DIR__ . '/../../fixtures/alpha/1.0.0');
+    $this->addRepository('updated_module', __DIR__ . '/../../fixtures/updated_module/1.0.0');
+    $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha drupal/updated_module --update-with-all-dependencies', 'project');
+
+    $this->installQuickStart('minimal');
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    // The updated_module provides actual Drupal-facing functionality that we're
+    // testing as well, so we need to install it.
+    $this->installModules(['package_manager_test_api', 'updated_module']);
+
+    // Change both modules' upstream version.
+    $this->addRepository('alpha', __DIR__ . '/../../fixtures/alpha/1.1.0');
+    $this->addRepository('updated_module', __DIR__ . '/../../fixtures/updated_module/1.1.0');
+
+    // Use the API endpoint to create a stage and update updated_module to
+    // 1.1.0. Even though both modules have version 1.1.0 available, only
+    // updated_module should be updated. We ask the API to return the contents
+    // of both modules' composer.json files, so we can assert that they were
+    // updated to the versions we expect.
+    // @see \Drupal\package_manager_test_api\ApiController::run()
+    $query = http_build_query([
+      'runtime' => [
+        'drupal/updated_module:1.1.0',
+      ],
+      'files_to_return' => [
+        'web/modules/contrib/alpha/composer.json',
+        'web/modules/contrib/updated_module/composer.json',
+        'bravo.txt',
+        "system_changes.json",
+      ],
+    ]);
+    $this->visit("/package-manager-test-api?$query");
+    $mink = $this->getMink();
+    $mink->assertSession()->statusCodeEquals(200);
+
+    $file_contents = $mink->getSession()->getPage()->getContent();
+    $file_contents = json_decode($file_contents, TRUE);
+
+    $expected_versions = [
+      'alpha' => '1.0.0',
+      'updated_module' => '1.1.0',
+    ];
+    foreach ($expected_versions as $module_name => $expected_version) {
+      $path = "web/modules/contrib/$module_name/composer.json";
+      $module_composer_json = json_decode($file_contents[$path]);
+      $this->assertSame($expected_version, $module_composer_json->version);
+    }
+    // The post-apply event subscriber in updated_module 1.1.0 should have
+    // created this file.
+    // @see \Drupal\updated_module\PostApplySubscriber::postApply()
+    $this->assertSame('Bravo!', $file_contents['bravo.txt']);
+
+    $results = json_decode($file_contents['system_changes.json'], TRUE);
+    $expected_pre_apply_results = [
+      'return value of existing global function' => 'pre-update-value',
+      'new global function exists' => 'not exists',
+      'path of changed route' => '/updated-module/changed/pre',
+      'deleted route exists' => 'exists',
+      'new route exists' => 'not exists',
+      'title of changed permission' => 'permission',
+      'deleted permission exists' => 'exists',
+      'new permission exists' => 'not exists',
+    ];
+    $this->assertSame($expected_pre_apply_results, $results['pre']);
+
+    $expected_post_apply_results = [
+      // Existing functions will still use the pre-update version.
+      'return value of existing global function' => 'pre-update-value',
+      // New functions that were added in .module files will not be available.
+      'new global function exists' => 'not exists',
+      // Definitions for existing routes should be updated.
+      'path of changed route' => '/updated-module/changed/post',
+      // Routes deleted from the updated module should not be available.
+      'deleted route exists' => 'not exists',
+      // Routes added to the updated module should be available.
+      'new route exists' => 'exists',
+      // Title of the existing permission should be changed.
+      'title of changed permission' => 'changed permission',
+      // Permissions deleted from the updated module should not be available.
+      'deleted permission exists' => 'not exists',
+      // Permissions added to the updated module should be available.
+      'new permission exists' => 'exists',
+    ];
+    $this->assertSame($expected_post_apply_results, $results['post']);
+  }
+
+  /**
+   * Adds a path repository to the test site.
+   *
+   * @param string $name
+   *   An arbitrary name for the repository.
+   * @param string $path
+   *   The path of the repository. Must exist in the file system.
+   */
+  private function addRepository(string $name, string $path): void {
+    $this->assertDirectoryExists($path);
+
+    $repository = json_encode([
+      'type' => 'path',
+      'url' => $path,
+      'options' => [
+        'symlink' => FALSE,
+      ],
+    ]);
+    $this->runComposer("composer config repo.$name '$repository'", 'project');
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php b/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php
deleted file mode 100644
index 8dbf40363380..000000000000
--- a/core/modules/package_manager/tests/src/Build/StagedUpdateTest.php
+++ /dev/null
@@ -1,87 +0,0 @@
-<?php
-
-namespace Drupal\Tests\package_manager\Build;
-
-/**
- * Tests updating packages in a staging area.
- *
- * @group package_manager
- */
-class StagedUpdateTest extends TemplateProjectTestBase {
-
-  /**
-   * Tests that a stage only updates packages with changed constraints.
-   */
-  public function testStagedUpdate(): void {
-    $this->createTestProject('RecommendedProject');
-
-    $this->createModule('alpha');
-    $this->createModule('bravo');
-    $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha drupal/bravo --update-with-all-dependencies', 'project');
-
-    $this->installQuickStart('minimal');
-    $this->formLogin($this->adminUsername, $this->adminPassword);
-    $this->installModules(['package_manager_test_api']);
-
-    // Change both modules' upstream version.
-    $this->runComposer('composer config version 1.1.0', 'alpha');
-    $this->runComposer('composer config version 1.1.0', 'bravo');
-
-    // Use the API endpoint to create a stage and update bravo to 1.1.0. Even
-    // though both modules are at version 1.1.0, only bravo should be updated.
-    // We ask the API to return the contents of both modules' staged
-    // composer.json files, so we can assert that the staged versions are what
-    // we expect.
-    // @see \Drupal\package_manager_test_api\ApiController::require()
-    $query = http_build_query([
-      'runtime' => [
-        'drupal/bravo:1.1.0',
-      ],
-      'files_to_return' => [
-        'web/modules/contrib/alpha/composer.json',
-        'web/modules/contrib/bravo/composer.json',
-      ],
-    ]);
-    $this->visit("/package-manager-test-api/require?$query");
-    $mink = $this->getMink();
-    $mink->assertSession()->statusCodeEquals(200);
-
-    $staged_file_contents = $mink->getSession()->getPage()->getContent();
-    $staged_file_contents = json_decode($staged_file_contents, TRUE);
-
-    $expected_versions = [
-      'alpha' => '1.0.0',
-      'bravo' => '1.1.0',
-    ];
-    foreach ($expected_versions as $module_name => $expected_version) {
-      $path = "web/modules/contrib/$module_name/composer.json";
-      $staged_composer_json = json_decode($staged_file_contents[$path]);
-      $this->assertSame($expected_version, $staged_composer_json->version);
-    }
-  }
-
-  /**
-   * Creates an empty module for testing purposes.
-   *
-   * @param string $name
-   *   The machine name of the module, which can be added to the test site as
-   *   'drupal/$name'.
-   */
-  private function createModule(string $name): void {
-    $dir = $this->getWorkspaceDirectory() . '/' . $name;
-    mkdir($dir);
-    $this->assertDirectoryExists($dir);
-    $this->runComposer("composer init --name drupal/$name --type drupal-module", $name);
-    $this->runComposer('composer config version 1.0.0', $name);
-
-    $repository = json_encode([
-      'type' => 'path',
-      'url' => $dir,
-      'options' => [
-        'symlink' => FALSE,
-      ],
-    ]);
-    $this->runComposer("composer config repo.$name '$repository'", 'project');
-  }
-
-}
diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
index d406fcac9504..eecde71a0a25 100644
--- a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
@@ -14,6 +14,17 @@
  */
 class ExcludedPathsTest extends PackageManagerKernelTestBase {
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
index 4126a41d4837..757481d81b81 100644
--- a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\package_manager\Event\PostRequireEvent;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreRequireEvent;
@@ -119,6 +120,21 @@ public function testNoStoredHash(string $event_class): void {
     $this->assertResults([$result], $event_class);
   }
 
+  /**
+   * Tests validation when the staged and active lock files are identical.
+   */
+  public function testApplyWithNoChange(): void {
+    $this->addListener(PostRequireEvent::class, function (PostRequireEvent $event) {
+      $stage_dir = $event->getStage()->getStageDirectory();
+      mkdir($stage_dir);
+      copy("$this->activeDir/composer.lock", "$stage_dir/composer.lock");
+    });
+    $result = ValidationResult::createError([
+      'There are no pending Composer operations.',
+    ]);
+    $this->assertResults([$result], PreApplyEvent::class);
+  }
+
   /**
    * Data provider for test methods that validate the staging area.
    *
diff --git a/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php
new file mode 100644
index 000000000000..79c7bbf6b049
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php
@@ -0,0 +1,62 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * @covers \Drupal\package_manager\Validator\MultisiteValidator
+ *
+ * @group package_manager
+ */
+class MultisiteValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Data provider for ::testMultisite().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerMultisite(): array {
+    return [
+      'multisite' => [
+        TRUE,
+        [
+          ValidationResult::createError([
+            'Multisites are not supported by Package Manager.',
+          ]),
+        ],
+      ],
+      'not multisite' => [
+        FALSE,
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that Package Manager flags an error if run in a multisite.
+   *
+   * @param bool $is_multisite
+   *   Whether the validator will be in a multisite.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerMultisite
+   */
+  public function testMultisite(bool $is_multisite, array $expected_results = []): void {
+    $this->createTestProject();
+
+    // If we should simulate a multisite, ensure there is a sites.php in the
+    // test project.
+    // @see \Drupal\package_manager\Validator\MultisiteValidator::isMultisite()
+    if ($is_multisite) {
+      $project_root = $this->container->get('package_manager.path_locator')
+        ->getProjectRoot();
+      touch($project_root . '/sites/sites.php');
+    }
+    $this->assertResults($expected_results, PreCreateEvent::class);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
index c8926bd92842..abe8c33145ba 100644
--- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
+++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
@@ -72,13 +72,15 @@ public function register(ContainerBuilder $container) {
    */
   protected function createStage(): TestStage {
     return new TestStage(
+      $this->container->get('config.factory'),
       $this->container->get('package_manager.path_locator'),
       $this->container->get('package_manager.beginner'),
       $this->container->get('package_manager.stager'),
       $this->container->get('package_manager.committer'),
       $this->container->get('file_system'),
       $this->container->get('event_dispatcher'),
-      $this->container->get('tempstore.shared')
+      $this->container->get('tempstore.shared'),
+      $this->container->get('datetime.time')
     );
   }
 
@@ -290,16 +292,16 @@ class TestStage extends Stage {
   /**
    * {@inheritdoc}
    */
-  public static function getStagingRoot(): string {
+  public function getStagingRoot(): string {
     return static::$stagingRoot ?: parent::getStagingRoot();
   }
 
   /**
    * {@inheritdoc}
    */
-  protected function dispatch(StageEvent $event): void {
+  protected function dispatch(StageEvent $event, callable $on_error = NULL): void {
     try {
-      parent::dispatch($event);
+      parent::dispatch($event, $on_error);
     }
     catch (StageException $e) {
       // Attach the event object to the exception so that test code can verify
diff --git a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
index b352b58889a4..e154e95d975c 100644
--- a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\package_manager\Event\PostApplyEvent;
 use Drupal\package_manager\Event\PostCreateEvent;
 use Drupal\package_manager\Event\PostDestroyEvent;
@@ -46,6 +47,18 @@ protected function setUp(): void {
     $this->stage = $this->createStage();
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    // Since this test adds arbitrary event listeners that aren't services, we
+    // need to ensure they will persist even if the container is rebuilt when
+    // staged changes are applied.
+    $container->getDefinition('event_dispatcher')->addTag('persist');
+  }
+
   /**
    * {@inheritdoc}
    */
diff --git a/core/modules/package_manager/tests/src/Kernel/StageTest.php b/core/modules/package_manager/tests/src/Kernel/StageTest.php
index 0ed5f56049ab..bf68cc8f39bc 100644
--- a/core/modules/package_manager/tests/src/Kernel/StageTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/StageTest.php
@@ -2,20 +2,76 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\Component\Datetime\Time;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Extension\ModuleUninstallValidatorException;
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\StageException;
+
 /**
  * @coversDefaultClass \Drupal\package_manager\Stage
  *
+ * @covers \Drupal\package_manager\PackageManagerUninstallValidator
+ *
  * @group package_manager
  */
 class StageTest extends PackageManagerKernelTestBase {
 
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['system'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->installConfig('system');
+    $this->config('system.site')->set('uuid', $this->randomMachineName())->save();
+    // Ensure that the core update system thinks that System's post-update
+    // functions have run.
+    $this->registerPostUpdateFunctions();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('datetime.time')
+      ->setClass(TestTime::class);
+
+    // Since this test adds arbitrary event listeners that aren't services, we
+    // need to ensure they will persist even if the container is rebuilt when
+    // staged changes are applied.
+    $container->getDefinition('event_dispatcher')->addTag('persist');
+  }
+
   /**
    * @covers ::getStageDirectory
+   * @covers ::getStagingRoot
    */
   public function testGetStageDirectory(): void {
+    // Ensure that a site ID was generated in ::setUp().
+    $site_id = $this->config('system.site')->get('uuid');
+    $this->assertNotEmpty($site_id);
+
     $stage = $this->createStage();
     $id = $stage->create();
-    $this->assertStringEndsWith("/.package_manager/$id", $stage->getStageDirectory());
+    $this->assertStringEndsWith("/.package_manager$site_id/$id", $stage->getStageDirectory());
+    $stage->destroy();
+
+    $stage = $this->createStage();
+    $another_id = $stage->create();
+    // The new stage ID should be unique, but the parent directory should be
+    // unchanged.
+    $this->assertNotSame($id, $another_id);
+    $this->assertStringEndsWith("/.package_manager$site_id/$another_id", $stage->getStageDirectory());
   }
 
   /**
@@ -27,4 +83,150 @@ public function testUncreatedGetStageDirectory(): void {
     $this->createStage()->getStageDirectory();
   }
 
+  /**
+   * Data provider for ::testDestroyDuringApply().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerDestroyDuringApply(): array {
+    return [
+      'force destroy on pre-apply, fresh' => [
+        PreApplyEvent::class,
+        TRUE,
+        1,
+        TRUE,
+      ],
+      'destroy on pre-apply, fresh' => [
+        PreApplyEvent::class,
+        FALSE,
+        1,
+        TRUE,
+      ],
+      'force destroy on pre-apply, stale' => [
+        PreApplyEvent::class,
+        TRUE,
+        7200,
+        FALSE,
+      ],
+      'destroy on pre-apply, stale' => [
+        PreApplyEvent::class,
+        FALSE,
+        7200,
+        FALSE,
+      ],
+      'force destroy on post-apply, fresh' => [
+        PostApplyEvent::class,
+        TRUE,
+        1,
+        TRUE,
+      ],
+      'destroy on post-apply, fresh' => [
+        PostApplyEvent::class,
+        FALSE,
+        1,
+        TRUE,
+      ],
+      'force destroy on post-apply, stale' => [
+        PostApplyEvent::class,
+        TRUE,
+        7200,
+        FALSE,
+      ],
+      'destroy on post-apply, stale' => [
+        PostApplyEvent::class,
+        FALSE,
+        7200,
+        FALSE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests destroying a stage while applying it.
+   *
+   * @param string $event_class
+   *   The event class for which to attempt to destroy the stage.
+   * @param bool $force
+   *   Whether or not the stage should be force destroyed.
+   * @param int $time_offset
+   *   How many simulated seconds should have elapsed between the PreApplyEvent
+   *   being dispatched and the attempt to destroy the stage.
+   * @param bool $expect_exception
+   *   Whether or not destroying the stage will raise an exception.
+   *
+   * @dataProvider providerDestroyDuringApply
+   */
+  public function testDestroyDuringApply(string $event_class, bool $force, int $time_offset, bool $expect_exception): void {
+    $listener = function (StageEvent $event) use ($force, $time_offset): void {
+      // Simulate that a certain amount of time has passed since we started
+      // applying staged changes. After a point, it should be possible to
+      // destroy the stage even if it hasn't finished.
+      TestTime::$offset = $time_offset;
+
+      // No real-life event subscriber should try to destroy the stage while
+      // handling another event. The only reason we're doing it here is to
+      // simulate an attempt to destroy the stage while it's being applied, for
+      // testing purposes.
+      $event->getStage()->destroy($force);
+    };
+    $this->container->get('event_dispatcher')
+      ->addListener($event_class, $listener);
+
+    $stage = $this->createStage();
+    $stage->create();
+    if ($expect_exception) {
+      $this->expectException(StageException::class);
+      $this->expectExceptionMessage('Cannot destroy the staging area while it is being applied to the active directory.');
+    }
+    $stage->apply();
+  }
+
+  /**
+   * Test uninstalling any module while the staged changes are being applied.
+   */
+  public function testUninstallModuleDuringApply(): void {
+    $listener = function (PreApplyEvent $event): void {
+      $this->assertTrue($event->getStage()->isApplying());
+
+      // Trying to uninstall any module while the stage is being applied should
+      // result in a module uninstall validation error.
+      try {
+        $this->container->get('module_installer')
+          ->uninstall(['package_manager_bypass']);
+        $this->fail('Expected an exception to be thrown while uninstalling a module.');
+      }
+      catch (ModuleUninstallValidatorException $e) {
+        $this->assertStringContainsString('Modules cannot be uninstalled while Package Manager is applying staged changes to the active code base.', $e->getMessage());
+      }
+    };
+    $this->container->get('event_dispatcher')
+      ->addListener(PreApplyEvent::class, $listener);
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->apply();
+  }
+
+}
+
+/**
+ * A test-only implementation of the time service.
+ */
+class TestTime extends Time {
+
+  /**
+   * An offset to add to the request time.
+   *
+   * @var int
+   */
+  public static $offset = 0;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRequestTime() {
+    return parent::getRequestTime() + static::$offset;
+  }
+
 }
-- 
GitLab