diff --git a/core/modules/auto_updates/auto_updates.info.yml b/core/modules/auto_updates/auto_updates.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..0f3a5c27e24d7aa6395b0a900a32b4875088409f
--- /dev/null
+++ b/core/modules/auto_updates/auto_updates.info.yml
@@ -0,0 +1,7 @@
+name: 'Automatic Updates'
+type: module
+description: 'Experimental module to develop automatic updates. Currently the module provides checks for update readiness but does not yet provide update functionality.'
+core_version_requirement: ^9.2
+dependencies:
+  - drupal:package_manager
+  - drupal:update
diff --git a/core/modules/auto_updates/auto_updates.install b/core/modules/auto_updates/auto_updates.install
new file mode 100644
index 0000000000000000000000000000000000000000..1923c8c5297f1407493e9b5c74a6796585904594
--- /dev/null
+++ b/core/modules/auto_updates/auto_updates.install
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * @file
+ * Contains install and update functions for Automatic Updates.
+ */
+
+use Drupal\auto_updates\Validation\ReadinessRequirements;
+
+/**
+ * Implements hook_requirements().
+ */
+function auto_updates_requirements($phase) {
+  if ($phase === 'runtime') {
+    /** @var \Drupal\auto_updates\Validation\ReadinessRequirements $readiness_requirement */
+    $readiness_requirement = \Drupal::classResolver(ReadinessRequirements::class);
+    return $readiness_requirement->getRequirements();
+  }
+}
diff --git a/core/modules/auto_updates/auto_updates.module b/core/modules/auto_updates/auto_updates.module
new file mode 100644
index 0000000000000000000000000000000000000000..889160c787798a591ff1d5a6d44bf65cf8be745a
--- /dev/null
+++ b/core/modules/auto_updates/auto_updates.module
@@ -0,0 +1,184 @@
+<?php
+
+/**
+ * @file
+ * Contains hook implementations for Automatic Updates.
+ */
+
+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\update\ProjectSecurityData;
+
+/**
+ * Implements hook_page_top().
+ */
+function auto_updates_page_top() {
+  /** @var \Drupal\auto_updates\Validation\AdminReadinessMessages $readiness_messages */
+  $readiness_messages = \Drupal::classResolver(AdminReadinessMessages::class);
+  $readiness_messages->displayAdminPageMessages();
+
+  // @todo Rely on the route option after https://www.drupal.org/i/3236497 is
+  //   committed.
+  // @todo Remove 'system.batch_page.html' after
+  //   https://www.drupal.org/i/3238311 is committed.
+  $skip_routes = [
+    'system.batch_page.html',
+    'auto_updates.confirmation_page',
+    'auto_updates.report_update',
+    'auto_updates.module_update',
+  ];
+  $route_name = \Drupal::routeMatch()->getRouteName();
+  if (!in_array($route_name, $skip_routes, TRUE) && function_exists('update_page_top')) {
+    update_page_top();
+  }
+}
+
+/**
+ * Implements hook_module_implements_alter().
+ *
+ * @todo Remove after https://www.drupal.org/i/3236497 is committed.
+ */
+function auto_updates_module_implements_alter(&$implementations, $hook) {
+  if ($hook === 'page_top') {
+    // Remove hook_page_top() implementation from the Update module. This '
+    // implementation displays error messages about security releases. We call
+    // this implementation in our own auto_updates_page_top() except on our
+    // own routes to avoid stale messages about the security releases after an
+    // update.
+    unset($implementations['update']);
+  }
+}
+
+/**
+ * Implements hook_cron().
+ */
+function auto_updates_cron() {
+  /** @var \Drupal\auto_updates\CronUpdater $cron_updater */
+  $cron_updater = \Drupal::classResolver(CronUpdater::class);
+  $cron_updater->handleCron();
+
+  /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $checker_manager */
+  $checker_manager = \Drupal::service('auto_updates.readiness_validation_manager');
+  $last_results = $checker_manager->getResults();
+  $last_run_time = $checker_manager->getLastRunTime();
+  // Do not run readiness checks more than once an hour unless there are no
+  // results available.
+  if ($last_results === NULL || !$last_run_time || \Drupal::time()->getRequestTime() - $last_run_time > 3600) {
+    $checker_manager->run();
+  }
+
+}
+
+/**
+ * Implements hook_modules_installed().
+ */
+function auto_updates_modules_installed() {
+  // Run the readiness checkers if needed when any modules are installed in
+  // case they provide readiness checker services.
+  /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $checker_manager */
+  $checker_manager = \Drupal::service('auto_updates.readiness_validation_manager');
+  $checker_manager->runIfNoStoredResults();
+}
+
+/**
+ * Implements hook_modules_uninstalled().
+ */
+function auto_updates_modules_uninstalled() {
+  // Run the readiness checkers if needed when any modules are uninstalled in
+  // case they provided readiness checker services.
+  /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $checker_manager */
+  $checker_manager = \Drupal::service('auto_updates.readiness_validation_manager');
+  $checker_manager->runIfNoStoredResults();
+}
+
+/**
+ * Implements hook_form_FORM_ID_alter() for 'update_manager_update_form'.
+ */
+function auto_updates_form_update_manager_update_form_alter(&$form, FormStateInterface $form_state, $form_id) {
+  // Remove current message that core updates are not supported with a link to
+  // use this modules form.
+  if (isset($form['manual_updates']['#rows']['drupal']['data']['title'])) {
+    $current_route = \Drupal::routeMatch()->getRouteName();
+    if ($current_route === 'update.module_update') {
+      $redirect_route = 'auto_updates.module_update';
+    }
+    elseif ($current_route === 'update.report_update') {
+      $redirect_route = 'auto_updates.report_update';
+    }
+    if (!empty($redirect_route)) {
+      $core_updates_message = t(
+        '<h2>Core updates required</h2>Drupal core updates are supported by the enabled <a href="@url">Automatic Updates module</a>',
+        ['@url' => Url::fromRoute($redirect_route)->toString()]
+      );
+      $form['manual_updates']['#prefix'] = $core_updates_message;
+    }
+  }
+}
+
+/**
+ * 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']);
+  $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion();
+  $supported_until_version = $version->getMajorVersion() . '.'
+      . ((int) $version->getMinorVersion() + ProjectSecurityData::CORE_MINORS_WITH_SECURITY_COVERAGE)
+      . '.0';
+
+  $form['auto_updates_cron'] = [
+    '#type' => 'radios',
+    '#title' => t('Automatically update Drupal core'),
+    '#options' => [
+      CronUpdater::DISABLED => t('Disabled'),
+      CronUpdater::ALL => t('All supported updates'),
+      CronUpdater::SECURITY => t('Security updates only'),
+    ],
+    '#default_value' => \Drupal::config('auto_updates.settings')->get('cron'),
+    '#description' => t(
+      'If enabled, Drupal core will be automatically updated when an update is available. Automatic updates are only supported for @current_minor.x versions of Drupal core. Drupal @current_minor will receive security updates until @supported_until_version is released.',
+      [
+        '@current_minor' => $current_minor,
+        '@supported_until_version' => $supported_until_version,
+      ]
+    ),
+  ];
+  $form += [
+    '#submit' => ['::submitForm'],
+  ];
+  $form['#submit'][] = '_auto_updates_update_settings_form_submit';
+}
+
+/**
+ * Submit function for the 'update_settings' form.
+ */
+function _auto_updates_update_settings_form_submit(array &$form, FormStateInterface $form_state) {
+  \Drupal::configFactory()
+    ->getEditable('auto_updates.settings')
+    ->set('cron', $form_state->getValue('auto_updates_cron'))
+    ->save();
+}
+
+/**
+ * Implements hook_local_tasks_alter().
+ */
+function auto_updates_local_tasks_alter(array &$local_tasks) {
+  // The Update module's update form only allows updating modules and themes
+  // via archive files, which could produce unexpected results on a site using
+  // our Composer-based updater.
+  $new_routes = [
+    'update.report_update' => 'auto_updates.report_update',
+    'update.module_update' => 'auto_updates.module_update',
+    'update.theme_update' => 'auto_updates.theme_update',
+  ];
+  foreach ($new_routes as $local_task_id => $new_route) {
+    if (!empty($local_tasks[$local_task_id])) {
+      $local_tasks[$local_task_id]['route_name'] = $new_route;
+    }
+  }
+}
diff --git a/core/modules/auto_updates/auto_updates.routing.yml b/core/modules/auto_updates/auto_updates.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e3a5d1bb9d7c2d943b95106a8d530823f62f1662
--- /dev/null
+++ b/core/modules/auto_updates/auto_updates.routing.yml
@@ -0,0 +1,44 @@
+auto_updates.update_readiness:
+  path: '/admin/auto_updates/readiness'
+  defaults:
+    _controller: '\Drupal\auto_updates\Controller\ReadinessCheckerController::run'
+    _title: 'Update readiness checking'
+  requirements:
+    _permission: 'administer software updates'
+auto_updates.confirmation_page:
+  path: '/admin/automatic-update-ready/{stage_id}'
+  defaults:
+    _form: '\Drupal\auto_updates\Form\UpdateReady'
+    _title: 'Ready to update'
+  requirements:
+    _permission: 'administer software updates'
+    _access_update_manager: 'TRUE'
+# Links to our updater form appear in two 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:
+  path: '/admin/reports/updates/automatic-update'
+  defaults:
+    _form: '\Drupal\auto_updates\Form\UpdaterForm'
+    _title: 'Update'
+  requirements:
+    _permission: 'administer software updates'
+  options:
+    _admin_route: TRUE
+auto_updates.module_update:
+  path: '/admin/modules/automatic-update'
+  defaults:
+    _form: '\Drupal\auto_updates\Form\UpdaterForm'
+    _title: 'Update'
+  requirements:
+    _permission: 'administer software updates'
+  options:
+    _admin_route: TRUE
+auto_updates.theme_update:
+  path: '/admin/theme/automatic-update'
+  defaults:
+    _form: '\Drupal\auto_updates\Form\UpdaterForm'
+    _title: 'Update'
+  requirements:
+    _permission: 'administer software updates'
+  options:
+    _admin_route: TRUE
diff --git a/core/modules/auto_updates/auto_updates.services.yml b/core/modules/auto_updates/auto_updates.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..d2b80b6847de8efaf6d1003f62689300486728b7
--- /dev/null
+++ b/core/modules/auto_updates/auto_updates.services.yml
@@ -0,0 +1,76 @@
+services:
+  auto_updates.readiness_validation_manager:
+    class: Drupal\auto_updates\Validation\ReadinessValidationManager
+    arguments:
+      - '@keyvalue.expirable'
+      - '@datetime.time'
+      - '@package_manager.path_locator'
+      - '@event_dispatcher'
+      - '@auto_updates.updater'
+      - 24
+  auto_updates.updater:
+    class: Drupal\auto_updates\Updater
+    arguments:
+      - '@package_manager.path_locator'
+      - '@package_manager.beginner'
+      - '@package_manager.stager'
+      - '@package_manager.committer'
+      - '@file_system'
+      - '@event_dispatcher'
+      - '@tempstore.shared'
+  auto_updates.excluded_paths_subscriber:
+    class: Drupal\auto_updates\Event\ExcludedPathsSubscriber
+    arguments:
+      - '@extension.list.module'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.staged_projects_validator:
+    class: Drupal\auto_updates\Validator\StagedProjectsValidator
+    arguments:
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.update_version_validator:
+    class: Drupal\auto_updates\Validator\UpdateVersionValidator
+    arguments:
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.composer_executable_validator:
+    class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+    arguments:
+      - '@package_manager.validator.composer_executable'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.disk_space_validator:
+    class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+    arguments:
+      - '@package_manager.validator.disk_space'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.pending_updates_validator:
+    class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+    arguments:
+      - '@package_manager.validator.pending_updates'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.validator.file_system_permissions:
+    class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+    arguments:
+      - '@package_manager.validator.file_system'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.validator.core_composer:
+    class: Drupal\auto_updates\Validator\CoreComposerValidator
+    tags:
+      - { name: event_subscriber }
+  auto_updates.cron_frequency_validator:
+    class: Drupal\auto_updates\Validator\CronFrequencyValidator
+    arguments:
+      - '@config.factory'
+      - '@module_handler'
+      - '@state'
+      - '@datetime.time'
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/auto_updates/composer.json b/core/modules/auto_updates/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..1dfb4b9a7b6c60bd8aebb646a26db00b8175a6b5
--- /dev/null
+++ b/core/modules/auto_updates/composer.json
@@ -0,0 +1,30 @@
+{
+  "name": "drupal/auto_updates",
+  "type": "drupal-module",
+  "description": "Drupal Automatic Updates",
+  "keywords": ["Drupal"],
+  "license": "GPL-2.0-or-later",
+  "homepage": "https://www.drupal.org/project/auto_updates",
+  "minimum-stability": "dev",
+  "support": {
+    "issues": "https://www.drupal.org/project/issues/auto_updates",
+    "source": "http://cgit.drupalcode.org/auto_updates"
+  },
+  "require": {
+    "ext-json": "*",
+    "drupal/core": "^9.2",
+    "php-tuf/composer-stager": "0.2.3",
+    "composer/composer": "^2"
+  },
+  "config": {
+    "platform": {
+      "php": "7.3.0"
+    }
+  },
+  "repositories": [
+    {
+      "type": "vcs",
+      "url": "https://github.com/php-tuf/composer-stager"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/config/install/auto_updates.settings.yml b/core/modules/auto_updates/config/install/auto_updates.settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..207c746331023223ff9ccc8c31f663e512e3a2d0
--- /dev/null
+++ b/core/modules/auto_updates/config/install/auto_updates.settings.yml
@@ -0,0 +1 @@
+cron: security
diff --git a/core/modules/auto_updates/config/schema/auto_updates.schema.yml b/core/modules/auto_updates/config/schema/auto_updates.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..fe97866c9319b9b64df82735801eb46bfe49e6ab
--- /dev/null
+++ b/core/modules/auto_updates/config/schema/auto_updates.schema.yml
@@ -0,0 +1,7 @@
+auto_updates.settings:
+  type: config_object
+  label: 'Automatic Updates settings'
+  mapping:
+    cron:
+      type: string
+      label: 'Enable automatic updates during cron'
diff --git a/core/modules/auto_updates/package_manager/config/install/package_manager.settings.yml b/core/modules/auto_updates/package_manager/config/install/package_manager.settings.yml
new file mode 100644
index 0000000000000000000000000000000000000000..bbe571a9cbae09a0a16aae99c6342585cdb0d2d6
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/config/install/package_manager.settings.yml
@@ -0,0 +1,4 @@
+file_syncer: php
+executables:
+  composer: ~
+  rsync: ~
diff --git a/core/modules/auto_updates/package_manager/config/schema/package_manager.schema.yml b/core/modules/auto_updates/package_manager/config/schema/package_manager.schema.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1d029371777a7d3a762d2d17dda2a01a3ccf03f2
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/config/schema/package_manager.schema.yml
@@ -0,0 +1,13 @@
+package_manager.settings:
+  type: config_object
+  label: 'Package Manager settings'
+  mapping:
+    file_syncer:
+      type: string
+      label: 'Which file syncer to use, or NULL to auto-detect'
+    executables:
+      type: sequence
+      label: 'Absolute paths to required executables, or NULL to rely on PATH'
+      sequence:
+        type: string
+        label: 'Absolute path to executable, or NULL'
diff --git a/core/modules/auto_updates/package_manager/core_packages.json b/core/modules/auto_updates/package_manager/core_packages.json
new file mode 100644
index 0000000000000000000000000000000000000000..1804b96aef831caadf38f3b2d2887d51e233c88b
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/core_packages.json
@@ -0,0 +1,9 @@
+[
+  "drupal/core",
+  "drupal/core-composer-scaffold",
+  "drupal/core-dev",
+  "drupal/core-dev-pinned",
+  "drupal/core-project-message",
+  "drupal/core-recommended",
+  "drupal/core-vendor-hardening"
+]
diff --git a/core/modules/auto_updates/package_manager/package_manager.info.yml b/core/modules/auto_updates/package_manager/package_manager.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..dfb5b5ffe2df25be41737a8ce1c2bcbc7d154316
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/package_manager.info.yml
@@ -0,0 +1,4 @@
+name: 'Package Manager'
+type: module
+description: 'API module providing functionality for staging package updates.'
+core_version_requirement: ^9
diff --git a/core/modules/auto_updates/package_manager/package_manager.install b/core/modules/auto_updates/package_manager/package_manager.install
new file mode 100644
index 0000000000000000000000000000000000000000..0793ddea7a27e2cef93be3aae26f01de6d642bec
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/package_manager.install
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * @file
+ * Contains install and update functions for Package Manager.
+ */
+
+/**
+ * Implements hook_requirements().
+ */
+function package_manager_requirements() {
+  if (!class_exists('PhpTuf\ComposerStager\Domain\Beginner')) {
+    return [
+      'package_manager' => [
+        'description' => t('External dependencies for Package Manager are not available. Composer must be used to download the module with dependencies.'),
+        'severity' => REQUIREMENT_ERROR,
+      ],
+    ];
+  }
+}
diff --git a/core/modules/auto_updates/package_manager/package_manager.services.yml b/core/modules/auto_updates/package_manager/package_manager.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..803c02b7c61ffdd2c92efc86d5bdec5fd83e9f66
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/package_manager.services.yml
@@ -0,0 +1,130 @@
+services:
+  # Underlying Symfony utilities.
+  package_manager.symfony_file_system:
+    class: Symfony\Component\Filesystem\Filesystem
+  package_manager.symfony_executable_finder:
+    class: Symfony\Component\Process\ExecutableFinder
+  package_manager.symfony_finder:
+    class: Symfony\Component\Finder\Finder
+
+  # Basic infrastructure services.
+  package_manager.process_factory:
+    class: Drupal\package_manager\ProcessFactory
+    arguments:
+      - '@file_system'
+      - '@config.factory'
+  package_manager.file_system:
+    class: PhpTuf\ComposerStager\Infrastructure\Filesystem\Filesystem
+    arguments:
+      - '@package_manager.symfony_file_system'
+  package_manager.executable_finder:
+    class: Drupal\package_manager\ExecutableFinder
+    arguments:
+      - '@package_manager.symfony_executable_finder'
+      - '@config.factory'
+
+  # Executable runners.
+  package_manager.rsync_runner:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\Runner\RsyncRunner
+    arguments:
+      - '@package_manager.executable_finder'
+      - '@package_manager.process_factory'
+  package_manager.composer_runner:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunner
+    arguments:
+      - '@package_manager.executable_finder'
+      - '@package_manager.process_factory'
+
+  # File syncers.
+  package_manager.file_syncer.rsync:
+    class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\RsyncFileSyncer
+    arguments:
+      - '@package_manager.file_system'
+      - '@package_manager.rsync_runner'
+  package_manager.file_syncer.php:
+    class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\PhpFileSyncer
+    arguments:
+      - '@package_manager.file_system'
+      - '@package_manager.symfony_finder'
+      - '@package_manager.symfony_finder'
+  package_manager.file_syncer.factory:
+    class: Drupal\package_manager\FileSyncerFactory
+    arguments:
+      - '@package_manager.symfony_executable_finder'
+      - '@package_manager.file_syncer.php'
+      - '@package_manager.file_syncer.rsync'
+      - '@config.factory'
+  package_manager.file_syncer:
+    class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface
+    factory: ['@package_manager.file_syncer.factory', 'create']
+
+  # Domain services.
+  package_manager.beginner:
+    class: PhpTuf\ComposerStager\Domain\Beginner
+    arguments:
+      - '@package_manager.file_syncer'
+      - '@package_manager.file_system'
+  package_manager.stager:
+    class: PhpTuf\ComposerStager\Domain\Stager
+    arguments:
+      - '@package_manager.composer_runner'
+      - '@package_manager.file_system'
+  package_manager.committer:
+    class: PhpTuf\ComposerStager\Domain\Committer
+    arguments:
+      - '@package_manager.file_syncer'
+      - '@package_manager.file_system'
+  package_manager.path_locator:
+    class: Drupal\package_manager\PathLocator
+    arguments:
+      - '%app.root%'
+
+  # Validators.
+  package_manager.validator.composer_executable:
+    class: Drupal\package_manager\EventSubscriber\ComposerExecutableValidator
+    arguments:
+      - '@package_manager.composer_runner'
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.disk_space:
+    class: Drupal\package_manager\EventSubscriber\DiskSpaceValidator
+    arguments:
+      - '@package_manager.path_locator'
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.pending_updates:
+    class: Drupal\package_manager\EventSubscriber\PendingUpdatesValidator
+    arguments:
+      - '%app.root%'
+      - '@update.post_update_registry'
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.lock_file:
+    class: Drupal\package_manager\EventSubscriber\LockFileValidator
+    arguments:
+      - '@state'
+      - '@package_manager.path_locator'
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.file_system:
+    class: Drupal\package_manager\EventSubscriber\WritableFileSystemValidator
+    arguments:
+      - '@package_manager.path_locator'
+      - '%app.root%'
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
+  package_manager.excluded_paths_subscriber:
+    class: Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
+    arguments:
+      - '%app.root%'
+      - '%site.path%'
+      - '@file_system'
+      - '@stream_wrapper_manager'
+      - '@database'
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/auto_updates/package_manager/src/ComposerUtility.php b/core/modules/auto_updates/package_manager/src/ComposerUtility.php
new file mode 100644
index 0000000000000000000000000000000000000000..6460b9e13c2aea78085a0468ae5220deb0333b4a
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/ComposerUtility.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Composer\Composer;
+use Composer\Factory;
+use Composer\IO\NullIO;
+use Composer\Package\PackageInterface;
+use Drupal\Component\Serialization\Json;
+
+/**
+ * Defines a utility object to get information from Composer's API.
+ */
+class ComposerUtility {
+
+  /**
+   * The Composer instance.
+   *
+   * @var \Composer\Composer
+   */
+  protected $composer;
+
+  /**
+   * The statically cached names of the Drupal core packages.
+   *
+   * @var string[]
+   */
+  private static $corePackages;
+
+  /**
+   * Constructs a new ComposerUtility object.
+   *
+   * @param \Composer\Composer $composer
+   *   The Composer instance.
+   */
+  public function __construct(Composer $composer) {
+    $this->composer = $composer;
+  }
+
+  /**
+   * Creates a utility object using the files in a given directory.
+   *
+   * @param string $dir
+   *   The directory that contains composer.json and composer.lock.
+   *
+   * @return \Drupal\package_manager\ComposerUtility
+   *   The utility object.
+   */
+  public static function createForDirectory(string $dir): self {
+    $io = new NullIO();
+    $configuration = $dir . DIRECTORY_SEPARATOR . 'composer.json';
+
+    // The Composer factory requires that either the HOME or COMPOSER_HOME
+    // environment variables be set, so momentarily set the COMPOSER_HOME
+    // variable to the directory we're trying to create a Composer instance for.
+    // We have to do this because the Composer factory doesn't give us a way to
+    // pass the home directory in.
+    // @see \Composer\Factory::getHomeDir()
+    $home = getenv('COMPOSER_HOME');
+    // Disable the automatic generation of .htaccess files in the Composer home
+    // directory, since we are temporarily overriding that directory.
+    // @see \Composer\Factory::createConfig()
+    // @see https://getcomposer.org/doc/06-config.md#htaccess-protect
+    $htaccess = getenv('COMPOSER_HTACCESS_PROTECT');
+
+    putenv("COMPOSER_HOME=$dir");
+    putenv("COMPOSER_HTACCESS_PROTECT=false");
+    $composer = Factory::create($io, $configuration);
+    putenv("COMPOSER_HOME=$home");
+    putenv("COMPOSER_HTACCESS_PROTECT=$htaccess");
+
+    return new static($composer);
+  }
+
+  /**
+   * Returns the canonical names of the supported core packages.
+   *
+   * @return string[]
+   *   The canonical list of supported core package names, as listed in
+   *   ../core_packages.json.
+   */
+  protected static function getCorePackageList(): array {
+    if (self::$corePackages === NULL) {
+      $file = __DIR__ . '/../core_packages.json';
+      assert(file_exists($file), "$file does not exist.");
+
+      $core_packages = file_get_contents($file);
+      $core_packages = Json::decode($core_packages);
+
+      assert(is_array($core_packages), "$file did not contain a list of core packages.");
+      self::$corePackages = $core_packages;
+    }
+    return self::$corePackages;
+  }
+
+  /**
+   * Returns the names of the core packages in the lock file.
+   *
+   * All packages listed in ../core_packages.json are considered core packages.
+   *
+   * @return string[]
+   *   The names of the required core packages.
+   *
+   * @todo Make this return a keyed array of packages, not just names.
+   */
+  public function getCorePackageNames(): array {
+    $core_packages = array_intersect(
+      array_keys($this->getLockedPackages()),
+      static::getCorePackageList()
+    );
+
+    // If drupal/core-recommended is present, it supersedes drupal/core, since
+    // drupal/core will always be one of its direct dependencies.
+    if (in_array('drupal/core-recommended', $core_packages, TRUE)) {
+      $core_packages = array_diff($core_packages, ['drupal/core']);
+    }
+    return array_values($core_packages);
+  }
+
+  /**
+   * Returns the names of the core packages in the dev dependencies.
+   *
+   * All packages listed in ../core_packages.json are considered core packages.
+   *
+   * @return string[]
+   *   The names of the core packages in the dev requirements.
+   *
+   * @todo Make this return a keyed array of packages, not just names.
+   */
+  public function getCoreDevPackageNames(): array {
+    $dev_packages = $this->composer->getPackage()->getDevRequires();
+    $dev_packages = array_keys($dev_packages);
+    return array_intersect(static::getCorePackageList(), $dev_packages);
+  }
+
+  /**
+   * Returns all Drupal extension packages in the lock file.
+   *
+   * The following package types are considered Drupal extension packages:
+   * drupal-module, drupal-theme, drupal-custom-module, and drupal-custom-theme.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   All Drupal extension packages in the lock file, keyed by name.
+   */
+  public function getDrupalExtensionPackages(): array {
+    $filter = function (PackageInterface $package): bool {
+      $drupal_package_types = [
+        'drupal-module',
+        'drupal-theme',
+        'drupal-custom-module',
+        'drupal-custom-theme',
+      ];
+      return in_array($package->getType(), $drupal_package_types, TRUE);
+    };
+    return array_filter($this->getLockedPackages(), $filter);
+  }
+
+  /**
+   * Returns all packages in the lock file.
+   *
+   * @return \Composer\Package\PackageInterface[]
+   *   All packages in the lock file, keyed by name.
+   */
+  protected function getLockedPackages(): array {
+    $locked_packages = $this->composer->getLocker()
+      ->getLockedRepository(TRUE)
+      ->getPackages();
+
+    $packages = [];
+    foreach ($locked_packages as $package) {
+      $key = $package->getName();
+      $packages[$key] = $package;
+    }
+    return $packages;
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/ExcludedPathsTrait.php b/core/modules/auto_updates/package_manager/src/Event/ExcludedPathsTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..3648446b80f2049d5f9dec6abcb77048a82d41fe
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/ExcludedPathsTrait.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Common functionality for events which can collect excluded paths.
+ */
+trait ExcludedPathsTrait {
+
+  /**
+   * Paths to exclude from the update.
+   *
+   * @var string[]
+   */
+  protected $excludedPaths = [];
+
+  /**
+   * Adds an absolute path to exclude from the current operation.
+   *
+   * @todo This should only accept paths relative to the active directory.
+   *
+   * @param string $path
+   *   The path to exclude.
+   */
+  public function excludePath(string $path): void {
+    $this->excludedPaths[] = $path;
+  }
+
+  /**
+   * Returns the paths to exclude from the current operation.
+   *
+   * @return string[]
+   *   The paths to exclude.
+   */
+  public function getExcludedPaths(): array {
+    return array_unique($this->excludedPaths);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PostApplyEvent.php b/core/modules/auto_updates/package_manager/src/Event/PostApplyEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..d2a9693cf9c3ee99970538fdbf404564c80309ac
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PostApplyEvent.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Event fired after staged changes are synced to the active directory.
+ */
+class PostApplyEvent extends StageEvent {
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PostCreateEvent.php b/core/modules/auto_updates/package_manager/src/Event/PostCreateEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..104bc78ad6ff0681c83ad3f494ee8e309c87f7e9
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PostCreateEvent.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Event fired after a staging area has been created.
+ */
+class PostCreateEvent extends StageEvent {
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PostDestroyEvent.php b/core/modules/auto_updates/package_manager/src/Event/PostDestroyEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..3388a988e126da919dfbc598946910f38b86cbbe
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PostDestroyEvent.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Event fired after the staging area is destroyed.
+ */
+class PostDestroyEvent extends StageEvent {
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PostRequireEvent.php b/core/modules/auto_updates/package_manager/src/Event/PostRequireEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..55b4184dd3d96daaeeba55317c227fd0bbfb089c
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PostRequireEvent.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Event fired after packages are added to the staging area.
+ */
+class PostRequireEvent extends StageEvent {
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PreApplyEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreApplyEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..d92a116b4a0fc50e279f53ae80c5dd56863484b5
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PreApplyEvent.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Event fired before staged changes are synced to the active directory.
+ */
+class PreApplyEvent extends PreOperationStageEvent {
+
+  use ExcludedPathsTrait;
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PreCreateEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreCreateEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..3997a80fc3b5f611ce73b929fe815f88efd8c5d1
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PreCreateEvent.php
@@ -0,0 +1,12 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Event fired before a staging area is created.
+ */
+class PreCreateEvent extends PreOperationStageEvent {
+
+  use ExcludedPathsTrait;
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PreDestroyEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreDestroyEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..d4918f0b8b75c96a1b62a141b029c086996a3f54
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PreDestroyEvent.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Event fired before the staging area is destroyed.
+ */
+class PreDestroyEvent extends PreOperationStageEvent {
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PreOperationStageEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreOperationStageEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..ee826f3ac35cdfe6f3a987112786d16a666b7ff9
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PreOperationStageEvent.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * Base class for events dispatched before a stage life cycle operation.
+ */
+abstract class PreOperationStageEvent extends StageEvent {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addError(array $messages, ?TranslatableMarkup $summary = NULL) {
+    $this->results[] = ValidationResult::createError($messages, $summary);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/PreRequireEvent.php b/core/modules/auto_updates/package_manager/src/Event/PreRequireEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..355bb5047fb89719c0b0711db51d675be0368387
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/PreRequireEvent.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+/**
+ * Event fired before packages are added to the staging area.
+ */
+class PreRequireEvent extends PreOperationStageEvent {
+}
diff --git a/core/modules/auto_updates/package_manager/src/Event/StageEvent.php b/core/modules/auto_updates/package_manager/src/Event/StageEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..79a26a5ac3c2c430ebcce2d4f5f5262fcf6ce60e
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Event/StageEvent.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+use Drupal\package_manager\Stage;
+use Symfony\Contracts\EventDispatcher\Event;
+
+/**
+ * Base class for all events related to the life cycle of the staging area.
+ */
+abstract class StageEvent extends Event {
+
+  /**
+   * The validation results.
+   *
+   * @var \Drupal\package_manager\ValidationResult[]
+   */
+  protected $results = [];
+
+  /**
+   * The stage which fired this event.
+   *
+   * @var \Drupal\package_manager\Stage
+   */
+  protected $stage;
+
+  /**
+   * Constructs a StageEvent object.
+   *
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage which fired this event.
+   */
+  public function __construct(Stage $stage) {
+    $this->stage = $stage;
+  }
+
+  /**
+   * Returns the stage which fired this event.
+   *
+   * @return \Drupal\package_manager\Stage
+   *   The stage which fired this event.
+   */
+  public function getStage(): Stage {
+    return $this->stage;
+  }
+
+  /**
+   * Gets the validation results.
+   *
+   * @param int|null $severity
+   *   (optional) The severity for the results to return. Should be one of the
+   *   SystemManager::REQUIREMENT_* constants.
+   *
+   * @return \Drupal\package_manager\ValidationResult[]
+   *   The validation results.
+   */
+  public function getResults(?int $severity = NULL): array {
+    if ($severity !== NULL) {
+      return array_filter($this->results, function ($result) use ($severity) {
+        return $result->getSeverity() === $severity;
+      });
+    }
+    return $this->results;
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/ComposerExecutableValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/ComposerExecutableValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..a4df9a35915540e9fbf3e644bd5c58f24adaaed4
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/ComposerExecutableValidator.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Drupal\package_manager\EventSubscriber;
+
+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 PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Exception\ExceptionInterface;
+use PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface;
+
+/**
+ * Validates that the Composer executable can be found in the correct version.
+ */
+class ComposerExecutableValidator implements PreOperationStageValidatorInterface, ProcessOutputCallbackInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The Composer runner.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface
+   */
+  protected $composer;
+
+  /**
+   * The detected version of Composer.
+   *
+   * @var string
+   */
+  protected $version;
+
+  /**
+   * Constructs a ComposerExecutableValidator object.
+   *
+   * @param \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface $composer
+   *   The Composer runner.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   */
+  public function __construct(ComposerRunnerInterface $composer, TranslationInterface $translation) {
+    $this->composer = $composer;
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    try {
+      $this->composer->run(['--version'], $this);
+    }
+    catch (ExceptionInterface $e) {
+      $event->addError([
+        $e->getMessage(),
+      ]);
+      return;
+    }
+
+    if ($this->version) {
+      $major_version = ExtensionVersion::createFromVersionString($this->version)
+        ->getMajorVersion();
+
+      if ($major_version < 2) {
+        $event->addError([
+          $this->t('Composer 2 or later is required, but version @version was detected.', [
+            '@version' => $this->version,
+          ]),
+        ]);
+      }
+    }
+    else {
+      $event->addError([
+        $this->t('The Composer version could not be detected.'),
+      ]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function __invoke(string $type, string $buffer): void {
+    $matched = [];
+    // Search for a semantic version number and optional stability flag.
+    if (preg_match('/([0-9]+\.?){3}-?((alpha|beta|rc)[0-9]*)?/i', $buffer, $matched)) {
+      $this->version = $matched[0];
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/DiskSpaceValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/DiskSpaceValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..c54a653052092cb6f5f6de96292ac8178fea9a0b
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/DiskSpaceValidator.php
@@ -0,0 +1,165 @@
+<?php
+
+namespace Drupal\package_manager\EventSubscriber;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\Component\FileSystem\FileSystem;
+use Drupal\Component\Utility\Bytes;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\PathLocator;
+
+/**
+ * Validates that there is enough free disk space to do automatic updates.
+ */
+class DiskSpaceValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a DiskSpaceValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   */
+  public function __construct(PathLocator $path_locator, TranslationInterface $translation) {
+    $this->pathLocator = $path_locator;
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * Wrapper around the disk_free_space() function.
+   *
+   * @param string $path
+   *   The path for which to retrieve the amount of free disk space.
+   *
+   * @return float
+   *   The number of bytes of free space on the disk.
+   *
+   * @throws \RuntimeException
+   *   If the amount of free space could not be determined.
+   */
+  protected function freeSpace(string $path): float {
+    $free_space = disk_free_space($path);
+    if ($free_space === FALSE) {
+      throw new \RuntimeException("Cannot get disk information for $path.");
+    }
+    return $free_space;
+  }
+
+  /**
+   * Wrapper around the stat() function.
+   *
+   * @param string $path
+   *   The path to check.
+   *
+   * @return array
+   *   The statistics for the path.
+   *
+   * @throws \RuntimeException
+   *   If the statistics could not be determined.
+   */
+  protected function stat(string $path): array {
+    $stat = stat($path);
+    if ($stat === FALSE) {
+      throw new \RuntimeException("Cannot get information for $path.");
+    }
+    return $stat;
+  }
+
+  /**
+   * Checks if two paths are located on the same logical disk.
+   *
+   * @param string $root
+   *   The path of the project root.
+   * @param string $vendor
+   *   The path of the vendor directory.
+   *
+   * @return bool
+   *   TRUE if the project root and vendor directory are on the same logical
+   *   disk, FALSE otherwise.
+   */
+  protected function areSameLogicalDisk(string $root, string $vendor): bool {
+    $root_statistics = $this->stat($root);
+    $vendor_statistics = $this->stat($vendor);
+    return $root_statistics['dev'] === $vendor_statistics['dev'];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    $root_path = $this->pathLocator->getProjectRoot();
+    $vendor_path = $this->pathLocator->getVendorDirectory();
+    $messages = [];
+
+    // @todo Make this configurable.
+    $minimum_mb = 1024;
+    $minimum_bytes = Bytes::toNumber($minimum_mb . 'M');
+
+    if (!$this->areSameLogicalDisk($root_path, $vendor_path)) {
+      if ($this->freeSpace($root_path) < $minimum_bytes) {
+        $messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [
+          '@root' => $root_path,
+          '@space' => $minimum_mb,
+        ]);
+      }
+      if (is_dir($vendor_path) && $this->freeSpace($vendor_path) < $minimum_bytes) {
+        $messages[] = $this->t('Vendor filesystem "@vendor" has insufficient space. There must be at least @space megabytes free.', [
+          '@vendor' => $vendor_path,
+          '@space' => $minimum_mb,
+        ]);
+      }
+    }
+    elseif ($this->freeSpace($root_path) < $minimum_bytes) {
+      $messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [
+        '@root' => $root_path,
+        '@space' => $minimum_mb,
+      ]);
+    }
+    $temp = $this->temporaryDirectory();
+    if ($this->freeSpace($temp) < $minimum_bytes) {
+      $messages[] = $this->t('Directory "@temp" has insufficient space. There must be at least @space megabytes free.', [
+        '@temp' => $temp,
+        '@space' => $minimum_mb,
+      ]);
+    }
+
+    if ($messages) {
+      $summary = count($messages) > 1
+        ? $this->t("There is not enough disk space to create a staging area.")
+        : NULL;
+      $event->addError($messages, $summary);
+    }
+  }
+
+  /**
+   * Returns the path of the system temporary directory.
+   *
+   * @return string
+   *   The absolute path of the system temporary directory.
+   */
+  protected function temporaryDirectory(): string {
+    return FileSystem::getOsTemporaryDirectory();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..4663284c7963ea7122f06401de72ff76723fa7e0
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace Drupal\package_manager\EventSubscriber;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\StreamWrapper\LocalStream;
+use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Defines an event subscriber to exclude certain paths from staging areas.
+ */
+class ExcludedPathsSubscriber implements EventSubscriberInterface {
+
+  /**
+   * The Drupal root.
+   *
+   * @var string
+   */
+  protected $appRoot;
+
+  /**
+   * The current site path, relative to the Drupal root.
+   *
+   * @var string
+   */
+  protected $sitePath;
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The stream wrapper manager service.
+   *
+   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
+   */
+  protected $streamWrapperManager;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * Constructs an ExcludedPathsSubscriber.
+   *
+   * @param string $app_root
+   *   The Drupal root.
+   * @param string $site_path
+   *   The current site path, relative to the Drupal root.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
+   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
+   *   The stream wrapper manager service.
+   * @param \Drupal\Core\Database\Connection $database
+   *   The database connection.
+   */
+  public function __construct(string $app_root, string $site_path, FileSystemInterface $file_system, StreamWrapperManagerInterface $stream_wrapper_manager, Connection $database) {
+    $this->appRoot = $app_root;
+    $this->sitePath = $site_path;
+    $this->fileSystem = $file_system;
+    $this->streamWrapperManager = $stream_wrapper_manager;
+    $this->database = $database;
+  }
+
+  /**
+   * Reacts before staged changes are committed the active directory.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   */
+  public function preApply(PreApplyEvent $event): void {
+    // Don't copy anything from the staging area's sites/default.
+    // @todo Make this a lot smarter in https://www.drupal.org/i/3228955.
+    $event->excludePath('sites/default');
+
+    // If the core-vendor-hardening plugin (used in the legacy-project template)
+    // is present, it may have written a web.config file into the vendor
+    // directory. We don't want to copy that.
+    $event->excludePath('web.config');
+  }
+
+  /**
+   * Excludes paths from a staging area before it is created.
+   *
+   * @param \Drupal\package_manager\Event\PreCreateEvent $event
+   *   The event object.
+   */
+  public function preCreate(PreCreateEvent $event): void {
+    // Automated test site directories should never be staged.
+    $event->excludePath('sites/simpletest');
+
+    // Windows server configuration files, like web.config, should never be
+    // staged either. (These can be written in the vendor directory by the
+    // core-vendor-hardening plugin, which is used in the drupal/legacy-project
+    // template.)
+    $event->excludePath('web.config');
+
+    if ($public = $this->getFilesPath('public')) {
+      $event->excludePath($public);
+    }
+    if ($private = $this->getFilesPath('private')) {
+      $event->excludePath($private);
+    }
+
+    // Exclude site-specific settings files.
+    $settings_files = [
+      'settings.php',
+      'settings.local.php',
+      'services.yml',
+    ];
+    $default_site = 'sites' . DIRECTORY_SEPARATOR . 'default';
+
+    foreach ($settings_files as $settings_file) {
+      $event->excludePath($this->sitePath . DIRECTORY_SEPARATOR . $settings_file);
+      $event->excludePath($default_site . DIRECTORY_SEPARATOR . $settings_file);
+    }
+
+    // If the database is SQLite, it might be located in the active directory
+    // and we should not stage it.
+    if ($this->database->driver() === 'sqlite') {
+      $options = $this->database->getConnectionOptions();
+      $database = str_replace($this->appRoot, NULL, $options['database']);
+      $database = ltrim($database, '/');
+      $event->excludePath($database);
+      $event->excludePath("$database-shm");
+      $event->excludePath("$database-wal");
+    }
+  }
+
+  /**
+   * Returns the storage path for a stream wrapper.
+   *
+   * This will only work for stream wrappers that extend
+   * \Drupal\Core\StreamWrapper\LocalStream, which includes the stream wrappers
+   * for public and private files.
+   *
+   * @param string $scheme
+   *   The stream wrapper scheme.
+   *
+   * @return string|null
+   *   The storage path for files using the given scheme, relative to the Drupal
+   *   root, or NULL if the stream wrapper does not extend
+   *   \Drupal\Core\StreamWrapper\LocalStream.
+   */
+  private function getFilesPath(string $scheme): ?string {
+    $wrapper = $this->streamWrapperManager->getViaScheme($scheme);
+    if ($wrapper instanceof LocalStream) {
+      return $wrapper->getDirectoryPath();
+    }
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'preCreate',
+      PreApplyEvent::class => 'preApply',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/LockFileValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/LockFileValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..dab3d8d298bbff2e71830ca55218b8cff4bfad8a
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/LockFileValidator.php
@@ -0,0 +1,142 @@
+<?php
+
+namespace Drupal\package_manager\EventSubscriber;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\Event\PostDestroyEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Event\PreRequireEvent;
+use Drupal\package_manager\PathLocator;
+
+/**
+ * Checks that the active lock file is unchanged during stage operations.
+ */
+class LockFileValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The state key under which to store the hash of the active lock file.
+   *
+   * @var string
+   */
+  protected const STATE_KEY = 'package_manager.lock_hash';
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a LockFileValidator object.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The string translation service.
+   */
+  public function __construct(StateInterface $state, PathLocator $path_locator, TranslationInterface $translation) {
+    $this->state = $state;
+    $this->pathLocator = $path_locator;
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * Returns the current hash of the active directory's lock file.
+   *
+   * @return string|false
+   *   The hash of the active directory's lock file, or FALSE if the lock file
+   *   does not exist.
+   */
+  protected function getHash() {
+    $file = $this->pathLocator->getActiveDirectory() . 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
+    // have not changed; we don't care about the constraints in composer.json.
+    try {
+      return hash_file('sha256', $file);
+    }
+    catch (\Throwable $exception) {
+      return FALSE;
+    }
+  }
+
+  /**
+   * Stores the current lock file hash.
+   */
+  public function storeHash(PreCreateEvent $event): void {
+    $hash = $this->getHash();
+    if ($hash) {
+      $this->state->set(static::STATE_KEY, $hash);
+    }
+    else {
+      $event->addError([
+        $this->t('Could not hash the active lock file.'),
+      ]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    // Ensure we can get a current hash of the lock file.
+    $hash = $this->getHash();
+    if (empty($hash)) {
+      $error = $this->t('Could not hash the active lock file.');
+    }
+
+    // Ensure we also have a stored hash of the lock file.
+    $stored_hash = $this->state->get(static::STATE_KEY);
+    if (empty($stored_hash)) {
+      $error = $this->t('Could not retrieve stored hash of the active lock file.');
+    }
+
+    // If we have both hashes, ensure they match.
+    if ($hash && $stored_hash && hash_equals($stored_hash, $hash) == FALSE) {
+      $error = $this->t('Stored lock file hash does not match the active lock file.');
+    }
+
+    // @todo Let the validation result carry all the relevant messages in
+    //   https://www.drupal.org/i/3247479.
+    if (isset($error)) {
+      $event->addError([$error]);
+    }
+  }
+
+  /**
+   * Deletes the stored lock file hash.
+   */
+  public function deleteHash(): void {
+    $this->state->delete(static::STATE_KEY);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'storeHash',
+      PreRequireEvent::class => 'validateStagePreOperation',
+      PreApplyEvent::class => 'validateStagePreOperation',
+      PostDestroyEvent::class => 'deleteHash',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/PendingUpdatesValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/PendingUpdatesValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..53cbc7420633dc26bc0485072ae11b0ede02ce77
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/PendingUpdatesValidator.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Drupal\package_manager\EventSubscriber;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Core\Update\UpdateRegistry;
+use Drupal\Core\Url;
+
+/**
+ * Validates that there are no pending database updates.
+ */
+class PendingUpdatesValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The Drupal root.
+   *
+   * @var string
+   */
+  protected $appRoot;
+
+  /**
+   * The update registry service.
+   *
+   * @var \Drupal\Core\Update\UpdateRegistry
+   */
+  protected $updateRegistry;
+
+  /**
+   * Constructs an PendingUpdatesValidator object.
+   *
+   * @param string $app_root
+   *   The Drupal root.
+   * @param \Drupal\Core\Update\UpdateRegistry $update_registry
+   *   The update registry service.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   */
+  public function __construct(string $app_root, UpdateRegistry $update_registry, TranslationInterface $translation) {
+    $this->appRoot = $app_root;
+    $this->updateRegistry = $update_registry;
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    require_once $this->appRoot . '/core/includes/install.inc';
+    require_once $this->appRoot . '/core/includes/update.inc';
+
+    drupal_load_updates();
+    $hook_updates = update_get_update_list();
+    $post_updates = $this->updateRegistry->getPendingUpdateFunctions();
+
+    if ($hook_updates || $post_updates) {
+      $message = $this->t('Some modules have database schema updates to install. You should run the <a href=":update">database update script</a> immediately.', [
+        ':update' => Url::fromRoute('system.db_update')->toString(),
+      ]);
+      $event->addError([$message]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..a48ff5127f42a5e5ce0c5af75b4c4e4319479013
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/PreOperationStageValidatorInterface.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\package_manager\EventSubscriber;
+
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Defines an interface for classes that validate a stage before an operation.
+ */
+interface PreOperationStageValidatorInterface extends EventSubscriberInterface {
+
+  /**
+   * Validates a stage before an operation.
+   *
+   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
+   *   The stage event.
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void;
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/UpdateDataSubscriber.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/UpdateDataSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..5b422b4be7621b56306dfc1d7678cf7aa3633b26
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/UpdateDataSubscriber.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\package_manager\EventSubscriber;
+
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\update\UpdateManagerInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Clears stale update data once staged changes have been applied.
+ */
+class UpdateDataSubscriber implements EventSubscriberInterface {
+
+  /**
+   * The update manager service.
+   *
+   * @var \Drupal\update\UpdateManagerInterface
+   */
+  protected $updateManager;
+
+  /**
+   * Constructs an UpdateRefreshSubscriber object.
+   *
+   * @param \Drupal\update\UpdateManagerInterface $update_manager
+   *   The update manager service.
+   */
+  public function __construct(UpdateManagerInterface $update_manager) {
+    $this->updateManager = $update_manager;
+  }
+
+  /**
+   * Clears stale update data.
+   *
+   * This will always run after any staging area is applied to the active
+   * directory, since it's likely that core and/or multiple extensions have been
+   * added, removed, or updated.
+   */
+  public function clearData(): void {
+    $this->updateManager->refreshUpdateData();
+    update_storage_clear();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PostApplyEvent::class => ['clearData', 1000],
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/EventSubscriber/WritableFileSystemValidator.php b/core/modules/auto_updates/package_manager/src/EventSubscriber/WritableFileSystemValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..95bc5d1104deac380383c2c28d996d6f928c1320
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/EventSubscriber/WritableFileSystemValidator.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\package_manager\EventSubscriber;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\PathLocator;
+
+/**
+ * Checks that the file system is writable.
+ */
+class WritableFileSystemValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * The Drupal root.
+   *
+   * @var string
+   */
+  protected $appRoot;
+
+  /**
+   * Constructs a WritableFileSystemValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param string $app_root
+   *   The Drupal root.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   */
+  public function __construct(PathLocator $path_locator, string $app_root, TranslationInterface $translation) {
+    $this->pathLocator = $path_locator;
+    $this->appRoot = $app_root;
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * {@inheritdoc}
+   *
+   * @todo It might make sense to use a more sophisticated method of testing
+   *   writability than is_writable(), since it's not clear if that can return
+   *   false negatives/positives due to things like SELinux, exotic file
+   *   systems, and so forth.
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    $messages = [];
+
+    if (!is_writable($this->appRoot)) {
+      $messages[] = $this->t('The Drupal directory "@dir" is not writable.', [
+        '@dir' => $this->appRoot,
+      ]);
+    }
+
+    $dir = $this->pathLocator->getVendorDirectory();
+    if (!is_writable($dir)) {
+      $messages[] = $this->t('The vendor directory "@dir" is not writable.', ['@dir' => $dir]);
+    }
+
+    if ($messages) {
+      $event->addError($messages, $this->t('The file system is not writable.'));
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/Exception/StageException.php b/core/modules/auto_updates/package_manager/src/Exception/StageException.php
new file mode 100644
index 0000000000000000000000000000000000000000..e8011bac34bddd19e135892215021de23991b8e5
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Exception/StageException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\package_manager\Exception;
+
+/**
+ * Base class for all exceptions related to staging operations.
+ */
+class StageException extends \RuntimeException {
+}
diff --git a/core/modules/auto_updates/package_manager/src/Exception/StageOwnershipException.php b/core/modules/auto_updates/package_manager/src/Exception/StageOwnershipException.php
new file mode 100644
index 0000000000000000000000000000000000000000..8a8e868ac99b11598727436e45313aa44f0b2e1c
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Exception/StageOwnershipException.php
@@ -0,0 +1,9 @@
+<?php
+
+namespace Drupal\package_manager\Exception;
+
+/**
+ * Exception thrown if a stage encounters an ownership or locking error.
+ */
+class StageOwnershipException extends StageException {
+}
diff --git a/core/modules/auto_updates/package_manager/src/Exception/StageValidationException.php b/core/modules/auto_updates/package_manager/src/Exception/StageValidationException.php
new file mode 100644
index 0000000000000000000000000000000000000000..1bb0a9c6960f20412b527ca4857c43d304f28233
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Exception/StageValidationException.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\package_manager\Exception;
+
+/**
+ * Exception thrown if a stage has validation errors.
+ */
+class StageValidationException extends StageException {
+
+  /**
+   * Any relevant validation results.
+   *
+   * @var \Drupal\package_manager\ValidationResult[]
+   */
+  protected $results = [];
+
+  /**
+   * Constructs a StageException object.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $results
+   *   Any relevant validation results.
+   * @param mixed ...$arguments
+   *   Arguments to pass to the parent constructor.
+   */
+  public function __construct(array $results = [], ...$arguments) {
+    $this->results = $results;
+    parent::__construct(...$arguments);
+  }
+
+  /**
+   * Gets the validation results.
+   *
+   * @return \Drupal\package_manager\ValidationResult[]
+   *   The validation results.
+   */
+  public function getResults(): array {
+    return $this->results;
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/ExecutableFinder.php b/core/modules/auto_updates/package_manager/src/ExecutableFinder.php
new file mode 100644
index 0000000000000000000000000000000000000000..d18f45be1327de05370db1c6cfefd8e3d2254c4a
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/ExecutableFinder.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinderInterface;
+use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinder as StagerExecutableFinder;
+use Symfony\Component\Process\ExecutableFinder as SymfonyExecutableFinder;
+
+/**
+ * An executable finder which looks for executable paths in configuration.
+ */
+class ExecutableFinder implements ExecutableFinderInterface {
+
+  /**
+   * The decorated executable finder.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinder
+   */
+  private $decorated;
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  private $configFactory;
+
+  /**
+   * Constructs an ExecutableFinder object.
+   *
+   * @param \Symfony\Component\Process\ExecutableFinder $symfony_executable_finder
+   *   The Symfony executable finder.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   */
+  public function __construct(SymfonyExecutableFinder $symfony_executable_finder, ConfigFactoryInterface $config_factory) {
+    $this->decorated = new StagerExecutableFinder($symfony_executable_finder);
+    $this->configFactory = $config_factory;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function find(string $name): string {
+    $executables = $this->configFactory->get('package_manager.settings')
+      ->get('executables');
+
+    return $executables[$name] ?? $this->decorated->find($name);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/FileSyncerFactory.php b/core/modules/auto_updates/package_manager/src/FileSyncerFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..727620a24857d3f7a1ab7e900b3d6b675393844f
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/FileSyncerFactory.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactoryInterface;
+use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactory as StagerFileSyncerFactory;
+use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface;
+use Symfony\Component\Process\ExecutableFinder;
+
+/**
+ * A file syncer factory which returns file syncers according to configuration.
+ */
+class FileSyncerFactory implements FileSyncerFactoryInterface {
+
+  /**
+   * The decorated file syncer factory.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactoryInterface
+   */
+  protected $decorated;
+
+  /**
+   * The PHP file syncer service.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface
+   */
+  protected $phpFileSyncer;
+
+  /**
+   * The rsync file syncer service.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface
+   */
+  protected $rsyncFileSyncer;
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * Constructs a FileCopierFactory object.
+   *
+   * @param \Symfony\Component\Process\ExecutableFinder $executable_finder
+   *   The Symfony executable finder.
+   * @param \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface $php_file_syncer
+   *   The PHP file syncer service.
+   * @param \PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerInterface $rsync_file_syncer
+   *   The rsync file syncer service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   */
+  public function __construct(ExecutableFinder $executable_finder, FileSyncerInterface $php_file_syncer, FileSyncerInterface $rsync_file_syncer, ConfigFactoryInterface $config_factory) {
+    $this->decorated = new StagerFileSyncerFactory($executable_finder, $php_file_syncer, $rsync_file_syncer);
+    $this->phpFileSyncer = $php_file_syncer;
+    $this->rsyncFileSyncer = $rsync_file_syncer;
+    $this->configFactory = $config_factory;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function create(): FileSyncerInterface {
+    $syncer = $this->configFactory->get('package_manager.settings')
+      ->get('file_syncer');
+
+    switch ($syncer) {
+      case 'rsync':
+        return $this->rsyncFileSyncer;
+
+      case 'php':
+        return $this->phpFileSyncer;
+
+      default:
+        return $this->decorated->create();
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/PackageManagerServiceProvider.php b/core/modules/auto_updates/package_manager/src/PackageManagerServiceProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..3d74d54d355c2bd60c9115d47a3af4a8805167dc
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/PackageManagerServiceProvider.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+use Drupal\package_manager\EventSubscriber\UpdateDataSubscriber;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * Defines dynamic container services for Package Manager.
+ */
+class PackageManagerServiceProvider extends ServiceProviderBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    if (array_key_exists('update', $container->getParameter('container.modules'))) {
+      $container->register('package_manager.update_data_subscriber')
+        ->setClass(UpdateDataSubscriber::class)
+        ->setArguments([
+          new Reference('update.manager'),
+        ])
+        ->addTag('event_subscriber');
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/PathLocator.php b/core/modules/auto_updates/package_manager/src/PathLocator.php
new file mode 100644
index 0000000000000000000000000000000000000000..8e14b31156c05ceed2d2ad26bb7213fc3540364d
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/PathLocator.php
@@ -0,0 +1,77 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Composer\Autoload\ClassLoader;
+
+/**
+ * Computes file system paths that are needed to stage code changes.
+ */
+class PathLocator {
+
+  /**
+   * The absolute path of the running Drupal code base.
+   *
+   * @var string
+   */
+  protected $appRoot;
+
+  /**
+   * Constructs a PathLocator object.
+   *
+   * @param string $app_root
+   *   The absolute path of the running Drupal code base.
+   */
+  public function __construct(string $app_root) {
+    $this->appRoot = $app_root;
+  }
+
+  /**
+   * Returns the path of the active code base.
+   *
+   * @return string
+   *   The absolute path of the active, running code base.
+   */
+  public function getActiveDirectory(): string {
+    return $this->getProjectRoot();
+  }
+
+  /**
+   * Returns the absolute path of the project root.
+   *
+   * This is where the project-level composer.json should normally be found, and
+   * may or may not be the same path as the Drupal code base.
+   *
+   * @return string
+   *   The absolute path of the project root.
+   */
+  public function getProjectRoot(): string {
+    // Assume that the vendor directory is immediately below the project root.
+    return realpath($this->getVendorDirectory() . DIRECTORY_SEPARATOR . '..');
+  }
+
+  /**
+   * Returns the absolute path of the vendor directory.
+   *
+   * @return string
+   *   The absolute path of the vendor directory.
+   */
+  public function getVendorDirectory(): string {
+    $reflector = new \ReflectionClass(ClassLoader::class);
+    return dirname($reflector->getFileName(), 2);
+  }
+
+  /**
+   * Returns the path of the Drupal installation, relative to the project root.
+   *
+   * @return string
+   *   The path of the Drupal installation, relative to the project root and
+   *   without leading or trailing slashes. Will return an empty string if the
+   *   project root and Drupal root are the same.
+   */
+  public function getWebRoot(): string {
+    $web_root = str_replace($this->getProjectRoot(), NULL, $this->appRoot);
+    return trim($web_root, DIRECTORY_SEPARATOR);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/ProcessFactory.php b/core/modules/auto_updates/package_manager/src/ProcessFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..8a1ca6583a3be2350d2ba5e53162e758ed512df8
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/ProcessFactory.php
@@ -0,0 +1,115 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\File\FileSystemInterface;
+use PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactory as StagerProcessFactory;
+use PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactoryInterface;
+use Symfony\Component\Process\Process;
+
+/**
+ * Defines a process factory which sets the COMPOSER_HOME environment variable.
+ *
+ * @todo Figure out how to do this in composer_stager.
+ */
+final class ProcessFactory implements ProcessFactoryInterface {
+
+  /**
+   * The decorated process factory.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactoryInterface
+   */
+  private $decorated;
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  private $fileSystem;
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  private $configFactory;
+
+  /**
+   * Constructs a ProcessFactory object.
+   *
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   */
+  public function __construct(FileSystemInterface $file_system, ConfigFactoryInterface $config_factory) {
+    $this->decorated = new StagerProcessFactory();
+    $this->fileSystem = $file_system;
+    $this->configFactory = $config_factory;
+  }
+
+  /**
+   * Returns the value of an environment variable.
+   *
+   * @param string $variable
+   *   The name of the variable.
+   *
+   * @return mixed
+   *   The value of the variable.
+   */
+  private function getEnv(string $variable) {
+    if (function_exists('apache_getenv')) {
+      return apache_getenv($variable);
+    }
+    return getenv($variable);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function create(array $command): Process {
+    $process = $this->decorated->create($command);
+
+    $env = $process->getEnv();
+    if ($this->isComposerCommand($command)) {
+      $env['COMPOSER_HOME'] = $this->getComposerHomePath();
+    }
+    // Ensure that the running PHP binary is in the PATH.
+    $env['PATH'] = $this->getEnv('PATH') . ':' . dirname(PHP_BINARY);
+    return $process->setEnv($env);
+  }
+
+  /**
+   * Returns the path to use as the COMPOSER_HOME environment variable.
+   *
+   * @return string
+   *   The path which should be used as COMPOSER_HOME.
+   */
+  private function getComposerHomePath(): string {
+    $home_path = $this->fileSystem->getTempDirectory();
+    $home_path .= '/auto_updates_composer_home-';
+    $home_path .= $this->configFactory->get('system.site')->get('uuid');
+    $this->fileSystem->prepareDirectory($home_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
+
+    return $home_path;
+  }
+
+  /**
+   * Determines if a command is running Composer.
+   *
+   * @param string[] $command
+   *   The command parts.
+   *
+   * @return bool
+   *   TRUE if the command is running Composer, FALSE otherwise.
+   */
+  private function isComposerCommand(array $command): bool {
+    $executable = $command[0];
+    $executable_parts = explode('/', $executable);
+    $file = array_pop($executable_parts);
+    return strpos($file, 'composer') === 0;
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/Stage.php b/core/modules/auto_updates/package_manager/src/Stage.php
new file mode 100644
index 0000000000000000000000000000000000000000..6548db10cf65b6addb8f6a034aa38ec1273394fb
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/Stage.php
@@ -0,0 +1,456 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Component\FileSystem\FileSystem;
+use Drupal\Component\Utility\Crypt;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\TempStore\SharedTempStoreFactory;
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\Event\PostCreateEvent;
+use Drupal\package_manager\Event\PostDestroyEvent;
+use Drupal\package_manager\Event\PostRequireEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreDestroyEvent;
+use Drupal\package_manager\Event\PreRequireEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\StageException;
+use Drupal\package_manager\Exception\StageOwnershipException;
+use Drupal\package_manager\Exception\StageValidationException;
+use PhpTuf\ComposerStager\Domain\BeginnerInterface;
+use PhpTuf\ComposerStager\Domain\CommitterInterface;
+use PhpTuf\ComposerStager\Domain\StagerInterface;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Creates and manages a staging area in which to install or update code.
+ *
+ * Allows calling code to copy the current Drupal site into a temporary staging
+ * directory, use Composer to require packages into it, sync changes from the
+ * staging directory back into the active code base, and then delete the
+ * staging directory.
+ *
+ * Only one staging area can exist at any given time, and the stage is owned by
+ * the user or session that originally created it. Only the owner can perform
+ * 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.
+ */
+class Stage {
+
+  /**
+   * The tempstore key under which to store the locking info for this stage.
+   *
+   * @var string
+   */
+  protected const TEMPSTORE_LOCK_KEY = 'lock';
+
+  /**
+   * The tempstore key under which to store arbitrary metadata for this stage.
+   *
+   * @var string
+   */
+  protected const TEMPSTORE_METADATA_KEY = 'metadata';
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * The beginner service from Composer Stager.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\BeginnerInterface
+   */
+  protected $beginner;
+
+  /**
+   * The stager service from Composer Stager.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\StagerInterface
+   */
+  protected $stager;
+
+  /**
+   * The committer service from Composer Stager.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\CommitterInterface
+   */
+  protected $committer;
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The event dispatcher service.
+   *
+   * @var \Symfony\Contracts\EventDispatcher\EventDispatcherInterface
+   */
+  protected $eventDispatcher;
+
+  /**
+   * The shared temp store.
+   *
+   * @var \Drupal\Core\TempStore\SharedTempStore
+   */
+  protected $tempStore;
+
+  /**
+   * The lock info for the stage.
+   *
+   * Consists of a unique random string and the current class name.
+   *
+   * @var string[]
+   */
+  private $lock;
+
+  /**
+   * Constructs a new Stage object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner
+   *   The beginner service from Composer Stager.
+   * @param \PhpTuf\ComposerStager\Domain\StagerInterface $stager
+   *   The stager service from Composer Stager.
+   * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer
+   *   The committer service from Composer Stager.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
+   * @param \Symfony\Contracts\EventDispatcher\EventDispatcherInterface $event_dispatcher
+   *   The event dispatcher service.
+   * @param \Drupal\Core\TempStore\SharedTempStoreFactory $shared_tempstore
+   *   The shared tempstore factory.
+   */
+  public function __construct(PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore) {
+    $this->pathLocator = $path_locator;
+    $this->beginner = $beginner;
+    $this->stager = $stager;
+    $this->committer = $committer;
+    $this->fileSystem = $file_system;
+    $this->eventDispatcher = $event_dispatcher;
+    $this->tempStore = $shared_tempstore->get('package_manager_stage');
+  }
+
+  /**
+   * Determines if the staging area can be created.
+   *
+   * @return bool
+   *   TRUE if the staging area can be created, otherwise FALSE.
+   */
+  final public function isAvailable(): bool {
+    return empty($this->tempStore->getMetadata(static::TEMPSTORE_LOCK_KEY));
+  }
+
+  /**
+   * Returns a specific piece of metadata associated with this stage.
+   *
+   * Only the owner of the stage can access metadata, and the stage must either
+   * be claimed by its owner, or created during the current request.
+   *
+   * @param string $key
+   *   The metadata key.
+   *
+   * @return mixed
+   *   The metadata value, or NULL if it is not set.
+   */
+  protected function getMetadata(string $key) {
+    $this->checkOwnership();
+
+    $metadata = $this->tempStore->getIfOwner(static::TEMPSTORE_METADATA_KEY) ?: [];
+    return $metadata[$key] ?? NULL;
+  }
+
+  /**
+   * Stores arbitrary metadata associated with this stage.
+   *
+   * Only the owner of the stage can set metadata, and the stage must either be
+   * claimed by its owner, or created during the current request.
+   *
+   * @param string $key
+   *   The key under which to store the metadata.
+   * @param mixed $data
+   *   The metadata to store.
+   */
+  protected function setMetadata(string $key, $data): void {
+    $this->checkOwnership();
+
+    $metadata = $this->tempStore->get(static::TEMPSTORE_METADATA_KEY);
+    $metadata[$key] = $data;
+    $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, $metadata);
+  }
+
+  /**
+   * Copies the active code base into the staging area.
+   *
+   * This will automatically claim the stage, so external code does NOT need to
+   * call ::claim(). However, if it was created during another request, the
+   * stage must be claimed before operations can be performed on it.
+   *
+   * @return string
+   *   Unique ID for the stage, which can be used to claim the stage before
+   *   performing other operations on it. Calling code should store this ID for
+   *   as long as the stage needs to exist.
+   *
+   * @see ::claim()
+   */
+  public function create(): string {
+    if (!$this->isAvailable()) {
+      throw new StageException('Cannot create a new stage because one already exists.');
+    }
+    // Mark the stage as unavailable as early as possible, before dispatching
+    // the pre-create event. The idea is to prevent a race condition if the
+    // event subscribers take a while to finish, and two different users attempt
+    // to create a staging area at around the same time. If an error occurs
+    // while the event is being processed, the stage is marked as available.
+    // @see ::dispatch()
+    $id = Crypt::randomBytesBase64();
+    $this->tempStore->set(static::TEMPSTORE_LOCK_KEY, [$id, static::class]);
+    $this->claim($id);
+
+    $active_dir = $this->pathLocator->getActiveDirectory();
+    $stage_dir = $this->getStageDirectory();
+
+    $event = new PreCreateEvent($this);
+    $this->dispatch($event);
+
+    $this->beginner->begin($active_dir, $stage_dir, $event->getExcludedPaths());
+    $this->dispatch(new PostCreateEvent($this));
+    return $id;
+  }
+
+  /**
+   * Requires packages in the staging area.
+   *
+   * @param string[] $constraints
+   *   The packages to require, in the form 'vendor/name:version'.
+   * @param bool $dev
+   *   (optional) Whether the packages should be required as dev dependencies.
+   *   Defaults to FALSE.
+   */
+  public function require(array $constraints, bool $dev = FALSE): void {
+    $this->checkOwnership();
+
+    $command = array_merge(['require'], $constraints);
+    $command[] = '--update-with-all-dependencies';
+    if ($dev) {
+      $command[] = '--dev';
+    }
+
+    $this->dispatch(new PreRequireEvent($this));
+    $this->stager->stage($command, $this->getStageDirectory());
+    $this->dispatch(new PostRequireEvent($this));
+  }
+
+  /**
+   * Applies staged changes to the active directory.
+   */
+  public function apply(): void {
+    $this->checkOwnership();
+
+    $active_dir = $this->pathLocator->getActiveDirectory();
+    $stage_dir = $this->getStageDirectory();
+
+    $event = new PreApplyEvent($this);
+    $this->dispatch($event);
+
+    $this->committer->commit($stage_dir, $active_dir, $event->getExcludedPaths());
+    $this->dispatch(new PostApplyEvent($this));
+  }
+
+  /**
+   * Deletes the staging area.
+   *
+   * @param bool $force
+   *   (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.
+   */
+  public function destroy(bool $force = FALSE): void {
+    if (!$force) {
+      $this->checkOwnership();
+    }
+
+    $this->dispatch(new PreDestroyEvent($this));
+    // Delete all directories in parent staging directory.
+    $parent_stage_dir = static::getStagingRoot();
+    if (is_dir($parent_stage_dir)) {
+      $this->fileSystem->deleteRecursive($parent_stage_dir, function (string $path): void {
+        $this->fileSystem->chmod($path, 0777);
+      });
+    }
+    $this->markAsAvailable();
+    $this->dispatch(new PostDestroyEvent($this));
+  }
+
+  /**
+   * Marks the stage as available.
+   */
+  protected function markAsAvailable(): void {
+    $this->tempStore->delete(static::TEMPSTORE_METADATA_KEY);
+    $this->tempStore->delete(static::TEMPSTORE_LOCK_KEY);
+    $this->lock = NULL;
+  }
+
+  /**
+   * Dispatches an event and handles any errors that it collects.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   *
+   * @throws \Drupal\package_manager\Exception\StageValidationException
+   *   If the event collects any validation errors, or a subscriber throws a
+   *   StageValidationException directly.
+   * @throws \RuntimeException
+   *   If any other sort of error occurs.
+   */
+  protected function dispatch(StageEvent $event): void {
+    try {
+      $this->eventDispatcher->dispatch($event);
+
+      $results = $event->getResults();
+      if ($results) {
+        throw new StageValidationException($results);
+      }
+    }
+    catch (\Throwable $error) {
+      // If we are not going to be able to create the staging area, mark it as
+      // available.
+      // @see ::create()
+      if ($event instanceof PreCreateEvent) {
+        $this->markAsAvailable();
+      }
+
+      // Wrap the exception to preserve the backtrace, and re-throw it.
+      if ($error instanceof StageValidationException) {
+        throw new StageValidationException($error->getResults(), $error->getMessage(), $error->getCode(), $error);
+      }
+      else {
+        throw new \RuntimeException($error->getMessage(), $error->getCode(), $error);
+      }
+    }
+  }
+
+  /**
+   * Returns a Composer utility object for the active directory.
+   *
+   * @return \Drupal\package_manager\ComposerUtility
+   *   The Composer utility object.
+   */
+  public function getActiveComposer(): ComposerUtility {
+    $dir = $this->pathLocator->getActiveDirectory();
+    return ComposerUtility::createForDirectory($dir);
+  }
+
+  /**
+   * Returns a Composer utility object for the stage directory.
+   *
+   * @return \Drupal\package_manager\ComposerUtility
+   *   The Composer utility object.
+   */
+  public function getStageComposer(): ComposerUtility {
+    $dir = $this->getStageDirectory();
+    return ComposerUtility::createForDirectory($dir);
+  }
+
+  /**
+   * Attempts to claim the stage.
+   *
+   * Once a stage has been created, no operations can be performed on it until
+   * it is claimed. This is to ensure that stage operations across multiple
+   * requests are being done by the same code, running under the same user or
+   * session that created the stage in the first place. To claim a stage, the
+   * calling code must provide the unique identifier that was generated when the
+   * stage was created.
+   *
+   * The stage is claimed when it is created, so external code does NOT need to
+   * call this method after calling ::create() in the same request.
+   *
+   * @param string $unique_id
+   *   The unique ID that was returned by ::create().
+   *
+   * @return $this
+   *
+   * @throws \Drupal\package_manager\Exception\StageException
+   *   If the stage cannot be claimed. This can happen if the current user or
+   *   session did not originally create the stage, if $unique_id doesn't match
+   *   the unique ID that was generated when the stage was created, or the
+   *   current class is not the same one that was used to create the stage.
+   *
+   * @see ::create()
+   */
+  final public function claim(string $unique_id): self {
+    if ($this->isAvailable()) {
+      throw new StageException('Cannot claim the stage because no stage has been created.');
+    }
+
+    $stored_lock = $this->tempStore->getIfOwner(self::TEMPSTORE_LOCK_KEY);
+    if (!$stored_lock) {
+      throw new StageOwnershipException('Cannot claim the stage because it is not owned by the current user or session.');
+    }
+
+    if ($stored_lock === [$unique_id, static::class]) {
+      $this->lock = $stored_lock;
+      return $this;
+    }
+    throw new StageOwnershipException('Cannot claim the stage because the current lock does not match the stored lock.');
+  }
+
+  /**
+   * Ensures that the current user or session owns the staging area.
+   *
+   * @throws \LogicException
+   *   If ::claim() has not been previously called.
+   * @throws \Drupal\package_manager\Exception\StageOwnershipException
+   *   If the current user or session does not own the staging area.
+   */
+  final protected function checkOwnership(): void {
+    if (empty($this->lock)) {
+      throw new \LogicException('Stage must be claimed before performing any operations on it.');
+    }
+
+    $stored_lock = $this->tempStore->getIfOwner(static::TEMPSTORE_LOCK_KEY);
+    if ($stored_lock !== $this->lock) {
+      throw new StageOwnershipException('Stage is not owned by the current user or session.');
+    }
+  }
+
+  /**
+   * Returns the path of the directory where changes should be staged.
+   *
+   * @return string
+   *   The absolute path of the directory where changes should be staged.
+   *
+   * @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];
+  }
+
+  /**
+   * Returns the directory where staging areas will be created.
+   *
+   * @return 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';
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/src/ValidationResult.php b/core/modules/auto_updates/package_manager/src/ValidationResult.php
new file mode 100644
index 0000000000000000000000000000000000000000..7651548eddad405436595452917caaa1e1203d27
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/src/ValidationResult.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\system\SystemManager;
+
+/**
+ * A value object to contain the results of a validation.
+ */
+class ValidationResult {
+
+  /**
+   * A succinct summary of the results.
+   *
+   * @var \Drupal\Core\StringTranslation\TranslatableMarkup
+   */
+  protected $summary;
+
+  /**
+   * The error messages.
+   *
+   * @var \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   */
+  protected $messages;
+
+  /**
+   * The severity of the result.
+   *
+   * @var int
+   */
+  protected $severity;
+
+  /**
+   * Creates a ValidationResult object.
+   *
+   * @param int $severity
+   *   The severity of the result. Should be one of the
+   *   SystemManager::REQUIREMENT_* constants.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
+   *   The error messages.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
+   *   The errors summary.
+   */
+  private function __construct(int $severity, array $messages, ?TranslatableMarkup $summary = NULL) {
+    if (count($messages) > 1 && !$summary) {
+      throw new \InvalidArgumentException('If more than one message is provided, a summary is required.');
+    }
+    $this->summary = $summary;
+    $this->messages = $messages;
+    $this->severity = $severity;
+  }
+
+  /**
+   * Creates an error ValidationResult object.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
+   *   The error messages.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
+   *   The errors summary.
+   *
+   * @return static
+   */
+  public static function createError(array $messages, ?TranslatableMarkup $summary = NULL): self {
+    return new static(SystemManager::REQUIREMENT_ERROR, $messages, $summary);
+  }
+
+  /**
+   * Creates a warning ValidationResult object.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
+   *   The error messages.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
+   *   The errors summary.
+   *
+   * @return static
+   */
+  public static function createWarning(array $messages, ?TranslatableMarkup $summary = NULL): self {
+    return new static(SystemManager::REQUIREMENT_WARNING, $messages, $summary);
+  }
+
+  /**
+   * Gets the summary.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup|null
+   *   The summary.
+   */
+  public function getSummary(): ?TranslatableMarkup {
+    return $this->summary;
+  }
+
+  /**
+   * Gets the messages.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error or warning messages.
+   */
+  public function getMessages(): array {
+    return $this->messages;
+  }
+
+  /**
+   * The severity of the result.
+   *
+   * @return int
+   *   Either SystemManager::REQUIREMENT_ERROR or
+   *   SystemManager::REQUIREMENT_WARNING.
+   */
+  public function getSeverity(): int {
+    return $this->severity;
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/db_update.php b/core/modules/auto_updates/package_manager/tests/fixtures/db_update.php
new file mode 100644
index 0000000000000000000000000000000000000000..7adacfb2e2e8d8bcc498735e7472f6fde2577340
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/db_update.php
@@ -0,0 +1,14 @@
+<?php
+
+/**
+ * @file
+ * Contains a fake database update function for testing.
+ */
+
+/**
+ * Here is a fake update hook.
+ *
+ * The schema version is the maximum possible value for a 32-bit integer.
+ */
+function package_manager_update_2147483647() {
+}
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.json b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..a7a8274cb6e5e850fba380e6670fbeb8b33a7cbd
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.json
@@ -0,0 +1,12 @@
+{
+    "require": {
+        "drupal/test-distribution": "*"
+    },
+    "extra": {
+        "_comment": [
+            "This is a fake composer.json simulating a site which requires a distribution.",
+            "The required core packages are determined by scanning the lock file.",
+            "The fake distribution requires Drupal core directly."
+        ]
+    }
+}
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.lock b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..56572fd029d8f859be438b6fa40b76ebbc9daf4c
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core/composer.lock
@@ -0,0 +1,16 @@
+{
+    "packages": [
+        {
+            "name": "drupal/test-distribution",
+            "version": "1.0.0",
+            "require": {
+                "drupal/core": "*"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        }
+    ],
+    "packages-dev": []
+}
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.json b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..3a62074ca2371426bbf177a0b941f808fbbfae3a
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.json
@@ -0,0 +1,12 @@
+{
+    "require": {
+        "drupal/test-distribution": "*"
+    },
+    "extra": {
+        "_comment": [
+            "This is a fake composer.json simulating a site which requires a distribution.",
+            "The required core packages are determined by scanning the lock file.",
+            "The fake distribution uses drupal/core-recommended to require Drupal core."
+        ]
+    }
+}
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.lock b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..dd29a0514d0ebdfc87484b52fc0c3256045ccb85
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/distro_core_recommended/composer.lock
@@ -0,0 +1,23 @@
+{
+    "packages": [
+        {
+            "name": "drupal/test-distribution",
+            "version": "1.0.0",
+            "require": {
+                "drupal/core-recommended": "*"
+            }
+        },
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        }
+    ],
+    "packages-dev": []
+}
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/composer.json b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/private/ignore.txt b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/private/ignore.txt
new file mode 100644
index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/private/ignore.txt
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/services.yml b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e11552b41d40377475700ab10cd3118257d93cc7
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/services.yml
@@ -0,0 +1 @@
+# This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php
new file mode 100644
index 0000000000000000000000000000000000000000..15b43d28125cc4a2e30348dc76d972ce240443ac
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.php b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.php
new file mode 100644
index 0000000000000000000000000000000000000000..15b43d28125cc4a2e30348dc76d972ce240443ac
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/stage.txt b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/stage.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0087269e33e50d1805db3c9ecf821660384c11bc
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/default/stage.txt
@@ -0,0 +1 @@
+This file should be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite
new file mode 100644
index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm
new file mode 100644
index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal
new file mode 100644
index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt
new file mode 100644
index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..e11552b41d40377475700ab10cd3118257d93cc7
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml
@@ -0,0 +1 @@
+# This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php
new file mode 100644
index 0000000000000000000000000000000000000000..15b43d28125cc4a2e30348dc76d972ce240443ac
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php
new file mode 100644
index 0000000000000000000000000000000000000000..15b43d28125cc4a2e30348dc76d972ce240443ac
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt
new file mode 100644
index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/vendor/web.config b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/vendor/web.config
new file mode 100644
index 0000000000000000000000000000000000000000..08874eba8bb924527069b41e1195da4b6b69d1dd
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/fixtures/fake_site/vendor/web.config
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..af1b5aac92f63110e9559a6bd0ce54b045d091ae
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/package_manager_bypass.info.yml
@@ -0,0 +1,6 @@
+name: 'Package Manager Bypass'
+description: 'Mocks Package Manager services for functional testing.'
+type: module
+package: Testing
+dependencies:
+  - auto_updates:package_manager
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Beginner.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
new file mode 100644
index 0000000000000000000000000000000000000000..742d38bd85dd15311fd7cf9125e55aea1adcb905
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\package_manager_bypass;
+
+use PhpTuf\ComposerStager\Domain\BeginnerInterface;
+use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+
+/**
+ * Defines an update beginner which doesn't do anything.
+ */
+class Beginner extends InvocationRecorderBase implements BeginnerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function begin(string $activeDir, string $stagingDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
+    $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Committer.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Committer.php
new file mode 100644
index 0000000000000000000000000000000000000000..3518237b5b1ba237b1d0b02a454bf0dfe9cd69ad
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Committer.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\package_manager_bypass;
+
+use PhpTuf\ComposerStager\Domain\CommitterInterface;
+use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+
+/**
+ * Defines an update committer which doesn't do any actual committing.
+ */
+class Committer extends InvocationRecorderBase implements CommitterInterface {
+
+  /**
+   * The decorated committer service.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\CommitterInterface
+   */
+  private $decorated;
+
+  /**
+   * Constructs a Committer object.
+   *
+   * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $decorated
+   *   The decorated committer service.
+   */
+  public function __construct(CommitterInterface $decorated) {
+    $this->decorated = $decorated;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function commit(string $stagingDir, string $activeDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
+    $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function directoryExists(string $stagingDir): bool {
+    return $this->decorated->directoryExists($stagingDir);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..feb4dc92f68825f6dc63f76cea8283269efcdc2f
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\package_manager_bypass;
+
+/**
+ * Records information about method invocations.
+ *
+ * This can be used by functional tests to ensure that the bypassed Composer
+ * Stager services were called as expected. Kernel and unit tests should use
+ * regular mocks instead.
+ */
+abstract class InvocationRecorderBase {
+
+  /**
+   * Returns the arguments from every invocation of the main class method.
+   *
+   * @return array[]
+   *   The arguments from every invocation of the main class method.
+   */
+  public function getInvocationArguments(): array {
+    return \Drupal::state()->get(static::class, []);
+  }
+
+  /**
+   * Records the arguments from an invocation of the main class method.
+   *
+   * @param mixed ...$arguments
+   *   The arguments that the main class method was called with.
+   */
+  protected function saveInvocationArguments(...$arguments) {
+    $invocations = $this->getInvocationArguments();
+    $invocations[] = $arguments;
+    \Drupal::state()->set(static::class, $invocations);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..3394cf4e159b39d300273529e119dc94929346c3
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Drupal\package_manager_bypass;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+use Drupal\Core\Site\Settings;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * Defines services to bypass Package Manager's core functionality.
+ */
+class PackageManagerBypassServiceProvider extends ServiceProviderBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    parent::alter($container);
+
+    // By default, bypass the Composer Stager library. This can be disabled for
+    // tests that want to use the real library, but only need to disable
+    // validators.
+    if (Settings::get('package_manager_bypass_stager', TRUE)) {
+      $container->getDefinition('package_manager.beginner')
+        ->setClass(Beginner::class);
+      $container->getDefinition('package_manager.stager')
+        ->setClass(Stager::class);
+
+      $container->register('package_manager_bypass.committer')
+        ->setClass(Committer::class)
+        ->setPublic(FALSE)
+        ->setDecoratedService('package_manager.committer')
+        ->setArguments([
+          new Reference('package_manager_bypass.committer.inner'),
+        ])
+        ->setProperty('_serviceId', 'package_manager.committer');
+    }
+
+    // Allow functional tests to disable specific validators as necessary.
+    // Kernel tests can override the ::register() method and modify the
+    // container directly.
+    $validators = Settings::get('package_manager_bypass_validators', []);
+    array_walk($validators, [$container, 'removeDefinition']);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Stager.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Stager.php
new file mode 100644
index 0000000000000000000000000000000000000000..bb93e47271dbe60e725ff33e574065d01e503715
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_bypass/src/Stager.php
@@ -0,0 +1,20 @@
+<?php
+
+namespace Drupal\package_manager_bypass;
+
+use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\StagerInterface;
+
+/**
+ * Defines an update stager which doesn't actually do anything.
+ */
+class Stager extends InvocationRecorderBase implements StagerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function stage(array $composerCommand, string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
+    $this->saveInvocationArguments($composerCommand, $stagingDir);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3558b4cd3c8872140eac8bcc224ea004d61d4708
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.info.yml
@@ -0,0 +1,6 @@
+name: 'Package Manager Validation Test'
+description: 'Provides an event subscriber to test Package Manager validation.'
+type: module
+package: Testing
+dependencies:
+  - auto_updates:package_manager
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..6cd0f446bf3932acd5e024006f8f8e4159ce6b07
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/package_manager_test_validation.services.yml
@@ -0,0 +1,7 @@
+services:
+  package_manager_test_validation.subscriber:
+    class: Drupal\package_manager_test_validation\TestSubscriber
+    arguments:
+      - '@state'
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/src/TestSubscriber.php b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/src/TestSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..a95381d3e80da1bc3ad0d87488143f46251e3538
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/modules/package_manager_test_validation/src/TestSubscriber.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Drupal\package_manager_test_validation;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\Event\PostCreateEvent;
+use Drupal\package_manager\Event\PostDestroyEvent;
+use Drupal\package_manager\Event\PostRequireEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreDestroyEvent;
+use Drupal\package_manager\Event\PreRequireEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\system\SystemManager;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Defines an event subscriber for testing validation of Package Manager events.
+ */
+class TestSubscriber implements EventSubscriberInterface {
+
+  /**
+   * The key to use store the test results.
+   *
+   * @var string
+   */
+  protected const STATE_KEY = 'package_manager_test_validation';
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * Creates a TestSubscriber object.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   */
+  public function __construct(StateInterface $state) {
+    $this->state = $state;
+  }
+
+  /**
+   * Sets validation results for a specific event.
+   *
+   * This method is static to enable setting the expected results before this
+   * module is enabled.
+   *
+   * @param \Drupal\package_manager\ValidationResult[]|null $results
+   *   The validation results, or NULL to delete stored results.
+   * @param string $event
+   *   The event class.
+   */
+  public static function setTestResult(?array $results, string $event): void {
+    $key = static::STATE_KEY . '.' . $event;
+
+    $state = \Drupal::state();
+    if (isset($results)) {
+      $state->set($key, $results);
+    }
+    else {
+      $state->delete($key);
+    }
+  }
+
+  /**
+   * Sets an exception to throw for a specific event.
+   *
+   * This method is static to enable setting the expected results before this
+   * module is enabled.
+   *
+   * @param \Throwable|null $error
+   *   The exception to throw, or NULL to delete a stored exception.
+   * @param string $event
+   *   The event class.
+   */
+  public static function setException(?\Throwable $error, string $event): void {
+    $key = static::STATE_KEY . '.' . $event;
+
+    $state = \Drupal::state();
+    if (isset($error)) {
+      $state->set($key, $error);
+    }
+    else {
+      $state->delete($key);
+    }
+  }
+
+  /**
+   * Adds validation results to a stage event.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function addResults(StageEvent $event): void {
+    $results = $this->state->get(static::STATE_KEY . '.' . get_class($event), []);
+
+    if ($results instanceof \Throwable) {
+      throw $results;
+    }
+    /** @var \Drupal\package_manager\ValidationResult $result */
+    foreach ($results as $result) {
+      if ($result->getSeverity() === SystemManager::REQUIREMENT_ERROR) {
+        $event->addError($result->getMessages(), $result->getSummary());
+      }
+      else {
+        $event->addWarning($result->getMessages(), $result->getSummary());
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $priority = defined('PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY') ? PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY : 5;
+
+    return [
+      PreCreateEvent::class => ['addResults', $priority],
+      PostCreateEvent::class => ['addResults', $priority],
+      PreRequireEvent::class => ['addResults', $priority],
+      PostRequireEvent::class => ['addResults', $priority],
+      PreApplyEvent::class => ['addResults', $priority],
+      PostApplyEvent::class => ['addResults', $priority],
+      PreDestroyEvent::class => ['addResults', $priority],
+      PostDestroyEvent::class => ['addResults', $priority],
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Functional/ExcludedPathsTest.php b/core/modules/auto_updates/package_manager/tests/src/Functional/ExcludedPathsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..94131a1ca3adfb438f0897bf7784c63d3d502e8f
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Functional/ExcludedPathsTest.php
@@ -0,0 +1,155 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Functional;
+
+use Drupal\Core\Database\Driver\sqlite\Connection;
+use Drupal\Core\Site\Settings;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\Stage;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
+ *
+ * @group package_manager
+ */
+class ExcludedPathsTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'package_manager',
+    'package_manager_bypass',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function prepareSettings() {
+    parent::prepareSettings();
+
+    // Disable the filesystem permissions validator, since we cannot guarantee
+    // that the current code base will be writable in all testing situations.
+    // We test this validator functionally in Automatic Updates' build tests,
+    // since those do give us control over the filesystem permissions.
+    // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
+    // @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest
+    $this->writeSettings([
+      'settings' => [
+        'package_manager_bypass_stager' => (object) [
+          'value' => FALSE,
+          'required' => TRUE,
+        ],
+        'package_manager_bypass_validators' => (object) [
+          'value' => ['package_manager.validator.file_system'],
+          'required' => TRUE,
+        ],
+      ],
+    ]);
+  }
+
+  /**
+   * Tests that certain paths are excluded from staging areas.
+   */
+  public function testExcludedPaths(): void {
+    $active_dir = __DIR__ . '/../../fixtures/fake_site';
+
+    $path_locator = $this->prophesize(PathLocator::class);
+    $path_locator->getActiveDirectory()->willReturn($active_dir);
+
+    $site_path = 'sites/example.com';
+
+    // Ensure that we are using directories within the fake site fixture for
+    // public and private files.
+    $settings = Settings::getAll();
+    $settings['file_public_path'] = "$site_path/files";
+    $settings['file_private_path'] = 'private';
+    new Settings($settings);
+
+    /** @var \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber $subscriber */
+    $subscriber = $this->container->get('package_manager.excluded_paths_subscriber');
+    $reflector = new \ReflectionObject($subscriber);
+    $property = $reflector->getProperty('sitePath');
+    $property->setAccessible(TRUE);
+    $property->setValue($subscriber, $site_path);
+
+    // Mock a SQLite database connection to a file in the active directory. The
+    // file should not be staged.
+    $database = $this->prophesize(Connection::class);
+    $database->driver()->willReturn('sqlite');
+    $database->getConnectionOptions()->willReturn([
+      'database' => $site_path . '/db.sqlite',
+    ]);
+    $property = $reflector->getProperty('database');
+    $property->setAccessible(TRUE);
+    $property->setValue($subscriber, $database->reveal());
+
+    $stage = new class(
+      $path_locator->reveal(),
+      $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'),
+    ) extends Stage {
+
+      /**
+       * The directory where staging areas will be created.
+       *
+       * @var string
+       */
+      public static $stagingRoot;
+
+      /**
+       * {@inheritdoc}
+       */
+      protected static function getStagingRoot(): string {
+        return static::$stagingRoot;
+      }
+
+    };
+    $stage::$stagingRoot = $this->siteDirectory . '/stage';
+    $stage_dir = $stage::$stagingRoot . DIRECTORY_SEPARATOR . $stage->create();
+
+    $this->assertDirectoryExists($stage_dir);
+    $this->assertDirectoryNotExists("$stage_dir/sites/simpletest");
+    $this->assertFileNotExists("$stage_dir/vendor/web.config");
+    $this->assertDirectoryNotExists("$stage_dir/$site_path/files");
+    $this->assertDirectoryNotExists("$stage_dir/private");
+    $this->assertFileNotExists("$stage_dir/$site_path/settings.php");
+    $this->assertFileNotExists("$stage_dir/$site_path/settings.local.php");
+    $this->assertFileNotExists("$stage_dir/$site_path/services.yml");
+    // SQLite databases and their support files should never be staged.
+    $this->assertFileNotExists("$stage_dir/$site_path/db.sqlite");
+    $this->assertFileNotExists("$stage_dir/$site_path/db.sqlite-shm");
+    $this->assertFileNotExists("$stage_dir/$site_path/db.sqlite-wal");
+    // Default site-specific settings files should never be staged.
+    $this->assertFileNotExists("$stage_dir/sites/default/settings.php");
+    $this->assertFileNotExists("$stage_dir/sites/default/settings.local.php");
+    $this->assertFileNotExists("$stage_dir/sites/default/services.yml");
+    // A non-excluded file in the default site directory should be staged.
+    $this->assertFileExists("$stage_dir/sites/default/stage.txt");
+
+    $files = [
+      'sites/default/no-copy.txt',
+      'web.config',
+    ];
+    foreach ($files as $file) {
+      $file = "$stage_dir/$file";
+      touch($file);
+      $this->assertFileExists($file);
+    }
+    $stage->apply();
+    foreach ($files as $file) {
+      $this->assertFileNotExists("$active_dir/$file");
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8905f788eddac081411dcadc5088121e7658ff96
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\EventSubscriber\ComposerExecutableValidator;
+use Drupal\package_manager\ValidationResult;
+use PhpTuf\ComposerStager\Exception\IOException;
+use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinderInterface;
+use Prophecy\Argument;
+
+/**
+ * @covers \Drupal\package_manager\EventSubscriber\ComposerExecutableValidator
+ *
+ * @group package_manager
+ */
+class ComposerExecutableValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Tests that an error is raised if the Composer executable isn't found.
+   */
+  public function testErrorIfComposerNotFound(): void {
+    $exception = new IOException("This is your regularly scheduled error.");
+
+    // The executable finder throws an exception if it can't find the requested
+    // executable.
+    $exec_finder = $this->prophesize(ExecutableFinderInterface::class);
+    $exec_finder->find('composer')
+      ->willThrow($exception)
+      ->shouldBeCalled();
+    $this->container->set('package_manager.executable_finder', $exec_finder->reveal());
+
+    // The validator should translate that exception into an error.
+    $error = ValidationResult::createError([
+      $exception->getMessage(),
+    ]);
+    $this->assertResults([$error], PreCreateEvent::class);
+  }
+
+  /**
+   * Data provider for ::testComposerVersionValidation().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerComposerVersionValidation(): array {
+    // Invalid or undetectable Composer versions will always produce the same
+    // error.
+    $invalid_version = ValidationResult::createError(['The Composer version could not be detected.']);
+
+    // Unsupported Composer versions will report the detected version number
+    // in the validation result, so we need a function to churn out those fake
+    // results for the test method.
+    $unsupported_version = function (string $version): ValidationResult {
+      return ValidationResult::createError([
+        "Composer 2 or later is required, but version $version was detected.",
+      ]);
+    };
+
+    return [
+      // A valid 2.x version of Composer should not produce any errors.
+      [
+        '2.1.6',
+        [],
+      ],
+      [
+        '1.10.22',
+        [$unsupported_version('1.10.22')],
+      ],
+      [
+        '1.7.3',
+        [$unsupported_version('1.7.3')],
+      ],
+      [
+        '2.0.0-alpha3',
+        [],
+      ],
+      [
+        '2.1.0-RC1',
+        [],
+      ],
+      [
+        '1.0.0-RC',
+        [$unsupported_version('1.0.0-RC')],
+      ],
+      [
+        '1.0.0-beta1',
+        [$unsupported_version('1.0.0-beta1')],
+      ],
+      [
+        '1.9-dev',
+        [$invalid_version],
+      ],
+      [
+        '@package_version@',
+        [$invalid_version],
+      ],
+    ];
+  }
+
+  /**
+   * Tests validation of various Composer versions.
+   *
+   * @param string $reported_version
+   *   The version of Composer that `composer --version` should report.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerComposerVersionValidation
+   */
+  public function testComposerVersionValidation(string $reported_version, array $expected_results): void {
+    // Mock the output of `composer --version`, will be passed to the validator,
+    // which is itself a callback function that gets called repeatedly as
+    // Composer produces output.
+    /** @var \PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface|\Prophecy\Prophecy\ObjectProphecy $runner */
+    $runner = $this->prophesize('\PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunnerInterface');
+
+    $runner->run(['--version'], Argument::type(ComposerExecutableValidator::class))
+      // Whatever is passed to ::run() will be passed to this mock callback in
+      // $arguments, and we know exactly what that will contain: an array of
+      // command arguments for Composer, and the validator object.
+      ->will(function (array $arguments) use ($reported_version) {
+        /** @var \Drupal\package_manager\EventSubscriber\ComposerExecutableValidator $validator */
+        $validator = $arguments[1];
+        // Invoke the validator (which, as mentioned, is a callback function),
+        // with fake output from `composer --version`. It should try to tease a
+        // recognized, supported version number out of this output.
+        $validator($validator::OUT, "Composer version $reported_version");
+      });
+    $this->container->set('package_manager.composer_runner', $runner->reveal());
+
+    // If the validator can't find a recognized, supported version of Composer,
+    // it should produce errors.
+    $this->assertResults($expected_results, PreCreateEvent::class);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerUtilityTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerUtilityTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d770cd3a12835108d85b667935c9b59b384cf4d1
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ComposerUtilityTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\package_manager\ComposerUtility;
+use org\bovigo\vfs\vfsStream;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\ComposerUtility
+ *
+ * @group package_manager
+ */
+class ComposerUtilityTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['package_manager'];
+
+  /**
+   * Tests that ComposerUtility disables automatic creation of .htaccess files.
+   */
+  public function testHtaccessProtectionDisabled(): void {
+    $dir = vfsStream::setup()->url();
+    file_put_contents($dir . '/composer.json', '{}');
+
+    ComposerUtility::createForDirectory($dir);
+    $this->assertFileNotExists($dir . '/.htaccess');
+  }
+
+  /**
+   * Data provider for ::testCorePackagesFromLockFile().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerCorePackagesFromLockFile(): array {
+    $fixtures_dir = __DIR__ . '/../../fixtures';
+
+    return [
+      'distro with drupal/core-recommended' => [
+        // This fixture's lock file mentions drupal/core, which is considered a
+        // canonical core package, but it will be ignored in favor of
+        // drupal/core-recommended, which always requires drupal/core as one of
+        // its direct dependencies.
+        "$fixtures_dir/distro_core_recommended",
+        ['drupal/core-recommended'],
+      ],
+      'distro with drupal/core' => [
+        "$fixtures_dir/distro_core",
+        ['drupal/core'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that required core packages are found by scanning the lock file.
+   *
+   * @param string $dir
+   *   The path of the fake site fixture.
+   * @param string[] $expected_packages
+   *   The names of the core packages which should be detected.
+   *
+   * @covers ::getCorePackageNames
+   *
+   * @dataProvider providerCorePackagesFromLockFile
+   */
+  public function testCorePackagesFromLockFile(string $dir, array $expected_packages): void {
+    $packages = ComposerUtility::createForDirectory($dir)
+      ->getCorePackageNames();
+    $this->assertSame($expected_packages, $packages);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/CorePackageManifestTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/CorePackageManifestTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5d82264e8afc36c6770e1d808e99852ac7869376
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/CorePackageManifestTest.php
@@ -0,0 +1,52 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\KernelTests\KernelTestBase;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * Tests that core's Composer packages are properly accounted for.
+ *
+ * In order to identify which Composer packages are part of Drupal core, we need
+ * to maintain a single hard-coded list (core_packages.json). This test confirms
+ * that the list mentions all of the Composer plugins and metapackages provided
+ * by Drupal core.
+ *
+ * @todo Move this test, and the package list, to a more central place in core.
+ *   For example, the list could live in core/assets, and this test could live
+ *   in the Drupal\Tests\Composer namespace.
+ *
+ * @group package_manager
+ */
+class CorePackageManifestTest extends KernelTestBase {
+
+  /**
+   * Tests that detected core packages match our hard-coded manifest file.
+   */
+  public function testCorePackagesMatchManifest(): void {
+    // Scan for all the composer.json files of said metapackages and plugins,
+    // ignoring the project templates. If we are not running in git clone of
+    // Drupal core, this will fail since the 'composer' directory won't exist.
+    $finder = Finder::create()
+      ->in($this->getDrupalRoot() . '/composer')
+      ->name('composer.json')
+      ->notPath('Template');
+
+    // Always consider drupal/core a valid core package, even though it's not a
+    // metapackage or plugin.
+    $packages = ['drupal/core'];
+    foreach ($finder as $file) {
+      $data = Json::decode($file->getContents());
+      $packages[] = $data['name'];
+    }
+    sort($packages);
+
+    // Ensure that the packages we detected matches the hard-coded list we ship.
+    $manifest = file_get_contents(__DIR__ . '/../../../core_packages.json');
+    $manifest = Json::decode($manifest);
+    $this->assertSame($packages, $manifest);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c4ee293ee0cace75c07a900acd227c0994807fcf
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
@@ -0,0 +1,225 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\EventSubscriber\DiskSpaceValidator;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Component\Utility\Bytes;
+
+/**
+ * @covers \Drupal\package_manager\EventSubscriber\DiskSpaceValidator
+ *
+ * @group package_manager
+ */
+class DiskSpaceValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    // Replace the validator under test with a mocked version which can be
+    // rigged up to return specific values for various filesystem checks.
+    $container->getDefinition('package_manager.validator.disk_space')
+      ->setClass(TestDiskSpaceValidator::class);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function disableValidators(ContainerBuilder $container): void {
+    parent::disableValidators($container);
+
+    // Disable the lock file validator, since in this test we are validating an
+    // imaginary file system which doesn't have any lock files.
+    $container->removeDefinition('package_manager.validator.lock_file');
+  }
+
+  /**
+   * Data provider for ::testDiskSpaceValidation().
+   *
+   * @return mixed[][]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerDiskSpaceValidation(): array {
+    $root_insufficient = t('Drupal root filesystem "root" has insufficient space. There must be at least 1024 megabytes free.');
+    $vendor_insufficient = t('Vendor filesystem "vendor" has insufficient space. There must be at least 1024 megabytes free.');
+    $temp_insufficient = t('Directory "temp" has insufficient space. There must be at least 1024 megabytes free.');
+    $summary = t("There is not enough disk space to create a staging area.");
+
+    return [
+      'shared, vendor and temp sufficient, root insufficient' => [
+        TRUE,
+        [
+          'root' => '1M',
+          'vendor' => '2G',
+          'temp' => '4G',
+        ],
+        [
+          ValidationResult::createError([$root_insufficient]),
+        ],
+      ],
+      'shared, root and vendor insufficient, temp sufficient' => [
+        TRUE,
+        [
+          'root' => '1M',
+          'vendor' => '2M',
+          'temp' => '2G',
+        ],
+        [
+          ValidationResult::createError([$root_insufficient]),
+        ],
+      ],
+      'shared, vendor and root sufficient, temp insufficient' => [
+        TRUE,
+        [
+          'root' => '2G',
+          'vendor' => '4G',
+          'temp' => '1M',
+        ],
+        [
+          ValidationResult::createError([$temp_insufficient]),
+        ],
+      ],
+      'shared, root and temp insufficient, vendor sufficient' => [
+        TRUE,
+        [
+          'root' => '1M',
+          'vendor' => '2G',
+          'temp' => '2M',
+        ],
+        [
+          ValidationResult::createError([
+            $root_insufficient,
+            $temp_insufficient,
+          ], $summary),
+        ],
+      ],
+      'not shared, root insufficient, vendor and temp sufficient' => [
+        FALSE,
+        [
+          'root' => '5M',
+          'vendor' => '1G',
+          'temp' => '4G',
+        ],
+        [
+          ValidationResult::createError([$root_insufficient]),
+        ],
+      ],
+      'not shared, vendor insufficient, root and temp sufficient' => [
+        FALSE,
+        [
+          'root' => '2G',
+          'vendor' => '10M',
+          'temp' => '4G',
+        ],
+        [
+          ValidationResult::createError([$vendor_insufficient]),
+        ],
+      ],
+      'not shared, root and vendor sufficient, temp insufficient' => [
+        FALSE,
+        [
+          'root' => '1G',
+          'vendor' => '2G',
+          'temp' => '3M',
+        ],
+        [
+          ValidationResult::createError([$temp_insufficient]),
+        ],
+      ],
+      'not shared, root and vendor insufficient, temp sufficient' => [
+        FALSE,
+        [
+          'root' => '500M',
+          'vendor' => '75M',
+          'temp' => '2G',
+        ],
+        [
+          ValidationResult::createError([
+            $root_insufficient,
+            $vendor_insufficient,
+          ], $summary),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests disk space validation.
+   *
+   * @param bool $shared_disk
+   *   Whether the root and vendor directories are on the same logical disk.
+   * @param array $free_space
+   *   The free space that should be reported for various locations. The keys
+   *   are the locations (only 'root', 'vendor', and 'temp' are supported), and
+   *   the values are the space that should be reported, in a format that can be
+   *   parsed by \Drupal\Component\Utility\Bytes::toNumber().
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerDiskSpaceValidation
+   */
+  public function testDiskSpaceValidation(bool $shared_disk, array $free_space, array $expected_results): void {
+    $path_locator = $this->prophesize('\Drupal\package_manager\PathLocator');
+    $path_locator->getProjectRoot()->willReturn('root');
+    $path_locator->getActiveDirectory()->willReturn('root');
+    $path_locator->getVendorDirectory()->willReturn('vendor');
+    $this->container->set('package_manager.path_locator', $path_locator->reveal());
+
+    /** @var \Drupal\Tests\package_manager\Kernel\TestDiskSpaceValidator $validator */
+    $validator = $this->container->get('package_manager.validator.disk_space');
+    $validator->sharedDisk = $shared_disk;
+    $validator->freeSpace = array_map([Bytes::class, 'toNumber'], $free_space);
+
+    $this->assertResults($expected_results, PreCreateEvent::class);
+  }
+
+}
+
+/**
+ * A test version of the disk space validator.
+ */
+class TestDiskSpaceValidator extends DiskSpaceValidator {
+
+  /**
+   * Whether the root and vendor directories are on the same logical disk.
+   *
+   * @var bool
+   */
+  public $sharedDisk;
+
+  /**
+   * The amount of free space, keyed by location.
+   *
+   * @var float[]
+   */
+  public $freeSpace = [];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function stat(string $path): array {
+    return [
+      'dev' => $this->sharedDisk ? 'disk' : uniqid(),
+    ];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function freeSpace(string $path): float {
+    return $this->freeSpace[$path];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function temporaryDirectory(): string {
+    return 'temp';
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..b44f7811d62c0699d5a2786747b5c4cf51051efa
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ExcludedPathsSubscriberTest.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Core\Database\Driver\sqlite\Connection;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber;
+
+/**
+ * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
+ *
+ * @group package_manager
+ */
+class ExcludedPathsSubscriberTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Data provider for ::testSqliteDatabaseExcluded().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerSqliteDatabaseExcluded(): array {
+    $drupal_root = $this->getDrupalRoot();
+
+    return [
+      'relative path, in site directory' => [
+        'sites/example.com/db.sqlite',
+        [
+          'sites/example.com/db.sqlite',
+          'sites/example.com/db.sqlite-shm',
+          'sites/example.com/db.sqlite-wal',
+        ],
+      ],
+      'relative path, at root' => [
+        'db.sqlite',
+        [
+          'db.sqlite',
+          'db.sqlite-shm',
+          'db.sqlite-wal',
+        ],
+      ],
+      'absolute path, in site directory' => [
+        $drupal_root . '/sites/example.com/db.sqlite',
+        [
+          'sites/example.com/db.sqlite',
+          'sites/example.com/db.sqlite-shm',
+          'sites/example.com/db.sqlite-wal',
+        ],
+      ],
+      'absolute path, at root' => [
+        $drupal_root . '/db.sqlite',
+        [
+          'db.sqlite',
+          'db.sqlite-shm',
+          'db.sqlite-wal',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that SQLite database paths are excluded from the staging area.
+   *
+   * The exclusion of SQLite databases from the staging area is functionally
+   * tested by \Drupal\Tests\package_manager\Functional\ExcludedPathsTest. The
+   * purpose of this test is to ensure that SQLite database paths are processed
+   * properly (e.g., converting an absolute path to a relative path) before
+   * being flagged for exclusion.
+   *
+   * @param string $database
+   *   The path of the SQLite database, as set in the database connection
+   *   options.
+   * @param string[] $expected_exclusions
+   *   The database paths which should be flagged for exclusion.
+   *
+   * @dataProvider providerSqliteDatabaseExcluded
+   *
+   * @see \Drupal\Tests\package_manager\Functional\ExcludedPathsTest
+   */
+  public function testSqliteDatabaseExcluded(string $database, array $expected_exclusions): void {
+    $connection = $this->prophesize(Connection::class);
+    $connection->driver()->willReturn('sqlite');
+    $connection->getConnectionOptions()->willReturn(['database' => $database]);
+
+    $subscriber = new ExcludedPathsSubscriber(
+      $this->getDrupalRoot(),
+      'sites/default',
+      $this->container->get('file_system'),
+      $this->container->get('stream_wrapper_manager'),
+      $connection->reveal()
+    );
+
+    $event = new PreCreateEvent($this->createStage());
+    $subscriber->preCreate($event);
+    // All of the expected exclusions should be flagged.
+    $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths()));
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ExecutableFinderTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ExecutableFinderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..fb59c652ca497251488702c1f90bb367c896d40b
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ExecutableFinderTest.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Symfony\Component\Process\ExecutableFinder as SymfonyExecutableFinder;
+
+/**
+ * @covers \Drupal\package_manager\ExecutableFinder
+ *
+ * @group package_manager
+ */
+class ExecutableFinderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Tests that the executable finder looks for paths in configuration.
+   */
+  public function testCheckConfigurationForExecutablePath(): void {
+    $symfony_executable_finder = new class () extends SymfonyExecutableFinder {
+
+      /**
+       * {@inheritdoc}
+       */
+      public function find($name, $default = NULL, array $extraDirs = []) {
+        return '/dev/null';
+      }
+
+    };
+    $this->container->set('package_manager.symfony_executable_finder', $symfony_executable_finder);
+
+    $this->config('package_manager.settings')
+      ->set('executables.composer', '/path/to/composer')
+      ->save();
+
+    $executable_finder = $this->container->get('package_manager.executable_finder');
+    $this->assertSame('/path/to/composer', $executable_finder->find('composer'));
+    $this->assertSame('/dev/null', $executable_finder->find('rsync'));
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..5609a64d4d7d861154b9521f1a5a8a3f91d2c012
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\FileSyncerFactory
+ *
+ * @group package_manager
+ */
+class FileSyncerFactoryTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['package_manager'];
+
+  /**
+   * Data provider for ::testFactory().
+   *
+   * @return mixed[][]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerFactory(): array {
+    return [
+      ['rsync'],
+      ['php'],
+      [NULL],
+    ];
+  }
+
+  /**
+   * Tests creating a file syncer using our specialized factory class.
+   *
+   * @param string|null $configured_syncer
+   *   The syncer to use, as configured in auto_updates.settings. Can be
+   *   'rsync', 'php', or NULL.
+   *
+   * @dataProvider providerFactory
+   */
+  public function testFactory(?string $configured_syncer): void {
+    $factory = $this->container->get('package_manager.file_syncer.factory');
+
+    switch ($configured_syncer) {
+      case 'rsync':
+        $expected_syncer = $this->container->get('package_manager.file_syncer.rsync');
+        break;
+
+      case 'php':
+        $expected_syncer = $this->container->get('package_manager.file_syncer.php');
+        break;
+
+      default:
+        $expected_syncer = $factory->create();
+        break;
+    }
+
+    $this->config('package_manager.settings')
+      ->set('file_syncer', $configured_syncer)
+      ->save();
+
+    $this->assertSame($expected_syncer, $factory->create());
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/LockFileValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..62c0142462432c6237f26c06f5df49c11d16d543
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/LockFileValidatorTest.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreRequireEvent;
+use Drupal\package_manager\EventSubscriber\LockFileValidator;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\ValidationResult;
+use org\bovigo\vfs\vfsStream;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\EventSubscriber\LockFileValidator
+ *
+ * @group package_manager
+ */
+class LockFileValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $vendor = vfsStream::newDirectory('vendor');
+    $this->vfsRoot->addChild($vendor);
+
+    $path_locator = $this->prophesize(PathLocator::class);
+    $path_locator->getActiveDirectory()->willReturn($this->vfsRoot->url());
+    $path_locator->getProjectRoot()->willReturn($this->vfsRoot->url());
+    $path_locator->getWebRoot()->willReturn('');
+    $path_locator->getVendorDirectory()->willReturn($vendor->url());
+    $this->container->set('package_manager.path_locator', $path_locator->reveal());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function disableValidators(ContainerBuilder $container): void {
+    parent::disableValidators($container);
+
+    // Disable the disk space validator, since it tries to inspect the file
+    // system in ways that vfsStream doesn't support, like calling stat() and
+    // disk_free_space().
+    $container->removeDefinition('package_manager.validator.disk_space');
+  }
+
+  /**
+   * Tests that if no active lock file exists, a stage cannot be created.
+   *
+   * @covers ::storeHash
+   */
+  public function testCreateWithNoLock(): void {
+    $no_lock = ValidationResult::createError(['Could not hash the active lock file.']);
+    $this->assertResults([$no_lock], PreCreateEvent::class);
+  }
+
+  /**
+   * Tests that if an active lock file exists, a stage can be created.
+   *
+   * @covers ::storeHash
+   * @covers ::deleteHash
+   */
+  public function testCreateWithLock(): void {
+    $this->createActiveLockFile();
+    $this->assertResults([]);
+
+    // Change the lock file to ensure the stored hash of the previous version
+    // has been deleted.
+    $this->vfsRoot->getChild('composer.lock')->setContent('"changed"');
+    $this->assertResults([]);
+  }
+
+  /**
+   * Tests validation when the lock file has changed.
+   *
+   * @dataProvider providerValidateStageEvents
+   */
+  public function testLockFileChanged(string $event_class): void {
+    $this->createActiveLockFile();
+
+    // Add a listener with an extremely high priority to the same event that
+    // should raise the validation error. Because the validator uses the default
+    // priority of 0, this listener changes lock file before the validator
+    // runs.
+    $this->addListener($event_class, function () {
+      $this->vfsRoot->getChild('composer.lock')->setContent('"changed"');
+    });
+    $result = ValidationResult::createError([
+      'Stored lock file hash does not match the active lock file.',
+    ]);
+    $this->assertResults([$result], $event_class);
+  }
+
+  /**
+   * Tests validation when the lock file is deleted.
+   *
+   * @dataProvider providerValidateStageEvents
+   */
+  public function testLockFileDeleted(string $event_class): void {
+    $this->createActiveLockFile();
+
+    // Add a listener with an extremely high priority to the same event that
+    // should raise the validation error. Because the validator uses the default
+    // priority of 0, this listener deletes lock file before the validator
+    // runs.
+    $this->addListener($event_class, function () {
+      $this->vfsRoot->removeChild('composer.lock');
+    });
+    $result = ValidationResult::createError([
+      'Could not hash the active lock file.',
+    ]);
+    $this->assertResults([$result], $event_class);
+  }
+
+  /**
+   * Tests validation when a stored hash of the active lock file is unavailable.
+   *
+   * @dataProvider providerValidateStageEvents
+   */
+  public function testNoStoredHash(string $event_class): void {
+    $this->createActiveLockFile();
+
+    $reflector = new \ReflectionClassConstant(LockFileValidator::class, 'STATE_KEY');
+    $state_key = $reflector->getValue();
+
+    // Add a listener with an extremely high priority to the same event that
+    // should raise the validation error. Because the validator uses the default
+    // priority of 0, this listener deletes stored hash before the validator
+    // runs.
+    $this->addListener($event_class, function () use ($state_key) {
+      $this->container->get('state')->delete($state_key);
+    });
+    $result = ValidationResult::createError([
+      'Could not retrieve stored hash of the active lock file.',
+    ]);
+    $this->assertResults([$result], $event_class);
+  }
+
+  /**
+   * Data provider for test methods that validate the staging area.
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerValidateStageEvents(): array {
+    return [
+      'pre-require' => [
+        PreRequireEvent::class,
+      ],
+      'pre-apply' => [
+        PreApplyEvent::class,
+      ],
+    ];
+  }
+
+  /**
+   * Creates a 'composer.lock' file in the active directory.
+   */
+  private function createActiveLockFile(): void {
+    $lock_file = vfsStream::newFile('composer.lock')->setContent('{}');
+    $this->vfsRoot->addChild($lock_file);
+  }
+
+  /**
+   * Adds an event listener with the highest possible priority.
+   *
+   * @param string $event_class
+   *   The event class to listen for.
+   * @param callable $listener
+   *   The listener to add.
+   */
+  private function addListener(string $event_class, callable $listener): void {
+    $this->container->get('event_dispatcher')
+      ->addListener($event_class, $listener, PHP_INT_MAX);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..414d3512422949c9a8908ab43744355bf30d6ebe
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
@@ -0,0 +1,136 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\StageException;
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\Stage;
+use Drupal\Tests\package_manager\Traits\ValidationTestTrait;
+
+/**
+ * Base class for kernel tests of Package Manager's functionality.
+ */
+abstract class PackageManagerKernelTestBase extends KernelTestBase {
+
+  use ValidationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'package_manager',
+    'package_manager_bypass',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+    $this->disableValidators($container);
+  }
+
+  /**
+   * Disables any validators that will interfere with this test.
+   */
+  protected function disableValidators(ContainerBuilder $container): void {
+    // Disable the filesystem permissions validator, since we cannot guarantee
+    // that the current code base will be writable in all testing situations.
+    // We test this validator functionally in Automatic Updates' build tests,
+    // since those do give us control over the filesystem permissions.
+    // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
+    // @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest
+    $container->removeDefinition('package_manager.validator.file_system');
+  }
+
+  /**
+   * Creates a stage object for testing purposes.
+   *
+   * @return \Drupal\Tests\package_manager\Kernel\TestStage
+   *   A stage object, with test-only modifications.
+   */
+  protected function createStage(): TestStage {
+    return new TestStage(
+      $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')
+    );
+  }
+
+  /**
+   * Asserts validation results are returned from a stage life cycle event.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param string|null $event_class
+   *   (optional) The class of the event which should return the results. Must
+   *   be passed if $expected_results is not empty.
+   */
+  protected function assertResults(array $expected_results, string $event_class = NULL): void {
+    $stage = $this->createStage();
+
+    try {
+      $stage->create();
+      $stage->require(['drupal/core:9.8.1']);
+      $stage->apply();
+      $stage->destroy();
+
+      // If we did not get an exception, ensure we didn't expect any results.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertNotEmpty($expected_results);
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+      // TestStage::dispatch() attaches the event object to the exception so
+      // that we can analyze it.
+      $this->assertNotEmpty($event_class);
+      $this->assertInstanceOf($event_class, $e->event);
+    }
+  }
+
+  /**
+   * Marks all pending post-update functions as completed.
+   *
+   * Since kernel tests don't normally install modules and register their
+   * updates, this method makes sure that we are testing from a clean, fully
+   * up-to-date state.
+   */
+  protected function registerPostUpdateFunctions(): void {
+    $updates = $this->container->get('update.post_update_registry')
+      ->getPendingUpdateFunctions();
+
+    $this->container->get('keyvalue')
+      ->get('post_update')
+      ->set('existing_updates', $updates);
+  }
+
+}
+
+/**
+ * Defines a stage specifically for testing purposes.
+ */
+class TestStage extends Stage {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function dispatch(StageEvent $event): void {
+    try {
+      parent::dispatch($event);
+    }
+    catch (StageException $e) {
+      // Attach the event object to the exception so that test code can verify
+      // that the exception was thrown when a specific event was dispatched.
+      $e->event = $event;
+      throw $e;
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ea90029ca28aa001c035cb076bf868158b580dca
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * @covers \Drupal\package_manager\EventSubscriber\PendingUpdatesValidator
+ *
+ * @group package_manager
+ */
+class PendingUpdatesValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['system'];
+
+  /**
+   * Tests that no error is raised if there are no pending updates.
+   */
+  public function testNoPendingUpdates(): void {
+    $this->registerPostUpdateFunctions();
+    $this->assertResults([], PreCreateEvent::class);
+  }
+
+  /**
+   * Tests that an error is raised if there are pending schema updates.
+   *
+   * @depends testNoPendingUpdates
+   */
+  public function testPendingUpdateHook(): void {
+    // Register the System module's post-update functions, so that any detected
+    // pending updates are guaranteed to be schema updates.
+    $this->registerPostUpdateFunctions();
+
+    // Set the installed schema version of Package Manager to its default value
+    // and import an empty update hook which is numbered much higher than will
+    // ever exist in the real world.
+    $this->container->get('keyvalue')
+      ->get('system.schema')
+      ->set('package_manager', \Drupal::CORE_MINIMUM_SCHEMA_VERSION);
+
+    require_once __DIR__ . '/../../fixtures/db_update.php';
+
+    $result = ValidationResult::createError([
+      'Some modules have database schema updates to install. You should run the <a href="/update.php">database update script</a> immediately.',
+    ]);
+    $this->assertResults([$result], PreCreateEvent::class);
+  }
+
+  /**
+   * Tests that an error is raised if there are pending post-updates.
+   */
+  public function testPendingPostUpdate(): void {
+    // The System module's post-update functions have not been registered, so
+    // the update registry will think they're pending.
+    $result = ValidationResult::createError([
+      'Some modules have database schema updates to install. You should run the <a href="/update.php">database update script</a> immediately.',
+    ]);
+    $this->assertResults([$result], PreCreateEvent::class);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/ServicesTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/ServicesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..ffa23a8a9cb61a2e4aac125629a81d17b8386062
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/ServicesTest.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests that Package Manager services are wired correctly.
+ *
+ * @group package_manager
+ */
+class ServicesTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['package_manager'];
+
+  /**
+   * Tests that Package Manager's public services can be instantiated.
+   */
+  public function testPackageManagerServices(): void {
+    $services = [
+      'package_manager.beginner',
+      'package_manager.stager',
+      'package_manager.committer',
+    ];
+    foreach ($services as $service) {
+      $this->assertIsObject($this->container->get($service));
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/StageEventsTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageEventsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..eb5f46c1b45bfd05691a3258960b5af4856082ef
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageEventsTest.php
@@ -0,0 +1,160 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\Event\PostCreateEvent;
+use Drupal\package_manager\Event\PostDestroyEvent;
+use Drupal\package_manager\Event\PostRequireEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreDestroyEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Event\PreRequireEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\Stage;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Tests that the staging area fires events during its lifecycle.
+ *
+ * @covers \Drupal\package_manager\Event\StageEvent
+ *
+ * @group package_manager
+ */
+class StageEventsTest extends PackageManagerKernelTestBase implements EventSubscriberInterface {
+
+  /**
+   * The events that were fired, in the order they were fired.
+   *
+   * @var string[]
+   */
+  private $events = [];
+
+  /**
+   * The stage under test.
+   *
+   * @var \Drupal\package_manager\Stage
+   */
+  private $stage;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->stage = new Stage(
+      $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')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'handleEvent',
+      PostCreateEvent::class => 'handleEvent',
+      PreRequireEvent::class => 'handleEvent',
+      PostRequireEvent::class => 'handleEvent',
+      PreApplyEvent::class => 'handleEvent',
+      PostApplyEvent::class => 'handleEvent',
+      PreDestroyEvent::class => 'handleEvent',
+      PostDestroyEvent::class => 'handleEvent',
+    ];
+  }
+
+  /**
+   * Handles a staging area life cycle event.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function handleEvent(StageEvent $event): void {
+    array_push($this->events, get_class($event));
+
+    // The event should have a reference to the stage which fired it.
+    $this->assertSame($event->getStage(), $this->stage);
+  }
+
+  /**
+   * Tests that the staging area fires life cycle events in a specific order.
+   */
+  public function testEvents(): void {
+    $this->container->get('event_dispatcher')->addSubscriber($this);
+
+    $this->stage->create();
+    $this->stage->require(['ext-json:*']);
+    $this->stage->apply();
+    $this->stage->destroy();
+
+    $this->assertSame($this->events, [
+      PreCreateEvent::class,
+      PostCreateEvent::class,
+      PreRequireEvent::class,
+      PostRequireEvent::class,
+      PreApplyEvent::class,
+      PostApplyEvent::class,
+      PreDestroyEvent::class,
+      PostDestroyEvent::class,
+    ]);
+  }
+
+  /**
+   * Data provider for ::testValidationResults().
+   *
+   * @return string[][]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerValidationResults(): array {
+    return [
+      [PreCreateEvent::class],
+      [PreRequireEvent::class],
+      [PreApplyEvent::class],
+      [PreDestroyEvent::class],
+    ];
+  }
+
+  /**
+   * Tests that an exception is thrown if an event has validation results.
+   *
+   * @param string $event_class
+   *   The event class to test.
+   *
+   * @dataProvider providerValidationResults
+   */
+  public function testValidationResults(string $event_class): void {
+    // Set up an event listener which will only flag an error for the event
+    // class under test.
+    $handler = function (StageEvent $event) use ($event_class): void {
+      if (get_class($event) === $event_class) {
+        if ($event instanceof PreOperationStageEvent) {
+          $event->addError([['Burn, baby, burn']]);
+        }
+      }
+    };
+    $this->container->get('event_dispatcher')
+      ->addListener($event_class, $handler);
+
+    try {
+      $this->stage->create();
+      $this->stage->require(['ext-json:*']);
+      $this->stage->apply();
+      $this->stage->destroy();
+
+      $this->fail('Expected \Drupal\package_manager\Exception\StageValidationException to be thrown.');
+    }
+    catch (StageValidationException $e) {
+      $this->assertCount(1, $e->getResults());
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/StageOwnershipTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageOwnershipTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..99339a969614da6e496a947e2f282abb6ba5779b
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageOwnershipTest.php
@@ -0,0 +1,224 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Exception\StageException;
+use Drupal\package_manager\Exception\StageOwnershipException;
+use Drupal\package_manager_test_validation\TestSubscriber;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * Tests that ownership of the stage is enforced.
+ *
+ * @group package_manger
+ */
+class StageOwnershipTest extends PackageManagerKernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'system',
+    'user',
+    'package_manager_test_validation',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installSchema('system', ['sequences']);
+    $this->installEntitySchema('user');
+    $this->registerPostUpdateFunctions();
+  }
+
+  /**
+   * Tests only the owner of stage can perform operations, even if logged out.
+   */
+  public function testOwnershipEnforcedWhenLoggedOut(): void {
+    $this->assertOwnershipIsEnforced($this->createStage(), $this->createStage());
+  }
+
+  /**
+   * Tests only the owner of stage can perform operations.
+   */
+  public function testOwnershipEnforcedWhenLoggedIn(): void {
+    $user_1 = $this->createUser([], NULL, FALSE, ['uid' => 2]);
+    $this->setCurrentUser($user_1);
+
+    $will_create = $this->createStage();
+    // Rebuild the container so that the shared tempstore factory is made
+    // properly aware of the new current user ($user_2) before another stage
+    // is created.
+    $kernel = $this->container->get('kernel');
+    $this->container = $kernel->rebuildContainer();
+    $user_2 = $this->createUser();
+    $this->setCurrentUser($user_2);
+    $this->assertOwnershipIsEnforced($will_create, $this->createStage());
+  }
+
+  /**
+   * Asserts that ownership is enforced across staging areas.
+   *
+   * @param \Drupal\Tests\package_manager\Kernel\TestStage $will_create
+   *   The stage that will be created, and owned by the current user or session.
+   * @param \Drupal\Tests\package_manager\Kernel\TestStage $never_create
+   *   The stage that will not be created, but should still respect the
+   *   ownership and status of the other stage.
+   */
+  private function assertOwnershipIsEnforced(TestStage $will_create, TestStage $never_create): void {
+    // Before the staging area is created, isAvailable() should return TRUE.
+    $this->assertTrue($will_create->isAvailable());
+    $this->assertTrue($never_create->isAvailable());
+
+    $stage_id = $will_create->create();
+    // Both staging areas should be considered unavailable (i.e., cannot be
+    // created until the existing one is destroyed first).
+    $this->assertFalse($will_create->isAvailable());
+    $this->assertFalse($never_create->isAvailable());
+
+    // We should get an error if we try to create the staging area again,
+    // regardless of who owns it.
+    foreach ([$will_create, $never_create] as $stage) {
+      try {
+        $stage->create();
+        $this->fail("Able to create a stage that already exists.");
+      }
+      catch (StageException $exception) {
+        $this->assertSame('Cannot create a new stage because one already exists.', $exception->getMessage());
+      }
+    }
+
+    try {
+      $never_create->claim($stage_id);
+    }
+    catch (StageOwnershipException $exception) {
+      $this->assertSame('Cannot claim the stage because it is not owned by the current user or session.', $exception->getMessage());
+    }
+
+    // Only the stage's owner should be able to move it through its life cycle.
+    $callbacks = [
+      'require' => [
+        ['vendor/lib:0.0.1'],
+      ],
+      'apply' => [],
+      'destroy' => [],
+    ];
+    foreach ($callbacks as $method => $arguments) {
+      try {
+        $never_create->$method(...$arguments);
+        $this->fail("Able to call '$method' on a stage that was never created.");
+      }
+      catch (\LogicException $exception) {
+        $this->assertSame('Stage must be claimed before performing any operations on it.', $exception->getMessage());
+      }
+      // The call should succeed on the created stage.
+      $will_create->$method(...$arguments);
+    }
+  }
+
+  /**
+   * Tests behavior of claiming a stage.
+   */
+  public function testClaim(): void {
+    // Log in as a user so that any stage instances created during the session
+    // should be able to successfully call ::claim().
+    $user_2 = $this->createUser([], NULL, FALSE, ['uid' => 2]);
+    $this->setCurrentUser($user_2);
+    $creator_stage = $this->createStage();
+
+    // Ensure that exceptions thrown during ::create() will not lock the stage.
+    $error = new \Exception('I am going to stop stage creation.');
+    TestSubscriber::setException($error, PreCreateEvent::class);
+    try {
+      $creator_stage->create();
+      $this->fail('Was able to create the stage despite throwing an exception in pre-create.');
+    }
+    catch (\RuntimeException $exception) {
+      $this->assertSame($error->getMessage(), $exception->getMessage());
+    }
+
+    // The stage should be available, and throw if we try to claim it.
+    $this->assertTrue($creator_stage->isAvailable());
+    try {
+      $creator_stage->claim('any-id-would-fail');
+      $this->fail('Was able to claim a stage that has not been created.');
+    }
+    catch (StageException $exception) {
+      $this->assertSame('Cannot claim the stage because no stage has been created.', $exception->getMessage());
+    }
+    TestSubscriber::setException(NULL, PreCreateEvent::class);
+
+    // Even if we own the stage, we should not be able to claim it with an
+    // incorrect ID.
+    $stage_id = $creator_stage->create();
+    try {
+      $this->createStage()->claim('not-correct-id');
+      $this->fail('Was able to claim an owned stage with an incorrect ID.');
+    }
+    catch (StageOwnershipException $exception) {
+      $this->assertSame('Cannot claim the stage because the current lock does not match the stored lock.', $exception->getMessage());
+    }
+
+    // A stage that is successfully claimed should be able to call any method
+    // for its life cycle.
+    $callbacks = [
+      'require' => [
+        ['vendor/lib:0.0.1'],
+      ],
+      'apply' => [],
+      'destroy' => [],
+    ];
+    foreach ($callbacks as $method => $arguments) {
+      // Create a new stage instance for each method.
+      $this->createStage()->claim($stage_id)->$method(...$arguments);
+    }
+
+    // The stage cannot be claimed after it's been destroyed.
+    try {
+      $this->createStage()->claim($stage_id);
+      $this->fail('Was able to claim an owned stage after it was destroyed.');
+    }
+    catch (StageException $exception) {
+      $this->assertSame('Cannot claim the stage because no stage has been created.', $exception->getMessage());
+    }
+
+    // Create a new stage and then log in as a different user.
+    $new_stage_id = $this->createStage()->create();
+    $user_3 = $this->createUser([], NULL, FALSE, ['uid' => 3]);
+    $this->setCurrentUser($user_3);
+
+    // Even if they use the correct stage ID, the current user cannot claim a
+    // stage they didn't create.
+    try {
+      $this->createStage()->claim($new_stage_id);
+    }
+    catch (StageOwnershipException $exception) {
+      $this->assertSame('Cannot claim the stage because it is not owned by the current user or session.', $exception->getMessage());
+    }
+  }
+
+  /**
+   * Tests a stage being destroyed by a user who doesn't own it.
+   */
+  public function testForceDestroy(): void {
+    $owned = $this->createStage();
+    $owned->create();
+
+    $not_owned = $this->createStage();
+    try {
+      $not_owned->destroy();
+      $this->fail("Able to destroy a stage that we don't own.");
+    }
+    catch (\LogicException $exception) {
+      $this->assertSame('Stage must be claimed before performing any operations on it.', $exception->getMessage());
+    }
+    // We should be able to destroy the stage if we ignore ownership.
+    $not_owned->destroy(TRUE);
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/StageTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0ed5f56049ab936218670b10af52aa2ec41e2e3c
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/StageTest.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\Stage
+ *
+ * @group package_manager
+ */
+class StageTest extends PackageManagerKernelTestBase {
+
+  /**
+   * @covers ::getStageDirectory
+   */
+  public function testGetStageDirectory(): void {
+    $stage = $this->createStage();
+    $id = $stage->create();
+    $this->assertStringEndsWith("/.package_manager/$id", $stage->getStageDirectory());
+  }
+
+  /**
+   * @covers ::getStageDirectory
+   */
+  public function testUncreatedGetStageDirectory(): void {
+    $this->expectException('LogicException');
+    $this->expectExceptionMessage('Drupal\package_manager\Stage::getStageDirectory() cannot be called because the stage has not been created or claimed.');
+    $this->createStage()->getStageDirectory();
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/core/modules/auto_updates/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d5db9b1afbb01f7a357817e1c220b1c38cd05b31
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\EventSubscriber\WritableFileSystemValidator;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\PathLocator;
+use org\bovigo\vfs\vfsStream;
+
+/**
+ * Unit tests the file system permissions validator.
+ *
+ * This validator is tested functionally in Automatic Updates' build tests,
+ * since those give us control over the file system permissions.
+ *
+ * @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
+ *
+ * @covers \Drupal\package_manager\EventSubscriber\WritableFileSystemValidator
+ *
+ * @group package_manager
+ */
+class WritableFileSystemValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    // Replace the file system permissions validator with our test-only
+    // implementation.
+    $container->getDefinition('package_manager.validator.file_system')
+      ->setClass(TestWritableFileSystemValidator::class);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function disableValidators(ContainerBuilder $container): void {
+    // Disable the disk space validator, since it tries to inspect the file
+    // system in ways that vfsStream doesn't support, like calling stat() and
+    // disk_free_space().
+    $container->removeDefinition('package_manager.validator.disk_space');
+
+    // Disable the lock file validator, since the mock file system we create in
+    // this test doesn't have any lock files to validate.
+    $container->removeDefinition('package_manager.validator.lock_file');
+  }
+
+  /**
+   * Data provider for ::testWritable().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerWritable(): array {
+    $root_error = t('The Drupal directory "vfs://root" is not writable.');
+    $vendor_error = t('The vendor directory "vfs://root/vendor" is not writable.');
+    $summary = t('The file system is not writable.');
+    $writable_permission = 0777;
+    $non_writable_permission = 0444;
+
+    return [
+      'root and vendor are writable' => [
+        $writable_permission,
+        $writable_permission,
+        [],
+      ],
+      'root writable, vendor not writable' => [
+        $writable_permission,
+        $non_writable_permission,
+        [
+          ValidationResult::createError([$vendor_error], $summary),
+        ],
+      ],
+      'root not writable, vendor writable' => [
+        $non_writable_permission,
+        $writable_permission,
+        [
+          ValidationResult::createError([$root_error], $summary),
+        ],
+      ],
+      'nothing writable' => [
+        $non_writable_permission,
+        $non_writable_permission,
+        [
+          ValidationResult::createError([$root_error, $vendor_error], $summary),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests the file system permissions validator.
+   *
+   * @param int $root_permissions
+   *   The file permissions for the root folder.
+   * @param int $vendor_permissions
+   *   The file permissions for the vendor folder.
+   * @param array $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerWritable
+   */
+  public function testWritable(int $root_permissions, int $vendor_permissions, array $expected_results): void {
+    $root = vfsStream::setup('root', $root_permissions);
+    $vendor = vfsStream::newDirectory('vendor', $vendor_permissions);
+    $root->addChild($vendor);
+
+    $path_locator = $this->prophesize(PathLocator::class);
+    $path_locator->getActiveDirectory()->willReturn($root->url());
+    $path_locator->getWebRoot()->willReturn('');
+    $path_locator->getVendorDirectory()->willReturn($vendor->url());
+    $this->container->set('package_manager.path_locator', $path_locator->reveal());
+
+    /** @var \Drupal\Tests\package_manager\Kernel\TestWritableFileSystemValidator $validator */
+    $validator = $this->container->get('package_manager.validator.file_system');
+    $validator->appRoot = $root->url();
+
+    $this->assertResults($expected_results, PreCreateEvent::class);
+  }
+
+}
+
+/**
+ * A test version of the file system permissions validator.
+ */
+class TestWritableFileSystemValidator extends WritableFileSystemValidator {
+
+  /**
+   * {@inheritdoc}
+   */
+  public $appRoot;
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Traits/ValidationTestTrait.php b/core/modules/auto_updates/package_manager/tests/src/Traits/ValidationTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..9cfeff13a2b8a16b7d659dc35441f9abb3201855
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Traits/ValidationTestTrait.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Traits;
+
+/**
+ * Contains helpful methods for testing stage validators.
+ */
+trait ValidationTestTrait {
+
+  /**
+   * Asserts two validation result sets are equal.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param \Drupal\package_manager\ValidationResult[] $actual_results
+   *   The actual validation results.
+   */
+  protected function assertValidationResultsEqual(array $expected_results, array $actual_results): void {
+    $this->assertCount(count($expected_results), $actual_results);
+
+    foreach ($expected_results as $expected_result) {
+      $actual_result = array_shift($actual_results);
+      $this->assertSame($expected_result->getSeverity(), $actual_result->getSeverity());
+      $this->assertSame((string) $expected_result->getSummary(), (string) $actual_result->getSummary());
+      $this->assertSame(
+        array_map('strval', $expected_result->getMessages()),
+        array_map('strval', $actual_result->getMessages())
+      );
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/package_manager/tests/src/Unit/ValidationResultTest.php b/core/modules/auto_updates/package_manager/tests/src/Unit/ValidationResultTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..26de5cd6e78afa16bb4232dd8ff78549bb58888b
--- /dev/null
+++ b/core/modules/auto_updates/package_manager/tests/src/Unit/ValidationResultTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Unit;
+
+use Drupal\package_manager\ValidationResult;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\system\SystemManager;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\ValidationResult
+ *
+ * @group package_manager
+ */
+class ValidationResultTest extends UnitTestCase {
+
+  /**
+   * @covers ::createWarning
+   *
+   * @dataProvider providerValidConstructorArguments
+   */
+  public function testCreateWarningResult(array $messages, ?string $summary): void {
+    $summary = $summary ? t($summary) : NULL;
+    $result = ValidationResult::createWarning($messages, $summary);
+    $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_WARNING);
+  }
+
+  /**
+   * @covers ::createError
+   *
+   * @dataProvider providerValidConstructorArguments
+   */
+  public function testCreateErrorResult(array $messages, ?string $summary): void {
+    $summary = $summary ? t($summary) : NULL;
+    $result = ValidationResult::createError($messages, $summary);
+    $this->assertResultValid($result, $messages, $summary, SystemManager::REQUIREMENT_ERROR);
+  }
+
+  /**
+   * @covers ::createWarning
+   */
+  public function testCreateWarningResultException(): void {
+    $this->expectException(\InvalidArgumentException::class);
+    $this->expectExceptionMessage('If more than one message is provided, a summary is required.');
+    ValidationResult::createWarning(['Something is wrong', 'Something else is also wrong'], NULL);
+  }
+
+  /**
+   * @covers ::createError
+   */
+  public function testCreateErrorResultException(): void {
+    $this->expectException(\InvalidArgumentException::class);
+    $this->expectExceptionMessage('If more than one message is provided, a summary is required.');
+    ValidationResult::createError(['Something is wrong', 'Something else is also wrong'], NULL);
+  }
+
+  /**
+   * Data provider for testCreateWarningResult().
+   *
+   * @return mixed[]
+   *   Test cases for testCreateWarningResult().
+   */
+  public function providerValidConstructorArguments(): array {
+    return [
+      '1 message no summary' => [
+        'messages' => ['Something is wrong'],
+        'summary' => NULL,
+      ],
+      '2 messages has summary' => [
+        'messages' => ['Something is wrong', 'Something else is also wrong'],
+        'summary' => 'This sums it up.',
+      ],
+    ];
+  }
+
+  /**
+   * Asserts a check result is valid.
+   *
+   * @param \Drupal\package_manager\ValidationResult $result
+   *   The validation result to check.
+   * @param array $expected_messages
+   *   The expected messages.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
+   *   The expected summary or NULL if not summary is expected.
+   * @param int $severity
+   *   The severity.
+   */
+  protected function assertResultValid(ValidationResult $result, array $expected_messages, ?TranslatableMarkup $summary, int $severity): void {
+    $this->assertSame($expected_messages, $result->getMessages());
+    if ($summary === NULL) {
+      $this->assertNull($result->getSummary());
+    }
+    else {
+      $this->assertSame($summary->getUntranslatedString(), $result->getSummary()
+        ->getUntranslatedString());
+    }
+    $this->assertSame($severity, $result->getSeverity());
+  }
+
+}
diff --git a/core/modules/auto_updates/src/BatchProcessor.php b/core/modules/auto_updates/src/BatchProcessor.php
new file mode 100644
index 0000000000000000000000000000000000000000..475aecefeb2811cf92298da8ed0f52a6d8c866a4
--- /dev/null
+++ b/core/modules/auto_updates/src/BatchProcessor.php
@@ -0,0 +1,193 @@
+<?php
+
+namespace Drupal\auto_updates;
+
+use Drupal\Core\Url;
+use Drupal\package_manager\Exception\StageValidationException;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+
+/**
+ * A batch processor for updates.
+ */
+class BatchProcessor {
+
+  /**
+   * Gets the updater service.
+   *
+   * @return \Drupal\auto_updates\Updater
+   *   The updater service.
+   */
+  protected static function getUpdater(): Updater {
+    return \Drupal::service('auto_updates.updater');
+  }
+
+  /**
+   * Records messages from a throwable, then re-throws it.
+   *
+   * @param \Throwable $error
+   *   The caught exception.
+   * @param array $context
+   *   The current context of the batch job.
+   *
+   * @throws \Throwable
+   *   The caught exception, which will always be re-thrown once its messages
+   *   have been recorded.
+   */
+  protected static function handleException(\Throwable $error, array &$context): void {
+    $error_messages = [
+      $error->getMessage(),
+    ];
+
+    if ($error instanceof StageValidationException) {
+      foreach ($error->getResults() as $result) {
+        $messages = $result->getMessages();
+        if (count($messages) > 1) {
+          array_unshift($messages, $result->getSummary());
+        }
+        $error_messages = array_merge($error_messages, $messages);
+      }
+    }
+
+    foreach ($error_messages as $error_message) {
+      $context['results']['errors'][] = $error_message;
+    }
+    throw $error;
+  }
+
+  /**
+   * Calls the updater's begin() method.
+   *
+   * @param string[] $project_versions
+   *   The project versions to be staged in the update, keyed by package name.
+   * @param array $context
+   *   The current context of the batch job.
+   *
+   * @see \Drupal\auto_updates\Updater::begin()
+   */
+  public static function begin(array $project_versions, array &$context): void {
+    try {
+      $stage_unique = static::getUpdater()->begin($project_versions);
+      $context['results']['stage_id'] = $stage_unique;
+    }
+    catch (\Throwable $e) {
+      static::handleException($e, $context);
+    }
+  }
+
+  /**
+   * Calls the updater's stageVersions() method.
+   *
+   * @param array $context
+   *   The current context of the batch job.
+   *
+   * @see \Drupal\auto_updates\Updater::stage()
+   */
+  public static function stage(array &$context): void {
+    try {
+      $stage_id = $context['results']['stage_id'];
+      static::getUpdater()->claim($stage_id)->stage();
+    }
+    catch (\Throwable $e) {
+      static::handleException($e, $context);
+    }
+  }
+
+  /**
+   * Calls the updater's commit() method.
+   *
+   * @param string $stage_id
+   *   The stage ID.
+   * @param array $context
+   *   The current context of the batch job.
+   *
+   * @see \Drupal\auto_updates\Updater::apply()
+   */
+  public static function commit(string $stage_id, array &$context): void {
+    try {
+      static::getUpdater()->claim($stage_id)->apply();
+    }
+    catch (\Throwable $e) {
+      static::handleException($e, $context);
+    }
+  }
+
+  /**
+   * Calls the updater's clean() method.
+   *
+   * @param string $stage_id
+   *   The stage ID.
+   * @param array $context
+   *   The current context of the batch job.
+   *
+   * @see \Drupal\auto_updates\Updater::clean()
+   */
+  public static function clean(string $stage_id, array &$context): void {
+    try {
+      static::getUpdater()->claim($stage_id)->destroy();
+    }
+    catch (\Throwable $e) {
+      static::handleException($e, $context);
+    }
+  }
+
+  /**
+   * Finishes the stage batch job.
+   *
+   * @param bool $success
+   *   Indicate that 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.
+   */
+  public static function finishStage(bool $success, array $results, array $operations): ?RedirectResponse {
+    if ($success) {
+      $url = Url::fromRoute('auto_updates.confirmation_page', [
+        'stage_id' => $results['stage_id'],
+      ]);
+      return new RedirectResponse($url->setAbsolute()->toString());
+    }
+    static::handleBatchError($results);
+    return NULL;
+  }
+
+  /**
+   * Finishes the commit batch job.
+   *
+   * @param bool $success
+   *   Indicate that 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.
+   */
+  public static function finishCommit(bool $success, array $results, array $operations): ?RedirectResponse {
+
+    if ($success) {
+      \Drupal::messenger()->addMessage('Update complete!');
+      // @todo redirect to update.php?
+      return new RedirectResponse(Url::fromRoute('update.status', [],
+        ['absolute' => TRUE])->toString());
+    }
+    static::handleBatchError($results);
+    return NULL;
+  }
+
+  /**
+   * Handles a batch job that finished with errors.
+   *
+   * @param array $results
+   *   The batch results.
+   */
+  protected static function handleBatchError(array $results): void {
+    if (isset($results['errors'])) {
+      foreach ($results['errors'] as $error) {
+        \Drupal::messenger()->addError($error);
+      }
+    }
+    else {
+      \Drupal::messenger()->addError("Update error");
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php b/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php
new file mode 100644
index 0000000000000000000000000000000000000000..adb05762b9450b3bb5342f01d90b8be49a500b3c
--- /dev/null
+++ b/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php
@@ -0,0 +1,90 @@
+<?php
+
+namespace Drupal\auto_updates\Controller;
+
+use Drupal\auto_updates\Validation\ReadinessValidationManager;
+use Drupal\auto_updates\Validation\ReadinessTrait;
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\system\SystemManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+
+/**
+ * A controller for running readiness checkers.
+ *
+ * @internal
+ *   Controller classes are internal.
+ */
+class ReadinessCheckerController extends ControllerBase {
+
+  use ReadinessTrait;
+
+  /**
+   * The readiness checker manager.
+   *
+   * @var \Drupal\auto_updates\Validation\ReadinessValidationManager
+   */
+  protected $readinessCheckerManager;
+
+  /**
+   * Constructs a ReadinessCheckerController object.
+   *
+   * @param \Drupal\auto_updates\Validation\ReadinessValidationManager $checker_manager
+   *   The readiness checker manager.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
+   *   The string translation service.
+   */
+  public function __construct(ReadinessValidationManager $checker_manager, TranslationInterface $string_translation) {
+    $this->readinessCheckerManager = $checker_manager;
+    $this->setStringTranslation($string_translation);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container): self {
+    return new static(
+      $container->get('auto_updates.readiness_validation_manager'),
+      $container->get('string_translation'),
+    );
+  }
+
+  /**
+   * Run the readiness checkers.
+   *
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse
+   *   A redirect to the status report page.
+   */
+  public function run(): RedirectResponse {
+    $results = $this->readinessCheckerManager->run()->getResults();
+    if (!$results) {
+      // @todo Link "automatic updates" to documentation in
+      //   https://www.drupal.org/node/3168405.
+      // If there are no messages from the readiness checkers display a message
+      // that the site is ready. If there are messages, the status report will
+      // display them.
+      $this->messenger()->addStatus($this->t('No issues found. Your site is ready for automatic updates'));
+    }
+    else {
+      // Determine if any of the results are errors.
+      $error_results = $this->readinessCheckerManager->getResults(SystemManager::REQUIREMENT_ERROR);
+      // If there are any errors, display a failure message as an error.
+      // Otherwise, display it as a warning.
+      $severity = $error_results ? SystemManager::REQUIREMENT_ERROR : SystemManager::REQUIREMENT_WARNING;
+      $failure_message = $this->getFailureMessageForSeverity($severity);
+      if ($severity === SystemManager::REQUIREMENT_ERROR) {
+        $this->messenger()->addError($failure_message);
+      }
+      else {
+        $this->messenger()->addWarning($failure_message);
+      }
+    }
+    // Set a redirect to the status report page. Any other page that provides a
+    // link to this controller should include 'destination' in the query string
+    // to ensure this redirect is overridden.
+    // @see \Drupal\Core\EventSubscriber\RedirectResponseSubscriber::checkRedirectUrl()
+    return $this->redirect('system.status');
+  }
+
+}
diff --git a/core/modules/auto_updates/src/CronUpdater.php b/core/modules/auto_updates/src/CronUpdater.php
new file mode 100644
index 0000000000000000000000000000000000000000..bae5fc8539a5403300d5126b6ab602b748c3fdc7
--- /dev/null
+++ b/core/modules/auto_updates/src/CronUpdater.php
@@ -0,0 +1,151 @@
+<?php
+
+namespace Drupal\auto_updates;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Logger\LoggerChannelFactoryInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a service that updates via cron.
+ *
+ * @internal
+ *   This class implements logic specific to Automatic Updates' cron hook
+ *   implementation. It should not be called directly.
+ */
+class CronUpdater implements ContainerInjectionInterface {
+
+  /**
+   * All automatic updates are disabled.
+   *
+   * @var string
+   */
+  public const DISABLED = 'disable';
+
+  /**
+   * Only perform automatic security updates.
+   *
+   * @var string
+   */
+  public const SECURITY = 'security';
+
+  /**
+   * All automatic updates are enabled.
+   *
+   * @var string
+   */
+  public const ALL = 'patch';
+
+  /**
+   * The updater service.
+   *
+   * @var \Drupal\auto_updates\Updater
+   */
+  protected $updater;
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The logger.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * Constructs a CronUpdater object.
+   *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater service.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
+   *   The logger channel factory.
+   */
+  public function __construct(Updater $updater, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory) {
+    $this->updater = $updater;
+    $this->configFactory = $config_factory;
+    $this->logger = $logger_factory->get('auto_updates');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('auto_updates.updater'),
+      $container->get('config.factory'),
+      $container->get('logger.factory')
+    );
+  }
+
+  /**
+   * 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) {
+      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;
+    }
+
+    $project = $recommender->getProjectInfo();
+    if (empty($project['existing_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();
+    try {
+      $this->updater->begin([
+        'drupal' => $recommended_version,
+      ]);
+      $this->updater->stage();
+      $this->updater->apply();
+      $this->updater->destroy();
+    }
+    catch (\Throwable $e) {
+      $this->logger->error($e->getMessage());
+      return;
+    }
+
+    $this->logger->info(
+      'Drupal core has been updated from %previous_version to %update_version',
+      [
+        '%previous_version' => $project['existing_version'],
+        '%update_version' => $recommended_version,
+      ]
+    );
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php b/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..a961b2b2ed6b756c0ec86fda08baadf57ef0c027
--- /dev/null
+++ b/core/modules/auto_updates/src/Event/ExcludedPathsSubscriber.php
@@ -0,0 +1,56 @@
+<?php
+
+namespace Drupal\auto_updates\Event;
+
+use Drupal\auto_updates\Updater;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Defines an event subscriber to exclude certain paths from update operations.
+ */
+class ExcludedPathsSubscriber implements EventSubscriberInterface {
+
+  /**
+   * The module list service.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleList;
+
+  /**
+   * Constructs an UpdateSubscriber.
+   *
+   * @param \Drupal\Core\Extension\ModuleExtensionList $module_list
+   *   The module list service.
+   */
+  public function __construct(ModuleExtensionList $module_list) {
+    $this->moduleList = $module_list;
+  }
+
+  /**
+   * Reacts to the beginning of an update process.
+   *
+   * @param \Drupal\package_manager\Event\PreCreateEvent $event
+   *   The event object.
+   */
+  public function preCreate(PreCreateEvent $event): void {
+    // If we are doing an automatic update and this module is a git clone,
+    // exclude it.
+    if ($event->getStage() instanceof Updater && is_dir(__DIR__ . '/../../.git')) {
+      $dir = $this->moduleList->getPath('auto_updates');
+      $event->excludePath($dir);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'preCreate',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..a1d2bfd9b42fd91275f18982400e7f5faa681a4a
--- /dev/null
+++ b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
@@ -0,0 +1,62 @@
+<?php
+
+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\ValidationResult;
+
+/**
+ * Event fired when checking if the site could perform an update.
+ *
+ * An update is not actually being started when this event is being fired. It
+ * should be used to notify site admins if the site is in a state which will
+ * not allow automatic updates to succeed.
+ *
+ * This event should only be dispatched from ReadinessValidationManager to
+ * allow caching of the results.
+ *
+ * @see \Drupal\auto_updates\Validation\ReadinessValidationManager
+ */
+class ReadinessCheckEvent extends PreOperationStageEvent {
+
+  /**
+   * The desired package versions to update to, keyed by package name.
+   *
+   * @var string[]
+   */
+  protected $packageVersions;
+
+  /**
+   * Constructs a ReadinessCheckEvent object.
+   *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater service.
+   * @param string[] $package_versions
+   *   (optional) The desired package versions to update to, keyed by package
+   *   name.
+   */
+  public function __construct(Updater $updater, array $package_versions = []) {
+    parent::__construct($updater);
+    $this->packageVersions = $package_versions;
+  }
+
+  /**
+   * Returns the desired package versions to update to.
+   *
+   * @return string[]
+   *   The desired package versions to update to, keyed by package name.
+   */
+  public function getPackageVersions(): array {
+    return $this->packageVersions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL) {
+    $this->results[] = ValidationResult::createWarning($messages, $summary);
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Exception/UpdateException.php b/core/modules/auto_updates/src/Exception/UpdateException.php
new file mode 100644
index 0000000000000000000000000000000000000000..98cc013f0aae3df5462fec27c8f2dc048dbb422a
--- /dev/null
+++ b/core/modules/auto_updates/src/Exception/UpdateException.php
@@ -0,0 +1,11 @@
+<?php
+
+namespace Drupal\auto_updates\Exception;
+
+use Drupal\package_manager\Exception\StageValidationException;
+
+/**
+ * Defines a custom exception for a failure during an update.
+ */
+class UpdateException extends StageValidationException {
+}
diff --git a/core/modules/auto_updates/src/Form/UpdateReady.php b/core/modules/auto_updates/src/Form/UpdateReady.php
new file mode 100644
index 0000000000000000000000000000000000000000..7a293aad812809476e5fdfb73283f47aa2ed28f3
--- /dev/null
+++ b/core/modules/auto_updates/src/Form/UpdateReady.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\auto_updates\Form;
+
+use Drupal\auto_updates\BatchProcessor;
+use Drupal\auto_updates\Updater;
+use Drupal\Core\Batch\BatchBuilder;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Exception\StageOwnershipException;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a form to commit staged updates.
+ *
+ * @internal
+ *   Form classes are internal.
+ */
+class UpdateReady extends FormBase {
+
+  /**
+   * The updater service.
+   *
+   * @var \Drupal\auto_updates\Updater
+   */
+  protected $updater;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * Constructs a new UpdateReady object.
+   *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater service.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   */
+  public function __construct(Updater $updater, MessengerInterface $messenger, StateInterface $state) {
+    $this->updater = $updater;
+    $this->setMessenger($messenger);
+    $this->state = $state;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'auto_updates_update_ready_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('auto_updates.updater'),
+      $container->get('messenger'),
+      $container->get('state')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state, string $stage_id = NULL) {
+    try {
+      $this->updater->claim($stage_id);
+    }
+    catch (StageOwnershipException $e) {
+      $this->messenger()->addError($this->t('Cannot continue the update because another Composer operation is currently in progress.'));
+      return $form;
+    }
+
+    $form['stage_id'] = [
+      '#type' => 'value',
+      '#value' => $stage_id,
+    ];
+
+    $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']['submit'] = [
+      '#type' => 'submit',
+      '#value' => $this->t('Continue'),
+    ];
+
+    return $form;
+  }
+
+  /**
+   * {@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->state->set('system.maintenance_mode', TRUE);
+      // @todo unset after updater. After db update?
+    }
+    $stage_id = $form_state->getValue('stage_id');
+    $batch = (new BatchBuilder())
+      ->setTitle($this->t('Apply updates'))
+      ->setInitMessage($this->t('Preparing to apply updates'))
+      ->addOperation([BatchProcessor::class, 'commit'], [$stage_id])
+      ->addOperation([BatchProcessor::class, 'clean'], [$stage_id])
+      ->setFinishCallback([BatchProcessor::class, 'finishCommit'])
+      ->toArray();
+
+    batch_set($batch);
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Form/UpdaterForm.php b/core/modules/auto_updates/src/Form/UpdaterForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..ff5899e9492e4945e8e26f8cd27ab3c23ece04a9
--- /dev/null
+++ b/core/modules/auto_updates/src/Form/UpdaterForm.php
@@ -0,0 +1,259 @@
+<?php
+
+namespace Drupal\auto_updates\Form;
+
+use Drupal\auto_updates\BatchProcessor;
+use Drupal\auto_updates\Updater;
+use Drupal\auto_updates\UpdateRecommender;
+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\system\SystemManager;
+use Drupal\update\UpdateManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a form to update Drupal core.
+ *
+ * @internal
+ *   Form classes are internal.
+ */
+class UpdaterForm extends FormBase {
+
+  /**
+   * The updater service.
+   *
+   * @var \Drupal\auto_updates\Updater
+   */
+  protected $updater;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The readiness validation manager service.
+   *
+   * @var \Drupal\auto_updates\Validation\ReadinessValidationManager
+   */
+  protected $readinessValidationManager;
+
+  /**
+   * Constructs a new UpdaterForm object.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   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.
+   */
+  public function __construct(StateInterface $state, Updater $updater, ReadinessValidationManager $readiness_validation_manager) {
+    $this->updater = $updater;
+    $this->state = $state;
+    $this->readinessValidationManager = $readiness_validation_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'auto_updates_updater_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('state'),
+      $container->get('auto_updates.updater'),
+      $container->get('auto_updates.readiness_validation_manager')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  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.'));
+
+    $form['last_check'] = [
+      '#theme' => 'update_last_check',
+      '#last' => $this->state->get('update.last_check', 0),
+    ];
+
+    $recommender = new UpdateRecommender();
+    try {
+      $recommended_release = $recommender->getRecommendedRelease(TRUE);
+    }
+    catch (\RuntimeException $e) {
+      $form['message'] = [
+        '#markup' => $e->getMessage(),
+      ];
+      return $form;
+    }
+
+    // @todo Should we be using the Update module's library here, or our own?
+    $form['#attached']['library'][] = 'update/drupal.update.admin';
+
+    // If we're already up-to-date, there's nothing else we need to do.
+    if ($recommended_release === NULL) {
+      $this->messenger()->addMessage('No update available');
+      return $form;
+    }
+
+    $form['update_version'] = [
+      '#type' => 'value',
+      '#value' => [
+        'drupal' => $recommended_release->getVersion(),
+      ],
+    ];
+
+    $project = $recommender->getProjectInfo();
+    if (empty($project['title']) || empty($project['link'])) {
+      throw new \UnexpectedValueException('Expected project data to have a title and link.');
+    }
+    $title = Link::fromTextAndUrl($project['title'], Url::fromUri($project['link']))->toRenderable();
+
+    switch ($project['status']) {
+      case UpdateManagerInterface::NOT_SECURE:
+      case UpdateManagerInterface::REVOKED:
+        $title['#suffix'] = ' ' . $this->t('(Security update)');
+        $type = 'update-security';
+        break;
+
+      case UpdateManagerInterface::NOT_SUPPORTED:
+        $title['#suffix'] = ' ' . $this->t('(Unsupported)');
+        $type = 'unsupported';
+        break;
+
+      default:
+        $type = 'recommended';
+        break;
+    }
+
+    // Create an entry for this project.
+    $entry = [
+      'title' => [
+        'data' => $title,
+      ],
+      'installed_version' => $project['existing_version'],
+      'recommended_version' => [
+        'data' => [
+          // @todo Is an inline template the right tool here? Is there an Update
+          // module template we should use instead?
+          '#type' => 'inline_template',
+          '#template' => '{{ release_version }} (<a href="{{ release_link }}" title="{{ project_title }}">{{ release_notes }}</a>)',
+          '#context' => [
+            'release_version' => $recommended_release->getVersion(),
+            'release_link' => $recommended_release->getReleaseUrl(),
+            'project_title' => $this->t('Release notes for @project_title', ['@project_title' => $project['title']]),
+            'release_notes' => $this->t('Release notes'),
+          ],
+        ],
+      ],
+    ];
+
+    $form['projects'] = [
+      '#type' => 'table',
+      '#header' => [
+        'title' => [
+          'data' => $this->t('Name'),
+          'class' => ['update-project-name'],
+        ],
+        'installed_version' => $this->t('Installed version'),
+        'recommended_version' => [
+          'data' => $this->t('Recommended version'),
+        ],
+      ],
+      '#rows' => [
+        'drupal' => [
+          'class' => "update-$type",
+          'data' => $entry,
+        ],
+      ],
+    ];
+
+    // @todo Add a hasErrors() or getErrors() method to
+    // ReadinessValidationManager to make validation more introspectable.
+    // Re-running the readiness checks now should mean that when we display
+    // cached errors in auto_updates_page_top(), we'll see errors that
+    // were raised during this run, instead of any previously cached results.
+    $errors = $this->readinessValidationManager->run()
+      ->getResults(SystemManager::REQUIREMENT_ERROR);
+
+    if (empty($errors)) {
+      $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
+      // 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'] = [
+        '#type' => 'submit',
+        '#value' => $this->t('Delete existing update'),
+        '#submit' => ['::deleteExistingUpdate'],
+      ];
+    }
+    else {
+      $actions['submit'] = [
+        '#type' => 'submit',
+        '#value' => $this->t('Update'),
+      ];
+    }
+    return $actions;
+  }
+
+  /**
+   * 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"));
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function submitForm(array &$form, FormStateInterface $form_state) {
+    $batch = (new BatchBuilder())
+      ->setTitle($this->t('Downloading updates'))
+      ->setInitMessage($this->t('Preparing to download updates'))
+      ->addOperation(
+        [BatchProcessor::class, 'begin'],
+        [$form_state->getValue('update_version')]
+      )
+      ->addOperation([BatchProcessor::class, 'stage'])
+      ->setFinishCallback([BatchProcessor::class, 'finishStage'])
+      ->toArray();
+
+    batch_set($batch);
+  }
+
+}
diff --git a/core/modules/auto_updates/src/UpdateRecommender.php b/core/modules/auto_updates/src/UpdateRecommender.php
new file mode 100644
index 0000000000000000000000000000000000000000..adf09b2ee1f12c5e08e8209ffffba6261283febf
--- /dev/null
+++ b/core/modules/auto_updates/src/UpdateRecommender.php
@@ -0,0 +1,69 @@
+<?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
new file mode 100644
index 0000000000000000000000000000000000000000..a5745c766b2c310f0dd4c572c1aad5e1f117c6af
--- /dev/null
+++ b/core/modules/auto_updates/src/Updater.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\auto_updates;
+
+use Drupal\auto_updates\Exception\UpdateException;
+use Drupal\package_manager\ComposerUtility;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\Stage;
+
+/**
+ * Defines a service to perform updates.
+ */
+class Updater extends Stage {
+
+  /**
+   * Begins the update.
+   *
+   * @param string[] $project_versions
+   *   The versions of the packages to update to, keyed by package name.
+   *
+   * @return string
+   *   The unique ID of the stage.
+   *
+   * @throws \InvalidArgumentException
+   *   Thrown if no project version for Drupal core is provided.
+   */
+  public function begin(array $project_versions): string {
+    if (count($project_versions) !== 1 || !array_key_exists('drupal', $project_versions)) {
+      throw new \InvalidArgumentException("Currently only updates to Drupal core are supported.");
+    }
+
+    $composer = ComposerUtility::createForDirectory($this->pathLocator->getActiveDirectory());
+    $package_versions = [
+      'production' => [],
+      'dev' => [],
+    ];
+
+    foreach ($composer->getCorePackageNames() as $package) {
+      $package_versions['production'][$package] = $project_versions['drupal'];
+    }
+    foreach ($composer->getCoreDevPackageNames() as $package) {
+      $package_versions['dev'][$package] = $project_versions['drupal'];
+    }
+
+    // Ensure that package versions are available to pre-create event
+    // subscribers. We can't use ::setMetadata() here because it requires the
+    // stage to be claimed, but that only happens during ::create().
+    $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, [
+      'packages' => $package_versions,
+    ]);
+    return $this->create();
+  }
+
+  /**
+   * Returns the package versions that will be required during the update.
+   *
+   * @return string[][]
+   *   An array with two sub-arrays: 'production' and 'dev'. Each is a set of
+   *   package versions, where the keys are package names and the values are
+   *   version constraints understood by Composer.
+   */
+  public function getPackageVersions(): array {
+    return $this->getMetadata('packages');
+  }
+
+  /**
+   * Stages the update.
+   */
+  public function stage(): void {
+    $this->checkOwnership();
+
+    // Convert an associative array of package versions, keyed by name, to
+    // command-line arguments in the form `vendor/name:version`.
+    $map = function (array $versions): array {
+      $requirements = [];
+      foreach ($versions as $package => $version) {
+        $requirements[] = "$package:$version";
+      }
+      return $requirements;
+    };
+    $versions = array_map($map, $this->getPackageVersions());
+
+    $this->require($versions['production']);
+
+    if ($versions['dev']) {
+      $this->require($versions['dev'], TRUE);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function dispatch(StageEvent $event): void {
+    try {
+      parent::dispatch($event);
+    }
+    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
new file mode 100644
index 0000000000000000000000000000000000000000..0878e0e3e8c37dcfb321c4e99c2c629fd8db9f6e
--- /dev/null
+++ b/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Drupal\auto_updates\Validation;
+
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Messenger\MessengerTrait;
+use Drupal\Core\Routing\AdminContext;
+use Drupal\Core\Routing\CurrentRouteMatch;
+use Drupal\Core\Routing\RedirectDestinationTrait;
+use Drupal\Core\Session\AccountProxyInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Core\Url;
+use Drupal\system\SystemManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Class for displaying readiness messages on admin pages.
+ *
+ * @internal
+ *   This class implements logic to output the messages from readiness checkers
+ *   on admin pages. It should not be called directly.
+ */
+final class AdminReadinessMessages implements ContainerInjectionInterface {
+
+  use MessengerTrait;
+  use StringTranslationTrait;
+  use RedirectDestinationTrait;
+  use ReadinessTrait;
+
+  /**
+   * The readiness checker manager.
+   *
+   * @var \Drupal\auto_updates\Validation\ReadinessValidationManager
+   */
+  protected $readinessCheckerManager;
+
+  /**
+   * The admin context service.
+   *
+   * @var \Drupal\Core\Routing\AdminContext
+   */
+  protected $adminContext;
+
+  /**
+   * The current user.
+   *
+   * @var \Drupal\Core\Session\AccountProxyInterface
+   */
+  protected $currentUser;
+
+  /**
+   * The current route match.
+   *
+   * @var \Drupal\Core\Routing\CurrentRouteMatch
+   */
+  protected $currentRouteMatch;
+
+  /**
+   * Constructs a ReadinessRequirement object.
+   *
+   * @param \Drupal\auto_updates\Validation\ReadinessValidationManager $readiness_checker_manager
+   *   The readiness checker manager service.
+   * @param \Drupal\Core\Messenger\MessengerInterface $messenger
+   *   The messenger service.
+   * @param \Drupal\Core\Routing\AdminContext $admin_context
+   *   The admin context service.
+   * @param \Drupal\Core\Session\AccountProxyInterface $current_user
+   *   The current user.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   * @param \Drupal\Core\Routing\CurrentRouteMatch $current_route_match
+   *   The current route match.
+   */
+  public function __construct(ReadinessValidationManager $readiness_checker_manager, MessengerInterface $messenger, AdminContext $admin_context, AccountProxyInterface $current_user, TranslationInterface $translation, CurrentRouteMatch $current_route_match) {
+    $this->readinessCheckerManager = $readiness_checker_manager;
+    $this->setMessenger($messenger);
+    $this->adminContext = $admin_context;
+    $this->currentUser = $current_user;
+    $this->setStringTranslation($translation);
+    $this->currentRouteMatch = $current_route_match;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container): self {
+    return new static(
+      $container->get('auto_updates.readiness_validation_manager'),
+      $container->get('messenger'),
+      $container->get('router.admin_context'),
+      $container->get('current_user'),
+      $container->get('string_translation'),
+      $container->get('current_route_match')
+    );
+  }
+
+  /**
+   * Displays the checker results messages on admin pages.
+   */
+  public function displayAdminPageMessages(): void {
+    if (!$this->displayResultsOnCurrentPage()) {
+      return;
+    }
+    if ($this->readinessCheckerManager->getResults() === NULL) {
+      $checker_url = Url::fromRoute('auto_updates.update_readiness')->setOption('query', $this->getDestinationArray());
+      if ($checker_url->access()) {
+        $this->messenger()->addError($this->t('Your site has not recently run an update readiness check. <a href=":url">Run readiness checks now.</a>', [
+          ':url' => $checker_url->toString(),
+        ]));
+      }
+    }
+    else {
+      // Display errors, if there are any. If there aren't, then display
+      // warnings, if there are any.
+      if (!$this->displayResultsForSeverity(SystemManager::REQUIREMENT_ERROR)) {
+        $this->displayResultsForSeverity(SystemManager::REQUIREMENT_WARNING);
+      }
+    }
+  }
+
+  /**
+   * Determines whether the messages should be displayed on the current page.
+   *
+   * @return bool
+   *   Whether the messages should be displayed on the current page.
+   */
+  protected function displayResultsOnCurrentPage(): bool {
+    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',
+      ];
+      return !in_array($this->currentRouteMatch->getRouteName(), $disabled_routes, TRUE);
+    }
+    return FALSE;
+  }
+
+  /**
+   * Displays the results for severity.
+   *
+   * @param int $severity
+   *   The severity for the results to display. Should be one of the
+   *   SystemManager::REQUIREMENT_* constants.
+   *
+   * @return bool
+   *   Whether any results were displayed.
+   */
+  protected function displayResultsForSeverity(int $severity): bool {
+    $results = $this->readinessCheckerManager->getResults($severity);
+    if (empty($results)) {
+      return FALSE;
+    }
+    $failure_message = $this->getFailureMessageForSeverity($severity);
+    if ($severity === SystemManager::REQUIREMENT_ERROR) {
+      $this->messenger()->addError($failure_message);
+    }
+    else {
+      $this->messenger()->addWarning($failure_message);
+    }
+
+    foreach ($results as $result) {
+      $messages = $result->getMessages();
+      $message = count($messages) === 1 ? $messages[0] : $result->getSummary();
+      if ($severity === SystemManager::REQUIREMENT_ERROR) {
+        $this->messenger()->addError($message);
+      }
+      else {
+        $this->messenger()->addWarning($message);
+      }
+    }
+    return TRUE;
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validation/ReadinessRequirements.php b/core/modules/auto_updates/src/Validation/ReadinessRequirements.php
new file mode 100644
index 0000000000000000000000000000000000000000..9a1e706264be12c588d7b92b85b22ed5cc0dc97f
--- /dev/null
+++ b/core/modules/auto_updates/src/Validation/ReadinessRequirements.php
@@ -0,0 +1,173 @@
+<?php
+
+namespace Drupal\auto_updates\Validation;
+
+use Drupal\Core\Datetime\DateFormatterInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Core\Url;
+use Drupal\system\SystemManager;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Class for generating the readiness checkers' output for hook_requirements().
+ *
+ * @see auto_updates_requirements()
+ *
+ * @internal
+ *   This class implements logic to output the messages from readiness checkers
+ *   on the status report page. It should not be called directly.
+ */
+final class ReadinessRequirements implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+  use ReadinessTrait;
+
+  /**
+   * The readiness checker manager.
+   *
+   * @var \Drupal\auto_updates\Validation\ReadinessValidationManager
+   */
+  protected $readinessCheckerManager;
+
+  /**
+   * The date formatter service.
+   *
+   * @var \Drupal\Core\Datetime\DateFormatterInterface
+   */
+  protected $dateFormatter;
+
+  /**
+   * Constructor ReadinessRequirement object.
+   *
+   * @param \Drupal\auto_updates\Validation\ReadinessValidationManager $readiness_checker_manager
+   *   The readiness checker manager service.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   * @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter
+   *   The date formatter service.
+   */
+  public function __construct(ReadinessValidationManager $readiness_checker_manager, TranslationInterface $translation, DateFormatterInterface $date_formatter) {
+    $this->readinessCheckerManager = $readiness_checker_manager;
+    $this->setStringTranslation($translation);
+    $this->dateFormatter = $date_formatter;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container): self {
+    return new static(
+      $container->get('auto_updates.readiness_validation_manager'),
+      $container->get('string_translation'),
+      $container->get('date.formatter')
+    );
+  }
+
+  /**
+   * Gets requirements arrays as specified in hook_requirements().
+   *
+   * @return array[]
+   *   Requirements arrays as specified by hook_requirements().
+   */
+  public function getRequirements(): array {
+    $results = $this->readinessCheckerManager->runIfNoStoredResults()->getResults();
+    $requirements = [];
+    if (empty($results)) {
+      $requirements['auto_updates_readiness'] = [
+        'title' => $this->t('Update readiness checks'),
+        'severity' => SystemManager::REQUIREMENT_OK,
+        // @todo Link "automatic updates" to documentation in
+        //   https://www.drupal.org/node/3168405.
+        'value' => $this->t('Your site is ready for automatic updates.'),
+      ];
+      $run_link = $this->createRunLink();
+      if ($run_link) {
+        $requirements['auto_updates_readiness']['description'] = $run_link;
+      }
+    }
+    else {
+      foreach ([SystemManager::REQUIREMENT_WARNING, SystemManager::REQUIREMENT_ERROR] as $severity) {
+        if ($requirement = $this->createRequirementForSeverity($severity)) {
+          $requirements["auto_updates_readiness_$severity"] = $requirement;
+        }
+      }
+    }
+    return $requirements;
+  }
+
+  /**
+   * Creates a requirement for checker results of a specific severity.
+   *
+   * @param int $severity
+   *   The severity for requirement. Should be one of the
+   *   SystemManager::REQUIREMENT_* constants.
+   *
+   * @return mixed[]|null
+   *   Requirements array as specified by hook_requirements(), or NULL
+   *   if no requirements can be determined.
+   */
+  protected function createRequirementForSeverity(int $severity): ?array {
+    $severity_messages = [];
+    $results = $this->readinessCheckerManager->getResults($severity);
+    if (!$results) {
+      return NULL;
+    }
+    foreach ($results as $result) {
+      $checker_messages = $result->getMessages();
+      if (count($checker_messages) === 1) {
+        $severity_messages[] = ['#markup' => array_pop($checker_messages)];
+      }
+      else {
+        $severity_messages[] = [
+          '#type' => 'details',
+          '#title' => $result->getSummary(),
+          '#open' => FALSE,
+          'messages' => [
+            '#theme' => 'item_list',
+            '#items' => $checker_messages,
+          ],
+        ];
+      }
+    }
+    $requirement = [
+      'title' => $this->t('Update readiness checks'),
+      'severity' => $severity,
+      'value' => $this->getFailureMessageForSeverity($severity),
+      'description' => [
+        'messages' => [
+          '#theme' => 'item_list',
+          '#items' => $severity_messages,
+        ],
+      ],
+    ];
+    if ($run_link = $this->createRunLink()) {
+      $requirement['description']['run_link'] = [
+        '#type' => 'container',
+        '#markup' => $run_link,
+      ];
+    }
+    return $requirement;
+  }
+
+  /**
+   * Creates a link to run the readiness checkers.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup|null
+   *   A link, if the user has access to run the readiness checkers, otherwise
+   *   NULL.
+   */
+  protected function createRunLink(): ?TranslatableMarkup {
+    $readiness_check_url = Url::fromRoute('auto_updates.update_readiness');
+    if ($readiness_check_url->access()) {
+      return $this->t(
+        '<a href=":link">Run readiness checks</a> now.',
+        [':link' => $readiness_check_url->toString()]
+      );
+    }
+    return NULL;
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validation/ReadinessTrait.php b/core/modules/auto_updates/src/Validation/ReadinessTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..c7482c7a1e155df3181cc11b0d54302e5a306d11
--- /dev/null
+++ b/core/modules/auto_updates/src/Validation/ReadinessTrait.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\auto_updates\Validation;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\system\SystemManager;
+
+/**
+ * Common methods for working with readiness checkers.
+ */
+trait ReadinessTrait {
+
+  /**
+   * Gets a message, based on severity, when readiness checkers fail.
+   *
+   * @param int $severity
+   *   The severity. Should be one of the SystemManager::REQUIREMENT_*
+   *   constants.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+   *   The message.
+   *
+   * @see \Drupal\system\SystemManager::REQUIREMENT_ERROR
+   * @see \Drupal\system\SystemManager::REQUIREMENT_WARNING
+   */
+  protected function getFailureMessageForSeverity(int $severity): TranslatableMarkup {
+    return $severity === SystemManager::REQUIREMENT_WARNING ?
+      // @todo Link "automatic updates" to documentation in
+      //   https://www.drupal.org/node/3168405.
+      $this->t('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might affect the eligibility for automatic updates.') :
+      $this->t('Your site does not pass some readiness checks for automatic updates. It cannot be automatically updated until further action is performed.');
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..a169cd49728d4dc1fbfd1fb3523d61aec696f38d
--- /dev/null
+++ b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php
@@ -0,0 +1,210 @@
+<?php
+
+namespace Drupal\auto_updates\Validation;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\Updater;
+use Drupal\auto_updates\UpdateRecommender;
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
+use Drupal\package_manager\ComposerUtility;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Defines a manager to run readiness validation.
+ */
+class ReadinessValidationManager {
+
+  /**
+   * The key/value expirable storage.
+   *
+   * @var \Drupal\Core\KeyValueStore\KeyValueStoreExpirableInterface
+   */
+  protected $keyValueExpirable;
+
+  /**
+   * The time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * The event dispatcher service.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   */
+  protected $eventDispatcher;
+
+  /**
+   * The number of hours to store results.
+   *
+   * @var int
+   */
+  protected $resultsTimeToLive;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * The updater service.
+   *
+   * @var \Drupal\auto_updates\Updater
+   */
+  protected $updater;
+
+  /**
+   * Constructs a ReadinessValidationManager.
+   *
+   * @param \Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface $key_value_expirable_factory
+   *   The key/value expirable factory.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher
+   *   The event dispatcher service.
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater service.
+   * @param int $results_time_to_live
+   *   The number of hours to store results.
+   */
+  public function __construct(KeyValueExpirableFactoryInterface $key_value_expirable_factory, TimeInterface $time, PathLocator $path_locator, EventDispatcherInterface $dispatcher, Updater $updater, int $results_time_to_live) {
+    $this->keyValueExpirable = $key_value_expirable_factory->get('auto_updates');
+    $this->time = $time;
+    $this->pathLocator = $path_locator;
+    $this->eventDispatcher = $dispatcher;
+    $this->updater = $updater;
+    $this->resultsTimeToLive = $results_time_to_live;
+  }
+
+  /**
+   * Dispatches the readiness check event and stores the results.
+   *
+   * @return $this
+   */
+  public function run(): self {
+    $composer = ComposerUtility::createForDirectory($this->pathLocator->getActiveDirectory());
+
+    $recommender = new UpdateRecommender();
+    $release = $recommender->getRecommendedRelease(TRUE);
+    if ($release) {
+      $core_packages = $composer->getCorePackageNames();
+      // Update all core packages to the same version.
+      $package_versions = array_fill(0, count($core_packages), $release->getVersion());
+      $package_versions = array_combine($core_packages, $package_versions);
+    }
+    else {
+      $package_versions = [];
+    }
+    $event = new ReadinessCheckEvent($this->updater, $package_versions);
+    $this->eventDispatcher->dispatch($event);
+    $results = $event->getResults();
+    $this->keyValueExpirable->setWithExpire(
+      'readiness_validation_last_run',
+      [
+        'results' => $results,
+        'listeners' => $this->getListenersAsString(ReadinessCheckEvent::class),
+      ],
+      $this->resultsTimeToLive * 60 * 60
+    );
+    $this->keyValueExpirable->set('readiness_check_timestamp', $this->time->getRequestTime());
+    return $this;
+  }
+
+  /**
+   * Gets all the listeners for a specific event as single string.
+   *
+   * @return string
+   *   The listeners as a string.
+   */
+  protected function getListenersAsString(string $event_name): string {
+    $listeners = $this->eventDispatcher->getListeners($event_name);
+    $string = '';
+    foreach ($listeners as $listener) {
+      /** @var object $object */
+      $object = $listener[0];
+      $method = $listener[1];
+      $string .= '-' . get_class($object) . '::' . $method;
+    }
+    return $string;
+  }
+
+  /**
+   * Dispatches the readiness check event if there no stored valid results.
+   *
+   * @return $this
+   *
+   * @see self::getResults()
+   * @see self::getStoredValidResults()
+   */
+  public function runIfNoStoredResults(): self {
+    if ($this->getResults() === NULL) {
+      $this->run();
+    }
+    return $this;
+  }
+
+  /**
+   * Gets the validation results from the last run.
+   *
+   * @param int|null $severity
+   *   (optional) The severity for the results to return. Should be one of the
+   *   SystemManager::REQUIREMENT_* constants.
+   *
+   * @return \Drupal\package_manager\ValidationResult[]|
+   *   The validation result objects or NULL if no results are
+   *   available or if the stored results are no longer valid.
+   *
+   * @see self::getStoredValidResults()
+   */
+  public function getResults(?int $severity = NULL): ?array {
+    $results = $this->getStoredValidResults();
+    if ($results !== NULL) {
+      if ($severity !== NULL) {
+        $results = array_filter($results, function ($result) use ($severity) {
+          return $result->getSeverity() === $severity;
+        });
+      }
+      return $results;
+    }
+    return NULL;
+  }
+
+  /**
+   * Gets stored valid results, if any.
+   *
+   * The stored results are considered valid if the current listeners for the
+   * readiness check event are the same as the last time the event was
+   * dispatched.
+   *
+   * @return \Drupal\package_manager\ValidationResult[]|null
+   *   The stored results if available and still valid, otherwise null.
+   */
+  protected function getStoredValidResults(): ?array {
+    $last_run = $this->keyValueExpirable->get('readiness_validation_last_run');
+
+    // If the listeners have not changed return the results.
+    if ($last_run && $last_run['listeners'] === $this->getListenersAsString(ReadinessCheckEvent::class)) {
+      return $last_run['results'];
+    }
+    return NULL;
+  }
+
+  /**
+   * Gets the timestamp of the last run.
+   *
+   * @return int|null
+   *   The timestamp of the last completed run, or NULL if no run has
+   *   been completed.
+   */
+  public function getLastRunTime(): ?int {
+    return $this->keyValueExpirable->get('readiness_check_timestamp');
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/CoreComposerValidator.php b/core/modules/auto_updates/src/Validator/CoreComposerValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..43ae5582f377d1593d31e0a7230239b942fff390
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/CoreComposerValidator.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates the Drupal core requirements defined in composer.json.
+ */
+class CoreComposerValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Validates the Drupal core requirements in composer.json.
+   *
+   * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event
+   *   The event object.
+   */
+  public function checkCoreRequirements(ReadinessCheckEvent $event): void {
+    // Ensure that either drupal/core or drupal/core-recommended is required.
+    // If neither is, then core cannot be updated, which we consider an error
+    // condition.
+    $core_requirements = array_intersect(
+      $event->getStage()->getActiveComposer()->getCorePackageNames(),
+      ['drupal/core', 'drupal/core-recommended']
+    );
+    if (empty($core_requirements)) {
+      $event->addError([
+        $this->t('Drupal core does not appear to be required in the project-level composer.json.'),
+      ]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => ['checkCoreRequirements', 1000],
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php b/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..1f86460908fff0d10b388bc6cebc65eb77faad85
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php
@@ -0,0 +1,180 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Drupal\auto_updates\CronUpdater;
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Core\Url;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates that cron runs frequently enough to perform automatic updates.
+ */
+class CronFrequencyValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The error-level interval between cron runs, in seconds.
+   *
+   * If cron runs less frequently than this, an error will be raised during
+   * validation. Defaults to 24 hours.
+   *
+   * @var int
+   */
+  protected const ERROR_INTERVAL = 86400;
+
+  /**
+   * The warning-level interval between cron runs, in seconds.
+   *
+   * If cron runs less frequently than this, a warning will be raised during
+   * validation. Defaults to 3 hours.
+   *
+   * @var int
+   */
+  protected const WARNING_INTERVAL = 10800;
+
+  /**
+   * The cron frequency, in hours, to suggest in errors or warnings.
+   *
+   * @var int
+   */
+  protected const SUGGESTED_INTERVAL = self::WARNING_INTERVAL / 3600;
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * CronFrequencyValidator constructor.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler, StateInterface $state, TimeInterface $time, TranslationInterface $translation) {
+    $this->configFactory = $config_factory;
+    $this->moduleHandler = $module_handler;
+    $this->state = $state;
+    $this->time = $time;
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * Validates that cron runs frequently enough to perform automatic updates.
+   *
+   * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event
+   *   The event object.
+   */
+  public function checkCronFrequency(ReadinessCheckEvent $event): void {
+    $cron_enabled = $this->configFactory->get('auto_updates.settings')
+      ->get('cron');
+
+    // If automatic updates are disabled during cron, there's nothing we need
+    // to validate.
+    if ($cron_enabled === CronUpdater::DISABLED) {
+      return;
+    }
+    elseif ($this->moduleHandler->moduleExists('automated_cron')) {
+      $this->validateAutomatedCron($event);
+    }
+    else {
+      $this->validateLastCronRun($event);
+    }
+  }
+
+  /**
+   * Validates the cron frequency according to Automated Cron settings.
+   *
+   * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event
+   *   The event object.
+   */
+  protected function validateAutomatedCron(ReadinessCheckEvent $event): void {
+    $message = $this->t('Cron is not set to run frequently enough. <a href=":configure">Configure it</a> to run at least every @frequency hours or disable automated cron and run it via an external scheduling system.', [
+      ':configure' => Url::fromRoute('system.cron_settings')->toString(),
+      '@frequency' => static::SUGGESTED_INTERVAL,
+    ]);
+
+    $interval = $this->configFactory->get('automated_cron.settings')->get('interval');
+
+    if ($interval > static::ERROR_INTERVAL) {
+      $event->addError([$message]);
+    }
+    elseif ($interval > static::WARNING_INTERVAL) {
+      $event->addWarning([$message]);
+    }
+  }
+
+  /**
+   * Validates the cron frequency according to the last cron run time.
+   *
+   * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event
+   *   The event object.
+   */
+  protected function validateLastCronRun(ReadinessCheckEvent $event): void {
+    // Determine when cron last ran. If not known, use the time that Drupal was
+    // installed, defaulting to the beginning of the Unix epoch.
+    $cron_last = $this->state->get('system.cron_last', $this->state->get('install_time', 0));
+
+    // @todo Should we allow a little extra time in case the server job takes
+    //   longer than expected? Otherwise a server setup with a 3-hour cron job
+    //   will always give this warning. Maybe this isn't necessary because the
+    //   last cron run time is recorded after cron runs. Address this in
+    //   https://www.drupal.org/project/auto_updates/issues/3248544.
+    if ($this->time->getRequestTime() - $cron_last > static::WARNING_INTERVAL) {
+      $event->addError([
+        $this->t('Cron has not run recently. For more information, see the online handbook entry for <a href=":cron-handbook">configuring cron jobs</a> to run at least every @frequency hours.', [
+          ':cron-handbook' => 'https://www.drupal.org/cron',
+          '@frequency' => static::SUGGESTED_INTERVAL,
+        ]),
+      ]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => 'checkCronFrequency',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php b/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php
new file mode 100644
index 0000000000000000000000000000000000000000..b8a6dbe9317365e5bbbfdaf698967e7d24d2d8ba
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * An adapter to run another stage validator during readiness checking.
+ *
+ * This class exists to facilitate re-use of Package Manager's stage validators
+ * during update readiness checks, in addition to whatever events they normally
+ * subscribe to.
+ */
+class PackageManagerReadinessCheck implements EventSubscriberInterface {
+
+  /**
+   * The validator to run.
+   *
+   * @var \Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface
+   */
+  protected $validator;
+
+  /**
+   * Constructs a PackageManagerReadinessCheck object.
+   *
+   * @param \Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface $validator
+   *   The Package Manager validator to run during readiness checking.
+   */
+  public function __construct(PreOperationStageValidatorInterface $validator) {
+    $this->validator = $validator;
+  }
+
+  /**
+   * Performs a readiness check by proxying to a Package Manager validator.
+   *
+   * @param \Drupal\auto_updates\Event\ReadinessCheckEvent $event
+   *   The event object.
+   */
+  public function validate(ReadinessCheckEvent $event): void {
+    $this->validator->validateStagePreOperation($event);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => 'validate',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php b/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..f82b93b11234ffd72cd1088181e8a82928cca04f
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php
@@ -0,0 +1,135 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Drupal\auto_updates\Updater;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates the staged Drupal projects.
+ */
+final class StagedProjectsValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Constructs a StagedProjectsValidation object.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   */
+  public function __construct(TranslationInterface $translation) {
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * Validates the staged packages.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   */
+  public function validateStagedProjects(PreApplyEvent $event): void {
+    $stage = $event->getStage();
+    // We only want to do this check if the stage belongs to Automatic Updates.
+    if (!$stage instanceof Updater) {
+      return;
+    }
+
+    try {
+      $active_packages = $stage->getActiveComposer()->getDrupalExtensionPackages();
+      $staged_packages = $stage->getStageComposer()->getDrupalExtensionPackages();
+    }
+    catch (\Throwable $e) {
+      $event->addError([
+        $e->getMessage(),
+      ]);
+      return;
+    }
+
+    $type_map = [
+      'drupal-module' => $this->t('module'),
+      'drupal-custom-module' => $this->t('custom module'),
+      'drupal-theme' => $this->t('theme'),
+      'drupal-custom-theme' => $this->t('custom theme'),
+    ];
+    // Check if any new Drupal projects were installed.
+    if ($new_packages = array_diff_key($staged_packages, $active_packages)) {
+      $new_packages_messages = [];
+
+      foreach ($new_packages as $new_package) {
+        $new_packages_messages[] = $this->t(
+          "@type '@name' installed.",
+          [
+            '@type' => $type_map[$new_package->getType()],
+            '@name' => $new_package->getName(),
+          ]
+        );
+      }
+      $new_packages_summary = $this->formatPlural(
+        count($new_packages_messages),
+        'The update cannot proceed because the following Drupal project was installed during the update.',
+        'The update cannot proceed because the following Drupal projects were installed during the update.'
+      );
+      $event->addError($new_packages_messages, $new_packages_summary);
+    }
+
+    // Check if any Drupal projects were removed.
+    if ($removed_packages = array_diff_key($active_packages, $staged_packages)) {
+      $removed_packages_messages = [];
+      foreach ($removed_packages as $removed_package) {
+        $removed_packages_messages[] = $this->t(
+          "@type '@name' removed.",
+          [
+            '@type' => $type_map[$removed_package->getType()],
+            '@name' => $removed_package->getName(),
+          ]
+        );
+      }
+      $removed_packages_summary = $this->formatPlural(
+        count($removed_packages_messages),
+        'The update cannot proceed because the following Drupal project was removed during the update.',
+        'The update cannot proceed because the following Drupal projects were removed during the update.'
+      );
+      $event->addError($removed_packages_messages, $removed_packages_summary);
+    }
+
+    // Get all the packages that are neither newly installed or removed to
+    // check if their version numbers changed.
+    if ($pre_existing_packages = array_diff_key($staged_packages, $removed_packages, $new_packages)) {
+      foreach ($pre_existing_packages as $package_name => $staged_existing_package) {
+        $active_package = $active_packages[$package_name];
+        if ($staged_existing_package->getVersion() !== $active_package->getVersion()) {
+          $version_change_messages[] = $this->t(
+            "@type '@name' from @active_version to  @staged_version.",
+            [
+              '@type' => $type_map[$active_package->getType()],
+              '@name' => $active_package->getName(),
+              '@staged_version' => $staged_existing_package->getPrettyVersion(),
+              '@active_version' => $active_package->getPrettyVersion(),
+            ]
+          );
+        }
+      }
+      if (!empty($version_change_messages)) {
+        $version_change_summary = $this->formatPlural(
+          count($version_change_messages),
+          'The update cannot proceed because the following Drupal project was unexpectedly updated. Only Drupal Core updates are currently supported.',
+          'The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.'
+        );
+        $event->addError($version_change_messages, $version_change_summary);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events[PreApplyEvent::class][] = ['validateStagedProjects'];
+    return $events;
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php
new file mode 100644
index 0000000000000000000000000000000000000000..d4f8079e782210a998f313365bbebec63aa6895d
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php
@@ -0,0 +1,122 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Composer\Semver\Semver;
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\Updater;
+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 Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates that core updates are within a supported version range.
+ */
+class UpdateVersionValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Constructs a UpdateVersionValidation object.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   */
+  public function __construct(TranslationInterface $translation) {
+    $this->setStringTranslation($translation);
+  }
+
+  /**
+   * Returns the running core version, according to the Update module.
+   *
+   * @return string
+   *   The running core version as known to the Update module.
+   */
+  protected function getCoreVersion(): string {
+    // 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'];
+  }
+
+  /**
+   * Validates that core is not being updated to another minor or major version.
+   *
+   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
+   *   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) {
+      return;
+    }
+
+    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'];
+    }
+
+    $from_version_string = $this->getCoreVersion();
+    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
+    $core_package_names = $stage->getActiveComposer()->getCorePackageNames();
+    // 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 = reset($core_package_names);
+    $to_version_string = $package_versions[$core_package_name];
+    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
+    if (Semver::satisfies($to_version_string, "< $from_version_string")) {
+      $messages[] = $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', [
+        '@to_version' => $to_version_string,
+        '@from_version' => $from_version_string,
+      ]);
+      $event->addError($messages);
+    }
+    elseif ($from_version->getVersionExtra() === 'dev') {
+      $messages[] = $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.', [
+        '@to_version' => $to_version_string,
+        '@from_version' => $from_version_string,
+      ]);
+      $event->addError($messages);
+    }
+    elseif ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) {
+      $messages[] = $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.', [
+        '@to_version' => $to_version_string,
+        '@from_version' => $from_version_string,
+      ]);
+      $event->addError($messages);
+    }
+    elseif ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
+      $messages[] = $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.', [
+        '@from_version' => $this->getCoreVersion(),
+        '@to_version' => $package_versions[$core_package_name],
+      ]);
+      $event->addError($messages);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'checkUpdateVersion',
+      ReadinessCheckEvent::class => 'checkUpdateVersion',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/composer.json b/core/modules/auto_updates/tests/fixtures/fake-site/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..ed5b67b7c8e9e583c7882d67592783157034c42d
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/composer.json
@@ -0,0 +1,15 @@
+{
+    "require": {
+        "drupal/test-distribution": "*"
+    },
+    "require-dev": {
+        "drupal/core-dev": "^9"
+    },
+    "extra": {
+        "_comment": [
+            "This is a fake composer.json simulating a site which requires a distribution.",
+            "The required core packages are determined by scanning the lock file.",
+            "The required dev packages are determined by looking at the require-dev section of this file."
+        ]
+    }
+}
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/composer.lock b/core/modules/auto_updates/tests/fixtures/fake-site/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..dd29a0514d0ebdfc87484b52fc0c3256045ccb85
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/composer.lock
@@ -0,0 +1,23 @@
+{
+    "packages": [
+        {
+            "name": "drupal/test-distribution",
+            "version": "1.0.0",
+            "require": {
+                "drupal/core-recommended": "*"
+            }
+        },
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        }
+    ],
+    "packages-dev": []
+}
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt
new file mode 100644
index 0000000000000000000000000000000000000000..4f9da38be56281946118f811a12648ba1098dc02
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt
@@ -0,0 +1 @@
+This private file should never be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt
new file mode 100644
index 0000000000000000000000000000000000000000..ab6a7649fa22b6d18458337f5e20802857106066
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt
@@ -0,0 +1 @@
+This public file should never be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/staged.txt b/core/modules/auto_updates/tests/fixtures/fake-site/files/staged.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0087269e33e50d1805db3c9ecf821660384c11bc
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/files/staged.txt
@@ -0,0 +1 @@
+This file should be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..ea9529af01099087a99ff93a5dee5bc3067032e6
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml
@@ -0,0 +1,2 @@
+# A fake services file that should never be staged.
+services: {}
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php
new file mode 100644
index 0000000000000000000000000000000000000000..e54016a9d2c06052d463443c460fa28447460549
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * A fake local settings file that should never be staged.
+ */
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php
new file mode 100644
index 0000000000000000000000000000000000000000..9b995731d127cc38eaec5ca8a1d6d42c358ea0e9
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * A fake settings file that should never be staged.
+ */
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt
new file mode 100644
index 0000000000000000000000000000000000000000..0087269e33e50d1805db3c9ecf821660384c11bc
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt
@@ -0,0 +1 @@
+This file should be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt
new file mode 100644
index 0000000000000000000000000000000000000000..e4525907f2639dbf494999ff10213d23f6709dc1
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt
@@ -0,0 +1 @@
+This file should not be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..67668976a3a41b6dd7a23c72c3fd9e3e126aa0f8
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock
@@ -0,0 +1,34 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.0",
+      "type": "drupal-core"
+    },
+    {
+      "name": "drupal/test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/removed",
+      "description": "This project is removed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/dev-removed",
+      "description": "This project is removed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    }
+
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..ba776321961b650a90d5459d0e5441b5a3575912
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock
@@ -0,0 +1,43 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.1",
+      "type": "drupal-core"
+    },
+    {
+      "name": "drupal/test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/test_module2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/new_project",
+      "description": "This is newly added project but there should be no error because it is not a drupal project",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/dev-test_module2",
+      "version": "1.3.1",
+      "type": "drupal-custom-module"
+    },
+    {
+      "name": "other/dev-new_project",
+      "description": "This is newly added project but there should be no error because it is not a drupal project",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..c6748473c811451d40f5b2e0b0dc5182d7238244
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json
@@ -0,0 +1,15 @@
+{
+    "require": {
+        "drupal/core-composer-scaffold": "*",
+        "drupal/core-vendor-hardening": "*",
+        "drupal/core-project-message": "*",
+        "drupal/core-dev": "*",
+        "drupal/core-dev-pinned": "*"
+    },
+    "extra": {
+        "_comment": [
+            "This is an example composer.json that does not require Drupal core.",
+            "@see \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\RequirementsValidatorTest"
+        ]
+    }
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..b44dcb4aea1835fe4164c00b3abdff1e29337a2f
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.lock
@@ -0,0 +1,4 @@
+{
+    "packages": [],
+    "packages-dev": []
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/vendor/.gitkeep b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/vendor/.gitkeep
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..24e46dc84c259bf9a0edf3400294e65e73488615
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.lock
@@ -0,0 +1,45 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.0",
+      "type": "drupal-core"
+    },
+    {
+      "name": "drupal/test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/removed",
+      "description": "This project is removed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    },
+    {
+      "name": "other/changed",
+      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/dev-removed",
+      "description": "This project is removed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    },
+    {
+      "name": "other/dev-changed",
+      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..5b77d4cbbd893f431d2b27e18efaf489988feea0
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock
@@ -0,0 +1,45 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.1",
+      "type": "drupal-core"
+    },
+    {
+      "name": "drupal/test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/new_project",
+      "description": "This is newly added project but there should be no error because it is not a drupal project",
+      "version": "1.3.1",
+      "type": "library"
+    },
+    {
+      "name": "other/changed",
+      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.2",
+      "type": "library"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/dev-new_project",
+      "description": "This is newly added project but there should be no error because it is not a drupal project",
+      "version": "1.3.1",
+      "type": "library"
+    },
+    {
+      "name": "other/dev-changed",
+      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.2",
+      "type": "library"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..ef86aa78d8df4942a0bcf8fe67d0561b044bfe62
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.lock
@@ -0,0 +1,43 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.0",
+      "type": "drupal-core"
+    },
+    {
+      "name": "drupal/test_theme",
+      "version": "1.3.0",
+      "type": "drupal-theme"
+    },
+    {
+      "name": "drupal/test_module2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/removed",
+      "description": "This project is removed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-test_theme",
+      "version": "1.3.0",
+      "type": "drupal-custom-theme"
+    },
+    {
+      "name": "drupal/dev-test_module2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/dev-removed",
+      "description": "This project is removed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..07773fab4cc96d6d4f41f2e0d0067e3fc51f4e59
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock
@@ -0,0 +1,21 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.1",
+      "type": "drupal-core"
+    },
+    {
+      "name": "drupal/test_module2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-test_module2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..36980fee6ffcbaa1fc93d2c970f8a1d951dd4d2d
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.lock
@@ -0,0 +1,33 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.0",
+      "type": "drupal-core"
+    },
+    {
+      "name": "drupal/test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/changed",
+      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-test_module",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/dev-changed",
+      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.1",
+      "type": "library"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json
new file mode 100644
index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json
@@ -0,0 +1 @@
+{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..c16841c9dd296bbf7cbeab7bd5373bd020e38890
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock
@@ -0,0 +1,33 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.1",
+      "type": "drupal-core"
+    },
+    {
+      "name": "drupal/test_module",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/changed",
+      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.2",
+      "type": "library"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-test_module",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    },
+    {
+      "name": "other/dev-changed",
+      "description": "This project version is changed but there should be no error because it is not a Drupal project.",
+      "version": "1.3.2",
+      "type": "library"
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml
new file mode 100644
index 0000000000000000000000000000000000000000..dbcb825a86d351652f72f2ff04b74fff9f8de8d8
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml
@@ -0,0 +1,40 @@
+<?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.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.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>
+</releases>
+</project>
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4d5268378ef31b4e9c59766f2572dd76c30c730e
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1.xml
@@ -0,0 +1,38 @@
+<?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.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.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>
+  </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>
+   </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..22479e83dbfe8b32534cd419c5447e98836e85b8
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.info.yml
@@ -0,0 +1,7 @@
+name: 'Automatic Updates Test module 1'
+type: module
+description: 'Module for testing Automatic Updates.'
+package: Testing
+dependencies:
+  - drupal:auto_updates
+  - auto_updates:package_manager_test_validation
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
new file mode 100644
index 0000000000000000000000000000000000000000..8c5c2a6988f0eba74dd9556ae9d84df912049f24
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml
@@ -0,0 +1,13 @@
+auto_updates_test.metadata:
+  path: '/automatic-update-test/{project_name}/{version}'
+  defaults:
+    _title: 'Update test'
+    _controller: '\Drupal\auto_updates_test\TestController::metadata'
+  requirements:
+    _access: 'TRUE'
+auto_updates_test.update:
+  path: '/automatic-update-test/update/{to_version}'
+  defaults:
+    _controller: '\Drupal\auto_updates_test\TestController::update'
+  requirements:
+    _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
new file mode 100644
index 0000000000000000000000000000000000000000..0902f6c78358f0025bba1558972f947920bab98f
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml
@@ -0,0 +1,10 @@
+services:
+  auto_updates_test.checker:
+    class: Drupal\auto_updates_test\ReadinessChecker\TestChecker1
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@state']
+  auto_updates_test.time:
+    class: Drupal\auto_updates_test\Datetime\TestTime
+    decorates: datetime.time
+    arguments: ['@auto_updates_test.time.inner','@request_stack']
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php
new file mode 100644
index 0000000000000000000000000000000000000000..c431159eedf84bf74df5173dec5b8abfcdcbc5ae
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/Datetime/TestTime.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\auto_updates_test\Datetime;
+
+use Drupal\Component\Datetime\Time;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * Test service for altering the request time.
+ */
+class TestTime extends Time {
+
+  /**
+   * The time service.
+   *
+   * @var \Drupal\Component\Datetime\Time
+   */
+  protected $decoratorTime;
+
+  /**
+   * Constructs an Updater object.
+   *
+   * @param \Drupal\Component\Datetime\Time $time
+   *   The time service.
+   * @param \Symfony\Component\HttpFoundation\RequestStack $request_stack
+   *   The RequestStack object.
+   */
+  public function __construct(Time $time, RequestStack $request_stack) {
+    $this->decoratorTime = $time;
+    parent::__construct($request_stack);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRequestTime(): int {
+    if ($faked_date = \Drupal::state()->get('auto_updates_test.fake_date_time')) {
+      return \DateTime::createFromFormat('U', $faked_date)->getTimestamp();
+    }
+    return $this->decoratorTime->getRequestTime();
+  }
+
+  /**
+   * Sets a fake time from an offset that will be used in the test.
+   *
+   * @param string $offset
+   *   A date/time offset string as used by \DateTime::modify.
+   */
+  public static function setFakeTimeByOffset(string $offset): void {
+    $fake_time = (new \DateTime())->modify($offset)->format('U');
+    \Drupal::state()->set('auto_updates_test.fake_date_time', $fake_time);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/ReadinessChecker/TestChecker1.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/ReadinessChecker/TestChecker1.php
new file mode 100644
index 0000000000000000000000000000000000000000..0eebfba434087bdb98c8be5cfdaffd1c9625b453
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/ReadinessChecker/TestChecker1.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\auto_updates_test\ReadinessChecker;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\package_manager_test_validation\TestSubscriber;
+
+/**
+ * A test readiness checker.
+ */
+class TestChecker1 extends TestSubscriber {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events = parent::getSubscribedEvents();
+    $events[ReadinessCheckEvent::class] = reset($events);
+    return $events;
+  }
+
+}
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
new file mode 100644
index 0000000000000000000000000000000000000000..3ad87828be6f0d7b70ab66e5aba2c16b43fc44c9
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php
@@ -0,0 +1,94 @@
+<?php
+
+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;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\Response;
+
+class TestController extends ControllerBase {
+
+  /**
+   * Performs an in-place update to a given version of Drupal core.
+   *
+   * This executes the update immediately, in one request, without using the
+   * batch system or cron as wrappers.
+   *
+   * @param string $to_version
+   *   The version of core to update to.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   The response object.
+   */
+  public function update(string $to_version): Response {
+    // Let it take as long as it needs.
+    Environment::setTimeLimit(0);
+
+    /** @var \Drupal\auto_updates\Updater $updater */
+    $updater = \Drupal::service('auto_updates.updater');
+    try {
+      $updater->begin(['drupal' => $to_version]);
+      $updater->stage();
+      $updater->apply();
+      $updater->destroy();
+
+      $project = (new UpdateRecommender())->getProjectInfo();
+      $content = $project['existing_version'];
+      $status = 200;
+    }
+    catch (UpdateException $e) {
+      $messages = [];
+      foreach ($e->getResults() as $result) {
+        if ($summary = $result->getSummary()) {
+          $messages[] = $summary;
+        }
+        $messages = array_merge($messages, $result->getMessages());
+      }
+      $content = implode('<br />', $messages);
+      $status = 500;
+    }
+    return new HtmlResponse($content, $status);
+  }
+
+  /**
+   * Page callback: Prints mock XML for the Update Manager module.
+   *
+   * This is a wholesale copy of
+   * \Drupal\update_test\Controller\UpdateTestController::updateTest() for
+   * testing automatic updates. This was done in order to use a different
+   * 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];
+    }
+    elseif (isset($xml_map['#all'])) {
+      $availability_scenario = $xml_map['#all'];
+    }
+    else {
+      // The test didn't specify (for example, the webroot has other modules and
+      // themes installed but they're disabled by the version of the site
+      // running the test. So, we default to a file we know won't exist, so at
+      // least we'll get an empty xml response instead of a bunch of Drupal page
+      // output.
+      $availability_scenario = '#broken#';
+    }
+
+    $file = __DIR__ . "/../../../fixtures/release-history/$project_name.$availability_scenario.xml";
+    $headers = ['Content-Type' => 'text/xml; charset=utf-8'];
+    if (!is_file($file)) {
+      // Return an empty response.
+      return new Response('', 200, $headers);
+    }
+    return new BinaryFileResponse($file, 200, $headers);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..62a1f80a413fb773ad973766a6b969b99b03dbb2
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.info.yml
@@ -0,0 +1,6 @@
+name: 'Automatic Updates Test module 2'
+type: module
+description: 'Test module to provide an additional readiness checker.'
+package: Testing
+dependencies:
+  - drupal:auto_updates_test
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.services.yml b/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..615204ebf9af9d9a28a2a1c26b7cc7e162b6ba75
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test2/auto_updates_test2.services.yml
@@ -0,0 +1,6 @@
+services:
+  auto_updates_test2.checker:
+    class: Drupal\auto_updates_test2\ReadinessChecker\TestChecker2
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@state']
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test2/src/ReadinessChecker/TestChecker2.php b/core/modules/auto_updates/tests/modules/auto_updates_test2/src/ReadinessChecker/TestChecker2.php
new file mode 100644
index 0000000000000000000000000000000000000000..1333e7c0a4eadb662e9b67f44a0d54d2ade9eaac
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test2/src/ReadinessChecker/TestChecker2.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\auto_updates_test2\ReadinessChecker;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates_test\ReadinessChecker\TestChecker1;
+use Drupal\package_manager\Event\PreCreateEvent;
+
+/**
+ * A test readiness checker.
+ */
+class TestChecker2 extends TestChecker1 {
+
+  protected const STATE_KEY = 'auto_updates_test2.checker_results';
+
+  public static function getSubscribedEvents() {
+    $events[ReadinessCheckEvent::class][] = ['addResults', 4];
+    $events[PreCreateEvent::class][] = ['addResults', 4];
+
+    return $events;
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/auto_updates_test_disable_validators.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/auto_updates_test_disable_validators.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..31c0d195f8debe32c65b9ff9b5eae5e77377f8e0
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/auto_updates_test_disable_validators.info.yml
@@ -0,0 +1,4 @@
+name: 'Automatic Updates Test: Disable validators'
+description: Allows certain update validators to be disabled during testing.
+type: module
+package: Testing
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..450c9b7a7a61f9a438eaa3f431c7bc2efbb1fd6a
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php
@@ -0,0 +1,26 @@
+<?php
+
+namespace Drupal\auto_updates_test_disable_validators;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+use Drupal\Core\Site\Settings;
+
+/**
+ * Allows specific validators to be disabled by site settings.
+ *
+ * This should only really be used by functional tests. Kernel tests should
+ * override their ::register() method to remove service definitions; build tests
+ * should stay out of the API/services layer unless absolutely necessary.
+ */
+class AutoUpdatesTestDisableValidatorsServiceProvider extends ServiceProviderBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    $validators = Settings::get('auto_updates_disable_validators', []);
+    array_walk($validators, [$container, 'removeDefinition']);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0a71eecce2db81e377355d00e07138f132816550
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
@@ -0,0 +1,316 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Build;
+
+use Drupal\Component\Serialization\Json;
+
+/**
+ * Tests an end-to-end update of Drupal core.
+ *
+ * @group auto_updates
+ */
+class CoreUpdateTest extends UpdateTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createTestSite(string $template): void {
+    $dir = $this->getWorkspaceDirectory();
+
+    // Build the test site and alter its copy of core so that it thinks it's
+    // running Drupal 9.8.0, which will never actually exist in the real world.
+    // Then, prepare a secondary copy of the core code base, masquerading as
+    // Drupal 9.8.1, which will be the version of core we update to. These two
+    // versions are referenced in the fake release metadata in our fake release
+    // metadata (see fixtures/release-history/drupal.0.0.xml).
+    parent::createTestSite($template);
+    $this->setCoreVersion($this->getWebRoot() . '/core', '9.8.0');
+    $this->alterPackage($dir, $this->getConfigurationForUpdate('9.8.1'));
+
+    // Install Drupal and ensure it's using the fake release metadata to fetch
+    // information about available updates.
+    $this->installQuickStart('minimal');
+    $this->setReleaseMetadata(['drupal' => '9.8.1-security']);
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->installModules([
+      'auto_updates',
+      'auto_updates_test',
+      'update_test',
+    ]);
+
+    // If using the drupal/recommended-project template, we don't expect there
+    // to be an .htaccess file at the project root. One would normally be
+    // generated by Composer when Package Manager or other code creates a
+    // ComposerUtility object in the active directory, except that Package
+    // Manager takes specific steps to prevent that. So, here we're just
+    // confirming that, in fact, Composer's .htaccess protection was disabled.
+    // We don't do this for the drupal/legacy-project template because its
+    // project root, which is also the document root, SHOULD contain a .htaccess
+    // generated by Drupal core.
+    // We do this check because this test uses PHP's built-in web server, which
+    // ignores .htaccess files and everything in them, so a Composer-generated
+    // .htaccess file won't cause this test to fail.
+    if ($template === 'drupal/recommended-project') {
+      $this->assertFileNotExists($dir . '/.htaccess');
+    }
+
+    // Ensure that Drupal thinks we are running 9.8.0, then refresh information
+    // about available updates.
+    $this->assertCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    // Ensure that an update to 9.8.1 is available.
+    $this->visit('/admin/modules/automatic-update');
+    $this->getMink()->assertSession()->pageTextContains('9.8.1');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    if ($this->destroyBuild) {
+      $this->deleteCopiedPackages();
+    }
+    parent::tearDown();
+  }
+
+  /**
+   * Modifies a Drupal core code base to set its version.
+   *
+   * @param string $dir
+   *   The directory of the Drupal core code base.
+   * @param string $version
+   *   The version number to set.
+   */
+  private function setCoreVersion(string $dir, string $version): void {
+    $this->alterPackage($dir, ['version' => $version]);
+
+    $drupal_php = "$dir/lib/Drupal.php";
+    $this->assertIsWritable($drupal_php);
+    $code = file_get_contents($drupal_php);
+    $code = preg_replace("/const VERSION = '([0-9]+\.?){3}(-dev)?';/", "const VERSION = '$version';", $code);
+    file_put_contents($drupal_php, $code);
+  }
+
+  /**
+   * Returns composer.json changes that are needed to update core.
+   *
+   * This will clone the following packages into temporary directories:
+   * - drupal/core
+   * - drupal/core-recommended
+   * - drupal/core-project-message
+   * - drupal/core-composer-scaffold
+   * The cloned packages will be assigned the given version number, and the test
+   * site's composer.json will use the clones as path repositories.
+   *
+   * @param string $version
+   *   The version of core we will be updating to.
+   *
+   * @return array
+   *   The changes to merge into the test site's composer.json.
+   */
+  protected function getConfigurationForUpdate(string $version): array {
+    $repositories = [];
+
+    // Create a fake version of core with the given version number, and change
+    // its README so that we can actually be certain that we update to this
+    // fake version.
+    $dir = $this->copyPackage($this->getWebRoot() . '/core');
+    $this->setCoreVersion($dir, $version);
+    file_put_contents("$dir/README.txt", "Placeholder for Drupal core $version.");
+    $repositories['drupal/core'] = $this->createPathRepository($dir);
+
+    $drupal_root = $this->getDrupalRoot();
+
+    // Create a fake version of drupal/core-recommended which itself requires
+    // the fake version of core we just created.
+    $dir = $this->copyPackage("$drupal_root/composer/Metapackage/CoreRecommended");
+    $this->alterPackage($dir, [
+      'require' => [
+        'drupal/core' => $version,
+      ],
+      'version' => $version,
+    ]);
+    $repositories['drupal/core-recommended'] = $this->createPathRepository($dir);
+
+    // Create fake target versions of core plugins and metapackages.
+    $packages = [
+      'drupal/core-dev' => "$drupal_root/composer/Metapackage/DevDependencies",
+      'drupal/core-project-message' => "$drupal_root/composer/Plugin/ProjectMessage",
+      'drupal/core-composer-scaffold' => "$drupal_root/composer/Plugin/Scaffold",
+      'drupal/core-vendor-hardening' => "$drupal_root/composer/Plugin/VendorHardening",
+    ];
+    foreach ($packages as $name => $dir) {
+      $dir = $this->copyPackage($dir);
+      $this->alterPackage($dir, ['version' => $version]);
+      $repositories[$name] = $this->createPathRepository($dir);
+    }
+
+    return [
+      'repositories' => $repositories,
+    ];
+  }
+
+  /**
+   * Data provider for end-to-end update tests.
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerTemplate(): array {
+    return [
+      ['drupal/recommended-project'],
+      ['drupal/legacy-project'],
+    ];
+  }
+
+  /**
+   * Tests an end-to-end core update via the API.
+   *
+   * @param string $template
+   *   The template project from which to build the test site.
+   *
+   * @dataProvider providerTemplate
+   */
+  public function testApi(string $template): void {
+    $this->createTestSite($template);
+
+    $mink = $this->getMink();
+    $assert_session = $mink->assertSession();
+
+    // Ensure that the update is prevented if the web root and/or vendor
+    // directories are not writable.
+    $this->assertReadOnlyFileSystemError($template, '/automatic-update-test/update/9.8.1');
+
+    $mink->getSession()->reload();
+    $assert_session->pageTextContains('9.8.1');
+  }
+
+  /**
+   * Tests an end-to-end core update via the UI.
+   *
+   * @param string $template
+   *   The template project from which to build the test site.
+   *
+   * @dataProvider providerTemplate
+   */
+  public function testUi(string $template): void {
+    $this->createTestSite($template);
+
+    $mink = $this->getMink();
+    $session = $mink->getSession();
+    $page = $session->getPage();
+    $assert_session = $mink->assertSession();
+
+    $this->visit('/admin/modules');
+    $assert_session->pageTextContains('There is a security update available for your version of Drupal.');
+    $page->clickLink('Update');
+
+    // Ensure that the update is prevented if the web root and/or vendor
+    // directories are not writable.
+    $this->assertReadOnlyFileSystemError($template, parse_url($session->getCurrentUrl(), PHP_URL_PATH));
+    $session->reload();
+
+    $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
+    $page->pressButton('Update');
+    $this->waitForBatchJob();
+    $assert_session->pageTextContains('Ready to update');
+    $page->pressButton('Continue');
+    $this->waitForBatchJob();
+    $assert_session->pageTextContains('Update complete!');
+    $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
+    $this->assertUpdateSuccessful();
+  }
+
+  /**
+   * Tests an end-to-end core update via cron.
+   *
+   * @param string $template
+   *   The template project from which to build the test site.
+   *
+   * @dataProvider providerTemplate
+   */
+  public function testCron(string $template): void {
+    $this->createTestSite($template);
+
+    $this->visit('/admin/reports/status');
+    $this->getMink()->getSession()->getPage()->clickLink('Run cron');
+    $this->assertUpdateSuccessful();
+  }
+
+  /**
+   * Asserts that the update is prevented if the filesystem isn't writable.
+   *
+   * @param string $template
+   *   The project template used to build the test site. See ::createTestSite()
+   *   for the possible values.
+   * @param string $url
+   *   A URL where we can see the error message which is raised when parts of
+   *   the file system are not writable. This URL will be visited twice: once
+   *   for the web root, and once for the vendor directory.
+   */
+  private function assertReadOnlyFileSystemError(string $template, string $url): void {
+    $directories = [
+      'Drupal' => rtrim($this->getWebRoot(), './'),
+    ];
+
+    // The location of the vendor directory depends on which project template
+    // was used to build the test site.
+    if ($template === 'drupal/recommended-project') {
+      $directories['vendor'] = $this->getWorkspaceDirectory() . '/vendor';
+    }
+    elseif ($template === 'drupal/legacy-project') {
+      $directories['vendor'] = $directories['Drupal'] . '/vendor';
+    }
+
+    $assert_session = $this->getMink()->assertSession();
+    foreach ($directories as $type => $path) {
+      chmod($path, 0555);
+      $this->assertDirectoryIsNotWritable($path);
+      $this->visit($url);
+      $assert_session->pageTextContains("The $type directory \"$path\" is not writable.");
+      chmod($path, 0755);
+      $this->assertDirectoryIsWritable($path);
+    }
+  }
+
+  /**
+   * Asserts that Drupal core was successfully updated.
+   */
+  private function assertUpdateSuccessful(): void {
+    // The update form should not have any available updates.
+    // @todo Figure out why this assertion fails when the batch processor
+    //   redirects directly to the update form, instead of update.status, when
+    //   updating via the UI.
+    $this->visit('/admin/modules/automatic-update');
+    $this->getMink()->assertSession()->pageTextContains('No update available');
+    // The status page should report that we're running Drupal 9.8.1.
+    $this->assertCoreVersion('9.8.1');
+    // The fake placeholder text from ::getConfigurationForUpdate() should be
+    // present in the README.
+    $placeholder = file_get_contents($this->getWebRoot() . '/core/README.txt');
+    $this->assertSame('Placeholder for Drupal core 9.8.1.', $placeholder);
+
+    $composer = file_get_contents($this->getWorkspaceDirectory() . '/composer.json');
+    $composer = Json::decode($composer);
+    // The production dependencies should have been updated.
+    $this->assertSame('9.8.1', $composer['require']['drupal/core-recommended']);
+    $this->assertSame('9.8.1', $composer['require']['drupal/core-composer-scaffold']);
+    $this->assertSame('9.8.1', $composer['require']['drupal/core-project-message']);
+    // The core-vendor-hardening plugin is only used by the legacy project
+    // template.
+    if ($composer['name'] === 'drupal/legacy-project') {
+      $this->assertSame('9.8.1', $composer['require']['drupal/core-vendor-hardening']);
+    }
+    // The production dependencies should not be listed as dev dependencies.
+    $this->assertArrayNotHasKey('drupal/core-recommended', $composer['require-dev']);
+    $this->assertArrayNotHasKey('drupal/core-composer-scaffold', $composer['require-dev']);
+    $this->assertArrayNotHasKey('drupal/core-project-message', $composer['require-dev']);
+    $this->assertArrayNotHasKey('drupal/core-vendor-hardening', $composer['require-dev']);
+
+    // The drupal/core-dev metapackage should not be a production dependency...
+    $this->assertArrayNotHasKey('drupal/core-dev', $composer['require']);
+    // ...but it should have been updated in the dev dependencies.
+    $this->assertSame('9.8.1', $composer['require-dev']['drupal/core-dev']);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..7bd97a62b0adccb787986d844c34356477ae4d6f
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
@@ -0,0 +1,318 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Build;
+
+use Drupal\BuildTests\QuickStart\QuickStartTestBase;
+use Drupal\Component\Serialization\Json;
+use Drupal\Component\Utility\Html;
+use Drupal\Tests\auto_updates\Traits\LocalPackagesTrait;
+use Drupal\Tests\auto_updates\Traits\SettingsTrait;
+
+/**
+ * Base class for tests that perform in-place updates.
+ */
+abstract class UpdateTestBase extends QuickStartTestBase {
+
+  use LocalPackagesTrait {
+    getPackagePath as traitGetPackagePath;
+    copyPackage as traitCopyPackage;
+  }
+  use SettingsTrait;
+
+  /**
+   * A secondary server instance, to serve XML metadata about available updates.
+   *
+   * @var \Symfony\Component\Process\Process
+   */
+  private $metadataServer;
+
+  /**
+   * The test site's document root, relative to the workspace directory.
+   *
+   * @var string
+   *
+   * @see ::createTestSite()
+   */
+  private $webRoot;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    if ($this->metadataServer) {
+      $this->metadataServer->stop();
+    }
+    parent::tearDown();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function copyPackage(string $source_dir, string $destination_dir = NULL): string {
+    return $this->traitCopyPackage($source_dir, $destination_dir ?: $this->getWorkspaceDirectory());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPackagePath(array $package): string {
+    if ($package['name'] === 'drupal/core') {
+      return 'core';
+    }
+
+    [$vendor, $name] = explode('/', $package['name']);
+
+    // Assume any contributed module is in modules/contrib/$name.
+    if ($vendor === 'drupal' && $package['type'] === 'drupal-module') {
+      return implode(DIRECTORY_SEPARATOR, ['modules', 'contrib', $name]);
+    }
+
+    return $this->traitGetPackagePath($package);
+  }
+
+  /**
+   * Returns the full path to the test site's document root.
+   *
+   * @return string
+   *   The full path of the test site's document root.
+   */
+  protected function getWebRoot(): string {
+    return $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $this->webRoot;
+  }
+
+  /**
+   * Prepares the test site to serve an XML feed of available release metadata.
+   *
+   * @param array $xml_map
+   *   The update XML map, as used by update_test.settings.
+   *
+   * @see \Drupal\auto_updates_test\TestController::metadata()
+   */
+  protected function setReleaseMetadata(array $xml_map): void {
+    $xml_map = var_export($xml_map, TRUE);
+    $code = <<<END
+\$config['update_test.settings']['xml_map'] = $xml_map;
+END;
+
+    // When checking for updates, we need to be able to make sub-requests, but
+    // the built-in PHP server is single-threaded. Therefore, if needed, open a
+    // second server instance on another port, which will serve the metadata
+    // about available updates.
+    if (empty($this->metadataServer)) {
+      $port = $this->findAvailablePort();
+      $this->metadataServer = $this->instantiateServer($port, $this->webRoot);
+      $code .= <<<END
+\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test';
+END;
+    }
+    $this->addSettings($code, $this->getWebRoot());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function visit($request_uri = '/', $working_dir = NULL) {
+    return parent::visit($request_uri, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formLogin($username, $password, $working_dir = NULL) {
+    parent::formLogin($username, $password, $working_dir ?: $this->webRoot);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function installQuickStart($profile, $working_dir = NULL) {
+    parent::installQuickStart($profile, $working_dir ?: $this->webRoot);
+
+    // Always allow test modules to be installed in the UI and, for easier
+    // debugging, always display errors in their dubious glory.
+    $php = <<<END
+\$settings['extension_discovery_scan_tests'] = TRUE;
+\$config['system.logging']['error_level'] = 'verbose';
+END;
+    $this->addSettings($php, $this->getWebRoot());
+  }
+
+  /**
+   * Uses our already-installed dependencies to build a test site to update.
+   *
+   * @param string $template
+   *   The template project from which to build the test site. Can be
+   *   'drupal/recommended-project' or 'drupal/legacy-project'.
+   */
+  protected function createTestSite(string $template): void {
+    // Create the test site using one of the core project templates, but don't
+    // install dependencies just yet.
+    $template_dir = implode(DIRECTORY_SEPARATOR, [
+      $this->getDrupalRoot(),
+      'composer',
+      'Template',
+    ]);
+    $recommended_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'RecommendedProject');
+    $legacy_template = $this->createPathRepository($template_dir . DIRECTORY_SEPARATOR . 'LegacyProject');
+
+    $dir = $this->getWorkspaceDirectory();
+    $command = sprintf(
+      "composer create-project %s %s --no-install --stability dev --repository '%s' --repository '%s'",
+      $template,
+      $dir,
+      Json::encode($recommended_template),
+      Json::encode($legacy_template)
+    );
+    $this->executeCommand($command);
+    $this->assertCommandSuccessful();
+
+    $composer = $dir . DIRECTORY_SEPARATOR . 'composer.json';
+    $data = $this->readJson($composer);
+
+    // Allow the test to configure the test site as necessary.
+    $data = $this->getInitialConfiguration($data);
+
+    // We need to know the path of the web root, relative to the project root,
+    // in order to install Drupal or visit the test site at all. Luckily, both
+    // template projects define this because the scaffold plugin needs to know
+    // it as well.
+    // @see ::visit()
+    // @see ::formLogin()
+    // @see ::installQuickStart()
+    $this->webRoot = $data['extra']['drupal-scaffold']['locations']['web-root'];
+
+    // Update the test site's composer.json.
+    $this->writeJson($composer, $data);
+    // Install dependencies, including dev.
+    $this->executeCommand('composer install');
+    $this->assertCommandSuccessful();
+  }
+
+  /**
+   * Returns the initial data to write to the test site's composer.json.
+   *
+   * This configuration will be used to build the pre-update test site.
+   *
+   * @param array $data
+   *   The current contents of the test site's composer.json.
+   *
+   * @return array
+   *   The data that should be written to the test site's composer.json.
+   */
+  protected function getInitialConfiguration(array $data): array {
+    $drupal_root = $this->getDrupalRoot();
+    $core_composer_dir = $drupal_root . DIRECTORY_SEPARATOR . 'composer';
+    $repositories = [];
+
+    // Add all the metapackages that are provided by Drupal core.
+    $metapackage_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Metapackage';
+    $repositories['drupal/core-recommended'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'CoreRecommended');
+    $repositories['drupal/core-dev'] = $this->createPathRepository($metapackage_dir . DIRECTORY_SEPARATOR . 'DevDependencies');
+
+    // Add all the Composer plugins that are provided by Drupal core.
+    $plugin_dir = $core_composer_dir . DIRECTORY_SEPARATOR . 'Plugin';
+    $repositories['drupal/core-project-message'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'ProjectMessage');
+    $repositories['drupal/core-composer-scaffold'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'Scaffold');
+    $repositories['drupal/core-vendor-hardening'] = $this->createPathRepository($plugin_dir . DIRECTORY_SEPARATOR . 'VendorHardening');
+
+    $repositories = array_merge($repositories, $this->getLocalPackageRepositories($drupal_root));
+    // To ensure the test runs entirely offline, don't allow Composer to contact
+    // Packagist.
+    $repositories['packagist.org'] = FALSE;
+
+    $repositories['drupal/auto_updates'] = [
+      'type' => 'path',
+      'url' => __DIR__ . '/../../..',
+    ];
+    // Use whatever the current branch of auto_updates is.
+    $data['require']['drupal/auto_updates'] = '*';
+
+    $data['repositories'] = $repositories;
+
+    // Since Drupal 9 requires PHP 7.3 or later, these packages are probably
+    // not installed, which can cause trouble during dependency resolution.
+    // The drupal/drupal package (defined with a composer.json that is part
+    // of core's repository) replaces these, so we need to emulate that here.
+    $data['replace']['symfony/polyfill-php72'] = '*';
+    $data['replace']['symfony/polyfill-php73'] = '*';
+
+    return $data;
+  }
+
+  /**
+   * Asserts that a specific version of Drupal core is running.
+   *
+   * Assumes that a user with permission to view the status report is logged in.
+   *
+   * @param string $expected_version
+   *   The version of core that should be running.
+   */
+  protected function assertCoreVersion(string $expected_version): void {
+    $this->visit('/admin/reports/status');
+    $item = $this->getMink()
+      ->assertSession()
+      ->elementExists('css', 'h3:contains("Drupal Version")')
+      ->getParent()
+      ->getText();
+    $this->assertStringContainsString($expected_version, $item);
+  }
+
+  /**
+   * Installs modules in the UI.
+   *
+   * Assumes that a user with the appropriate permissions is logged in.
+   *
+   * @param string[] $modules
+   *   The machine names of the modules to install.
+   */
+  protected function installModules(array $modules): void {
+    $mink = $this->getMink();
+    $page = $mink->getSession()->getPage();
+    $assert_session = $mink->assertSession();
+
+    $this->visit('/admin/modules');
+    foreach ($modules as $module) {
+      $page->checkField("modules[$module][enable]");
+    }
+    $page->pressButton('Install');
+
+    $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]')
+      ->getValue();
+    if ($form_id === 'system_modules_confirm_form') {
+      $page->pressButton('Continue');
+      $assert_session->statusCodeEquals(200);
+    }
+  }
+
+  /**
+   * Checks for available updates.
+   *
+   * Assumes that a user with the appropriate access is logged in.
+   */
+  protected function checkForUpdates(): void {
+    $this->visit('/admin/reports/updates');
+    $this->getMink()->getSession()->getPage()->clickLink('Check manually');
+    $this->waitForBatchJob();
+  }
+
+  /**
+   * Waits for an active batch job to finish.
+   */
+  protected function waitForBatchJob(): void {
+    $refresh = $this->getMink()
+      ->getSession()
+      ->getPage()
+      ->find('css', 'meta[http-equiv="Refresh"], meta[http-equiv="refresh"]');
+
+    if ($refresh) {
+      // Parse the content attribute of the meta tag for the format:
+      // "[delay]: URL=[page_to_redirect_to]".
+      if (preg_match('/\d+;\s*URL=\'?(?<url>[^\']*)/i', $refresh->getAttribute('content'), $match)) {
+        $url = Html::decodeEntities($match['url']);
+        $this->visit($url);
+        $this->waitForBatchJob();
+      }
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..1455257e575588772a93b8ad9618e82aadee715b
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Functional;
+
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Base class for functional tests of the Automatic Updates module.
+ */
+abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates_test_disable_validators',
+    'update',
+    'update_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function prepareSettings() {
+    parent::prepareSettings();
+
+    // Disable the filesystem permissions validator, since we cannot guarantee
+    // that the current code base will be writable in all testing situations. We
+    // test this validator in our build tests, since those do give us control
+    // over the filesystem permissions.
+    // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
+    $settings['settings']['auto_updates_disable_validators'] = (object) [
+      'value' => [
+        'auto_updates.validator.file_system_permissions',
+        'package_manager.validator.file_system',
+      ],
+      'required' => TRUE,
+    ];
+    $this->writeSettings($settings);
+  }
+
+  /**
+   * Sets the current (running) version of core, as known to the Update module.
+   *
+   * @param string $version
+   *   The current version of core.
+   */
+  protected function setCoreVersion(string $version): void {
+    $this->config('update_test.settings')
+      ->set('system_info.#all.version', $version)
+      ->save();
+  }
+
+  /**
+   * Sets the release metadata file to use when fetching available updates.
+   *
+   * @param string $file
+   *   The path of the XML metadata file to use.
+   */
+  protected function setReleaseMetadata(string $file): void {
+    $this->config('update.settings')
+      ->set('fetch.url', $this->baseUrl . '/automatic-update-test')
+      ->save();
+
+    [$project, $fixture] = explode('.', basename($file, '.xml'), 2);
+    $this->config('update_test.settings')
+      ->set('xml_map', [
+        $project => $fixture,
+      ])
+      ->save();
+  }
+
+  /**
+   * Checks for available updates.
+   *
+   * Assumes that a user with appropriate permissions is logged in.
+   */
+  protected function checkForUpdates(): void {
+    $this->drupalGet('/admin/reports/updates');
+    $this->getSession()->getPage()->clickLink('Check manually');
+    $this->checkForMetaRefresh();
+  }
+
+  /**
+   * Asserts that we are on the "update ready" form.
+   */
+  protected function assertUpdateReady(): void {
+    $this->assertSession()
+      ->addressMatches('/\/admin\/automatic-update-ready\/[a-zA-Z0-9_\-]+$/');
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php b/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..d95a5c633fcb03e862c656e01001e896e219f287
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Functional/FileSystemOperationsTest.php
@@ -0,0 +1,148 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Functional;
+
+use Drupal\auto_updates\Updater;
+use Drupal\Core\Site\Settings;
+use Drupal\package_manager\PathLocator;
+
+/**
+ * Tests handling of files and directories during an update.
+ *
+ * @group auto_updates
+ */
+class FileSystemOperationsTest extends AutoUpdatesFunctionalTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['auto_updates_test'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * The updater service under test.
+   *
+   * @var \Drupal\Tests\auto_updates\Functional\TestUpdater
+   */
+  private $updater;
+
+  /**
+   * The full path of the staging directory.
+   *
+   * @var string
+   */
+  protected $stageDir;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // Create a mocked path locator that uses the fake site fixture as its
+    // active directory, and has a staging area within the site directory for
+    // this test.
+    $drupal_root = $this->getDrupalRoot();
+    /** @var \Drupal\package_manager\PathLocator|\Prophecy\Prophecy\ObjectProphecy $locator */
+    $locator = $this->prophesize(PathLocator::class);
+    $locator->getActiveDirectory()->willReturn(__DIR__ . '/../../fixtures/fake-site');
+    $this->stageDir = implode(DIRECTORY_SEPARATOR, [
+      $drupal_root,
+      $this->siteDirectory,
+      'stage',
+    ]);
+    $locator->getProjectRoot()->willReturn($drupal_root);
+    $locator->getWebRoot()->willReturn('');
+
+    $this->updater = new TestUpdater(
+      $locator->reveal(),
+      $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->updater::$stagingRoot = $this->stageDir;
+
+    // Use the public and private files directories in the fake site fixture.
+    $settings = Settings::getAll();
+    $settings['file_public_path'] = 'files/public';
+    $settings['file_private_path'] = 'files/private';
+    new Settings($settings);
+
+    // Updater::begin() will trigger update validators, such as
+    // \Drupal\auto_updates\Validator\UpdateVersionValidator, that need to
+    // fetch release metadata. We need to ensure that those HTTP request(s)
+    // succeed, so set them up to point to our fake release metadata.
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml');
+    $this->setCoreVersion('9.8.0');
+
+    $this->drupalLogin($this->rootUser);
+    $this->checkForUpdates();
+    $this->drupalLogout();
+  }
+
+  /**
+   * Tests that certain files and directories are not staged.
+   *
+   * @covers \Drupal\auto_updates\Updater::getExclusions
+   */
+  public function testExclusions(): void {
+    $stage_id = $this->updater->begin(['drupal' => '9.8.1']);
+    $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/settings.php");
+    $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/settings.local.php");
+    $this->assertFileDoesNotExist("$this->stageDir/$stage_id/sites/default/services.yml");
+    // A file in sites/default, that isn't one of the site-specific settings
+    // files, should be staged.
+    $this->assertFileExists("$this->stageDir/$stage_id/sites/default/staged.txt");
+    $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/sites/simpletest");
+    $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/files/public");
+    $this->assertDirectoryDoesNotExist("$this->stageDir/$stage_id/files/private");
+    // A file that's in the general files directory, but not in the public or
+    // private directories, should be staged.
+    $this->assertFileExists("$this->stageDir/$stage_id/files/staged.txt");
+  }
+
+  /**
+   * Tests that the staging directory is properly cleaned up.
+   *
+   * @covers \Drupal\auto_updates\Cleaner
+   */
+  public function testClean(): void {
+    $stage_id = $this->updater->begin(['drupal' => '9.8.1']);
+    // Make the staged site directory read-only, so we can test that it will be
+    // made writable on clean-up.
+    $this->assertTrue(chmod("$this->stageDir/$stage_id/sites/default", 0400));
+    $this->assertNotIsWritable("$this->stageDir/$stage_id/sites/default/staged.txt");
+    // If the site directory is not writable, this will throw an exception.
+    $this->updater->destroy();
+    $this->assertDirectoryDoesNotExist($this->stageDir);
+  }
+
+}
+
+/**
+ * A test-only version of the updater.
+ */
+class TestUpdater extends Updater {
+
+  /**
+   * The directory where staging areas will be created.
+   *
+   * @var string
+   */
+  public static $stagingRoot;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getStagingRoot(): string {
+    return static::$stagingRoot ?: parent::getStagingRoot();
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..9c70a78bacdc7a362b28ea15fbecfb96910f7b1e
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php
@@ -0,0 +1,495 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Functional;
+
+use Behat\Mink\Element\NodeElement;
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates_test\Datetime\TestTime;
+use Drupal\auto_updates_test\ReadinessChecker\TestChecker1;
+use Drupal\auto_updates_test2\ReadinessChecker\TestChecker2;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\system\SystemManager;
+use Drupal\Tests\auto_updates\Traits\ValidationTestTrait;
+use Drupal\Tests\Traits\Core\CronRunTrait;
+
+/**
+ * Tests readiness validation.
+ *
+ * @group auto_updates
+ */
+class ReadinessValidationTest extends AutoUpdatesFunctionalTestBase {
+
+  use StringTranslationTrait;
+  use CronRunTrait;
+  use ValidationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * A user who can view the status report.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $reportViewerUser;
+
+  /**
+   * A user who can view the status report and run readiness checkers.
+   *
+   * @var \Drupal\user\Entity\User
+   */
+  protected $checkerRunnerUser;
+
+  /**
+   * The test checker.
+   *
+   * @var \Drupal\auto_updates_test\ReadinessChecker\TestChecker1
+   */
+  protected $testChecker;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1.xml');
+    $this->setCoreVersion('9.8.1');
+
+    $this->reportViewerUser = $this->createUser([
+      'administer site configuration',
+      'access administration pages',
+    ]);
+    $this->checkerRunnerUser = $this->createUser([
+      'administer site configuration',
+      'administer software updates',
+      'access administration pages',
+    ]);
+    $this->createTestValidationResults();
+    $this->drupalLogin($this->reportViewerUser);
+  }
+
+  /**
+   * Tests readiness checkers on status report page.
+   */
+  public function testReadinessChecksStatusReport(): void {
+    $assert = $this->assertSession();
+
+    // Ensure automated_cron is disabled before installing auto_updates. This
+    // ensures we are testing that auto_updates runs the checkers when the
+    // module itself is installed and they weren't run on cron.
+    $this->assertFalse($this->container->get('module_handler')->moduleExists('automated_cron'));
+    $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']);
+
+    // If the site is ready for updates, the users will see the same output
+    // regardless of whether the user has permission to run updates.
+    $this->drupalLogin($this->reportViewerUser);
+    $this->checkForUpdates();
+    $this->drupalGet('admin/reports/status');
+    $this->assertNoErrors();
+    $this->drupalLogin($this->checkerRunnerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertNoErrors(TRUE);
+
+    // Confirm a user without the permission to run readiness checks does not
+    // have a link to run the checks when the checks need to be run again.
+    // @todo Change this to fake the request time in
+    //   https://www.drupal.org/node/3113971.
+    /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */
+    $key_value = $this->container->get('keyvalue.expirable')->get('auto_updates');
+    $key_value->delete('readiness_validation_last_run');
+    $this->drupalLogin($this->reportViewerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertNoErrors();
+    $this->drupalLogin($this->checkerRunnerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertNoErrors(TRUE);
+
+    // Confirm a user with the permission to run readiness checks does have a
+    // link to run the checks when the checks need to be run again.
+    $this->drupalLogin($this->reportViewerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertNoErrors();
+    $this->drupalLogin($this->checkerRunnerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertNoErrors(TRUE);
+    /** @var \Drupal\package_manager\ValidationResult[] $expected_results */
+    $expected_results = $this->testResults['checker_1']['1 error'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+
+    // Run the readiness checks.
+    $this->clickLink('Run readiness checks');
+    $assert->statusCodeEquals(200);
+    // Confirm redirect back to status report page.
+    $assert->addressEquals('/admin/reports/status');
+    // Assert that when the runners are run manually the message that updates
+    // will not be performed because of errors is displayed on the top of the
+    // page in message.
+    $assert->pageTextMatchesCount(2, '/' . preg_quote(static::$errorsExplanation) . '/');
+    $this->assertErrors($expected_results, TRUE);
+
+    // @todo Should we always show when the checks were last run and a link to
+    //   run when there is an error?
+    // Confirm a user without permission to run the checks sees the same error.
+    $this->drupalLogin($this->reportViewerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertErrors($expected_results);
+
+    $expected_results = $this->testResults['checker_1']['1 error 1 warning'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    $key_value->delete('readiness_validation_last_run');
+    // Confirm a new message is displayed if the stored messages are deleted.
+    $this->drupalGet('admin/reports/status');
+    // Confirm that on the status page if there is only 1 warning or error the
+    // the summaries will not be displayed.
+    $this->assertErrors([$expected_results['1:error']]);
+    $this->assertWarnings([$expected_results['1:warning']]);
+    $assert->pageTextNotContains($expected_results['1:error']->getSummary());
+    $assert->pageTextNotContains($expected_results['1:warning']->getSummary());
+
+    $key_value->delete('readiness_validation_last_run');
+    $expected_results = $this->testResults['checker_1']['2 errors 2 warnings'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    $this->drupalGet('admin/reports/status');
+    // Confirm that both messages and summaries will be displayed on status
+    // report when there multiple messages.
+    $this->assertErrors([$expected_results['1:errors']]);
+    $this->assertWarnings([$expected_results['1:warnings']]);
+
+    $key_value->delete('readiness_validation_last_run');
+    $expected_results = $this->testResults['checker_1']['2 warnings'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    $this->drupalGet('admin/reports/status');
+    $assert->pageTextContainsOnce('Update readiness checks');
+    // Confirm that warnings will display on the status report if there are no
+    // errors.
+    $this->assertWarnings($expected_results);
+
+    $key_value->delete('readiness_validation_last_run');
+    $expected_results = $this->testResults['checker_1']['1 warning'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    $this->drupalGet('admin/reports/status');
+    $assert->pageTextContainsOnce('Update readiness checks');
+    $this->assertWarnings($expected_results);
+  }
+
+  /**
+   * Tests readiness checkers results on admin pages..
+   */
+  public function testReadinessChecksAdminPages(): void {
+    $assert = $this->assertSession();
+    $messages_section_selector = '[data-drupal-messages]';
+
+    // Ensure automated_cron is disabled before installing auto_updates. This
+    // ensures we are testing that auto_updates runs the checkers when the
+    // module itself is installed and they weren't run on cron.
+    $this->assertFalse($this->container->get('module_handler')->moduleExists('automated_cron'));
+    $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']);
+
+    // If site is ready for updates no message will be displayed on admin pages.
+    $this->drupalLogin($this->reportViewerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertNoErrors();
+    $this->drupalGet('admin/structure');
+    $assert->elementNotExists('css', $messages_section_selector);
+
+    // Confirm a user without the permission to run readiness checks does not
+    // have a link to run the checks when the checks need to be run again.
+    $expected_results = $this->testResults['checker_1']['1 error'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    // @todo Change this to use ::delayRequestTime() to simulate running cron
+    //   after a 24 wait instead of directly deleting 'readiness_validation_last_run'
+    //   https://www.drupal.org/node/3113971.
+    /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */
+    $key_value = $this->container->get('keyvalue.expirable')->get('auto_updates');
+    $key_value->delete('readiness_validation_last_run');
+    // A user without the permission to run the checkers will not see a message
+    // on other pages if the checkers need to be run again.
+    $this->drupalGet('admin/structure');
+    $assert->elementNotExists('css', $messages_section_selector);
+
+    // Confirm that a user with the correct permission can also run the checkers
+    // on another admin page.
+    $this->drupalLogin($this->checkerRunnerUser);
+    $this->drupalGet('admin/structure');
+    $assert->elementExists('css', $messages_section_selector);
+    $assert->pageTextContainsOnce('Your site has not recently run an update readiness check. Run readiness checks now.');
+    $this->clickLink('Run readiness checks now.');
+    $assert->addressEquals('admin/structure');
+    $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]);
+
+    $expected_results = $this->testResults['checker_1']['1 error 1 warning'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    // Confirm a new message is displayed if the cron is run after an hour.
+    $this->delayRequestTime();
+    $this->cronRun();
+    $this->drupalGet('admin/structure');
+    $assert->pageTextContainsOnce(static::$errorsExplanation);
+    // Confirm on admin pages that a single error will be displayed instead of a
+    // summary.
+    $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['1:error']->getSeverity());
+    $assert->pageTextContainsOnce($expected_results['1:error']->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results['1:error']->getSummary());
+    // Warnings are not displayed on admin pages if there are any errors.
+    $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['1:warning']->getSeverity());
+    $assert->pageTextNotContains($expected_results['1:warning']->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results['1:warning']->getSummary());
+
+    // Confirm that if cron runs less than hour after it previously ran it will
+    // not run the checkers again.
+    $unexpected_results = $this->testResults['checker_1']['2 errors 2 warnings'];
+    TestChecker1::setTestResult($unexpected_results, ReadinessCheckEvent::class);
+    $this->delayRequestTime(30);
+    $this->cronRun();
+    $this->drupalGet('admin/structure');
+    $assert->pageTextNotContains($unexpected_results['1:errors']->getSummary());
+    $assert->pageTextContainsOnce($expected_results['1:error']->getMessages()[0]);
+    $assert->pageTextNotContains($unexpected_results['1:warnings']->getSummary());
+    $assert->pageTextNotContains($expected_results['1:warning']->getMessages()[0]);
+
+    // Confirm that is if cron is run over an hour after the checkers were
+    // previously run the checkers will be run again.
+    $this->delayRequestTime(31);
+    $this->cronRun();
+    $expected_results = $unexpected_results;
+    $this->drupalGet('admin/structure');
+    // Confirm on admin pages only the error summary will be displayed if there
+    // is more than 1 error.
+    $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['1:errors']->getSeverity());
+    $assert->pageTextNotContains($expected_results['1:errors']->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results['1:errors']->getMessages()[1]);
+    $assert->pageTextContainsOnce($expected_results['1:errors']->getSummary());
+    $assert->pageTextContainsOnce(static::$errorsExplanation);
+    // Warnings are not displayed on admin pages if there are any errors.
+    $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['1:warnings']->getSeverity());
+    $assert->pageTextNotContains($expected_results['1:warnings']->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results['1:warnings']->getMessages()[1]);
+    $assert->pageTextNotContains($expected_results['1:warnings']->getSummary());
+
+    $expected_results = $this->testResults['checker_1']['2 warnings'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    $this->delayRequestTime();
+    $this->cronRun();
+    $this->drupalGet('admin/structure');
+    // Confirm that the warnings summary is displayed on admin pages if there
+    // are no errors.
+    $assert->pageTextNotContains(static::$errorsExplanation);
+    $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results[0]->getSeverity());
+    $assert->pageTextNotContains($expected_results[0]->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results[0]->getMessages()[1]);
+    $assert->pageTextContainsOnce(static::$warningsExplanation);
+    $assert->pageTextContainsOnce($expected_results[0]->getSummary());
+
+    $expected_results = $this->testResults['checker_1']['1 warning'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    $this->delayRequestTime();
+    $this->cronRun();
+    $this->drupalGet('admin/structure');
+    $assert->pageTextNotContains(static::$errorsExplanation);
+    // Confirm that a single warning is displayed and not the summary on admin
+    // pages if there is only 1 warning and there are no errors.
+    $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results[0]->getSeverity());
+    $assert->pageTextContainsOnce(static::$warningsExplanation);
+    $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results[0]->getSummary());
+  }
+
+  /**
+   * Tests installing a module with a checker before installing auto_updates.
+   */
+  public function testReadinessCheckAfterInstall(): void {
+    $assert = $this->assertSession();
+    $this->drupalLogin($this->checkerRunnerUser);
+
+    $this->drupalGet('admin/reports/status');
+    $assert->pageTextNotContains('Update readiness checks');
+
+    // We have to install the auto_updates_test module because it provides
+    // the functionality to retrieve our fake release history metadata.
+    $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']);
+    $this->drupalGet('admin/reports/status');
+    $this->assertNoErrors(TRUE);
+
+    $expected_results = $this->testResults['checker_1']['1 error'];
+    TestChecker2::setTestResult($expected_results, ReadinessCheckEvent::class);
+    $this->container->get('module_installer')->install(['auto_updates_test2']);
+    $this->drupalGet('admin/structure');
+    $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]);
+
+    // Confirm that installing a module that does not provide a new checker does
+    // not run the checkers on install.
+    $unexpected_results = $this->testResults['checker_1']['2 errors 2 warnings'];
+    TestChecker2::setTestResult($unexpected_results, ReadinessCheckEvent::class);
+    $this->container->get('module_installer')->install(['help']);
+    // Check for message on 'admin/structure' instead of the status report
+    // because checkers will be run if needed on the status report.
+    $this->drupalGet('admin/structure');
+    // Confirm that new checker message is not displayed because the checker was
+    // not run again.
+    $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]);
+    $assert->pageTextNotContains($unexpected_results['1:errors']->getMessages()[0]);
+    $assert->pageTextNotContains($unexpected_results['1:errors']->getSummary());
+  }
+
+  /**
+   * Tests that checker message for an uninstalled module is not displayed.
+   */
+  public function testReadinessCheckerUninstall(): void {
+    $assert = $this->assertSession();
+    $this->drupalLogin($this->checkerRunnerUser);
+
+    $expected_results_1 = $this->testResults['checker_1']['1 error'];
+    TestChecker1::setTestResult($expected_results_1, ReadinessCheckEvent::class);
+    $expected_results_2 = $this->testResults['checker_2']['1 error'];
+    TestChecker2::setTestResult($expected_results_2, ReadinessCheckEvent::class);
+    $this->container->get('module_installer')->install([
+      'auto_updates',
+      'auto_updates_test',
+      'auto_updates_test2',
+    ]);
+    // Check for message on 'admin/structure' instead of the status report
+    // because checkers will be run if needed on the status report.
+    $this->drupalGet('admin/structure');
+    $assert->pageTextContainsOnce($expected_results_1[0]->getMessages()[0]);
+    $assert->pageTextContainsOnce($expected_results_2[0]->getMessages()[0]);
+
+    // Confirm that when on of the module is uninstalled the other module's
+    // checker result is still displayed.
+    $this->container->get('module_installer')->uninstall(['auto_updates_test2']);
+    $this->drupalGet('admin/structure');
+    $assert->pageTextNotContains($expected_results_2[0]->getMessages()[0]);
+    $assert->pageTextContainsOnce($expected_results_1[0]->getMessages()[0]);
+
+    // Confirm that when on of the module is uninstalled the other module's
+    // checker result is still displayed.
+    $this->container->get('module_installer')->uninstall(['auto_updates_test']);
+    $this->drupalGet('admin/structure');
+    $assert->pageTextNotContains($expected_results_2[0]->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results_1[0]->getMessages()[0]);
+  }
+
+  /**
+   * Asserts that the readiness requirement displays no errors or warnings.
+   *
+   * @param bool $run_link
+   *   (optional) Whether there should be a link to run the readiness checks.
+   *   Defaults to FALSE.
+   */
+  private function assertNoErrors(bool $run_link = FALSE): void {
+    $this->assertRequirement('checked', 'Your site is ready for automatic updates.', [], $run_link);
+  }
+
+  /**
+   * Asserts that the displayed readiness requirement contains warnings.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The readiness check results that should be visible.
+   * @param bool $run_link
+   *   (optional) Whether there should be a link to run the readiness checks.
+   *   Defaults to FALSE.
+   */
+  private function assertWarnings(array $expected_results, bool $run_link = FALSE): void {
+    $this->assertRequirement('warning', static::$warningsExplanation, $expected_results, $run_link);
+  }
+
+  /**
+   * Asserts that the displayed readiness requirement contains errors.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The readiness check results that should be visible.
+   * @param bool $run_link
+   *   (optional) Whether there should be a link to run the readiness checks.
+   *   Defaults to FALSE.
+   */
+  private function assertErrors(array $expected_results, bool $run_link = FALSE): void {
+    $this->assertRequirement('error', static::$errorsExplanation, $expected_results, $run_link);
+  }
+
+  /**
+   * Asserts that the readiness requirement is correct.
+   *
+   * @param string $section
+   *   The section of the status report in which the requirement is expected to
+   *   be. Can be one of 'error', 'warning', 'checked', or 'ok'.
+   * @param string $preamble
+   *   The text that should appear before the result messages.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected readiness check results, in the order we expect them to be
+   *   displayed.
+   * @param bool $run_link
+   *   (optional) Whether there should be a link to run the readiness checks.
+   *   Defaults to FALSE.
+   *
+   * @see \Drupal\Core\Render\Element\StatusReport::getInfo()
+   */
+  private function assertRequirement(string $section, string $preamble, array $expected_results, bool $run_link = FALSE): void {
+    // Get the meaty part of the requirement element, and ensure that it begins
+    // with the preamble, if any.
+    $requirement = $this->assertSession()
+      ->elementExists('css', "h3#$section ~ details.system-status-report__entry:contains('Update readiness checks') .system-status-report__entry__value");
+
+    if ($preamble) {
+      $this->assertStringStartsWith($preamble, $requirement->getText());
+    }
+
+    // Convert the expected results into strings.
+    $expected_messages = [];
+    foreach ($expected_results as $result) {
+      $messages = $result->getMessages();
+      if (count($messages) > 1) {
+        $expected_messages[] = $result->getSummary();
+      }
+      $expected_messages = array_merge($expected_messages, $messages);
+    }
+    $expected_messages = array_map('strval', $expected_messages);
+
+    // The results should appear in the given order.
+    $this->assertSame($expected_messages, $this->getMessagesFromRequirement($requirement));
+    // Check for the presence or absence of a link to run the checks.
+    $this->assertSame($run_link, $requirement->hasLink('Run readiness checks'));
+  }
+
+  /**
+   * Extracts the readiness result messages from the requirement element.
+   *
+   * @param \Behat\Mink\Element\NodeElement $requirement
+   *   The page element containing the readiness check results.
+   *
+   * @return string[]
+   *   The readiness result messages (including summaries), in the order they
+   *   appear on the page.
+   */
+  private function getMessagesFromRequirement(NodeElement $requirement): array {
+    $messages = [];
+
+    // Each list item will either contain a simple string (for results with only
+    // one message), or a details element with a series of messages.
+    $items = $requirement->findAll('css', 'li');
+    foreach ($items as $item) {
+      $details = $item->find('css', 'details');
+
+      if ($details) {
+        $messages[] = $details->find('css', 'summary')->getText();
+        $messages = array_merge($messages, $this->getMessagesFromRequirement($details));
+      }
+      else {
+        $messages[] = $item->getText();
+      }
+    }
+    return array_unique($messages);
+  }
+
+  /**
+   * Delays the request for the test.
+   *
+   * @param int $minutes
+   *   The number of minutes to delay request time. Defaults to 61 minutes.
+   */
+  private function delayRequestTime(int $minutes = 61): void {
+    static $total_delay = 0;
+    $total_delay += $minutes;
+    TestTime::setFakeTimeByOffset("+$total_delay minutes");
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..df9157dd2672b8683e61d4bcddb6c96ec27ae526
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Functional;
+
+/**
+ * Tests that only one Automatic Update operation can be performed at a time.
+ *
+ * @group auto_updates
+ */
+class UpdateLockTest extends AutoUpdatesFunctionalTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'auto_updates_test',
+    'package_manager_bypass',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1.xml');
+    $this->drupalLogin($this->rootUser);
+    $this->checkForUpdates();
+  }
+
+  /**
+   * Tests that only user who started an update can continue through it.
+   */
+  public function testLock() {
+    $page = $this->getSession()->getPage();
+    $assert_session = $this->assertSession();
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $permissions = ['administer software updates'];
+    $user_1 = $this->createUser($permissions);
+    $user_2 = $this->createUser($permissions);
+
+    // We should be able to get partway through an update without issue.
+    $this->drupalLogin($user_1);
+    $this->drupalGet('/admin/modules/automatic-update');
+    $page->pressButton('Update');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateReady();
+    $assert_session->buttonExists('Continue');
+    $url = parse_url($this->getSession()->getCurrentUrl(), PHP_URL_PATH);
+
+    // Another user cannot show up and try to start an update, since the other
+    // user already started one.
+    $this->drupalLogin($user_2);
+    $this->drupalGet('/admin/modules/automatic-update');
+    $assert_session->buttonNotExists('Update');
+    $assert_session->pageTextContains('Cannot begin an update because another Composer operation is currently in progress.');
+
+    // If the current user did not start the update, they should not be able to
+    // continue it, either.
+    $this->drupalGet($url);
+    $assert_session->pageTextContains('Cannot continue the update because another Composer operation is currently in progress.');
+    $assert_session->buttonNotExists('Continue');
+
+    // The user who started the update should be able to continue it.
+    $this->drupalLogin($user_1);
+    $this->drupalGet($url);
+    $assert_session->pageTextNotContains('Cannot continue the update because another Composer operation is currently in progress.');
+    $assert_session->buttonExists('Continue');
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..0a942b4ececfa451858cadf12da4bddd94f8d6d3
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
@@ -0,0 +1,292 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Functional;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\Exception\UpdateException;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\ValidationResult;
+use Drupal\auto_updates_test\ReadinessChecker\TestChecker1;
+use Drupal\Tests\auto_updates\Traits\ValidationTestTrait;
+
+/**
+ * @covers \Drupal\auto_updates\Form\UpdaterForm
+ *
+ * @group auto_updates
+ */
+class UpdaterFormTest extends AutoUpdatesFunctionalTestBase {
+
+  use ValidationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'block',
+    'auto_updates',
+    'auto_updates_test',
+    'package_manager_bypass',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml');
+    $this->drupalLogin($this->rootUser);
+    $this->checkForUpdates();
+  }
+
+  /**
+   * Data provider for URLs to the update form.
+   *
+   * @return string[][]
+   *   Test case parameters.
+   */
+  public function providerUpdateFormReferringUrl(): array {
+    return [
+      'Modules page' => ['/admin/modules/automatic-update'],
+      'Reports page' => ['/admin/reports/updates/automatic-update'],
+    ];
+  }
+
+  /**
+   * Data provider for testTableLooksCorrect().
+   *
+   * @return string[][]
+   *   Test case parameters.
+   */
+  public function providerTableLooksCorrect(): array {
+    return [
+      'Modules page' => ['modules'],
+      'Reports page' => ['reports'],
+    ];
+  }
+
+  /**
+   * Tests that the form doesn't display any buttons if Drupal is up-to-date.
+   *
+   * @todo Mark this test as skipped if the web server is PHP's built-in, single
+   *   threaded server.
+   *
+   * @param string $update_form_url
+   *   The URL of the update form to visit.
+   *
+   * @dataProvider providerUpdateFormReferringUrl
+   */
+  public function testFormNotDisplayedIfAlreadyCurrent(string $update_form_url): void {
+    $this->setCoreVersion('9.8.1');
+    $this->checkForUpdates();
+
+    $this->drupalGet($update_form_url);
+
+    $assert_session = $this->assertSession();
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains('No update available');
+    $assert_session->buttonNotExists('Update');
+  }
+
+  /**
+   * Tests that available updates are rendered correctly in a table.
+   *
+   * @param string $access_page
+   *   The page from which the update form should be visited.
+   *   Can be one of 'modules' to visit via the module list, or 'reports' to
+   *   visit via the administrative reports page.
+   *
+   * @dataProvider providerTableLooksCorrect
+   */
+  public function testTableLooksCorrect(string $access_page): void {
+    $this->drupalPlaceBlock('local_tasks_block', ['primary' => TRUE]);
+    $assert_session = $this->assertSession();
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+
+    // Navigate to the automatic updates form.
+    $this->drupalGet('/admin');
+    if ($access_page === 'modules') {
+      $this->clickLink('Extend');
+      $assert_session->pageTextContainsOnce('There is a security update available for your version of Drupal.');
+    }
+    else {
+      $this->clickLink('Reports');
+      $assert_session->pageTextContainsOnce('There is a security update available for your version of Drupal.');
+      $this->clickLink('Available updates');
+    }
+    $this->clickLink('Update');
+    $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
+    $cells = $assert_session->elementExists('css', '#edit-projects .update-update-security')
+      ->findAll('css', 'td');
+    $this->assertCount(3, $cells);
+    $assert_session->elementExists('named', ['link', 'Drupal'], $cells[0]);
+    $this->assertSame('9.8.0', $cells[1]->getText());
+    $this->assertSame('9.8.1 (Release notes)', $cells[2]->getText());
+    $release_notes = $assert_session->elementExists('named', ['link', 'Release notes'], $cells[2]);
+    $this->assertSame('Release notes for Drupal', $release_notes->getAttribute('title'));
+    $assert_session->buttonExists('Update');
+    $this->assertUpdateStagedTimes(0);
+  }
+
+  /**
+   * Tests handling of errors and warnings during the update process.
+   */
+  public function testUpdateErrors(): void {
+    $session = $this->getSession();
+    $assert_session = $this->assertSession();
+    $page = $session->getPage();
+
+    // Store a fake readiness error, which will be cached.
+    $message = t("You've not experienced Shakespeare until you have read him in the original Klingon.");
+    $error = ValidationResult::createError([$message]);
+    TestChecker1::setTestResult([$error], ReadinessCheckEvent::class);
+
+    $this->drupalGet('/admin/reports/status');
+    $page->clickLink('Run readiness checks');
+    $assert_session->pageTextContainsOnce((string) $message);
+    // Ensure that the fake error is cached.
+    $session->reload();
+    $assert_session->pageTextContainsOnce((string) $message);
+
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+
+    // Set up a new fake error.
+    $this->createTestValidationResults();
+    $expected_results = $this->testResults['checker_1']['1 error'];
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+
+    // If a validator raises an error during readiness checking, the form should
+    // not have a submit button.
+    $this->drupalGet('/admin/modules/automatic-update');
+    $assert_session->buttonNotExists('Update');
+    // Since this is an administrative page, the error message should be visible
+    // thanks to auto_updates_page_top(). The readiness checks were re-run
+    // during the form build, which means the new error should be cached and
+    // displayed instead of the previously cached error.
+    $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]);
+    $assert_session->pageTextContainsOnce(static::$errorsExplanation);
+    $assert_session->pageTextNotContains(static::$warningsExplanation);
+    $assert_session->pageTextNotContains((string) $message);
+    TestChecker1::setTestResult(NULL, ReadinessCheckEvent::class);
+
+    // Repackage the validation error as an exception, so we can test what
+    // happens if a validator throws once the update has started.
+    $error = new UpdateException($expected_results, 'The update exploded.');
+    TestChecker1::setException($error, PreCreateEvent::class);
+    $session->reload();
+    $assert_session->pageTextNotContains(static::$errorsExplanation);
+    $assert_session->pageTextNotContains(static::$warningsExplanation);
+    $page->pressButton('Update');
+    $this->checkForMetaRefresh();
+
+    // If a validator flags an error, but doesn't throw, the update should still
+    // be halted.
+    $this->assertUpdateStagedTimes(0);
+    $assert_session->pageTextContainsOnce('An error has occurred.');
+    $page->clickLink('the error page');
+    $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]);
+    // Since there's only one error message, we shouldn't see the summary...
+    $assert_session->pageTextNotContains($expected_results[0]->getSummary());
+    // ...but we should see the exception message.
+    $assert_session->pageTextContainsOnce('The update exploded.');
+    // If the error is thrown in PreCreateEvent the update stage will not have
+    // been created.
+    $assert_session->buttonNotExists('Delete existing update');
+    TestChecker1::setTestResult($expected_results, PreCreateEvent::class);
+    $page->pressButton('Update');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateStagedTimes(0);
+    $assert_session->pageTextContainsOnce('An error has occurred.');
+    $page->clickLink('the error page');
+    // Since there's only one message, we shouldn't see the summary.
+    $assert_session->pageTextNotContains($expected_results[0]->getSummary());
+    $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]);
+  }
+
+  /**
+   * Tests that updating to a different minor version isn't supported.
+   *
+   * @param string $update_form_url
+   *   The URL of the update form to visit.
+   *
+   * @dataProvider providerUpdateFormReferringUrl
+   */
+  public function testMinorVersionUpdateNotSupported(string $update_form_url): void {
+    $this->setCoreVersion('9.7.1');
+    $this->checkForUpdates();
+
+    $this->drupalGet($update_form_url);
+
+    $assert_session = $this->assertSession();
+    $assert_session->pageTextContainsOnce('Drupal cannot be automatically updated from its current version, 9.7.1, to the recommended version, 9.8.1, because automatic updates from one minor version to another are not supported.');
+    $assert_session->buttonNotExists('Update');
+  }
+
+  /**
+   * 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() {
+    $assert_session = $this->assertSession();
+    $page = $this->getSession()->getPage();
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+
+    $this->drupalGet('/admin/modules/automatic-update');
+    $page->pressButton('Update');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateStagedTimes(1);
+
+    // Confirm we are on the confirmation page.
+    $this->assertUpdateReady();
+    $assert_session->buttonExists('Continue');
+
+    // Return to the start 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');
+
+    // Delete the existing update.
+    $page->pressButton('Delete existing update');
+    $assert_session->pageTextNotContains('Cannot begin an update because another Composer operation is currently in progress.');
+
+    // 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->assertUpdateStagedTimes(2);
+    $assert_session->buttonExists('Continue');
+  }
+
+  /**
+   * Asserts the number of times an update was staged.
+   *
+   * @param int $attempted_times
+   *   The expected number of times an update was staged.
+   */
+  private function assertUpdateStagedTimes(int $attempted_times): void {
+    /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $beginner */
+    $beginner = $this->container->get('package_manager.beginner');
+    $this->assertCount($attempted_times, $beginner->getInvocationArguments());
+
+    /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */
+    $stager = $this->container->get('package_manager.stager');
+    $this->assertCount($attempted_times, $stager->getInvocationArguments());
+
+    /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $committer */
+    $committer = $this->container->get('package_manager.committer');
+    $this->assertEmpty($committer->getInvocationArguments());
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..f5a80df5bd3964fddfc76dff100b95933fae34fa
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\auto_updates\Traits\ValidationTestTrait;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\Utils;
+
+/**
+ * Base class for kernel tests of the Automatic Updates module.
+ */
+abstract class AutoUpdatesKernelTestBase extends KernelTestBase {
+
+  use ValidationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['system', 'update', 'update_test'];
+
+  /**
+   * The mocked HTTP client that returns metadata about available updates.
+   *
+   * We need to preserve this as a class property so that we can re-inject it
+   * into the container when a rebuild is triggered by module installation.
+   *
+   * @var \GuzzleHttp\Client
+   *
+   * @see ::register()
+   */
+  private $client;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    // The Update module's default configuration must be installed for our
+    // fake release metadata to be fetched.
+    $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);
+
+    // By default, pretend we're running Drupal core 9.8.0 and a non-security
+    // update to 9.8.1 is available.
+    $this->setCoreVersion('9.8.0');
+    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1.xml');
+
+    // Set a last cron run time so that the cron frequency validator will run
+    // from a sane state.
+    // @see \Drupal\auto_updates\Validator\CronFrequencyValidator
+    $this->container->get('state')->set('system.cron_last', time());
+  }
+
+  /**
+   * Sets the current (running) version of core, as known to the Update module.
+   *
+   * @param string $version
+   *   The current version of core.
+   */
+  protected function setCoreVersion(string $version): void {
+    $this->config('update_test.settings')
+      ->set('system_info.#all.version', $version)
+      ->save();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    // If we previously set up a mock HTTP client in ::setReleaseMetadata(),
+    // re-inject it into the container.
+    if ($this->client) {
+      $container->set('http_client', $this->client);
+    }
+
+    $this->disableValidators($container);
+  }
+
+  /**
+   * Disables any validators that will interfere with this test.
+   */
+  protected function disableValidators(ContainerBuilder $container): void {
+    // Disable the filesystem permissions validator, since we cannot guarantee
+    // that the current code base will be writable in all testing situations.
+    // We test this validator functionally in our build tests, since those do
+    // give us control over the filesystem permissions.
+    // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
+    $container->removeDefinition('auto_updates.validator.file_system_permissions');
+    $container->removeDefinition('package_manager.validator.file_system');
+  }
+
+  /**
+   * Sets the release metadata file to use when fetching available updates.
+   *
+   * @param string $file
+   *   The path of the XML metadata file to use.
+   */
+  protected function setReleaseMetadata(string $file): void {
+    $metadata = Utils::tryFopen($file, 'r');
+    $response = new Response(200, [], Utils::streamFor($metadata));
+    $handler = new MockHandler([$response]);
+    $this->client = new Client([
+      'handler' => HandlerStack::create($handler),
+    ]);
+    $this->container->set('http_client', $this->client);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2d3dc10143fb2c1340170ee5d7b63711030e3c9f
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
@@ -0,0 +1,117 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel;
+
+use Drupal\auto_updates\CronUpdater;
+use Drupal\Core\Form\FormState;
+use Drupal\package_manager\ComposerUtility;
+use Drupal\update\UpdateSettingsForm;
+
+/**
+ * @covers \Drupal\auto_updates\CronUpdater
+ * @covers \auto_updates_form_update_settings_alter
+ *
+ * @group auto_updates
+ */
+class CronUpdaterTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'package_manager',
+  ];
+
+  /**
+   * Data provider for ::testUpdaterCalled().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerUpdaterCalled(): array {
+    $fixture_dir = __DIR__ . '/../../fixtures/release-history';
+
+    return [
+      'disabled, normal release' => [
+        CronUpdater::DISABLED,
+        "$fixture_dir/drupal.9.8.1.xml",
+        FALSE,
+      ],
+      'disabled, security release' => [
+        CronUpdater::DISABLED,
+        "$fixture_dir/drupal.9.8.1-security.xml",
+        FALSE,
+      ],
+      'security only, security release' => [
+        CronUpdater::SECURITY,
+        "$fixture_dir/drupal.9.8.1-security.xml",
+        TRUE,
+      ],
+      'security only, normal release' => [
+        CronUpdater::SECURITY,
+        "$fixture_dir/drupal.9.8.1.xml",
+        FALSE,
+      ],
+      'enabled, normal release' => [
+        CronUpdater::ALL,
+        "$fixture_dir/drupal.9.8.1.xml",
+        TRUE,
+      ],
+      'enabled, security release' => [
+        CronUpdater::ALL,
+        "$fixture_dir/drupal.9.8.1-security.xml",
+        TRUE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the cron handler calls the updater as expected.
+   *
+   * @param string $setting
+   *   Whether automatic updates should be enabled during cron. Possible values
+   *   are 'disable', 'security', and 'patch'.
+   * @param string $release_data
+   *   If automatic updates are enabled, the path of the fake release metadata
+   *   that should be served when fetching information on available updates.
+   * @param bool $will_update
+   *   Whether an update should be performed, given the previous two arguments.
+   *
+   * @dataProvider providerUpdaterCalled
+   */
+  public function testUpdaterCalled(string $setting, string $release_data, bool $will_update): void {
+    // Our form alter does not refresh information on available updates, so
+    // ensure that the appropriate update data is loaded beforehand.
+    $this->setReleaseMetadata($release_data);
+    update_get_available(TRUE);
+
+    // Submit the configuration form programmatically, to prove our alterations
+    // work as expected.
+    $form_builder = $this->container->get('form_builder');
+    $form_state = new FormState();
+    $form = $form_builder->buildForm(UpdateSettingsForm::class, $form_state);
+    // Ensure that the version ranges in the setting's description, which are
+    // computed dynamically, look correct.
+    $this->assertStringContainsString('Automatic updates are only supported for 9.8.x versions of Drupal core. Drupal 9.8 will receive security updates until 9.10.0 is released.', $form['auto_updates_cron']['#description']);
+    $form_state->setValue('auto_updates_cron', $setting);
+    $form_builder->submitForm(UpdateSettingsForm::class, $form_state);
+
+    // Mock the updater so we can assert that its methods are called or bypassed
+    // depending on configuration.
+    $will_update = (int) $will_update;
+    $updater = $this->prophesize('\Drupal\auto_updates\Updater');
+
+    $composer = ComposerUtility::createForDirectory(__DIR__ . '/../../fixtures/fake-site');
+    $updater->getActiveComposer()->willReturn($composer);
+
+    $updater->begin(['drupal' => '9.8.1'])->shouldBeCalledTimes($will_update);
+    $updater->stage()->shouldBeCalledTimes($will_update);
+    $updater->apply()->shouldBeCalledTimes($will_update);
+    $updater->destroy()->shouldBeCalledTimes($will_update);
+    $this->container->set('auto_updates.updater', $updater->reveal());
+
+    $this->container->get('cron')->run();
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CoreComposerValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CoreComposerValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..df8eb2932406ebe619559f43dce929e86fa9aa9c
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CoreComposerValidatorTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\package_manager\ValidationResult;
+use Drupal\package_manager\PathLocator;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\CoreComposerValidator
+ *
+ * @group auto_updates
+ */
+class CoreComposerValidatorTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'package_manager',
+  ];
+
+  /**
+   * Tests that an error is raised if core is not required in composer.json.
+   */
+  public function testCoreNotRequired(): void {
+    // Point to a valid composer.json with no requirements.
+    $active_dir = __DIR__ . '/../../../fixtures/project_staged_validation/no_core_requirements';
+    $locator = $this->prophesize(PathLocator::class);
+    $locator->getActiveDirectory()->willReturn($active_dir);
+    $locator->getProjectRoot()->willReturn($active_dir);
+    $locator->getVendorDirectory()->willReturn($active_dir . '/vendor');
+    $this->container->set('package_manager.path_locator', $locator->reveal());
+
+    $error = ValidationResult::createError([
+      'Drupal core does not appear to be required in the project-level composer.json.',
+    ]);
+    $this->assertCheckerResultsFromManager([$error], TRUE);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..942bd897d265765fe12564382b7a9ed74436ad00
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php
@@ -0,0 +1,167 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\auto_updates\CronUpdater;
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\Validator\CronFrequencyValidator;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+use PHPUnit\Framework\AssertionFailedError;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\CronFrequencyValidator
+ *
+ * @group auto_updates
+ */
+class CronFrequencyValidatorTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'package_manager',
+    'package_manager_bypass',
+  ];
+
+  /**
+   * Tests that nothing is validated if updates are disabled during cron.
+   */
+  public function testNoValidationIfCronDisabled(): void {
+    $this->config('auto_updates.settings')
+      ->set('cron', CronUpdater::DISABLED)
+      ->save();
+
+    $validator = new class (
+      $this->container->get('config.factory'),
+      $this->container->get('module_handler'),
+      $this->container->get('state'),
+      $this->container->get('datetime.time'),
+      $this->container->get('string_translation')
+    ) extends CronFrequencyValidator {
+
+      /**
+       * {@inheritdoc}
+       */
+      protected function validateAutomatedCron(ReadinessCheckEvent $event): void {
+        throw new AssertionFailedError(__METHOD__ . '() should not have been called.');
+      }
+
+      /**
+       * {@inheritdoc}
+       */
+      protected function validateLastCronRun(ReadinessCheckEvent $event): void {
+        throw new AssertionFailedError(__METHOD__ . '() should not have been called.');
+      }
+
+    };
+    $this->container->set('auto_updates.cron_frequency_validator', $validator);
+    $this->assertCheckerResultsFromManager([], TRUE);
+  }
+
+  /**
+   * Data provider for ::testLastCronRunValidation().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerLastCronRunValidation(): array {
+    $error = ValidationResult::createError([
+      'Cron has not run recently. For more information, see the online handbook entry for <a href="https://www.drupal.org/cron">configuring cron jobs</a> to run at least every 3 hours.',
+    ]);
+
+    return [
+      'cron never ran' => [
+        0,
+        [$error],
+      ],
+      'cron ran four hours ago' => [
+        time() - 14400,
+        [$error],
+      ],
+      'cron ran an hour ago' => [
+        time() - 3600,
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests validation based on the last cron run time.
+   *
+   * @param int $last_run
+   *   A timestamp of the last time cron ran.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerLastCronRunValidation
+   */
+  public function testLastCronRunValidation(int $last_run, array $expected_results): void {
+    $this->container->get('state')->set('system.cron_last', $last_run);
+    $this->assertCheckerResultsFromManager($expected_results, TRUE);
+
+    // After running cron, any errors or warnings should be gone.
+    $this->container->get('cron')->run();
+    $this->assertCheckerResultsFromManager([], TRUE);
+  }
+
+  /**
+   * Data provider for ::testAutomatedCronValidation().
+   *
+   * @return array[]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerAutomatedCronValidation(): array {
+    return [
+      'default configuration' => [
+        NULL,
+        [],
+      ],
+      'every 6 hours' => [
+        21600,
+        [
+          ValidationResult::createWarning([
+            'Cron is not set to run frequently enough. <a href="/admin/config/system/cron">Configure it</a> to run at least every 3 hours or disable automated cron and run it via an external scheduling system.',
+          ]),
+        ],
+      ],
+      'every 25 hours' => [
+        90000,
+        [
+          ValidationResult::createError([
+            'Cron is not set to run frequently enough. <a href="/admin/config/system/cron">Configure it</a> to run at least every 3 hours or disable automated cron and run it via an external scheduling system.',
+          ]),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests validation based on Automated Cron settings.
+   *
+   * @param int|null $interval
+   *   The configured interval for Automated Cron. If NULL, the default value
+   *   will be used.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerAutomatedCronValidation
+   */
+  public function testAutomatedCronValidation(?int $interval, array $expected_results): void {
+    $this->enableModules(['automated_cron']);
+    $this->installConfig('automated_cron');
+
+    if (isset($interval)) {
+      $this->config('automated_cron.settings')
+        ->set('interval', $interval)
+        ->save();
+    }
+    $this->assertCheckerResultsFromManager($expected_results, TRUE);
+
+    // Even after running cron, we should have the same results.
+    $this->container->get('cron')->run();
+    $this->assertCheckerResultsFromManager($expected_results);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..2a385f1775129f695b0488ff13ea706633945ffe
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\EventSubscriber\PreOperationStageValidatorInterface;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+use Prophecy\Argument;
+
+/**
+ * Tests that Package Manager validators are invoked during readiness checking.
+ *
+ * @group auto_updates
+ *
+ * @covers \Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+ *
+ * @see \Drupal\Tests\package_manager\Kernel\ComposerExecutableValidatorTest
+ * @see \Drupal\Tests\package_manager\Kernel\DiskSpaceValidatorTest
+ * @see \Drupal\Tests\package_manager\Kernel\PendingUpdatesValidatorTest
+ * @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest
+ */
+class PackageManagerReadinessChecksTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'package_manager',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function disableValidators(ContainerBuilder $container): void {
+    // No need to disable any validators in this test.
+  }
+
+  /**
+   * Data provider for ::testValidatorInvoked().
+   *
+   * @return string[][]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerValidatorInvoked(): array {
+    return [
+      ['package_manager.validator.composer_executable'],
+      ['package_manager.validator.disk_space'],
+      ['package_manager.validator.pending_updates'],
+      ['package_manager.validator.file_system'],
+    ];
+  }
+
+  /**
+   * Tests that a Package Manager validator is invoked during readiness checks.
+   *
+   * @param string $service_id
+   *   The service ID of the validator that should be invoked.
+   *
+   * @dataProvider providerValidatorInvoked
+   */
+  public function testValidatorInvoked(string $service_id): void {
+    // Set up a mocked version of the Composer executable validator, to prove
+    // that it gets called with a readiness check event, when we run readiness
+    // checks.
+    $event = Argument::type(ReadinessCheckEvent::class);
+    $validator = $this->prophesize(PreOperationStageValidatorInterface::class);
+    $validator->validateStagePreOperation($event)->shouldBeCalled();
+    $this->container->set($service_id, $validator->reveal());
+
+    $this->container->get('auto_updates.readiness_validation_manager')
+      ->run();
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f7a9a29f836415e289815403e1411a24a3a0234d
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
@@ -0,0 +1,213 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates_test\ReadinessChecker\TestChecker1;
+use Drupal\auto_updates_test2\ReadinessChecker\TestChecker2;
+use Drupal\system\SystemManager;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+
+/**
+ * @coversDefaultClass \Drupal\auto_updates\Validation\ReadinessValidationManager
+ *
+ * @group auto_updates
+ */
+class ReadinessValidationManagerTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates_test',
+    'package_manager',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installEntitySchema('user');
+    $this->installSchema('user', ['users_data']);
+    $this->createTestValidationResults();
+  }
+
+  /**
+   * @covers ::getResults
+   */
+  public function testGetResults(): void {
+    $this->enableModules(['auto_updates', 'auto_updates_test2']);
+    $this->assertCheckerResultsFromManager([], TRUE);
+
+    $expected_results = [
+      array_pop($this->testResults['checker_1']),
+      array_pop($this->testResults['checker_2']),
+    ];
+    TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
+    TestChecker2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
+    $expected_results_all = array_merge($expected_results[0], $expected_results[1]);
+    $this->assertCheckerResultsFromManager($expected_results_all, TRUE);
+
+    // Define a constant flag that will cause the readiness checker
+    // service priority to be altered.
+    define('PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY', 1);
+    // Rebuild the container to trigger the service to be altered.
+    $kernel = $this->container->get('kernel');
+    $this->container = $kernel->rebuildContainer();
+    // Confirm that results will be NULL if the run() is not called again
+    // because the readiness checker services order has been altered.
+    $this->assertNull($this->getResultsFromManager());
+    // Confirm that after calling run() the expected results order has changed.
+    $expected_results_all_reversed = array_reverse($expected_results_all);
+    $this->assertCheckerResultsFromManager($expected_results_all_reversed, TRUE);
+
+    $expected_results = [
+      $this->testResults['checker_1']['2 errors 2 warnings'],
+      $this->testResults['checker_2']['2 errors 2 warnings'],
+    ];
+    TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
+    TestChecker2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
+    $expected_results_all = array_merge($expected_results[1], $expected_results[0]);
+    $this->assertCheckerResultsFromManager($expected_results_all, TRUE);
+
+    // Confirm that filtering by severity works.
+    $warnings_only_results = [
+      $expected_results[1]['2:warnings'],
+      $expected_results[0]['1:warnings'],
+    ];
+    $this->assertCheckerResultsFromManager($warnings_only_results, FALSE, SystemManager::REQUIREMENT_WARNING);
+
+    $errors_only_results = [
+      $expected_results[1]['2:errors'],
+      $expected_results[0]['1:errors'],
+    ];
+    $this->assertCheckerResultsFromManager($errors_only_results, FALSE, SystemManager::REQUIREMENT_ERROR);
+  }
+
+  /**
+   * Tests that the manager is run after modules are installed.
+   */
+  public function testRunOnInstall(): void {
+    $expected_results = [array_pop($this->testResults['checker_1'])];
+    TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
+    // Confirm that messages from an existing module are displayed when
+    // 'auto_updates' is installed.
+    $this->container->get('module_installer')->install(['auto_updates']);
+    $this->assertCheckerResultsFromManager($expected_results[0]);
+
+    // Confirm that the checkers are run when a module that provides a readiness
+    // checker is installed.
+    $expected_results = [
+      array_pop($this->testResults['checker_1']),
+      array_pop($this->testResults['checker_2']),
+    ];
+    TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
+    TestChecker2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
+    $this->container->get('module_installer')->install(['auto_updates_test2']);
+    $expected_results_all = array_merge($expected_results[0], $expected_results[1]);
+    $this->assertCheckerResultsFromManager($expected_results_all);
+
+    // Confirm that the checkers are not run when a module that does not provide
+    // a readiness checker is installed.
+    $unexpected_results = [
+      array_pop($this->testResults['checker_1']),
+      array_pop($this->testResults['checker_2']),
+    ];
+    TestChecker1::setTestResult($unexpected_results[0], ReadinessCheckEvent::class);
+    TestChecker2::setTestResult($unexpected_results[1], ReadinessCheckEvent::class);
+    $this->container->get('module_installer')->install(['help']);
+    $this->assertCheckerResultsFromManager($expected_results_all);
+  }
+
+  /**
+   * Tests that the manager is run after modules are uninstalled.
+   */
+  public function testRunOnUninstall(): void {
+    $expected_results = [
+      array_pop($this->testResults['checker_1']),
+      array_pop($this->testResults['checker_2']),
+    ];
+    TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
+    TestChecker2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
+    // Confirm that messages from existing modules are displayed when
+    // 'auto_updates' is installed.
+    $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test2', 'help']);
+    $expected_results_all = array_merge($expected_results[0], $expected_results[1]);
+    $this->assertCheckerResultsFromManager($expected_results_all);
+
+    // Confirm that the checkers are run when a module that provides a readiness
+    // checker is uninstalled.
+    $expected_results = [
+      array_pop($this->testResults['checker_1']),
+    ];
+    TestChecker1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
+    TestChecker2::setTestResult(array_pop($this->testResults['checker_2']), ReadinessCheckEvent::class);
+    $this->container->get('module_installer')->uninstall(['auto_updates_test2']);
+    $this->assertCheckerResultsFromManager($expected_results[0]);
+
+    // Confirm that the checkers are not run when a module that does provide a
+    // readiness checker is uninstalled.
+    $unexpected_results = [
+      array_pop($this->testResults['checker_1']),
+    ];
+    TestChecker1::setTestResult($unexpected_results[0], ReadinessCheckEvent::class);
+    $this->container->get('module_installer')->uninstall(['help']);
+    $this->assertCheckerResultsFromManager($expected_results[0]);
+  }
+
+  /**
+   * @covers ::runIfNoStoredResults
+   */
+  public function testRunIfNeeded(): void {
+    $expected_results = array_pop($this->testResults['checker_1']);
+    TestChecker1::setTestResult($expected_results, ReadinessCheckEvent::class);
+    $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test2']);
+    $this->assertCheckerResultsFromManager($expected_results);
+
+    $unexpected_results = array_pop($this->testResults['checker_1']);
+    TestChecker1::setTestResult($unexpected_results, ReadinessCheckEvent::class);
+    $manager = $this->container->get('auto_updates.readiness_validation_manager');
+    // Confirm that the new results will not be returned because the checkers
+    // will not be run.
+    $manager->runIfNoStoredResults();
+    $this->assertCheckerResultsFromManager($expected_results);
+
+    // Confirm that the new results will be returned because the checkers will
+    // be run if the stored results are deleted.
+    /** @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface $key_value */
+    $key_value = $this->container->get('keyvalue.expirable')->get('auto_updates');
+    $key_value->delete('readiness_validation_last_run');
+    $expected_results = $unexpected_results;
+    $manager->runIfNoStoredResults();
+    $this->assertCheckerResultsFromManager($expected_results);
+
+    // Confirm that the results are the same after rebuilding the container.
+    $unexpected_results = array_pop($this->testResults['checker_1']);
+    TestChecker1::setTestResult($unexpected_results, ReadinessCheckEvent::class);
+    /** @var \Drupal\Core\DrupalKernel $kernel */
+    $kernel = $this->container->get('kernel');
+    $this->container = $kernel->rebuildContainer();
+    $this->assertCheckerResultsFromManager($expected_results);
+
+    // Define a constant flag that will cause the readiness checker
+    // service priority to be altered. This will cause the priority of
+    // 'auto_updates_test.checker' to change from 2 to 4 which will be now
+    // higher than 'auto_updates_test2.checker' which has a priority of 3.
+    // Because the list of checker IDs is not identical to the previous checker
+    // run runIfNoStoredValidResults() will run the checkers again.
+    define('PACKAGE_MANAGER_TEST_VALIDATOR_PRIORITY', 1);
+
+    // Rebuild the container to trigger the readiness checker services to be
+    // reordered.
+    $kernel = $this->container->get('kernel');
+    $this->container = $kernel->rebuildContainer();
+    $expected_results = $unexpected_results;
+    /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $manager */
+    $manager = $this->container->get('auto_updates.readiness_validation_manager');
+    $manager->runIfNoStoredResults();
+    $this->assertCheckerResultsFromManager($expected_results);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..7c07a2b9731eacfe0196b8757c50c9f8e3f6dcb7
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
@@ -0,0 +1,247 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\auto_updates\Updater;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\PathLocator;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+use org\bovigo\vfs\vfsStream;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\StagedProjectsValidator
+ *
+ * @group auto_updates
+ */
+class StagedProjectsValidatorTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'package_manager',
+    'package_manager_bypass',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('auto_updates.updater')
+      ->setClass(TestUpdater::class);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function disableValidators(ContainerBuilder $container): void {
+    parent::disableValidators($container);
+
+    // This test deals with fake sites that don't necessarily have lock files,
+    // so disable lock file validation.
+    $container->removeDefinition('package_manager.validator.lock_file');
+  }
+
+  /**
+   * Runs the validator under test against an arbitrary pair of directories.
+   *
+   * @param string $active_dir
+   *   The active directory to validate.
+   * @param string $stage_dir
+   *   The stage directory to validate.
+   *
+   * @return \Drupal\package_manager\ValidationResult[]
+   *   The validation results.
+   */
+  private function validate(string $active_dir, string $stage_dir): array {
+    $locator = $this->prophesize(PathLocator::class);
+
+    $locator->getActiveDirectory()->willReturn($active_dir);
+    $locator->getProjectRoot()->willReturn($active_dir);
+    $locator->getVendorDirectory()->willReturn($active_dir);
+
+    $stage_dir_exists = is_dir($stage_dir);
+    if ($stage_dir_exists) {
+      // If we are testing a fixture with existing stage directory then we
+      // need to use a virtual file system directory, so we can create a
+      // subdirectory using the stage ID after it is created below.
+      $vendor = vfsStream::newDirectory('au_stage');
+      $this->vfsRoot->addChild($vendor);
+      TestUpdater::$stagingRoot = $vendor->url();
+    }
+    else {
+      // If we are testing non-existent staging directory we can use the path
+      // directly.
+      TestUpdater::$stagingRoot = $stage_dir;
+    }
+
+    $this->container->set('package_manager.path_locator', $locator->reveal());
+
+    $updater = $this->container->get('auto_updates.updater');
+    $stage_id = $updater->begin(['drupal' => '9.8.1']);
+    if ($stage_dir_exists) {
+      // Copy the fixture's staging directory into a subdirectory using the
+      // stage ID as the directory name.
+      $sub_directory = vfsStream::newDirectory($stage_id);
+      $vendor->addChild($sub_directory);
+      (new Filesystem())->mirror($stage_dir, $sub_directory->url());
+    }
+
+    // The staged projects validator only runs before staged updates are
+    // applied. Since the active and stage directories may not exist, we don't
+    // want to invoke the other stages of the update because they may raise
+    // errors that are outside of the scope of what we're testing here.
+    try {
+      $updater->apply();
+      return [];
+    }
+    catch (StageValidationException $e) {
+      return $e->getResults();
+    }
+  }
+
+  /**
+   * Tests that if an exception is thrown, the event will absorb it.
+   */
+  public function testEventConsumesExceptionResults(): void {
+    // Prepare a fake site in the virtual file system which contains valid
+    // Composer data.
+    $fixture = __DIR__ . '/../../../fixtures/fake-site';
+    copy("$fixture/composer.json", 'public://composer.json');
+    copy("$fixture/composer.lock", 'public://composer.lock');
+
+    $event_dispatcher = $this->container->get('event_dispatcher');
+    // Disable the disk space validator, since it doesn't work with vfsStream.
+    $disk_space_validator = $this->container->get('package_manager.validator.disk_space');
+    $event_dispatcher->removeSubscriber($disk_space_validator);
+    // Just before the staged changes are applied, delete the composer.json file
+    // to trigger an error. This uses the highest possible priority to guarantee
+    // it runs before any other subscribers.
+    $listener = function () {
+      unlink('public://composer.json');
+    };
+    $event_dispatcher->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
+
+    $results = $this->validate('public://', '/fake/stage/dir');
+    $this->assertCount(1, $results);
+    $messages = reset($results)->getMessages();
+    $this->assertCount(1, $messages);
+    $this->assertStringContainsString('Composer could not find the config file: public:///composer.json', (string) reset($messages));
+  }
+
+  /**
+   * Tests validations errors.
+   *
+   * @param string $fixtures_dir
+   *   The fixtures directory that provides the active and staged composer.lock
+   *   files.
+   * @param string $expected_summary
+   *   The expected error summary.
+   * @param string[] $expected_messages
+   *   The expected error messages.
+   *
+   * @dataProvider providerErrors
+   */
+  public function testErrors(string $fixtures_dir, string $expected_summary, array $expected_messages): void {
+    $this->assertNotEmpty($fixtures_dir);
+    $this->assertDirectoryExists($fixtures_dir);
+
+    $results = $this->validate("$fixtures_dir/active", "$fixtures_dir/staged");
+    $this->assertCount(1, $results);
+    $result = array_pop($results);
+    $this->assertSame($expected_summary, (string) $result->getSummary());
+    $actual_messages = $result->getMessages();
+    $this->assertCount(count($expected_messages), $actual_messages);
+    foreach ($expected_messages as $message) {
+      $actual_message = array_shift($actual_messages);
+      $this->assertSame($message, (string) $actual_message);
+    }
+  }
+
+  /**
+   * Data provider for testErrors().
+   *
+   * @return \string[][]
+   *   Test cases for testErrors().
+   */
+  public function providerErrors(): array {
+    $fixtures_folder = realpath(__DIR__ . '/../../../fixtures/project_staged_validation');
+    return [
+      'new_project_added' => [
+        "$fixtures_folder/new_project_added",
+        'The update cannot proceed because the following Drupal projects were installed during the update.',
+        [
+          "module 'drupal/test_module2' installed.",
+          "custom module 'drupal/dev-test_module2' installed.",
+        ],
+      ],
+      'project_removed' => [
+        "$fixtures_folder/project_removed",
+        'The update cannot proceed because the following Drupal projects were removed during the update.',
+        [
+          "theme 'drupal/test_theme' removed.",
+          "custom theme 'drupal/dev-test_theme' removed.",
+        ],
+      ],
+      'version_changed' => [
+        "$fixtures_folder/version_changed",
+        'The update cannot proceed because the following Drupal projects were unexpectedly updated. Only Drupal Core updates are currently supported.',
+        [
+          "module 'drupal/test_module' from 1.3.0 to  1.3.1.",
+          "module 'drupal/dev-test_module' from 1.3.0 to  1.3.1.",
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests validation when there are no errors.
+   */
+  public function testNoErrors(): void {
+    $fixtures_dir = realpath(__DIR__ . '/../../../fixtures/project_staged_validation/no_errors');
+    $results = $this->validate("$fixtures_dir/active", "$fixtures_dir/staged");
+    $this->assertIsArray($results);
+    $this->assertEmpty($results);
+  }
+
+  /**
+   * Tests validation when a composer.lock file is not found.
+   */
+  public function testNoLockFile(): void {
+    $fixtures_dir = realpath(__DIR__ . '/../../../fixtures/project_staged_validation/no_errors');
+
+    $results = $this->validate("$fixtures_dir/active", $fixtures_dir);
+    $this->assertCount(1, $results);
+    $result = array_pop($results);
+    $this->assertSame("No lockfile found. Unable to read locked packages", (string) $result->getMessages()[0]);
+    $this->assertSame('', (string) $result->getSummary());
+  }
+
+}
+
+/**
+ * A test-only version of the updater.
+ */
+class TestUpdater extends Updater {
+
+  /**
+   * The directory where staging areas will be created.
+   *
+   * @var string
+   */
+  public static $stagingRoot;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static function getStagingRoot(): string {
+    return static::$stagingRoot ?: parent::getStagingRoot();
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c51c425cf02fd7b4617dfb91f7abc3481ad80f8
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\UpdateVersionValidator
+ *
+ * @group auto_updates
+ */
+class UpdateVersionValidatorTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'package_manager',
+  ];
+
+  /**
+   * Tests an update version that is same major & minor version as the current.
+   */
+  public function testNoMajorOrMinorUpdates(): void {
+    $this->assertCheckerResultsFromManager([], TRUE);
+  }
+
+  /**
+   * Tests an update version that is a different major version than the current.
+   */
+  public function testMajorUpdates(): void {
+    $this->setCoreVersion('8.9.1');
+    $result = ValidationResult::createError([
+      'Drupal cannot be automatically updated from its current version, 8.9.1, to the recommended version, 9.8.1, because automatic updates from one major version to another are not supported.',
+    ]);
+    $this->assertCheckerResultsFromManager([$result], TRUE);
+
+  }
+
+  /**
+   * Tests an update version that is a different minor version than the current.
+   */
+  public function testMinorUpdates(): void {
+    $this->setCoreVersion('9.7.1');
+    $result = ValidationResult::createError([
+      'Drupal cannot be automatically updated from its current version, 9.7.1, to the recommended version, 9.8.1, because automatic updates from one minor version to another are not supported.',
+    ]);
+    $this->assertCheckerResultsFromManager([$result], TRUE);
+  }
+
+  /**
+   * Tests an update version that is a lower version than the current.
+   */
+  public function testDowngrading(): void {
+    $this->setCoreVersion('9.8.2');
+    $result = ValidationResult::createError(['Update version 9.8.1 is lower than 9.8.2, downgrading is not supported.']);
+    $this->assertCheckerResultsFromManager([$result], TRUE);
+  }
+
+  /**
+   * Tests a current version that is a dev version.
+   */
+  public function testUpdatesFromDevVersion(): void {
+    $this->setCoreVersion('9.8.0-dev');
+    $result = ValidationResult::createError(['Drupal cannot be automatically updated from its current version, 9.8.0-dev, to the recommended version, 9.8.1, because automatic updates from a dev version to any other version are not supported.']);
+    $this->assertCheckerResultsFromManager([$result], TRUE);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..3741a174499754bba6e5898de317112d5b243bef
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/UpdateRecommenderTest.php
@@ -0,0 +1,47 @@
+<?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.1', $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.1');
+
+    $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
new file mode 100644
index 0000000000000000000000000000000000000000..ad7e46193b85721aad286baeb854e1185cdef083
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
@@ -0,0 +1,124 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel;
+
+use Drupal\package_manager\PathLocator;
+use Drupal\Tests\user\Traits\UserCreationTrait;
+
+/**
+ * @coversDefaultClass \Drupal\auto_updates\Updater
+ *
+ * @group auto_updates
+ */
+class UpdaterTest extends AutoUpdatesKernelTestBase {
+
+  use UserCreationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'auto_updates_test',
+    'package_manager',
+    'package_manager_bypass',
+    'system',
+    'user',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $this->installEntitySchema('user');
+  }
+
+  /**
+   * Tests that correct versions are staged after calling ::begin().
+   */
+  public function testCorrectVersionsStaged() {
+    $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.
+    $user = $this->createUser([], NULL, TRUE, ['uid' => 2]);
+    $this->setCurrentUser($user);
+
+    // Point to a fake site which requires Drupal core via a distribution. The
+    // lock file should be scanned to determine the core packages, which should
+    // result in drupal/core-recommended being updated.
+    $fixture_dir = __DIR__ . '/../../fixtures/fake-site';
+    $locator = $this->prophesize(PathLocator::class);
+    $locator->getActiveDirectory()->willReturn($fixture_dir);
+    $locator->getProjectRoot()->willReturn($fixture_dir);
+    $locator->getVendorDirectory()->willReturn($fixture_dir);
+    $this->container->set('package_manager.path_locator', $locator->reveal());
+
+    $id = $this->container->get('auto_updates.updater')->begin([
+      'drupal' => '9.8.1',
+    ]);
+    // Rebuild the container to ensure the project versions are kept in state.
+    /** @var \Drupal\Core\DrupalKernel $kernel */
+    $kernel = $this->container->get('kernel');
+    $kernel->rebuildContainer();
+    $this->container = $kernel->getContainer();
+    // Keep using the mocked path locator and current user.
+    $this->container->set('package_manager.path_locator', $locator->reveal());
+    $this->setCurrentUser($user);
+
+    // When we call Updater::stage(), the stored project versions should be
+    // read from state and passed to Composer Stager's Stager service, in the
+    // form of a Composer command. This is done using package_manager_bypass's
+    // invocation recorder, rather than a regular mock, in order to test that
+    // the invocation recorder itself works.
+    // The production dependencies should be updated first...
+    $expected_require_arguments = [
+      'require',
+      'drupal/core-recommended:9.8.1',
+      '--update-with-all-dependencies',
+    ];
+    // ...followed by the dev dependencies.
+    $expected_require_dev_arguments = [
+      'require',
+      'drupal/core-dev:9.8.1',
+      '--update-with-all-dependencies',
+      '--dev',
+    ];
+    $this->container->get('auto_updates.updater')->claim($id)->stage();
+
+    /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */
+    $stager = $this->container->get('package_manager.stager');
+    [
+      $actual_require_arguments,
+      $actual_require_dev_arguments,
+    ] = $stager->getInvocationArguments();
+    $this->assertSame($expected_require_arguments, $actual_require_arguments[0]);
+    $this->assertSame($expected_require_dev_arguments, $actual_require_dev_arguments[0]);
+  }
+
+  /**
+   * @covers ::begin
+   *
+   * @dataProvider providerInvalidProjectVersions
+   */
+  public function testInvalidProjectVersions(array $project_versions): void {
+    $this->expectException(\InvalidArgumentException::class);
+    $this->expectExceptionMessage('Currently only updates to Drupal core are supported.');
+    $this->container->get('auto_updates.updater')->begin($project_versions);
+  }
+
+  /**
+   * Data provider for testInvalidProjectVersions().
+   *
+   * @return array
+   *   The test cases for testInvalidProjectVersions().
+   */
+  public function providerInvalidProjectVersions(): array {
+    return [
+      'only not drupal' => [['not_drupal' => '1.1.3']],
+      'not drupal and drupal' => [['drupal' => '9.8.0', 'not_drupal' => '1.2.3']],
+      'empty' => [[]],
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Traits/JsonTrait.php b/core/modules/auto_updates/tests/src/Traits/JsonTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..3c89c720bbe6c5e4f830abd82d5420505ef81270
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Traits/JsonTrait.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Traits;
+
+use Drupal\Component\Serialization\Json;
+use PHPUnit\Framework\Assert;
+
+/**
+ * Provides assertive methods to read and write JSON data in files.
+ */
+trait JsonTrait {
+
+  /**
+   * Reads JSON data from a file and returns it as an array.
+   *
+   * @param string $path
+   *   The path of the file to read.
+   *
+   * @return mixed[]
+   *   The parsed data in the file.
+   */
+  protected function readJson(string $path): array {
+    Assert::assertIsReadable($path);
+    $data = file_get_contents($path);
+    return Json::decode($data);
+  }
+
+  /**
+   * Writes an array of data to a file as JSON.
+   *
+   * @param string $path
+   *   The path of the file to write.
+   * @param array $data
+   *   The data to be written.
+   */
+  protected function writeJson(string $path, array $data): void {
+    Assert::assertIsWritable(file_exists($path) ? $path : dirname($path));
+    file_put_contents($path, Json::encode($data));
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php b/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..6b2d0407270ef675ae4c97354b62b9bf9d8305a8
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Traits/LocalPackagesTrait.php
@@ -0,0 +1,163 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Traits;
+
+use Drupal\Component\FileSystem\FileSystem;
+use Drupal\Component\Utility\NestedArray;
+use PHPUnit\Framework\Assert;
+use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
+
+/**
+ * Provides methods for interacting with installed Composer packages.
+ */
+trait LocalPackagesTrait {
+
+  use JsonTrait;
+
+  /**
+   * The paths of temporary copies of packages.
+   *
+   * @see ::copyPackage()
+   * @see ::deleteCopiedPackages()
+   *
+   * @var string[]
+   */
+  private $copiedPackages = [];
+
+  /**
+   * Returns the path of an installed package, relative to composer.json.
+   *
+   * @param array $package
+   *   The package information, as read from the lock file.
+   *
+   * @return string
+   *   The path of the installed package, relative to composer.json.
+   */
+  protected function getPackagePath(array $package): string {
+    return 'vendor' . DIRECTORY_SEPARATOR . $package['name'];
+  }
+
+  /**
+   * Deletes all copied packages.
+   *
+   * @see ::copyPackage()
+   */
+  protected function deleteCopiedPackages(): void {
+    (new SymfonyFilesystem())->remove($this->copiedPackages);
+  }
+
+  /**
+   * Copies a package's entire directory to another location.
+   *
+   * The copies' paths will be stored so that they can be easily deleted by
+   * ::deleteCopiedPackages().
+   *
+   * @param string $source_dir
+   *   The path of the package directory to copy.
+   * @param string|null $destination_dir
+   *   (optional) The directory to which the package should be copied. Will be
+   *   suffixed with a random string to ensure uniqueness. If not given, the
+   *   system temporary directory will be used.
+   *
+   * @return string
+   *   The path of the temporary copy.
+   *
+   * @see ::deleteCopiedPackages()
+   */
+  protected function copyPackage(string $source_dir, string $destination_dir = NULL): string {
+    Assert::assertDirectoryExists($source_dir);
+
+    if (empty($destination_dir)) {
+      $destination_dir = FileSystem::getOsTemporaryDirectory();
+      Assert::assertNotEmpty($destination_dir);
+      $destination_dir .= DIRECTORY_SEPARATOR;
+    }
+    $destination_dir = uniqid($destination_dir);
+    Assert::assertDirectoryDoesNotExist($destination_dir);
+
+    (new SymfonyFilesystem())->mirror($source_dir, $destination_dir);
+    array_push($this->copiedPackages, $destination_dir);
+
+    return $destination_dir;
+  }
+
+  /**
+   * Generates local path repositories for a set of installed packages.
+   *
+   * @param string $dir
+   *   The directory which contains composer.lock.
+   *
+   * @return mixed[][]
+   *   The local path repositories' configuration, for inclusion in a
+   *   composer.json file.
+   */
+  protected function getLocalPackageRepositories(string $dir): array {
+    $repositories = [];
+
+    foreach ($this->getPackagesFromLockFile($dir) as $package) {
+      // Ensure the package directory is writable, since we'll need to make a
+      // few changes to it.
+      $path = $dir . DIRECTORY_SEPARATOR . $this->getPackagePath($package);
+      Assert::assertIsWritable($path);
+      $composer = $path . DIRECTORY_SEPARATOR . 'composer.json';
+
+      // Overwrite the composer.json with the fully resolved package information
+      // from the lock file.
+      // @todo Back up composer.json before overwriting it?
+      $this->writeJson($composer, $package);
+
+      $name = $package['name'];
+      $repositories[$name] = $this->createPathRepository($path);
+    }
+    return $repositories;
+  }
+
+  /**
+   * Defines a local path repository for a given path.
+   *
+   * @param string $path
+   *   The path of the repository.
+   *
+   * @return array
+   *   The local path repository definition.
+   */
+  protected function createPathRepository(string $path): array {
+    return [
+      'type' => 'path',
+      'url' => $path,
+      'options' => [
+        'symlink' => FALSE,
+      ],
+    ];
+  }
+
+  /**
+   * Alters a package's composer.json file.
+   *
+   * @param string $package_dir
+   *   The package directory.
+   * @param array $changes
+   *   The changes to merge into composer.json.
+   */
+  protected function alterPackage(string $package_dir, array $changes): void {
+    $composer = $package_dir . DIRECTORY_SEPARATOR . 'composer.json';
+    $data = $this->readJson($composer);
+    $data = NestedArray::mergeDeep($data, $changes);
+    $this->writeJson($composer, $data);
+  }
+
+  /**
+   * Reads all package information from a composer.lock file.
+   *
+   * @param string $dir
+   *   The directory which contains the lock file.
+   *
+   * @return mixed[][]
+   *   All package information (including dev packages) from the lock file.
+   */
+  private function getPackagesFromLockFile(string $dir): array {
+    $lock = $this->readJson($dir . DIRECTORY_SEPARATOR . 'composer.lock');
+    return array_merge($lock['packages'], $lock['packages-dev']);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php b/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..f1c1919b9b2806d35fbf1a215b12c3bf45a1e15c
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Traits/SettingsTrait.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Traits;
+
+use PHPUnit\Framework\Assert;
+
+/**
+ * Provides methods for manipulating site settings.
+ */
+trait SettingsTrait {
+
+  /**
+   * Appends some PHP code to settings.php.
+   *
+   * @param string $php
+   *   The PHP code to append to settings.php.
+   * @param string $drupal_root
+   *   The path of the Drupal root.
+   * @param string $site
+   *   (optional) The name of the site whose settings.php should be amended.
+   *   Defaults to 'default'.
+   */
+  protected function addSettings(string $php, string $drupal_root, string $site = 'default'): void {
+    $settings = $this->makeSettingsWritable($drupal_root, $site);
+    $settings = fopen($settings, 'a');
+    Assert::assertIsResource($settings);
+    Assert::assertIsInt(fwrite($settings, $php));
+    Assert::assertTrue(fclose($settings));
+  }
+
+  /**
+   * Ensures that settings.php is writable.
+   *
+   * @param string $drupal_root
+   *   The path of the Drupal root.
+   * @param string $site
+   *   (optional) The name of the site whose settings should be made writable.
+   *   Defaults to 'default'.
+   *
+   * @return string
+   *   The path to settings.php for the specified site.
+   */
+  private function makeSettingsWritable(string $drupal_root, string $site = 'default'): string {
+    $settings = implode(DIRECTORY_SEPARATOR, [
+      $drupal_root,
+      'sites',
+      $site,
+      'settings.php',
+    ]);
+    chmod(dirname($settings), 0744);
+    chmod($settings, 0744);
+    Assert::assertIsWritable($settings);
+
+    return $settings;
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php b/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..d287aa8d80e376cc69f85a48582514b0f22cce91
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php
@@ -0,0 +1,131 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Traits;
+
+use Drupal\package_manager\ValidationResult;
+
+use Drupal\Tests\package_manager\Traits\ValidationTestTrait as PackageManagerValidationTestTrait;
+
+/**
+ * Common methods for testing validation.
+ */
+trait ValidationTestTrait {
+
+  use PackageManagerValidationTestTrait;
+
+  /**
+   * Expected explanation text when readiness checkers return error messages.
+   *
+   * @var string
+   */
+  protected static $errorsExplanation = 'Your site does not pass some readiness checks for automatic updates. It cannot be automatically updated until further action is performed.';
+
+  /**
+   * Expected explanation text when readiness checkers return warning messages.
+   *
+   * @var string
+   */
+  protected static $warningsExplanation = 'Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might affect the eligibility for automatic updates.';
+
+  /**
+   * Test validation results.
+   *
+   * @var \Drupal\package_manager\ValidationResult[][][]
+   */
+  protected $testResults;
+
+  /**
+   * Creates ValidationResult objects to be used in tests.
+   */
+  protected function createTestValidationResults(): void {
+    // Set up various validation results for the test checkers.
+    foreach ([1, 2] as $listener_number) {
+      // Set test validation results.
+      $this->testResults["checker_$listener_number"]['1 error'] = [
+        ValidationResult::createError(
+          [t("$listener_number:OMG 🚒. Your server is on 🔥!")],
+          t("$listener_number:Summary: 🔥")
+        ),
+      ];
+      $this->testResults["checker_$listener_number"]['1 error 1 warning'] = [
+        "$listener_number:error" => ValidationResult::createError(
+          [t("$listener_number:OMG 🔌. Some one unplugged the server! How is this site even running?")],
+          t("$listener_number:Summary: 🔥")
+        ),
+        "$listener_number:warning" => ValidationResult::createWarning(
+          [t("$listener_number:It looks like it going to rain and your server is outside.")],
+          t("$listener_number:Warnings summary not displayed because only 1 warning message.")
+        ),
+      ];
+      $this->testResults["checker_$listener_number"]['2 errors 2 warnings'] = [
+        "$listener_number:errors" => ValidationResult::createError(
+          [
+            t("$listener_number:😬Your server is in a cloud, a literal cloud!☁️."),
+            t("$listener_number:😂PHP only has 32k memory."),
+          ],
+          t("$listener_number:Errors summary displayed because more than 1 error message")
+        ),
+        "$listener_number:warnings" => ValidationResult::createWarning(
+          [
+            t("$listener_number:Your server is a smart fridge. Will this work?"),
+            t("$listener_number:Your server case is duct tape!"),
+          ],
+          t("$listener_number:Warnings summary displayed because more than 1 warning message.")
+        ),
+
+      ];
+      $this->testResults["checker_$listener_number"]['2 warnings'] = [
+        ValidationResult::createWarning(
+          [
+            t("$listener_number:The universe could collapse in on itself in the next second, in which case automatic updates will not run."),
+            t("$listener_number:An asteroid could hit your server farm, which would also stop automatic updates from running."),
+          ],
+          t("$listener_number:Warnings summary displayed because more than 1 warning message.")
+        ),
+      ];
+      $this->testResults["checker_$listener_number"]['1 warning'] = [
+        ValidationResult::createWarning(
+          [t("$listener_number:This is your one and only warning. You have been warned.")],
+          t("$listener_number:No need for this summary with only 1 warning.")
+        ),
+      ];
+    }
+  }
+
+  /**
+   * Gets the messages of a particular type from the manager.
+   *
+   * @param bool $call_run
+   *   Whether to run the checkers.
+   * @param int|null $severity
+   *   (optional) The severity for the results to return. Should be one of the
+   *   SystemManager::REQUIREMENT_* constants.
+   *
+   * @return \Drupal\package_manager\ValidationResult[]|null
+   *   The messages of the type.
+   */
+  protected function getResultsFromManager(bool $call_run = FALSE, ?int $severity = NULL): ?array {
+    $manager = $this->container->get('auto_updates.readiness_validation_manager');
+    if ($call_run) {
+      $manager->run();
+    }
+    return $manager->getResults($severity);
+  }
+
+  /**
+   * Asserts expected validation results from the manager.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected results.
+   * @param bool $call_run
+   *   (Optional) Whether to call ::run() on the manager. Defaults to FALSE.
+   * @param int|null $severity
+   *   (optional) The severity for the results to return. Should be one of the
+   *   SystemManager::REQUIREMENT_* constants.
+   */
+  protected function assertCheckerResultsFromManager(array $expected_results, bool $call_run = FALSE, ?int $severity = NULL): void {
+    $actual_results = $this->getResultsFromManager($call_run, $severity);
+    $this->assertValidationResultsEqual($expected_results, $actual_results);
+  }
+
+}