diff --git a/README.md b/README.md
index 3d69389485eb06474a31cd152e3afd2334de69f7..ccefe5ff2059a0dbf4ff790e32a0de8a31449870 100644
--- a/README.md
+++ b/README.md
@@ -1,68 +1,6 @@
 Automatic Updates
 ---------------
 
-### About this Module
-
-Updating a Drupal site is difficult, time-consuming, and expensive. It is a
-tricky problem that, on its face appears easy, however, ensuring secure and
-reliable updates that give assurance to site owners and availability to site
-visitors.
-
-The Automatic Updates module is not yet in core. In its initial form, it is
-being made available as a contributed module. Please note that Automatic Updates
-is a [Strategic Initiative](https://www.drupal.org/project/ideas/issues/2940731)
-for the Drupal Project. The Initiative is still in progress and additional
-features and bug fixes are regularly added.
-
-The primary use case for this module:
-
-- **Public service announcements (PSAs)**
-
-Announcements for highly critical security releases for core and contrib modules
-are done infrequently. When a PSA is released, site owners should review their
-sites to verify they are up to date with the latest releases and the site is in
-a good state to quickly update once the fixes are provided to the community.
-
-- **Update readiness checks**
-
-Not all sites are able to always update. The readiness checks are an automated
-method to determine if a site is ready for automatically updating once a new
-release is provided to the community. For example, sites that have un-run
-database updates, are mounted on read only filesystems or do not have sufficient
-disk space to update in-place can't receive automatic updates. If your site is
-failing readiness checks and a PSA is released, it is important to resolve the
-underlying readiness issues so the site can quickly be updated.
-
-- **In-Place Updates**
-
-Once the PSA service has notified a Drupal site owner of an available update,
-and the readiness checks have confirmed that the site is ready to be updated,
-the Automatic Update service can then apply the update.
-
-
-### Goals
-
-The Automatic Update service for Drupal aims to simplify the update process and
-provide confidence that an update will apply cleanly.
-
-
-### Demo
-
-> [Watch a demo](https://youtu.be/fT2--EBhzuE) of the module from DriesNote at
-> DrupalCon Amsterdam 2019.
-
-
-### Installing the Automatic Updates Module
-
-1. Copy/upload the automatic_updates module to the modules directory of your
-   Drupal installation.
-
-1. Enable the 'Automatic Updates' module in 'Extend' (/admin/modules).
-
-1. Configure the module to enable PSA notifications, readiness checks and
-   in-place updates (/admin/config/automatic_updates).
-
-
 ### Automatic Updates Initiative
 
 - Follow and read up on
diff --git a/artifacts/keys/root.pub b/artifacts/keys/root.pub
deleted file mode 100644
index c70e201279501a63c41e7624642aee7edd20a5e9..0000000000000000000000000000000000000000
--- a/artifacts/keys/root.pub
+++ /dev/null
@@ -1,2 +0,0 @@
-untrusted comment: signify public key
-RWQVj5RBijXj1b4WXWOlakzu1EzALf24UtLk1q/D3LV0H5Uh+BP9kdHU
diff --git a/automatic_updates.info.yml b/automatic_updates.info.yml
index d8043c555025d773fd442e6da1ec344de6287826..923883316d37879a516ecdfbce9fb3e994eadf3e 100644
--- a/automatic_updates.info.yml
+++ b/automatic_updates.info.yml
@@ -1,10 +1,7 @@
 name: 'Automatic Updates'
 type: module
-description: 'Display Public service announcements and verify readiness for applying automatic updates to the site.'
-core: 8.x
-core_version_requirement: ^8 || ^9
-package: 'Security'
-configure: automatic_updates.settings
+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
 dependencies:
-  - drupal:system (>= 8.7)
-  - drupal:update (>= 8.7)
+  - automatic_updates_9_3_shim
+  - update
diff --git a/automatic_updates.install b/automatic_updates.install
index 9a2e68821f96cd009333bbe65772015915590b07..a4b7e81fd50f2b4c93a99a855c528ac9ed368551 100644
--- a/automatic_updates.install
+++ b/automatic_updates.install
@@ -2,120 +2,20 @@
 
 /**
  * @file
- * Automatic updates install file.
+ * Contains install and update functions for Automatic Updates.
  */
 
-use Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface;
-use Drupal\Core\StringTranslation\PluralTranslatableMarkup;
-use Drupal\Core\Url;
+use Drupal\automatic_updates\Validation\ReadinessRequirements;
 
 /**
  * Implements hook_requirements().
  */
 function automatic_updates_requirements($phase) {
-  // Mimic the functionality of the vendor checker procedurally since class
-  // loading isn't available pre module install.
-  $vendor_autoloader = [
-    DRUPAL_ROOT,
-    'vendor',
-    'autoload.php',
-  ];
-  if (!file_exists(implode(DIRECTORY_SEPARATOR, $vendor_autoloader))) {
-    return [
-      'not installable' => [
-        'title' => t('Automatic Updates'),
-        'severity' => REQUIREMENT_ERROR,
-        'value' => '1.x',
-        'description' => t('This module does not currently support relocated vendor folder and composer-based workflows.'),
-      ],
-    ];
-  }
   if ($phase !== 'runtime') {
-    return NULL;
-  }
-
-  $requirements = [];
-  _automatic_updates_checker_requirements($requirements);
-  _automatic_updates_psa_requirements($requirements);
-  return $requirements;
-}
-
-/**
- * Display requirements from results of readiness checker.
- *
- * @param array $requirements
- *   The requirements array.
- */
-function _automatic_updates_checker_requirements(array &$requirements) {
-  /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */
-  $checker = \Drupal::service('automatic_updates.readiness_checker');
-  if (!$checker->isEnabled()) {
-    return;
+    return [];
   }
 
-  $last_check_timestamp = $checker->timestamp();
-  $requirements['automatic_updates_readiness'] = [
-    'title' => t('Update readiness checks'),
-    'severity' => REQUIREMENT_OK,
-    'value' => t('Your site is ready to for <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']),
-  ];
-  $error_results = $checker->getResults(ReadinessCheckerManagerInterface::ERROR);
-  $warning_results = $checker->getResults(ReadinessCheckerManagerInterface::WARNING);
-  $checker_results = array_merge($error_results, $warning_results);
-  if (!empty($checker_results)) {
-    $requirements['automatic_updates_readiness']['severity'] = $error_results ? REQUIREMENT_ERROR : REQUIREMENT_WARNING;
-    $requirements['automatic_updates_readiness']['value'] = new PluralTranslatableMarkup(count($checker_results), '@count check failed:', '@count checks failed:');
-    $requirements['automatic_updates_readiness']['description'] = [
-      '#theme' => 'item_list',
-      '#items' => $checker_results,
-    ];
-  }
-  if (\Drupal::time()->getRequestTime() > $last_check_timestamp + ReadinessCheckerManagerInterface::LAST_CHECKED_WARNING) {
-    $requirements['automatic_updates_readiness']['severity'] = REQUIREMENT_ERROR;
-    $requirements['automatic_updates_readiness']['value'] = t('Your site has not recently checked if it is ready to apply <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']);
-    $readiness_check = Url::fromRoute('automatic_updates.update_readiness');
-    $time_ago = \Drupal::service('date.formatter')->formatTimeDiffSince($last_check_timestamp);
-    if ($last_check_timestamp === 0) {
-      $requirements['automatic_updates_readiness']['description'] = t('<a href="@link">Run readiness checks</a> manually.', [
-        '@link' => $readiness_check->toString(),
-      ]);
-    }
-    elseif ($readiness_check->access()) {
-      $requirements['automatic_updates_readiness']['description'] = t('Last run @time ago. <a href="@link">Run readiness checks</a> manually.', [
-        '@time' => $time_ago,
-        '@link' => $readiness_check->toString(),
-      ]);
-    }
-    else {
-      $requirements['automatic_updates_readiness']['description'] = t('Readiness checks were last run @time ago.', ['@time' => $time_ago]);
-    }
-  }
-}
-
-/**
- * Display requirements from Public service announcements.
- *
- * @param array $requirements
- *   The requirements array.
- */
-function _automatic_updates_psa_requirements(array &$requirements) {
-  if (!\Drupal::config('automatic_updates.settings')->get('enable_psa')) {
-    return;
-  }
-  /** @var \Drupal\automatic_updates\Services\AutomaticUpdatesPsa $psa */
-  $psa = \Drupal::service('automatic_updates.psa');
-  $messages = $psa->getPublicServiceMessages();
-  $requirements['automatic_updates_psa'] = [
-    'title' => t('<a href="@link">Public service announcements</a>', ['@link' => 'https://www.drupal.org/docs/8/update/automatic-updates#psas']),
-    'severity' => REQUIREMENT_OK,
-    'value' => t('No announcements requiring attention.'),
-  ];
-  if (!empty($messages)) {
-    $requirements['automatic_updates_psa']['severity'] = REQUIREMENT_ERROR;
-    $requirements['automatic_updates_psa']['value'] = new PluralTranslatableMarkup(count($messages), '@count urgent announcement requires your attention:', '@count urgent announcements require your attention:');
-    $requirements['automatic_updates_psa']['description'] = [
-      '#theme' => 'item_list',
-      '#items' => $messages,
-    ];
-  }
+  /** @var \Drupal\automatic_updates\Validation\ReadinessRequirements $readiness_requirement */
+  $readiness_requirement = \Drupal::classResolver(ReadinessRequirements::class);
+  return $readiness_requirement->getRequirements();
 }
diff --git a/automatic_updates.links.menu.yml b/automatic_updates.links.menu.yml
deleted file mode 100644
index a2d959f9a84c1fcb84f3efe17d7bf0c30d7be774..0000000000000000000000000000000000000000
--- a/automatic_updates.links.menu.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-automatic_updates.settings:
-  title: 'Automatic updates'
-  route_name: automatic_updates.settings
-  description: 'Configure automatic update settings.'
-  parent: system.admin_config_system
diff --git a/automatic_updates.module b/automatic_updates.module
index 1d96f034ff05c65ce865a7ad25ec832a3612218e..131049dcbe5949b8ef56b634d0703247508c7786 100644
--- a/automatic_updates.module
+++ b/automatic_updates.module
@@ -2,198 +2,71 @@
 
 /**
  * @file
- * Contains automatic_updates.module..
+ * Contains hook implementations for Automatic Updates.
  */
 
-use Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface;
-use Drupal\automatic_updates\UpdateMetadata;
+use Drupal\automatic_updates\Validation\AdminReadinessMessages;
+use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Url;
-use Drupal\update\UpdateManagerInterface;
-use Symfony\Component\Process\PhpExecutableFinder;
-use Symfony\Component\Process\Process;
 
 /**
  * Implements hook_page_top().
  */
-function automatic_updates_page_top(array &$page_top) {
-  /** @var \Drupal\Core\Routing\AdminContext $admin_context */
-  $admin_context = \Drupal::service('router.admin_context');
-  $route_match = \Drupal::routeMatch();
-  if ($admin_context->isAdminRoute($route_match->getRouteObject()) && \Drupal::currentUser()->hasPermission('administer site configuration')) {
-    $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',
-    ];
-    // These routes don't need additional nagging.
-    if (in_array(\Drupal::routeMatch()->getRouteName(), $disabled_routes, TRUE)) {
-      return;
-    }
-    /** @var \Drupal\automatic_updates\Services\AutomaticUpdatesPsaInterface $psa */
-    $psa = \Drupal::service('automatic_updates.psa');
-    $messages = $psa->getPublicServiceMessages();
-    if ($messages) {
-      \Drupal::messenger()->addError(t('Public service announcements:'));
-      foreach ($messages as $message) {
-        \Drupal::messenger()->addError($message);
-      }
-    }
-    $last_check_timestamp = \Drupal::service('automatic_updates.readiness_checker')->timestamp();
-    if (\Drupal::time()->getRequestTime() > $last_check_timestamp + ReadinessCheckerManagerInterface::LAST_CHECKED_WARNING) {
-      $readiness_settings = Url::fromRoute('automatic_updates.settings');
-      \Drupal::messenger()->addError(t('Your site has not recently run an update readiness check. <a href="@link">Administer automatic updates</a> and run readiness checks manually.', [
-        '@link' => $readiness_settings->toString(),
-      ]));
-    }
-    /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */
-    $checker = \Drupal::service('automatic_updates.readiness_checker');
-    $results = $checker->getResults(ReadinessCheckerManagerInterface::ERROR);
-    if ($results) {
-      \Drupal::messenger()->addError(t('Your site is currently failing readiness checks for automatic updates. It cannot be <a href="@readiness_checks">automatically updated</a> until further action is performed:', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']));
-      foreach ($results as $message) {
-        \Drupal::messenger()->addError($message);
-      }
-    }
-    $results = $checker->getResults('warning');
-    if ($results) {
-      \Drupal::messenger()->addWarning(t('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might effect the eligibility for <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']));
-      foreach ($results as $message) {
-        \Drupal::messenger()->addWarning($message);
-      }
-    }
-  }
+function automatic_updates_page_top() {
+  /** @var \Drupal\automatic_updates\Validation\AdminReadinessMessages $readiness_messages */
+  $readiness_messages = \Drupal::classResolver(AdminReadinessMessages::class);
+  $readiness_messages->displayAdminPageMessages();
 }
 
 /**
  * Implements hook_cron().
  */
 function automatic_updates_cron() {
-  $state = \Drupal::state();
-  $request_time = \Drupal::time()->getRequestTime();
-  $last_check = $state->get('automatic_updates.cron_last_check', 0);
-  // Only allow cron to run once every hour.
-  if (($request_time - $last_check) < 3600) {
-    return;
-  }
-
-  // Checkers should run before updates because of class caching.
-  /** @var \Drupal\automatic_updates\Services\NotifyInterface $notify */
-  $notify = \Drupal::service('automatic_updates.psa_notify');
-  $notify->send();
-  /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */
-  $checker = \Drupal::service('automatic_updates.readiness_checker');
-  foreach ($checker->getCategories() as $category) {
-    $checker->run($category);
+  /** @var \Drupal\automatic_updates\Validation\ReadinessValidationManager $checker_manager */
+  $checker_manager = \Drupal::service('automatic_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();
   }
 
-  // In-place updates won't function for dev releases of Drupal core.
-  $dev_core = strpos(\Drupal::VERSION, '-dev') !== FALSE;
-  /** @var \Drupal\Core\Config\ImmutableConfig $config */
-  $config = \Drupal::config('automatic_updates.settings');
-  if (!$dev_core && $config->get('enable_cron_updates')) {
-    \Drupal::service('update.manager')->refreshUpdateData();
-    \Drupal::service('update.processor')->fetchData();
-    $available = update_get_available(TRUE);
-    $projects = update_calculate_project_data($available);
-    $not_recommended_version = $projects['drupal']['status'] !== UpdateManagerInterface::CURRENT;
-    $security_update = in_array($projects['drupal']['status'], [UpdateManagerInterface::NOT_SECURE, UpdateManagerInterface::REVOKED], TRUE);
-    $recommended_release = isset($projects['drupal']['releases'][$projects['drupal']['recommended']]) ? $projects['drupal']['releases'][$projects['drupal']['recommended']] : NULL;
-    $existing_minor_version = explode('.', \Drupal::VERSION, -1);
-    $recommended_minor_version = explode('.', $recommended_release['version'], -1);
-    $major_upgrade = $existing_minor_version !== $recommended_minor_version;
-    if ($major_upgrade) {
-      foreach (range(1, 30) as $point_version) {
-        $potential_version = implode('.', array_merge($existing_minor_version, (array) $point_version));
-        if (isset($available['drupal']['releases'][$potential_version])) {
-          $recommended_release = $available['drupal']['releases'][$potential_version];
-        }
-        else {
-          break;
-        }
-      }
-    }
-    // Don't automatically update major version bumps or from/to same version.
-    if ($not_recommended_version && $projects['drupal']['existing_version'] !== $recommended_release['version']) {
-      if ($config->get('enable_cron_security_updates')) {
-        if ($security_update) {
-          $metadata = new UpdateMetadata('drupal', 'core', \Drupal::VERSION, $recommended_release['version']);
-          /** @var \Drupal\automatic_updates\Services\UpdateInterface $updater */
-          $updater = \Drupal::service('automatic_updates.update');
-          $updater->update($metadata);
-        }
-      }
-      else {
-        $metadata = new UpdateMetadata('drupal', 'core', \Drupal::VERSION, $recommended_release['version']);
-        /** @var \Drupal\automatic_updates\Services\UpdateInterface $updater */
-        $updater = \Drupal::service('automatic_updates.update');
-        $updater->update($metadata);
-      }
-    }
-  }
-
-  $state->set('automatic_updates.cron_last_check', \Drupal::time()->getCurrentTime());
 }
 
 /**
- * Implements hook_theme().
+ * Implements hook_modules_installed().
  */
-function automatic_updates_theme(array $existing, $type, $theme, $path) {
-  return [
-    'automatic_updates_psa_notify' => [
-      'variables' => [
-        'messages' => [],
-      ],
-    ],
-    'automatic_updates_post_update' => [
-      'variables' => [
-        'success' => NULL,
-        'metadata' => NULL,
-      ],
-    ],
-  ];
+function automatic_updates_modules_installed() {
+  // Run the readiness checkers if needed when any modules are installed in
+  // case they provide readiness checker services.
+  /** @var \Drupal\automatic_updates\Validation\ReadinessValidationManager $checker_manager */
+  $checker_manager = \Drupal::service('automatic_updates.readiness_validation_manager');
+  $checker_manager->runIfNoStoredResults();
 }
 
 /**
- * Implements hook_mail().
+ * Implements hook_modules_uninstalled().
  */
-function automatic_updates_mail($key, &$message, $params) {
-  /** @var \Drupal\Core\Render\RendererInterface $renderer */
-  $renderer = \Drupal::service('renderer');
-
-  $message['subject'] = $params['subject'];
-  $message['body'][] = $renderer->render($params['body']);
+function automatic_updates_modules_uninstalled() {
+  // Run the readiness checkers if needed when any modules are uninstalled in
+  // case they provided readiness checker services.
+  /** @var \Drupal\automatic_updates\Validation\ReadinessValidationManager $checker_manager */
+  $checker_manager = \Drupal::service('automatic_updates.readiness_validation_manager');
+  $checker_manager->runIfNoStoredResults();
 }
 
 /**
- * Helper method to execute console command.
- *
- * @param string $command_argument
- *   The command argument.
- *
- * @return \Symfony\Component\Process\Process
- *   The console command process.
+ * Implements hook_form_FORM_ID_alter() for 'update_manager_update_form'.
  */
-function automatic_updates_console_command($command_argument) {
-  $module_path = drupal_get_path('module', 'automatic_updates');
-  $command = [
-    (new PhpExecutableFinder())->find(),
-    $module_path . '/scripts/automatic_update_tools',
-    $command_argument,
-    '--script-filename',
-    \Drupal::request()->server->get('SCRIPT_FILENAME'),
-    '--base-url',
-    \Drupal::request()->getBaseUrl(),
-    '--base-path',
-    \Drupal::request()->getBasePath(),
-  ];
-  $process = new Process($command, (string) \Drupal::root(), NULL, NULL, NULL);
-  $process->run();
-  return $process;
+function automatic_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'])) {
+    $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('automatic_updates.update_form')->toString()]
+    );
+    $form['manual_updates']['#prefix'] = $core_updates_message;
+  }
 }
diff --git a/automatic_updates.routing.yml b/automatic_updates.routing.yml
index 64909e0ed0e96204506feb061817e2132df966e8..4c192676a80225b427c1825129fb7ba8b5803892 100644
--- a/automatic_updates.routing.yml
+++ b/automatic_updates.routing.yml
@@ -1,28 +1,24 @@
-automatic_updates.settings:
-  path: '/admin/config/automatic_updates'
+automatic_updates.update_readiness:
+  path: '/admin/automatic_updates/readiness'
   defaults:
-    _form: '\Drupal\automatic_updates\Form\SettingsForm'
-    _title: 'Automatic Updates'
+    _controller: '\Drupal\automatic_updates\Controller\ReadinessCheckerController::run'
+    _title: 'Update readiness checking'
   requirements:
     _permission: 'administer software updates'
-  options:
-    _admin_route: TRUE
-automatic_updates.update_readiness:
-  path: '/admin/config/automatic_updates/readiness'
+automatic_updates.update_form:
+  path: '/admin/automatic-update'
   defaults:
-    _controller: '\Drupal\automatic_updates\Controller\ReadinessCheckerController::run'
-    _title: 'Update readiness checking...'
+    _form: '\Drupal\automatic_updates\Form\UpdaterForm'
+    _title: 'Automatic Updates'
   requirements:
     _permission: 'administer software updates'
   options:
     _admin_route: TRUE
-automatic_updates.inplace-update:
-  path: '/automatic_updates/in-place-update/{project}/{type}/{from}/{to}'
+automatic_updates.confirmation_page:
+  path: '/admin/automatic-update-ready'
   defaults:
-    _title: 'Update'
-    _controller: '\Drupal\automatic_updates\Controller\InPlaceUpdateController::update'
+    _form: '\Drupal\automatic_updates\Form\UpdateReady'
+    _title: 'Ready to update'
   requirements:
     _permission: 'administer software updates'
-    _csrf_token: 'TRUE'
-  options:
-    no_cache: 'TRUE'
+    _access_update_manager: 'TRUE'
diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index 566cba6b9c518aaea7591b9ea6722bfe31fd9ff1..1c50c87ab723832dab752bbd0c2985769b88346d 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -1,133 +1,75 @@
 services:
-  logger.channel.automatic_updates:
-    parent: logger.channel_base
-    arguments: ['automatic_updates']
-  automatic_updates.psa:
-    class: Drupal\automatic_updates\Services\AutomaticUpdatesPsa
-    arguments:
-      - '@config.factory'
-      - '@cache.default'
-      - '@datetime.time'
-      - '@http_client'
-      - '@extension.list.module'
-      - '@extension.list.profile'
-      - '@extension.list.theme'
-      - '@logger.channel.automatic_updates'
-  automatic_updates.psa_notify:
-    class: Drupal\automatic_updates\Services\Notify
-    arguments:
-      - '@plugin.manager.mail'
-      - '@automatic_updates.psa'
-      - '@config.factory'
-      - '@language_manager'
-      - '@state'
-      - '@datetime.time'
-      - '@entity_type.manager'
-      - '@string_translation'
-  automatic_updates.cron_override:
-    class: Drupal\automatic_updates\EventSubscriber\CronOverride
-    tags:
-      - { name: config.factory.override }
-  automatic_updates.modified_files:
-    class: Drupal\automatic_updates\Services\ModifiedFiles
-    arguments:
-      - '@logger.channel.automatic_updates'
-      - '@http_client'
-      - '@config.factory'
-  automatic_updates.update:
-    class: Drupal\automatic_updates\Services\InPlaceUpdate
-    arguments:
-      - '@logger.channel.automatic_updates'
-      - '@plugin.manager.archiver'
-      - '@config.factory'
-      - '@file_system'
-      - '@http_client'
-      - '@app.root'
-  plugin.manager.database_update_handler:
-    class: Drupal\automatic_updates\DatabaseUpdateHandlerPluginManager
-    parent: default_plugin_manager
-  automatic_updates.post_update_subscriber:
-    class: Drupal\automatic_updates\EventSubscriber\PostUpdateSubscriber
-    arguments:
-      - '@config.factory'
-      - '@plugin.manager.mail'
-      - '@language_manager'
-      - '@entity_type.manager'
+  automatic_updates.readiness_validation_manager:
+    class: Drupal\automatic_updates\Validation\ReadinessValidationManager
+    arguments: ['@keyvalue.expirable', '@datetime.time', 24]
+  automatic_updates.recommender:
+    class: Drupal\automatic_updates\UpdateRecommender
+    arguments: [ '@update.manager', '@update.processor' ]
+  automatic_updates.updater:
+    class: Drupal\automatic_updates\Updater
+    arguments: [ '@state', '@string_translation','@automatic_updates.beginner', '@automatic_updates.stager', '@automatic_updates.cleaner', '@automatic_updates.committer' , '@file_system', '@event_dispatcher']
+  automatic_updates.staged_package_validator:
+    class: Drupal\automatic_updates\Validation\StagedProjectsValidation
+    arguments: [ '@string_translation', '@automatic_updates.updater' ]
     tags:
       - { name: event_subscriber }
-
-  automatic_updates.readiness_checker:
-    class: Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManager
+  automatic_updates.beginner:
+    class: Drupal\automatic_updates\ComposerStager\Beginner
     arguments:
-      - '@keyvalue'
-      - '@config.factory'
-    tags:
-      - { name: service_collector, tag: readiness_checker, call: addChecker }
-
-  # Readiness checkers.
-  automatic_updates.readonly_checker:
-    class: Drupal\automatic_updates\ReadinessChecker\ReadOnlyFilesystem
+      [ '@automatic_updates.file_copier', '@automatic_updates.file_system' ]
+  automatic_updates.stager:
+    class: PhpTuf\ComposerStager\Domain\Stager
     arguments:
-      - '@app.root'
-      - '@logger.channel.automatic_updates'
-      - '@file_system'
-    tags:
-      - { name: readiness_checker, priority: 100, category: error }
-  automatic_updates.disk_space_checker:
-    class: Drupal\automatic_updates\ReadinessChecker\DiskSpace
+      [ '@automatic_updates.composer_runner', '@automatic_updates.file_system' ]
+  automatic_updates.cleaner:
+    class: PhpTuf\ComposerStager\Domain\Cleaner
     arguments:
-      - '@app.root'
-    tags:
-      - { name: readiness_checker, category: error}
-  automatic_updates.modified_files_checker:
-    class: Drupal\automatic_updates\ReadinessChecker\ModifiedFiles
+      [ '@automatic_updates.file_system' ]
+  automatic_updates.committer:
+    class: PhpTuf\ComposerStager\Domain\Committer
     arguments:
-      - '@automatic_updates.modified_files'
-      - '@extension.list.module'
-      - '@extension.list.profile'
-      - '@extension.list.theme'
-    tags:
-      - { name: readiness_checker, category: warning}
-  automatic_updates.file_ownership:
-    class: Drupal\automatic_updates\ReadinessChecker\FileOwnership
+      [ '@automatic_updates.file_copier', '@automatic_updates.file_system' ]
+  automatic_updates.composer_runner:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunner
     arguments:
-      - '@app.root'
-    tags:
-      - { name: readiness_checker, category: warning}
-  automatic_updates.pending_db_updates:
-    class: Drupal\automatic_updates\ReadinessChecker\PendingDbUpdates
+      [ '@automatic_updates.exec_finder', '@automatic_updates.process_factory' ]
+  automatic_updates.file_copier.factory:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\FileCopier\FileCopierFactory
     arguments:
-      - '@update.post_update_registry'
-    tags:
-      - { name: readiness_checker, category: error}
-  automatic_updates.missing_project_info:
-    class: Drupal\automatic_updates\ReadinessChecker\MissingProjectInfo
+      - '@automatic_updates.symfony_exec_finder'
+      - '@automatic_updates.file_copier.rsync'
+      - '@automatic_updates.file_copier.symfony'
+  automatic_updates.file_copier.rsync:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\FileCopier\RsyncFileCopier
     arguments:
-      - '@extension.list.module'
-      - '@extension.list.profile'
-      - '@extension.list.theme'
-    tags:
-      - { name: readiness_checker, category: warning}
-  automatic_updates.opcode_cache:
-    class: Drupal\automatic_updates\ReadinessChecker\OpcodeCache
-    tags:
-      - { name: readiness_checker, category: error}
-  automatic_updates.php_sapi:
-    class: Drupal\automatic_updates\ReadinessChecker\PhpSapi
+      - '@automatic_updates.file_system'
+      - '@automatic_updates.rsync'
+  automatic_updates.file_copier.symfony:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\FileCopier\SymfonyFileCopier
     arguments:
-      - '@state'
-    tags:
-      - { name: readiness_checker, category: warning}
-  automatic_updates.cron_frequency:
-    class: Drupal\automatic_updates\ReadinessChecker\CronFrequency
+      - '@automatic_updates.symfony_file_system'
+      - '@automatic_updates.finder'
+  automatic_updates.finder:
+    class: Symfony\Component\Finder\Finder
+    public: false
+  automatic_updates.file_copier:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\FileCopier\FileCopierInterface
+    factory: ['@automatic_updates.file_copier.factory', 'create']
+  automatic_updates.file_system:
+    class: PhpTuf\ComposerStager\Infrastructure\Filesystem\Filesystem
     arguments:
-      - '@config.factory'
-      - '@module_handler'
-    tags:
-      - { name: readiness_checker, category: warning}
-  automatic_updates.vendor:
-    class: Drupal\automatic_updates\ReadinessChecker\Vendor
+      [ '@automatic_updates.symfony_file_system' ]
+  automatic_updates.symfony_file_system:
+    class: Symfony\Component\Filesystem\Filesystem
+  automatic_updates.symfony_exec_finder:
+    class: Symfony\Component\Process\ExecutableFinder
+  automatic_updates.rsync:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\Runner\RsyncRunner
     arguments:
-      - '@app.root'
-    tags:
-      - { name: readiness_checker, category: error}
+      [ '@automatic_updates.exec_finder', '@automatic_updates.process_factory' ]
+  automatic_updates.exec_finder:
+    class: PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinder
+    arguments:
+      [ '@automatic_updates.symfony_exec_finder' ]
+  automatic_updates.process_factory:
+    class: Drupal\automatic_updates\ComposerStager\ProcessFactory
diff --git a/automatic_updates_9_3_shim/automatic_updates_9_3_shim.info.yml b/automatic_updates_9_3_shim/automatic_updates_9_3_shim.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aed1b4bb79f8688bf1eec8e1b063d8c68e02bd52
--- /dev/null
+++ b/automatic_updates_9_3_shim/automatic_updates_9_3_shim.info.yml
@@ -0,0 +1,5 @@
+name: 'Automatic Updates 9.3.x shim'
+type: module
+description: 'Shim module to allow using improvements to the Update module in 9.3.x'
+core_version_requirement: ~9.2
+package: 'Security'
diff --git a/automatic_updates_9_3_shim/src/ProjectRelease.php b/automatic_updates_9_3_shim/src/ProjectRelease.php
new file mode 100644
index 0000000000000000000000000000000000000000..9a157fd37a3b1fa19a833d20cf87825502244385
--- /dev/null
+++ b/automatic_updates_9_3_shim/src/ProjectRelease.php
@@ -0,0 +1,288 @@
+<?php
+
+namespace Drupal\automatic_updates_9_3_shim;
+
+use Symfony\Component\Validator\Constraints\Choice;
+use Symfony\Component\Validator\Constraints\Collection;
+use Symfony\Component\Validator\Constraints\NotBlank;
+use Symfony\Component\Validator\Constraints\Optional;
+use Symfony\Component\Validator\Constraints\Type;
+use Symfony\Component\Validator\Validation;
+
+/**
+ * Provides a project release value object.
+ */
+final class ProjectRelease {
+
+  /**
+   * Whether the release is compatible with the site's Drupal core version.
+   *
+   * @var bool
+   */
+  private $coreCompatible;
+
+  /**
+   * The core compatibility message or NULL if not set.
+   *
+   * @var string|null
+   */
+  private $coreCompatibilityMessage;
+
+  /**
+   * The download URL or NULL if none is available.
+   *
+   * @var string|null
+   */
+  private $downloadUrl;
+
+  /**
+   * The URL for the release.
+   *
+   * @var string
+   */
+  private $releaseUrl;
+
+  /**
+   * The release types or NULL if not set.
+   *
+   * @var string[]|null
+   */
+  private $releaseTypes;
+
+  /**
+   * Whether the release is published.
+   *
+   * @var bool
+   */
+  private $published;
+
+  /**
+   * The release version.
+   *
+   * @var string
+   */
+  private $version;
+
+  /**
+   * The release date as a Unix timestamp or NULL if no date was set.
+   *
+   * @var int|null
+   */
+  private $date;
+
+  /**
+   * Constructs a ProjectRelease object.
+   *
+   * @param bool $published
+   *   Whether the release is published.
+   * @param string $version
+   *   The release version.
+   * @param string $release_url
+   *   The URL for the release.
+   * @param string[]|null $release_types
+   *   The release types or NULL if not set.
+   * @param bool|null $core_compatible
+   *   Whether the release is compatible with the site's version of Drupal core.
+   * @param string|null $core_compatibility_message
+   *   The core compatibility message or NULL if not set.
+   * @param string|null $download_url
+   *   The download URL or NULL if not available.
+   * @param int|null $date
+   *   The release date in Unix timestamp format.
+   */
+  private function __construct(bool $published, string $version, string $release_url, ?array $release_types, ?bool $core_compatible, ?string $core_compatibility_message, ?string $download_url, ?int $date) {
+    $this->published = $published;
+    $this->version = $version;
+    $this->releaseUrl = $release_url;
+    $this->releaseTypes = $release_types;
+    $this->coreCompatible = $core_compatible;
+    $this->coreCompatibilityMessage = $core_compatibility_message;
+    $this->downloadUrl = $download_url;
+    $this->date = $date;
+  }
+
+  /**
+   * Creates a ProjectRelease instance from an array.
+   *
+   * @param array $release_data
+   *   The project release data as returned by update_get_available().
+   *
+   * @return \Drupal\update\ProjectRelease
+   *   The ProjectRelease instance.
+   *
+   * @throws \UnexpectedValueException
+   *   Thrown if project release data is not valid.
+   *
+   * @see \update_get_available()
+   */
+  public static function createFromArray(array $release_data): ProjectRelease {
+    static::validateReleaseData($release_data);
+    return new ProjectRelease(
+      $release_data['status'] === 'published',
+      $release_data['version'],
+      $release_data['release_link'],
+      $release_data['terms']['Release type'] ?? NULL,
+      $release_data['core_compatible'] ?? NULL,
+      $release_data['core_compatibility_message'] ?? NULL,
+      $release_data['download_link'] ?? NULL,
+      $release_data['date'] ?? NULL
+    );
+  }
+
+  /**
+   * Validates the project release data.
+   *
+   * @param array $data
+   *   The project release data.
+   *
+   * @throws \UnexpectedValueException
+   *   Thrown if project release data is not valid.
+   */
+  private static function validateReleaseData(array $data): void {
+    $not_blank_constraints = [
+      new Type('string'),
+      new NotBlank(),
+    ];
+    $collection_constraint = new Collection([
+      'fields' => [
+        'version' => $not_blank_constraints,
+        'date' => new Optional([new Type('numeric')]),
+        'core_compatible' => new Optional([new Type('boolean')]),
+        'core_compatibility_message' => new Optional($not_blank_constraints),
+        'status' => new Choice(['published', 'unpublished']),
+        'download_link' => new Optional($not_blank_constraints),
+        'release_link' => $not_blank_constraints,
+        'terms' => new Optional([
+          new Type('array'),
+          new Collection([
+            'Release type' => new Optional([
+              new Type('array'),
+            ]),
+          ]),
+        ]),
+      ],
+      'allowExtraFields' => TRUE,
+    ]);
+    $violations = Validation::createValidator()->validate($data, $collection_constraint);
+    if (count($violations)) {
+      foreach ($violations as $violation) {
+        $violation_messages[] = "Field " . $violation->getPropertyPath() . ": " . $violation->getMessage();
+      }
+      throw new \UnexpectedValueException('Malformed release data: ' . implode(",\n", $violation_messages));
+    }
+  }
+
+  /**
+   * Gets the project version.
+   *
+   * @return string
+   *   The project version.
+   */
+  public function getVersion(): string {
+    return $this->version;
+  }
+
+  /**
+   * Gets the release date if set.
+   *
+   * @return int|null
+   *   The date of the release or null if no date is available.
+   */
+  public function getDate(): ?int {
+    return $this->date;
+  }
+
+  /**
+   * Determines if the release is a security release.
+   *
+   * @return bool
+   *   TRUE if the release is security release, or FALSE otherwise.
+   */
+  public function isSecurityRelease(): bool {
+    return $this->isReleaseType('Security update');
+  }
+
+  /**
+   * Determines if the release is unsupported.
+   *
+   * @return bool
+   *   TRUE if the release is unsupported, or FALSE otherwise.
+   */
+  public function isUnsupported(): bool {
+    return $this->isReleaseType('Unsupported');
+  }
+
+  /**
+   * Determines if the release is insecure.
+   *
+   * @return bool
+   *   TRUE if the release is insecure, or FALSE otherwise.
+   */
+  public function isInsecure(): bool {
+    return $this->isReleaseType('Insecure');
+  }
+
+  /**
+   * Determines if the release is matches a type.
+   *
+   * @param string $type
+   *   The release type.
+   *
+   * @return bool
+   *   TRUE if the release matches the type, or FALSE otherwise.
+   */
+  private function isReleaseType(string $type): bool {
+    return $this->releaseTypes && in_array($type, $this->releaseTypes, TRUE);
+  }
+
+  /**
+   * Determines if the release is published.
+   *
+   * @return bool
+   *   TRUE if the release is published, or FALSE otherwise.
+   */
+  public function isPublished(): bool {
+    return $this->published;
+  }
+
+  /**
+   * Determines whether release is compatible the site's version of Drupal core.
+   *
+   * @return bool|null
+   *   Whether the release is compatible or NULL if no data is set.
+   */
+  public function isCoreCompatible(): ?bool {
+    return $this->coreCompatible;
+  }
+
+  /**
+   * Gets the core compatibility message for the site's version of Drupal core.
+   *
+   * @return string|null
+   *   The core compatibility message or NULL if none is available.
+   */
+  public function getCoreCompatibilityMessage(): ?string {
+    return $this->coreCompatibilityMessage;
+  }
+
+  /**
+   * Gets the download URL of the release.
+   *
+   * @return string|null
+   *   The download URL or NULL if none is available.
+   */
+  public function getDownloadUrl(): ?string {
+    return $this->downloadUrl;
+  }
+
+  /**
+   * Gets the URL of the release.
+   *
+   * @return string
+   *   The URL of the release.
+   */
+  public function getReleaseUrl(): string {
+    return $this->releaseUrl;
+  }
+
+}
diff --git a/composer.json b/composer.json
index fb184a791c1617cf63ab2734d76c7530572769a3..277f450e46e2910b2bf449a3ccb5d47c9a999c63 100644
--- a/composer.json
+++ b/composer.json
@@ -11,13 +11,18 @@
     "source": "http://cgit.drupalcode.org/automatic_updates"
   },
   "require": {
-    "php": "^7.3",
     "ext-json": "*",
-    "ext-zip": "*",
-    "drupal/php-signify": "^1.0",
-    "ocramius/package-versions": "^1.5.0"
+    "php-tuf/composer-stager": "0.1.1"
   },
   "config": {
-    "sort-packages": true
-  }
+    "platform": {
+      "php": "7.3.0"
+    }
+  },
+  "repositories": [
+    {
+      "type": "vcs",
+      "url": "https://github.com/php-tuf/composer-stager"
+    }
+  ]
 }
diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml
deleted file mode 100644
index a6d4891a6f8a70d2139004ed9c7bee9525b97e40..0000000000000000000000000000000000000000
--- a/config/install/automatic_updates.settings.yml
+++ /dev/null
@@ -1,14 +0,0 @@
-psa_endpoint: 'https://updates.drupal.org/psa.json'
-enable_psa: true
-notify: true
-check_frequency: 43200
-enable_readiness_checks: true
-hashes_uri: 'https://updates.drupal.org/release-hashes'
-ignored_paths: "modules/*\nthemes/*\nprofiles/*"
-download_uri: 'https://www.drupal.org/in-place-updates'
-enable_cron_updates: false
-enable_cron_security_updates: false
-database_update_handling:
-  - maintenance_mode_activate
-  - execute_updates
-  - maintenance_mode_disactivate
diff --git a/config/optional/views.view.automatic_updates_log.yml b/config/optional/views.view.automatic_updates_log.yml
deleted file mode 100644
index bec769f4188f824ec94f97408c2ad49de207f84e..0000000000000000000000000000000000000000
--- a/config/optional/views.view.automatic_updates_log.yml
+++ /dev/null
@@ -1,336 +0,0 @@
-langcode: en
-status: true
-dependencies:
-  config:
-    - system.menu.admin
-  module:
-    - dblog
-    - user
-id: automatic_updates_log
-label: 'Automatic updates log'
-module: views
-description: 'Lists the files which have been updated by the Automatic updates module.'
-tag: ''
-base_table: watchdog
-base_field: wid
-core: 8.x
-display:
-  default:
-    display_plugin: default
-    id: default
-    display_title: Master
-    position: 0
-    display_options:
-      access:
-        type: perm
-        options:
-          perm: 'administer software updates'
-      cache:
-        type: tag
-        options: {  }
-      query:
-        type: views_query
-        options:
-          disable_sql_rewrite: false
-          distinct: false
-          replica: false
-          query_comment: ''
-          query_tags: {  }
-      exposed_form:
-        type: basic
-        options:
-          submit_button: Apply
-          reset_button: true
-          reset_button_label: Reset
-          exposed_sorts_label: 'Sort by'
-          expose_sort_order: false
-          sort_asc_label: Asc
-          sort_desc_label: Desc
-      pager:
-        type: full
-        options:
-          items_per_page: 100
-          offset: 0
-          id: 0
-          total_pages: null
-          tags:
-            previous: ‹‹
-            next: ››
-            first: '« First'
-            last: 'Last »'
-          expose:
-            items_per_page: true
-            items_per_page_label: 'Items per page'
-            items_per_page_options: '100, 250, 500, 1000'
-            items_per_page_options_all: true
-            items_per_page_options_all_label: '- All -'
-            offset: false
-            offset_label: Offset
-          quantity: 9
-      style:
-        type: default
-        options:
-          grouping:
-            -
-              field: timestamp
-              rendered: true
-              rendered_strip: false
-          row_class: ''
-          default_row_class: false
-      row:
-        type: fields
-        options:
-          default_field_elements: false
-          inline: {  }
-          separator: ''
-          hide_empty: false
-      fields:
-        timestamp:
-          id: timestamp
-          table: watchdog
-          field: timestamp
-          relationship: none
-          group_type: group
-          admin_label: ''
-          label: ''
-          exclude: true
-          alter:
-            alter_text: false
-            text: ''
-            make_link: false
-            path: ''
-            absolute: false
-            external: false
-            replace_spaces: false
-            path_case: none
-            trim_whitespace: false
-            alt: ''
-            rel: ''
-            link_class: ''
-            prefix: ''
-            suffix: ''
-            target: ''
-            nl2br: false
-            max_length: 0
-            word_boundary: true
-            ellipsis: true
-            more_link: false
-            more_link_text: ''
-            more_link_path: ''
-            strip_tags: false
-            trim: false
-            preserve_tags: ''
-            html: false
-          element_type: ''
-          element_class: ''
-          element_label_type: ''
-          element_label_class: ''
-          element_label_colon: false
-          element_wrapper_type: ''
-          element_wrapper_class: ''
-          element_default_classes: true
-          empty: ''
-          hide_empty: false
-          empty_zero: false
-          hide_alter_empty: true
-          date_format: short
-          custom_date_format: ''
-          timezone: ''
-          plugin_id: date
-        message:
-          id: message
-          table: watchdog
-          field: message
-          relationship: none
-          group_type: group
-          admin_label: ''
-          label: ''
-          exclude: false
-          alter:
-            alter_text: false
-            text: ''
-            make_link: false
-            path: ''
-            absolute: false
-            external: false
-            replace_spaces: false
-            path_case: none
-            trim_whitespace: false
-            alt: ''
-            rel: ''
-            link_class: ''
-            prefix: ''
-            suffix: ''
-            target: ''
-            nl2br: false
-            max_length: 0
-            word_boundary: true
-            ellipsis: true
-            more_link: false
-            more_link_text: ''
-            more_link_path: ''
-            strip_tags: false
-            trim: false
-            preserve_tags: ''
-            html: false
-          element_type: ''
-          element_class: ''
-          element_label_type: ''
-          element_label_class: ''
-          element_label_colon: false
-          element_wrapper_type: ''
-          element_wrapper_class: ''
-          element_default_classes: true
-          empty: ''
-          hide_empty: false
-          empty_zero: false
-          hide_alter_empty: true
-          replace_variables: true
-          plugin_id: dblog_message
-      filters:
-        type:
-          id: type
-          table: watchdog
-          field: type
-          relationship: none
-          group_type: group
-          admin_label: ''
-          operator: in
-          value:
-            automatic_updates: automatic_updates
-          group: 1
-          exposed: false
-          expose:
-            operator_id: ''
-            label: ''
-            description: ''
-            use_operator: false
-            operator: ''
-            identifier: ''
-            required: false
-            remember: false
-            multiple: false
-            remember_roles:
-              authenticated: authenticated
-            reduce: false
-            operator_limit_selection: false
-            operator_list: {  }
-          is_grouped: false
-          group_info:
-            label: ''
-            description: ''
-            identifier: ''
-            optional: true
-            widget: select
-            multiple: false
-            remember: false
-            default_group: All
-            default_group_multiple: {  }
-            group_items: {  }
-          plugin_id: dblog_types
-        severity:
-          id: severity
-          table: watchdog
-          field: severity
-          relationship: none
-          group_type: group
-          admin_label: ''
-          operator: in
-          value:
-            6: '6'
-          group: 1
-          exposed: true
-          expose:
-            operator_id: severity_op
-            label: 'Severity level'
-            description: ''
-            use_operator: false
-            operator: severity_op
-            identifier: severity
-            required: false
-            remember: false
-            multiple: true
-            remember_roles:
-              authenticated: authenticated
-              anonymous: '0'
-              administrator: '0'
-            reduce: false
-          is_grouped: false
-          group_info:
-            label: ''
-            description: ''
-            identifier: ''
-            optional: true
-            widget: select
-            multiple: false
-            remember: false
-            default_group: All
-            default_group_multiple: {  }
-            group_items: {  }
-          plugin_id: in_operator
-      sorts:
-        wid:
-          id: wid
-          table: watchdog
-          field: wid
-          relationship: none
-          group_type: group
-          admin_label: ''
-          order: DESC
-          exposed: false
-          expose:
-            label: ''
-          plugin_id: standard
-      title: 'Automatic updates log'
-      header: {  }
-      footer: {  }
-      empty:
-        area:
-          id: area
-          table: views
-          field: area
-          relationship: none
-          group_type: group
-          admin_label: ''
-          empty: true
-          tokenize: false
-          content:
-            value: '<p>No automatic update log entries.</p>'
-            format: basic_html
-          plugin_id: text
-      relationships: {  }
-      arguments: {  }
-      display_extenders: {  }
-    cache_metadata:
-      max-age: -1
-      contexts:
-        - 'languages:language_interface'
-        - url
-        - url.query_args
-        - user.permissions
-      tags: {  }
-  page_1:
-    display_plugin: page
-    id: page_1
-    display_title: Page
-    position: 1
-    display_options:
-      display_extenders: {  }
-      path: admin/reports/automatic_updates_log
-      menu:
-        type: normal
-        title: 'Automatic updates log'
-        description: ''
-        expanded: false
-        parent: system.admin_reports
-        weight: -10
-        context: '0'
-        menu_name: admin
-    cache_metadata:
-      max-age: -1
-      contexts:
-        - 'languages:language_interface'
-        - url
-        - url.query_args
-        - user.permissions
-      tags: {  }
diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml
deleted file mode 100644
index 77515d7334deba2675c680ab1b7d08136a8c93a5..0000000000000000000000000000000000000000
--- a/config/schema/automatic_updates.schema.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-automatic_updates.settings:
-  type: config_object
-  label: 'Automatic updates settings'
-  mapping:
-    psa_endpoint:
-      type: string
-      label: 'Endpoint URI for PSAs'
-    enable_psa:
-      type: boolean
-      label: 'Enable PSA notices'
-    notify:
-      type: boolean
-      label: 'Notify when PSAs are available'
-    check_frequency:
-      type: integer
-      label: 'Frequency to check for PSAs, defaults to 12 hours'
-    enable_readiness_checks:
-      type: boolean
-      label: 'Enable readiness checks'
-    hashes_uri:
-      type: string
-      label: 'Endpoint URI for file hashes'
-    ignored_paths:
-      type: string
-      label: 'List of files paths to ignore when running readiness checks'
-    download_uri:
-      type: string
-      label: 'URI for downloading in-place update assets'
-    enable_cron_updates:
-      type: boolean
-      label: 'Enable automatic updates via cron'
-    enable_cron_security_updates:
-      type: boolean
-      label: 'Enable automatic updates for security releases via cron'
-    database_update_handling:
-      type: sequence
-      label: 'Database update handling'
-      sequence:
-        type: string
-        label: 'Tagged service to handle database updates'
diff --git a/drupalci.yml b/drupalci.yml
deleted file mode 100644
index 29bee715f5d4a39816d42034a272ece2b7dcad81..0000000000000000000000000000000000000000
--- a/drupalci.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-# Learn to make one for your own drupal.org project:
-# https://www.drupal.org/drupalorg/docs/drupal-ci/customizing-drupalci-testing
-build:
-  assessment:
-    validate_codebase:
-      phplint:
-      phpcs:
-        # phpcs will use core's specified version of Coder.
-        sniff-all-files: true
-        halt-on-fail: true
-    testing:
-      # run_tests task is executed several times in order of performance speeds.
-      # halt-on-fail can be set on the run_tests tasks in order to fail fast.
-      # suppress-deprecations is false in order to be alerted to usages of
-      # deprecated code.
-      run_tests.standard:
-        types: 'PHPUnit-Unit,PHPUnit-Kernel,PHPUnit-Functional,PHPUnit-Build'
-        testgroups: '--all'
-        suppress-deprecations: false
-        halt-on-fail: false
diff --git a/phpcs.xml.dist b/phpcs.xml.dist
deleted file mode 100644
index 367301f3d1ab173b9c6b2ba7ddd70470004277c5..0000000000000000000000000000000000000000
--- a/phpcs.xml.dist
+++ /dev/null
@@ -1,7 +0,0 @@
-<ruleset name="automatic_updates">
-  <description>Must ignore vendor folder, but otherwise default Drupal code standards.</description>
-  <file>.</file>
-  <exclude-pattern>*/vendor/*$</exclude-pattern>
-  <arg name="extensions" value="php,module,inc,install,test,profile,theme,css,info,txt,md"/>
-  <rule ref="Drupal"/>
-</ruleset>
diff --git a/scripts/automatic_update_tools b/scripts/automatic_update_tools
deleted file mode 100644
index 78da7dbeb9ef7d650d5573f3279647a6ef7e6204..0000000000000000000000000000000000000000
--- a/scripts/automatic_update_tools
+++ /dev/null
@@ -1,41 +0,0 @@
-#!/usr/bin/env php
-<?php
-
-/**
- * @file
- * Provides helper commands for automatic updates.
- *
- * This must be a separate application so class caches aren't an issue during
- * in place updates.
- */
-
-use Drupal\automatic_updates\Command\CacheRebuild;
-use Drupal\automatic_updates\Command\DatabaseUpdate;
-use Drupal\automatic_updates\Command\DatabaseUpdateStatus;
-use Symfony\Component\Console\Application;
-
-if (PHP_SAPI !== 'cli') {
-  return;
-}
-
-// Set up autoloader
-$loader = false;
-if (file_exists($autoloader = __DIR__ . '/../../../../autoload.php')
-  || file_exists($autoloader = __DIR__ . '/../../../../../autoload.php')
-  || file_exists($autoloader = __DIR__ . '/../../../autoload.php')
-) {
-  /** @var \Composer\Autoload\ClassLoader $loader */
-  $loader = require_once $autoloader;
-  // Drupal's autoloader doesn't bootstrap this module's classes yet. Do so
-  // manually.
-  $loader->addPsr4('Drupal\\automatic_updates\\', __DIR__ . '/../src');
-}
-else {
-  throw new \RuntimeException('Could not locate autoload.php; __DIR__ is ' . __DIR__);
-}
-
-$application = new Application('automatic_update_tools', 'stable');
-$application->add(new CacheRebuild($loader));
-$application->add(new DatabaseUpdate($loader));
-$application->add(new DatabaseUpdateStatus($loader));
-$application->run();
diff --git a/src/Annotation/DatabaseUpdateHandler.php b/src/Annotation/DatabaseUpdateHandler.php
deleted file mode 100644
index 8e881e8c2ef49a6a0c6c6a3532f5ab3574870740..0000000000000000000000000000000000000000
--- a/src/Annotation/DatabaseUpdateHandler.php
+++ /dev/null
@@ -1,39 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Annotation;
-
-use Drupal\Component\Annotation\Plugin;
-
-/**
- * Defines a DatabaseUpdateHandler annotation object.
- *
- * Plugin Namespace: Plugin\DatabaseUpdateHandler.
- *
- * For a working example, see
- * \Drupal\automatic_updates\Plugin\DatabaseUpdateHandler\MaintenanceMode.
- *
- * @see \Drupal\automatic_updates\DatabaseUpdateHandlerInterface
- * @see \Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase
- * @see \Drupal\automatic_updates\DatabaseUpdateHandlerPluginManager
- * @see hook_database_update_handler_plugin_info_alter()
- * @see plugin_api
- *
- * @Annotation
- */
-final class DatabaseUpdateHandler extends Plugin {
-
-  /**
-   * The ID of the handler, should match the service name.
-   *
-   * @var string
-   */
-  public $id;
-
-  /**
-   * The name of the handler.
-   *
-   * @var string
-   */
-  public $label;
-
-}
diff --git a/src/AutomaticUpdatesEvents.php b/src/AutomaticUpdatesEvents.php
new file mode 100644
index 0000000000000000000000000000000000000000..7f04539a9c98be2c336fd8a88019cfd6d92924c6
--- /dev/null
+++ b/src/AutomaticUpdatesEvents.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\automatic_updates;
+
+/**
+ * Defines events for the automatic_updates module.
+ *
+ * These events allow listeners to validate updates at various points in the
+ * update process.  Listeners to these events should add validation results via
+ * \Drupal\automatic_updates\Event\UpdateEvent::addValidationResult() if
+ * necessary. Only error level validation results will stop an update from
+ * continuing.
+ *
+ * @see \Drupal\automatic_updates\Event\UpdateEvent
+ * @see \Drupal\automatic_updates\Validation\ValidationResult
+ */
+final class AutomaticUpdatesEvents {
+
+  /**
+   * Name of the 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.
+   *
+   * @Event
+   *
+   * @see \Drupal\automatic_updates\Validation\ReadinessValidationManager
+   *
+   * @var string
+   */
+  const READINESS_CHECK = 'automatic_updates.readiness_check';
+
+  /**
+   * Name of the event fired when an automatic update is starting.
+   *
+   * This event is fired before any files are staged. Validation results added
+   * by subscribers are not cached.
+   *
+   * @Event
+   *
+   * @var string
+   */
+  const PRE_START = 'automatic_updates.pre_start';
+
+  /**
+   * Name of the event fired when an automatic update is about to be committed.
+   *
+   * Validation results added by subscribers are not cached.
+   *
+   * @Event
+   *
+   * @var string
+   */
+  const PRE_COMMIT = 'automatic_updates.pre_commit';
+
+}
diff --git a/src/BatchProcessor.php b/src/BatchProcessor.php
new file mode 100644
index 0000000000000000000000000000000000000000..2c315f0c5dbfea820f5b8687c6a47afe6cc6c15b
--- /dev/null
+++ b/src/BatchProcessor.php
@@ -0,0 +1,127 @@
+<?php
+
+namespace Drupal\automatic_updates;
+
+use Drupal\automatic_updates\Exception\UpdateException;
+use Drupal\Core\Url;
+use Symfony\Component\HttpFoundation\RedirectResponse;
+
+/**
+ * A batch processor for updates.
+ */
+class BatchProcessor {
+
+  /**
+   * Get the updater service.
+   *
+   * @return \Drupal\automatic_updates\Updater
+   *   The updater service.
+   */
+  protected static function getUpdater(): Updater {
+    return \Drupal::service('automatic_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 UpdateException) {
+      foreach ($error->getValidationResults() 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 array $context
+   *   The current context of the batch job.
+   *
+   * @see \Drupal\automatic_updates\Updater::begin()
+   */
+  public static function begin(array &$context): void {
+    try {
+      static::getUpdater()->begin();
+    }
+    catch (\Throwable $e) {
+      static::handleException($e, $context);
+    }
+  }
+
+  /**
+   * Calls the updater's stageVersions() 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\automatic_updates\Updater::stageVersions()
+   */
+  public static function stageProjectVersions(array $project_versions, array &$context): void {
+    try {
+      static::getUpdater()->stageVersions($project_versions);
+    }
+    catch (\Throwable $e) {
+      static::handleException($e, $context);
+    }
+  }
+
+  /**
+   * Calls the updater's validateStaged() method.
+   *
+   * @see \Drupal\automatic_updates\Updater::validateStaged()
+   */
+  public static function validateStaged() {
+    static::getUpdater()->validateStaged();
+  }
+
+  /**
+   * Finishes the 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 finish(bool $success, array $results, array $operations): ?RedirectResponse {
+    if ($success) {
+      return new RedirectResponse(Url::fromRoute('automatic_updates.confirmation_page', [], ['absolute' => TRUE])->toString());
+    }
+    if (isset($results['errors'])) {
+      foreach ($results['errors'] as $error) {
+        \Drupal::messenger()->addError($error);
+      }
+    }
+    else {
+      \Drupal::messenger()->addError("Update error");
+    }
+    return NULL;
+  }
+
+}
diff --git a/src/Command/BaseCommand.php b/src/Command/BaseCommand.php
deleted file mode 100644
index 268d029fc5acbbe58338da5d120e256bdbda1ac8..0000000000000000000000000000000000000000
--- a/src/Command/BaseCommand.php
+++ /dev/null
@@ -1,71 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Command;
-
-use Drupal\Core\DrupalKernel;
-use Symfony\Component\Console\Command\Command;
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Input\InputOption;
-use Symfony\Component\Console\Output\OutputInterface;
-use Symfony\Component\HttpFoundation\Request;
-
-/**
- * Base command class.
- */
-class BaseCommand extends Command {
-
-  /**
-   * The class loader.
-   *
-   * @var object
-   */
-  protected $classLoader;
-
-  /**
-   * Constructs a new InstallCommand command.
-   *
-   * @param object $class_loader
-   *   The class loader.
-   */
-  public function __construct($class_loader) {
-    $this->classLoader = $class_loader;
-    parent::__construct();
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function configure() {
-    parent::configure();
-    $this->addOption('script-filename', NULL, InputOption::VALUE_REQUIRED, 'The script filename')
-      ->addOption('base-url', NULL, InputOption::VALUE_REQUIRED, 'The base URL, i.e. http://example.com/index.php')
-      ->addOption('base-path', NULL, InputOption::VALUE_REQUIRED, 'The base path, i.e. http://example.com');
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function execute(InputInterface $input, OutputInterface $output) {
-    $this->bootstrapDrupal($input);
-  }
-
-  /**
-   * Bootstrap Drupal.
-   *
-   * @param \Symfony\Component\Console\Input\InputInterface $input
-   *   The input.
-   */
-  protected function bootstrapDrupal(InputInterface $input) {
-    $kernel = new DrupalKernel('prod', $this->classLoader);
-    $script_filename = $input->getOption('script-filename');
-    $base_url = $input->getOption('base-url');
-    $base_path = $input->getOption('base-path');
-    $server = [
-      'SCRIPT_FILENAME' => $script_filename,
-      'SCRIPT_NAME' => $base_url,
-    ];
-    $request = Request::create($base_path, 'GET', [], [], [], $server);
-    $kernel->handle($request);
-  }
-
-}
diff --git a/src/Command/CacheRebuild.php b/src/Command/CacheRebuild.php
deleted file mode 100644
index a97c6121bca218430185b1ba61301bcfbbd18f1b..0000000000000000000000000000000000000000
--- a/src/Command/CacheRebuild.php
+++ /dev/null
@@ -1,33 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Command;
-
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
-
-/**
- * Cache rebuild command.
- */
-class CacheRebuild extends BaseCommand {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function configure() {
-    parent::configure();
-    $this->setName('cache:rebuild')
-      ->setAliases(['cr, rebuild'])
-      ->setDescription('Rebuild a Drupal site and clear all its caches.');
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function execute(InputInterface $input, OutputInterface $output) {
-    parent::execute($input, $output);
-    drupal_flush_all_caches();
-    $output->writeln('Cache rebuild complete.');
-    return 0;
-  }
-
-}
diff --git a/src/Command/DatabaseUpdate.php b/src/Command/DatabaseUpdate.php
deleted file mode 100644
index 4bf7cddbcb238734052ee62eb64710e8b8e048dc..0000000000000000000000000000000000000000
--- a/src/Command/DatabaseUpdate.php
+++ /dev/null
@@ -1,103 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Command;
-
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
-
-/**
- * Database update command.
- */
-class DatabaseUpdate extends BaseCommand {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function configure() {
-    parent::configure();
-    $this->setName('updatedb')
-      ->setDescription('Apply any database updates required (as with running update.php).')
-      ->setAliases(['updb']);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function execute(InputInterface $input, OutputInterface $output) {
-    parent::execute($input, $output);
-    $pending_updates = \Drupal::service('automatic_updates.pending_db_updates')
-      ->run();
-    if ($pending_updates) {
-      $output->writeln('Started database updates.');
-      $this->executeDatabaseUpdates();
-      $output->writeln('Finished database updates.');
-    }
-    else {
-      $output->writeln('No database updates required.');
-    }
-    return 0;
-  }
-
-  /**
-   * Execute all outstanding database updates.
-   */
-  protected function executeDatabaseUpdates() {
-    require_once DRUPAL_ROOT . '/core/includes/install.inc';
-    require_once DRUPAL_ROOT . '/core/includes/update.inc';
-    $logger = \Drupal::logger('automatic_updates');
-    drupal_load_updates();
-    $start = $dependency_map = $operations = [];
-    foreach (update_get_update_list() as $module => $update) {
-      $start[$module] = $update['start'];
-    }
-    $updates = update_resolve_dependencies($start);
-    foreach ($updates as $function => $update) {
-      $dependency_map[$function] = !empty($update['reverse_paths']) ? array_keys($update['reverse_paths']) : [];
-    }
-    foreach ($updates as $function => $update) {
-      if ($update['allowed']) {
-        // Set the installed version of each module so updates will start at the
-        // correct place. (The updates are already sorted, so we can simply base
-        // this on the first one we come across in the above foreach loop.)
-        if (isset($start[$update['module']])) {
-          drupal_set_installed_schema_version($update['module'], $update['number'] - 1);
-          unset($start[$update['module']]);
-        }
-        $this->executeDatabaseUpdate('update_do_one', [
-          $update['module'],
-          $update['number'],
-          $dependency_map[$function],
-        ]);
-      }
-    }
-
-    $post_updates = \Drupal::service('update.post_update_registry')->getPendingUpdateFunctions();
-    if ($post_updates) {
-      // Now we rebuild all caches and after that execute hook_post_update().
-      $logger->info('Starting cache clear pre-step of database update.');
-      automatic_updates_console_command('cache:rebuild');
-      $logger->info('Finished cache clear pre-step of database update.');
-      foreach ($post_updates as $function) {
-        $this->executeDatabaseUpdate('update_invoke_post_update', [$function]);
-      }
-    }
-  }
-
-  /**
-   * Execute a single database update.
-   *
-   * @param callable $invoker
-   *   Callable update invoker.
-   * @param array $args
-   *   The arguments to pass to the invoker.
-   */
-  protected function executeDatabaseUpdate(callable $invoker, array $args) {
-    \Drupal::logger('automatic_updates')->notice('Database update running with arguments "@arguments"', ['@arguments' => print_r($args, TRUE)]);
-    $context = [
-      'sandbox'  => [],
-    ];
-    call_user_func_array($invoker, array_merge($args, [&$context]));
-    \Drupal::logger('automatic_updates')->notice('Database update finished with arguments "@arguments"', ['@arguments' => print_r($args, TRUE)]);
-  }
-
-}
diff --git a/src/Command/DatabaseUpdateStatus.php b/src/Command/DatabaseUpdateStatus.php
deleted file mode 100644
index f9fc47327e5c92552e1982655626ce9ecb8bb9dd..0000000000000000000000000000000000000000
--- a/src/Command/DatabaseUpdateStatus.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Command;
-
-use Symfony\Component\Console\Input\InputInterface;
-use Symfony\Component\Console\Output\OutputInterface;
-
-/**
- * Database update status command.
- */
-class DatabaseUpdateStatus extends BaseCommand {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function configure() {
-    parent::configure();
-    $this->setName('updatedb-status')
-      ->setDescription('List any pending database updates.')
-      ->setAliases(['updbst', 'updatedb:status']);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function execute(InputInterface $input, OutputInterface $output) {
-    parent::execute($input, $output);
-    $pending_updates = \Drupal::service('automatic_updates.pending_db_updates')
-      ->run();
-    $output->writeln($pending_updates);
-    return 0;
-  }
-
-}
diff --git a/src/ComposerStager/Beginner.php b/src/ComposerStager/Beginner.php
new file mode 100644
index 0000000000000000000000000000000000000000..d6ab0b0d7e91cc7d9213e5d49d9b849dc7d27fee
--- /dev/null
+++ b/src/ComposerStager/Beginner.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\automatic_updates\ComposerStager;
+
+use PhpTuf\ComposerStager\Domain\BeginnerInterface;
+use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Exception\DirectoryAlreadyExistsException;
+use PhpTuf\ComposerStager\Exception\DirectoryNotFoundException;
+use PhpTuf\ComposerStager\Infrastructure\Filesystem\FilesystemInterface;
+use PhpTuf\ComposerStager\Infrastructure\Process\FileCopier\FileCopierInterface;
+
+/**
+ * An implementation of Composer Stager's Beginner which supports exclusions.
+ *
+ * @todo Remove this class when composer_stager implements this functionality.
+ */
+final class Beginner implements BeginnerInterface {
+
+  /**
+   * The file copier service.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\Process\FileCopier\FileCopierInterface
+   */
+  private $fileCopier;
+
+  /**
+   * The file system service.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\Filesystem\FilesystemInterface
+   */
+  private $filesystem;
+
+  /**
+   * Constructs a Beginner object.
+   *
+   * @param \PhpTuf\ComposerStager\Infrastructure\Process\FileCopier\FileCopierInterface $fileCopier
+   *   The file copier service.
+   * @param \PhpTuf\ComposerStager\Infrastructure\Filesystem\FilesystemInterface $filesystem
+   *   The file system service.
+   */
+  public function __construct(FileCopierInterface $fileCopier, FilesystemInterface $filesystem) {
+    $this->fileCopier = $fileCopier;
+    $this->filesystem = $filesystem;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function begin(string $activeDir, string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120, array $exclusions = []): void {
+    if (!$this->filesystem->exists($activeDir)) {
+      throw new DirectoryNotFoundException($activeDir, 'The active directory does not exist at "%s"');
+    }
+
+    if ($this->filesystem->exists($stagingDir)) {
+      throw new DirectoryAlreadyExistsException($stagingDir, 'The staging directory already exists at "%s"');
+    }
+
+    $this->fileCopier->copy(
+          $activeDir,
+          $stagingDir,
+          $exclusions,
+          $callback,
+          $timeout
+      );
+  }
+
+}
diff --git a/src/ComposerStager/ProcessFactory.php b/src/ComposerStager/ProcessFactory.php
new file mode 100644
index 0000000000000000000000000000000000000000..018040c93ae514b52bcbb82a59b20fe3558fde7f
--- /dev/null
+++ b/src/ComposerStager/ProcessFactory.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\automatic_updates\ComposerStager;
+
+use PhpTuf\ComposerStager\Exception\LogicException;
+use PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactoryInterface;
+use Symfony\Component\Process\Exception\ExceptionInterface;
+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 {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function create(array $command): Process {
+    try {
+      if ($this->isComposerCommand($command)) {
+        return new Process($command, NULL, ['COMPOSER_HOME' => $this->getComposerHomePath()]);
+      }
+      return new Process($command);
+      // @codeCoverageIgnore
+    }
+    catch (ExceptionInterface $e) {
+      // @codeCoverageIgnore
+      throw new LogicException($e->getMessage(), (int) $e->getCode(), $e);
+    }
+  }
+
+  /**
+   * 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 {
+    /** @var \Drupal\Core\File\FileSystemInterface $file_system */
+    $file_system = \Drupal::service('file_system');
+    $home_path = $file_system->getTempDirectory() . '/automatic_updates_composer_home';
+    if (!is_dir($home_path)) {
+      mkdir($home_path);
+    }
+    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/src/ComposerStager/README.txt b/src/ComposerStager/README.txt
new file mode 100644
index 0000000000000000000000000000000000000000..f17637771ad20b171109c56ba1ed6c69d48e1d91
--- /dev/null
+++ b/src/ComposerStager/README.txt
@@ -0,0 +1,2 @@
+In a perfect world we would not need these classes and we would options in composer_stager not need the overrides.
+@todo Make issues in composer_stager.
diff --git a/src/Controller/InPlaceUpdateController.php b/src/Controller/InPlaceUpdateController.php
deleted file mode 100644
index e4332576de03c3bf9e6f32303a443d9dc78e06e0..0000000000000000000000000000000000000000
--- a/src/Controller/InPlaceUpdateController.php
+++ /dev/null
@@ -1,58 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Controller;
-
-use Drupal\automatic_updates\Services\UpdateInterface;
-use Drupal\automatic_updates\UpdateMetadata;
-use Drupal\Core\Controller\ControllerBase;
-use Drupal\Core\Messenger\MessengerInterface;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-
-/**
- * Returns responses for Automatic Updates routes.
- */
-class InPlaceUpdateController extends ControllerBase {
-
-  /**
-   * Updater service.
-   *
-   * @var \Drupal\automatic_updates\Services\UpdateInterface
-   */
-  protected $updater;
-
-  /**
-   * InPlaceUpdateController constructor.
-   *
-   * @param \Drupal\automatic_updates\Services\UpdateInterface $updater
-   *   The updater service.
-   */
-  public function __construct(UpdateInterface $updater) {
-    $this->updater = $updater;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container) {
-    return new static(
-      $container->get('automatic_updates.update')
-    );
-  }
-
-  /**
-   * Builds the response.
-   */
-  public function update($project, $type, $from, $to) {
-    $metadata = new UpdateMetadata($project, $type, $from, $to);
-    $updated = $this->updater->update($metadata);
-    $message_type = MessengerInterface::TYPE_STATUS;
-    $message = $this->t('Update successful');
-    if (!$updated) {
-      $message_type = MessengerInterface::TYPE_ERROR;
-      $message = $this->t('Update failed. Please review logs to determine the cause.');
-    }
-    $this->messenger()->addMessage($message, $message_type);
-    return $this->redirect('automatic_updates.settings');
-  }
-
-}
diff --git a/src/Controller/ReadinessCheckerController.php b/src/Controller/ReadinessCheckerController.php
index d49d0d4af8f70c064ed1e0bd1c7151a814188d9f..fbc14ba7f389f0c292c9d57d3c7d20c977c8262b 100644
--- a/src/Controller/ReadinessCheckerController.php
+++ b/src/Controller/ReadinessCheckerController.php
@@ -2,43 +2,51 @@
 
 namespace Drupal\automatic_updates\Controller;
 
-use Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface;
+use Drupal\automatic_updates\Validation\ReadinessValidationManager;
+use Drupal\automatic_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;
 
 /**
- * Class ReadinessCheckerController.
+ * A controller for running readiness checkers.
+ *
+ * @internal
+ *   Controller classes are internal.
  */
 class ReadinessCheckerController extends ControllerBase {
 
+  use ReadinessTrait;
+
   /**
-   * The readiness checker.
+   * The readiness checker manager.
    *
-   * @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface
+   * @var \Drupal\automatic_updates\Validation\ReadinessValidationManager
    */
-  protected $checker;
+  protected $readinessCheckerManager;
 
   /**
-   * ReadinessCheckerController constructor.
+   * Constructs a ReadinessCheckerController object.
    *
-   * @param \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker
-   *   The readiness checker.
+   * @param \Drupal\automatic_updates\Validation\ReadinessValidationManager $checker_manager
+   *   The readiness checker manager.
    * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
    *   The string translation service.
    */
-  public function __construct(ReadinessCheckerManagerInterface $checker, TranslationInterface $string_translation) {
-    $this->checker = $checker;
-    $this->stringTranslation = $string_translation;
+  public function __construct(ReadinessValidationManager $checker_manager, TranslationInterface $string_translation) {
+    $this->readinessCheckerManager = $checker_manager;
+    $this->setStringTranslation($string_translation);
   }
 
   /**
    * {@inheritdoc}
    */
-  public static function create(ContainerInterface $container) {
+  public static function create(ContainerInterface $container): self {
     return new static(
-      $container->get('automatic_updates.readiness_checker'),
-      $container->get('string_translation')
+      $container->get('automatic_updates.readiness_validation_manager'),
+      $container->get('string_translation'),
     );
   }
 
@@ -46,18 +54,37 @@ class ReadinessCheckerController extends ControllerBase {
    * Run the readiness checkers.
    *
    * @return \Symfony\Component\HttpFoundation\RedirectResponse
-   *   A redirect
+   *   A redirect to the status report page.
    */
-  public function run() {
-    $messages = [];
-    foreach ($this->checker->getCategories() as $category) {
-      $messages[] = $this->checker->run($category);
+  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'));
     }
-    $messages = array_merge(...$messages);
-    if (empty($messages)) {
-      $this->messenger()->addStatus($this->t('No issues found. Your site is ready for <a href="@readiness_checks">automatic updates</a>.', ['@readiness_checks' => 'https://www.drupal.org/docs/8/update/automatic-updates#readiness-checks']));
+    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);
+      }
     }
-    return $this->redirect('automatic_updates.settings');
+    // 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/src/DatabaseUpdateHandlerInterface.php b/src/DatabaseUpdateHandlerInterface.php
deleted file mode 100644
index 0d14e3374c48d3dc5fed81459ee50b393eb68bc4..0000000000000000000000000000000000000000
--- a/src/DatabaseUpdateHandlerInterface.php
+++ /dev/null
@@ -1,26 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates;
-
-/**
- * Interface for database_update_handler plugins.
- */
-interface DatabaseUpdateHandlerInterface {
-
-  /**
-   * Returns the translated plugin label.
-   *
-   * @return string
-   *   The translated title.
-   */
-  public function label();
-
-  /**
-   * Handle database updates.
-   *
-   * @return bool
-   *   TRUE if database update was handled successfully, FALSE otherwise.
-   */
-  public function execute();
-
-}
diff --git a/src/DatabaseUpdateHandlerPluginBase.php b/src/DatabaseUpdateHandlerPluginBase.php
deleted file mode 100644
index 0aef167b5c20dc8b0198ddb781a82ea4163f39a7..0000000000000000000000000000000000000000
--- a/src/DatabaseUpdateHandlerPluginBase.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates;
-
-use Drupal\Component\Plugin\PluginBase;
-
-/**
- * Base class for database_update_handler plugins.
- */
-abstract class DatabaseUpdateHandlerPluginBase extends PluginBase implements DatabaseUpdateHandlerInterface {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function label() {
-    return $this->pluginDefinition['label'];
-  }
-
-}
diff --git a/src/DatabaseUpdateHandlerPluginManager.php b/src/DatabaseUpdateHandlerPluginManager.php
deleted file mode 100644
index 5fc1da1facda7dc9d6397009965e28929d5d5b88..0000000000000000000000000000000000000000
--- a/src/DatabaseUpdateHandlerPluginManager.php
+++ /dev/null
@@ -1,37 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates;
-
-use Drupal\Core\Cache\CacheBackendInterface;
-use Drupal\Core\Extension\ModuleHandlerInterface;
-use Drupal\Core\Plugin\DefaultPluginManager;
-
-/**
- * DatabaseUpdateHandler plugin manager.
- */
-class DatabaseUpdateHandlerPluginManager extends DefaultPluginManager {
-
-  /**
-   * Constructs DatabaseUpdateHandlerPluginManager object.
-   *
-   * @param \Traversable $namespaces
-   *   An object that implements \Traversable which contains the root paths
-   *   keyed by the corresponding namespace to look for plugin implementations.
-   * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend
-   *   Cache backend instance to use.
-   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
-   *   The module handler to invoke the alter hook with.
-   */
-  public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler) {
-    parent::__construct(
-      'Plugin/DatabaseUpdateHandler',
-      $namespaces,
-      $module_handler,
-      'Drupal\automatic_updates\DatabaseUpdateHandlerInterface',
-      'Drupal\automatic_updates\Annotation\DatabaseUpdateHandler'
-    );
-    $this->alterInfo('database_update_handler_info');
-    $this->setCacheBackend($cache_backend, 'database_update_handler_plugins');
-  }
-
-}
diff --git a/src/Event/PostUpdateEvent.php b/src/Event/PostUpdateEvent.php
deleted file mode 100644
index 789b01c449ccf01629559703302a521d1f05485b..0000000000000000000000000000000000000000
--- a/src/Event/PostUpdateEvent.php
+++ /dev/null
@@ -1,62 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Event;
-
-use Drupal\automatic_updates\UpdateMetadata;
-use Symfony\Component\EventDispatcher\Event;
-
-/**
- * Defines the post update event.
- *
- * @see \Drupal\automatic_updates\Event\UpdateEvents
- */
-class PostUpdateEvent extends Event {
-
-  /**
-   * The update metadata.
-   *
-   * @var \Drupal\automatic_updates\UpdateMetadata
-   */
-  protected $updateMetadata;
-
-  /**
-   * The update success status.
-   *
-   * @var bool
-   */
-  protected $success;
-
-  /**
-   * Constructs a new PostUpdateEvent.
-   *
-   * @param \Drupal\automatic_updates\UpdateMetadata $metadata
-   *   The update metadata.
-   * @param bool $success
-   *   TRUE if update succeeded, FALSE otherwise.
-   */
-  public function __construct(UpdateMetadata $metadata, $success) {
-    $this->updateMetadata = $metadata;
-    $this->success = $success;
-  }
-
-  /**
-   * Get the update metadata.
-   *
-   * @return \Drupal\automatic_updates\UpdateMetadata
-   *   The update metadata.
-   */
-  public function getUpdateMetadata() {
-    return $this->updateMetadata;
-  }
-
-  /**
-   * Gets the update success status.
-   *
-   * @return bool
-   *   TRUE if update succeeded, FALSE otherwise.
-   */
-  public function success() {
-    return $this->success;
-  }
-
-}
diff --git a/src/Event/UpdateEvent.php b/src/Event/UpdateEvent.php
new file mode 100644
index 0000000000000000000000000000000000000000..be7eace54d0ee87d876aea5465cd374afe27ea27
--- /dev/null
+++ b/src/Event/UpdateEvent.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\automatic_updates\Event;
+
+use Drupal\automatic_updates\Validation\ValidationResult;
+use Drupal\Component\EventDispatcher\Event;
+
+/**
+ * Event fired when a site is updating.
+ *
+ * Subscribers to this event should call ::addValidationResult().
+ *
+ * @see \Drupal\automatic_updates\AutomaticUpdatesEvents
+ */
+class UpdateEvent extends Event {
+
+  /**
+   * The validation results.
+   *
+   * @var \Drupal\automatic_updates\Validation\ValidationResult[]
+   */
+  protected $results = [];
+
+  /**
+   * Adds a validation result.
+   *
+   * @param \Drupal\automatic_updates\Validation\ValidationResult $validation_result
+   *   The validation result.
+   */
+  public function addValidationResult(ValidationResult $validation_result): void {
+    $this->results[] = $validation_result;
+  }
+
+  /**
+   * 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\automatic_updates\Validation\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/src/Event/UpdateEvents.php b/src/Event/UpdateEvents.php
deleted file mode 100644
index 842b6d6ddf294e359a989d50a265a0a5fb5c07d8..0000000000000000000000000000000000000000
--- a/src/Event/UpdateEvents.php
+++ /dev/null
@@ -1,19 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Event;
-
-/**
- * Defines events for the automatic_updates module.
- */
-final class UpdateEvents {
-
-  /**
-   * Name of the event fired after updating a site.
-   *
-   * @Event
-   *
-   * @see \Drupal\automatic_updates\Event\PostUpdateEvent
-   */
-  const POST_UPDATE = 'automatic_updates.post_update';
-
-}
diff --git a/src/EventSubscriber/CronOverride.php b/src/EventSubscriber/CronOverride.php
deleted file mode 100644
index cb50f5f92584306a2055bf2e53f3647c8f876324..0000000000000000000000000000000000000000
--- a/src/EventSubscriber/CronOverride.php
+++ /dev/null
@@ -1,62 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\EventSubscriber;
-
-use Drupal\Core\Cache\CacheableMetadata;
-use Drupal\Core\Config\ConfigFactoryOverrideInterface;
-use Drupal\Core\Config\StorageInterface;
-
-/**
- * This class overrides the default system warning and error limits for cron.
- *
- * It notifies site administrators on a more strict time frame if cron has not
- * recently run.
- */
-class CronOverride implements ConfigFactoryOverrideInterface {
-
-  /**
-   * Warn at 3 hours.
-   */
-  const WARNING_THRESHOLD = 10800;
-
-  /**
-   * Error at 6 hours.
-   */
-  const ERROR_THRESHOLD = 21600;
-
-  /**
-   * {@inheritdoc}
-   */
-  public function loadOverrides($names) {
-    $overrides = [];
-    if (in_array('system.cron', $names, TRUE)) {
-      $overrides['system.cron']['threshold'] = [
-        'requirements_warning' => $this::WARNING_THRESHOLD,
-        'requirements_error' => $this::ERROR_THRESHOLD,
-      ];
-    }
-    return $overrides;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getCacheSuffix() {
-    return 'CronOverride';
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getCacheableMetadata($name) {
-    return new CacheableMetadata();
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function createConfigObject($name, $collection = StorageInterface::DEFAULT_COLLECTION) {
-    return NULL;
-  }
-
-}
diff --git a/src/EventSubscriber/PostUpdateSubscriber.php b/src/EventSubscriber/PostUpdateSubscriber.php
deleted file mode 100644
index f701f6ef5d35bac20a0f79c4dbbc5c10a496a2e0..0000000000000000000000000000000000000000
--- a/src/EventSubscriber/PostUpdateSubscriber.php
+++ /dev/null
@@ -1,123 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\EventSubscriber;
-
-use Drupal\automatic_updates\Event\PostUpdateEvent;
-use Drupal\automatic_updates\Event\UpdateEvents;
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Language\LanguageManagerInterface;
-use Drupal\Core\Mail\MailManagerInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-
-/**
- * Post update event subscriber.
- */
-class PostUpdateSubscriber implements EventSubscriberInterface {
-  use StringTranslationTrait;
-
-  /**
-   * The config factory.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $configFactory;
-
-  /**
-   * Mail manager.
-   *
-   * @var \Drupal\Core\Mail\MailManagerInterface
-   */
-  protected $mailManager;
-
-  /**
-   * The language manager.
-   *
-   * @var \Drupal\Core\Language\LanguageManagerInterface
-   */
-  protected $languageManager;
-
-  /**
-   * Entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
-   */
-  protected $entityTypeManager;
-
-  /**
-   * PostUpdateSubscriber constructor.
-   *
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory.
-   * @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
-   *   The mail manager.
-   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
-   *   The language manager.
-   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
-   *   Entity type manager.
-   */
-  public function __construct(ConfigFactoryInterface $config_factory, MailManagerInterface $mail_manager, LanguageManagerInterface $language_manager, EntityTypeManagerInterface $entity_type_manager) {
-    $this->configFactory = $config_factory;
-    $this->mailManager = $mail_manager;
-    $this->languageManager = $language_manager;
-    $this->entityTypeManager = $entity_type_manager;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      UpdateEvents::POST_UPDATE => ['onPostUpdate'],
-    ];
-  }
-
-  /**
-   * Send notification on post update with success/failure.
-   *
-   * @param \Drupal\automatic_updates\Event\PostUpdateEvent $event
-   *   The post update event.
-   */
-  public function onPostUpdate(PostUpdateEvent $event) {
-    $notify_list = $this->configFactory->get('update.settings')->get('notification.emails');
-    if (!empty($notify_list)) {
-      $params['subject'] = $this->t('Automatic update of "@project" succeeded', ['@project' => $event->getUpdateMetadata()->getProjectName()]);
-      if (!$event->success()) {
-        $params['subject'] = $this->t('Automatic update of "@project" failed', ['@project' => $event->getUpdateMetadata()->getProjectName()]);
-      }
-      $params['body'] = [
-        '#theme' => 'automatic_updates_post_update',
-        '#success' => $event->success(),
-        '#metadata' => $event->getUpdateMetadata(),
-      ];
-      $default_langcode = $this->languageManager->getDefaultLanguage()->getId();
-      $params['langcode'] = $default_langcode;
-      foreach ($notify_list as $to) {
-        $this->doSend($to, $params);
-      }
-    }
-  }
-
-  /**
-   * Composes and send the email message.
-   *
-   * @param string $to
-   *   The email address where the message will be sent.
-   * @param array $params
-   *   Parameters to build the email.
-   *
-   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
-   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
-   */
-  protected function doSend($to, array $params) {
-    $users = $this->entityTypeManager->getStorage('user')
-      ->loadByProperties(['mail' => $to]);
-    foreach ($users as $user) {
-      $to_user = reset($users);
-      $params['langcode'] = $to_user->getPreferredLangcode();
-      $this->mailManager->mail('automatic_updates', 'post_update', $to, $params['langcode'], $params);
-    }
-  }
-
-}
diff --git a/src/Exception/UpdateException.php b/src/Exception/UpdateException.php
new file mode 100644
index 0000000000000000000000000000000000000000..d1e8b75e4ca6454cc4ba972be26aa7462f1f5b30
--- /dev/null
+++ b/src/Exception/UpdateException.php
@@ -0,0 +1,40 @@
+<?php
+
+namespace Drupal\automatic_updates\Exception;
+
+/**
+ * Defines a custom exception for a failure during an update.
+ */
+class UpdateException extends \RuntimeException {
+
+  /**
+   * The validation results for the exception.
+   *
+   * @var \Drupal\automatic_updates\Validation\ValidationResult[]
+   */
+  protected $validationResults;
+
+  /**
+   * Constructs an UpdateException object.
+   *
+   * @param \Drupal\automatic_updates\Validation\ValidationResult[] $validation_results
+   *   The validation results.
+   * @param string $message
+   *   The exception message.
+   */
+  public function __construct(array $validation_results, string $message) {
+    parent::__construct($message);
+    $this->validationResults = $validation_results;
+  }
+
+  /**
+   * Gets the validation results for the exception.
+   *
+   * @return \Drupal\automatic_updates\Validation\ValidationResult[]
+   *   The validation results.
+   */
+  public function getValidationResults(): array {
+    return $this->validationResults;
+  }
+
+}
diff --git a/src/Form/SettingsForm.php b/src/Form/SettingsForm.php
deleted file mode 100644
index 43005c01fba21260c31c532165134d90d9477a74..0000000000000000000000000000000000000000
--- a/src/Form/SettingsForm.php
+++ /dev/null
@@ -1,229 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Form;
-
-use Drupal\Core\Form\ConfigFormBase;
-use Drupal\Core\Form\FormStateInterface;
-use Drupal\Core\Url;
-use Drupal\update\UpdateManagerInterface;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-
-/**
- * Settings form for automatic updates.
- */
-class SettingsForm extends ConfigFormBase {
-  /**
-   * The readiness checker.
-   *
-   * @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface
-   */
-  protected $checker;
-
-  /**
-   * The data formatter.
-   *
-   * @var \Drupal\Core\Datetime\DateFormatterInterface
-   */
-  protected $dateFormatter;
-
-  /**
-   * Drupal root path.
-   *
-   * @var string
-   */
-  protected $drupalRoot;
-
-  /**
-   * The update manager service.
-   *
-   * @var \Drupal\update\UpdateManagerInterface
-   */
-  protected $updateManager;
-
-  /**
-   * The update processor.
-   *
-   * @var \Drupal\update\UpdateProcessorInterface
-   */
-  protected $updateProcessor;
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container) {
-    $instance = parent::create($container);
-    $instance->checker = $container->get('automatic_updates.readiness_checker');
-    $instance->dateFormatter = $container->get('date.formatter');
-    $instance->drupalRoot = (string) $container->get('app.root');
-    $instance->updateManager = $container->get('update.manager');
-    $instance->updateProcessor = $container->get('update.processor');
-    return $instance;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getEditableConfigNames() {
-    return [
-      'automatic_updates.settings',
-    ];
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getFormId() {
-    return 'automatic_updates_settings_form';
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function buildForm(array $form, FormStateInterface $form_state) {
-    $config = $this->config('automatic_updates.settings');
-
-    $form['psa'] = [
-      '#type' => 'details',
-      '#title' => $this->t('Public service announcements'),
-      '#open' => TRUE,
-    ];
-    $form['psa']['description'] = [
-      '#markup' => '<p>' . $this->t('Public service announcements are compared against the entire code for the site, not just installed extensions.') . '</p>',
-    ];
-
-    $form['psa']['enable_psa'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Show Public service announcements on administrative pages.'),
-      '#default_value' => $config->get('enable_psa'),
-    ];
-    $form['psa']['notify'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Send email notifications for Public service announcements.'),
-      '#default_value' => $config->get('notify'),
-      '#description' => $this->t('The email addresses listed in <a href="@update_manager">update manager settings</a> will be notified.', ['@update_manager' => Url::fromRoute('update.settings')->toString()]),
-    ];
-
-    $form['readiness'] = [
-      '#type' => 'details',
-      '#title' => $this->t('Readiness checks'),
-      '#open' => TRUE,
-    ];
-
-    $last_check_timestamp = $this->checker->timestamp();
-    $form['readiness']['enable_readiness_checks'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Check the readiness of automatically updating the site.'),
-      '#default_value' => $config->get('enable_readiness_checks'),
-    ];
-    if ($this->checker->isEnabled()) {
-      $form['readiness']['enable_readiness_checks']['#description'] = $this->t('Readiness checks were last run @time ago. Manually <a href="@link">run the readiness checks</a>.', [
-        '@time' => $this->dateFormatter->formatTimeDiffSince($last_check_timestamp),
-        '@link' => Url::fromRoute('automatic_updates.update_readiness')->toString(),
-      ]);
-    }
-    $form['readiness']['ignored_paths'] = [
-      '#type' => 'textarea',
-      '#title' => $this->t('Paths to ignore for readiness checks'),
-      '#description' => $this->t('Paths relative to %drupal_root. One path per line. Automatic Updates is intentionally limited to Drupal core. It is recommended to ignore paths to contrib extensions.', ['%drupal_root' => $this->drupalRoot]),
-      '#default_value' => $config->get('ignored_paths'),
-      '#states' => [
-        'visible' => [
-          ':input[name="enable_readiness_checks"]' => ['checked' => TRUE],
-        ],
-      ],
-    ];
-
-    $this->updateManager->refreshUpdateData();
-    $this->updateProcessor->fetchData();
-    $available = update_get_available(TRUE);
-    $projects = update_calculate_project_data($available);
-    $not_recommended_version = $projects['drupal']['status'] !== UpdateManagerInterface::CURRENT;
-    $not_dev_core = strpos(\Drupal::VERSION, '-dev') === FALSE;
-    $security_update = in_array($projects['drupal']['status'], [UpdateManagerInterface::NOT_SECURE, UpdateManagerInterface::REVOKED], TRUE);
-    $recommended_release = $projects['drupal']['releases'][$projects['drupal']['recommended']];
-    $form['experimental'] = [
-      '#type' => 'details',
-      '#title' => $this->t('Experimental'),
-      '#states' => [
-        'visible' => [
-          ':input[name="enable_readiness_checks"]' => ['checked' => TRUE],
-        ],
-      ],
-    ];
-    if ($not_recommended_version && $not_dev_core) {
-      $existing_major_version = explode('.', \Drupal::VERSION, -2);
-      $recommended_major_version = explode('.', $recommended_release['version'], -2);
-      $major_upgrade = $existing_major_version !== $recommended_major_version;
-      if ($security_update) {
-        $form['experimental']['security'] = [
-          '#type' => 'html_tag',
-          '#tag' => 'p',
-          '#value' => $this->t('A security update is available for your version of Drupal.'),
-        ];
-      }
-      if ($major_upgrade) {
-        $form['experimental']['major_version'] = [
-          '#type' => 'html_tag',
-          '#tag' => 'p',
-          '#value' => $this->t('This update is a major version update which means that it may not be backwards compatible with your currently running version. It is recommended that you read the release notes and proceed at your own risk.'),
-        ];
-      }
-    }
-
-    $update_text = $this->t('Your site is running %version of Drupal core. No recommended update is available at this time.</i>', ['%version' => \Drupal::VERSION]);
-    if ($not_recommended_version && $not_dev_core) {
-      $update_text = $this->t('A newer version of Drupal is available and you may <a href="@link">manually update now</a>.', [
-        '@link' => Url::fromRoute('automatic_updates.inplace-update', [
-          'project' => 'drupal',
-          'type' => 'core',
-          'from' => \Drupal::VERSION,
-          'to' => $recommended_release['version'],
-        ])->toString(),
-      ]);
-    }
-
-    $form['experimental']['update'] = [
-      '#type' => 'html_tag',
-      '#tag' => 'p',
-      '#value' => $update_text,
-    ];
-
-    $form['experimental']['enable_cron_updates'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Enable automatic updates of Drupal core via cron.'),
-      '#default_value' => $config->get('enable_cron_updates'),
-      '#description' => $this->t('When a recommended update for Drupal core is available, a manual method to update is available. As an alternative to the full control of manually executing an update, enable automated updates via cron.'),
-    ];
-    $form['experimental']['enable_cron_security_updates'] = [
-      '#type' => 'checkbox',
-      '#title' => $this->t('Enable security only updates'),
-      '#default_value' => $config->get('enable_cron_security_updates'),
-      '#description' => $this->t('Enable automated updates via cron for security-only releases of Drupal core.'),
-      '#states' => [
-        'visible' => [
-          ':input[name="enable_cron_updates"]' => ['checked' => TRUE],
-        ],
-      ],
-    ];
-
-    return parent::buildForm($form, $form_state);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function submitForm(array &$form, FormStateInterface $form_state) {
-    parent::submitForm($form, $form_state);
-    $form_state->cleanValues();
-    $config = $this->config('automatic_updates.settings');
-    foreach ($form_state->getValues() as $key => $value) {
-      $config->set($key, $value);
-      // Disable cron automatic updates if readiness checks are disabled.
-      if (in_array($key, ['enable_cron_updates', 'enable_cron_security_updates'], TRUE) && !$form_state->getValue('enable_readiness_checks')) {
-        $config->set($key, FALSE);
-      }
-    }
-    $config->save();
-  }
-
-}
diff --git a/src/Form/UpdateFormBase.php b/src/Form/UpdateFormBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..c5485b4096bc623b1d8870533fdd63b4fe23b82d
--- /dev/null
+++ b/src/Form/UpdateFormBase.php
@@ -0,0 +1,105 @@
+<?php
+
+namespace Drupal\automatic_updates\Form;
+
+use Drupal\automatic_updates\Exception\UpdateException;
+use Drupal\automatic_updates\Updater;
+use Drupal\automatic_updates\Validation\ValidationResult;
+use Drupal\Core\Form\FormBase;
+use Drupal\Core\Form\FormStateInterface;
+
+/**
+ * Defines a base class for forms which are part of the attended update process.
+ */
+abstract class UpdateFormBase extends FormBase {
+
+  /**
+   * The updater service.
+   *
+   * @var \Drupal\automatic_updates\Updater
+   */
+  protected $updater;
+
+  /**
+   * Constructs an UpdateFormBase object.
+   *
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater service.
+   */
+  public function __construct(Updater $updater) {
+    $this->updater = $updater;
+  }
+
+  /**
+   * Fires an update validation event and handles any detected errors.
+   *
+   * If $form and $form_state are passed, errors will be flagged against the
+   * form_id element, since it's guaranteed to exist in all forms. Otherwise,
+   * the errors will be displayed in the messages area.
+   *
+   * @param string $event
+   *   The name of the event to fire. Should be one of the constants from
+   *   \Drupal\automatic_updates\AutomaticUpdatesEvents.
+   * @param array|null $form
+   *   (optional) The complete form array.
+   * @param \Drupal\Core\Form\FormStateInterface|null $form_state
+   *   (optional) The current form state.
+   *
+   * @return bool
+   *   TRUE if no errors were found, FALSE otherwise.
+   */
+  protected function validateUpdate(string $event, array &$form = NULL, FormStateInterface $form_state = NULL): bool {
+    $errors = FALSE;
+    foreach ($this->getValidationErrors($event) as $error) {
+      if ($form && $form_state) {
+        $form_state->setError($form['form_id'], $error);
+      }
+      else {
+        $this->messenger()->addError($error);
+      }
+      $errors = TRUE;
+    }
+    return !$errors;
+  }
+
+  /**
+   * Fires an update validation event and returns all resulting errors.
+   *
+   * @param string $event
+   *   The name of the event to fire. Should be one of the constants from
+   *   \Drupal\automatic_updates\AutomaticUpdatesEvents.
+   *
+   * @return \Drupal\Component\Render\MarkupInterface[]
+   *   The validation errors, if any.
+   */
+  protected function getValidationErrors(string $event): array {
+    $errors = [];
+    try {
+      $this->updater->dispatchUpdateEvent($event);
+    }
+    catch (UpdateException $e) {
+      foreach ($e->getValidationResults() as $result) {
+        $errors = array_merge($errors, $this->getMessagesFromValidationResult($result));
+      }
+    }
+    return $errors;
+  }
+
+  /**
+   * Extracts all relevant messages from an update validation result.
+   *
+   * @param \Drupal\automatic_updates\Validation\ValidationResult $result
+   *   The validation result.
+   *
+   * @return \Drupal\Component\Render\MarkupInterface[]
+   *   The messages to display from the validation result.
+   */
+  protected function getMessagesFromValidationResult(ValidationResult $result): array {
+    $messages = $result->getMessages();
+    if (count($messages) > 1) {
+      array_unshift($messages, $result->getSummary());
+    }
+    return $messages;
+  }
+
+}
diff --git a/src/Form/UpdateReady.php b/src/Form/UpdateReady.php
new file mode 100644
index 0000000000000000000000000000000000000000..619d0dd1ea1ac177ba85af2edd0b6fa317e81590
--- /dev/null
+++ b/src/Form/UpdateReady.php
@@ -0,0 +1,120 @@
+<?php
+
+namespace Drupal\automatic_updates\Form;
+
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates\Updater;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\State\StateInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a form to commit staged updates.
+ *
+ * @internal
+ *   Form classes are internal.
+ */
+class UpdateReady extends UpdateFormBase {
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * Constructs a new UpdateReady object.
+   *
+   * @param \Drupal\automatic_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) {
+    parent::__construct($updater);
+    $this->setMessenger($messenger);
+    $this->state = $state;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'automatic_updates_update_ready_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('automatic_updates.updater'),
+      $container->get('messenger'),
+      $container->get('state')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    if (!$this->validateUpdate(AutomaticUpdatesEvents::PRE_COMMIT)) {
+      return $form;
+    }
+
+    $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 validateForm(array &$form, FormStateInterface $form_state) {
+    parent::validateForm($form, $form_state);
+    $this->validateUpdate(AutomaticUpdatesEvents::PRE_COMMIT, $form, $form_state);
+  }
+
+  /**
+   * {@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?
+    }
+
+    // @todo Should these operations be done in batch.
+    $this->updater->commit();
+    // Clean could be done in another page load or on cron to reduce page time.
+    $this->updater->clean();
+    $this->messenger->addMessage("Update complete!");
+
+    // @todo redirect to update.php?
+    $form_state->setRedirect('automatic_updates.update_form');
+  }
+
+}
diff --git a/src/Form/UpdaterForm.php b/src/Form/UpdaterForm.php
new file mode 100644
index 0000000000000000000000000000000000000000..6dce8d146fc696f324aeb38b26956487b8cafae3
--- /dev/null
+++ b/src/Form/UpdaterForm.php
@@ -0,0 +1,249 @@
+<?php
+
+namespace Drupal\automatic_updates\Form;
+
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates\BatchProcessor;
+use Drupal\automatic_updates\Updater;
+use Drupal\automatic_updates_9_3_shim\ProjectRelease;
+use Drupal\Core\Batch\BatchBuilder;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\Core\Link;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\Url;
+use Drupal\update\UpdateManagerInterface;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Defines a form to update Drupal core.
+ *
+ * @internal
+ *   Form classes are internal.
+ */
+class UpdaterForm extends UpdateFormBase {
+
+  /**
+   * The module handler.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * Constructs a new UpdateManagerUpdate object.
+   *
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater service.
+   */
+  public function __construct(ModuleHandlerInterface $module_handler, StateInterface $state, Updater $updater) {
+    parent::__construct($updater);
+    $this->moduleHandler = $module_handler;
+    $this->state = $state;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getFormId() {
+    return 'automatic_updates_updater_form';
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('module_handler'),
+      $container->get('state'),
+      $container->get('automatic_updates.updater')
+    );
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function buildForm(array $form, FormStateInterface $form_state) {
+    $this->messenger()->addWarning($this->t('This is an experimental updater using Composer. Use at your own risk 💀'));
+    $this->moduleHandler->loadInclude('update', 'inc', 'update.manager');
+
+    $form['last_check'] = [
+      '#theme' => 'update_last_check',
+      '#last' => $this->state->get('update.last_check', 0),
+    ];
+
+    $available = update_get_available(TRUE);
+    if (empty($available)) {
+      $form['message'] = [
+        '#markup' => $this->t('There was a problem getting update information. Try again later.'),
+      ];
+      return $form;
+    }
+
+    // @todo Should we be using the Update module's library here, or our own?
+    $form['#attached']['library'][] = 'update/drupal.update.admin';
+
+    $this->moduleHandler->loadInclude('update', 'inc', 'update.compare');
+    $project_data = update_calculate_project_data($available);
+    $project = $project_data['drupal'];
+
+    // If we're already up-to-date, there's nothing else we need to do.
+    if ($project['status'] == UpdateManagerInterface::CURRENT) {
+      $this->messenger()->addMessage('No update available');
+      return $form;
+    }
+    // If we don't know what to recommend they upgrade to, time to freak out.
+    elseif (empty($project['recommended'])) {
+      // @todo Can we fail more gracefully here? Maybe link to the status report
+      // page, or do anything other than throw a nasty exception?
+      throw new \LogicException("Should always have an update at this point");
+    }
+
+    $recommended_release = ProjectRelease::createFromArray($project['releases'][$project['recommended']]);
+
+    $form['update_version'] = [
+      '#type' => 'value',
+      '#value' => [
+        'drupal' => $recommended_release->getVersion(),
+      ],
+    ];
+
+    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,
+        ],
+      ],
+    ];
+
+    $form['actions'] = $this->actions();
+    return $form;
+  }
+
+  /**
+   * Builds the form actions.
+   *
+   * @return array
+   *   The form's actions elements.
+   */
+  protected function actions(): array {
+    $actions = ['#type' => 'actions'];
+
+    if ($this->updater->hasActiveUpdate()) {
+      $this->messenger()->addError($this->t('Another Composer update process is currently active'));
+      $actions['delete'] = [
+        '#type' => 'submit',
+        '#value' => $this->t('Delete existing update'),
+        '#submit' => ['::deleteExistingUpdate'],
+      ];
+    }
+    else {
+      $actions['submit'] = [
+        '#type' => 'submit',
+        '#value' => $this->t('Download these updates'),
+      ];
+    }
+    return $actions;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateForm(array &$form, FormStateInterface $form_state) {
+    parent::validateForm($form, $form_state);
+    $this->validateUpdate(AutomaticUpdatesEvents::PRE_START, $form, $form_state);
+  }
+
+  /**
+   * Submit function to delete an existing in-progress update.
+   */
+  public function deleteExistingUpdate(): void {
+    $this->updater->clean();
+    $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'])
+      ->addOperation([BatchProcessor::class, 'stageProjectVersions'], [
+        $form_state->getValue('update_version'),
+      ])
+      ->setFinishCallback([BatchProcessor::class, 'finish'])
+      ->toArray();
+
+    batch_set($batch);
+  }
+
+}
diff --git a/src/IgnoredPathsIteratorFilter.php b/src/IgnoredPathsIteratorFilter.php
deleted file mode 100644
index e673263d96833691e6f0956f1f6bb62511e341a3..0000000000000000000000000000000000000000
--- a/src/IgnoredPathsIteratorFilter.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates;
-
-/**
- * Provides an iterator filter for file paths which are ignored.
- */
-class IgnoredPathsIteratorFilter extends \FilterIterator {
-  use IgnoredPathsTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  public function accept() {
-    return !$this->isIgnoredPath($this->current());
-  }
-
-}
diff --git a/src/IgnoredPathsTrait.php b/src/IgnoredPathsTrait.php
deleted file mode 100644
index 6ae7c439297565256f43edeb8a6ba9e844e21cb5..0000000000000000000000000000000000000000
--- a/src/IgnoredPathsTrait.php
+++ /dev/null
@@ -1,53 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates;
-
-/**
- * Provide a helper to check if file paths are ignored.
- */
-trait IgnoredPathsTrait {
-
-  /**
-   * Check if the file path is ignored.
-   *
-   * @param string $file_path
-   *   The file path.
-   *
-   * @return bool
-   *   TRUE if file path is ignored, else FALSE.
-   */
-  protected function isIgnoredPath($file_path) {
-    $paths = $this->getConfigFactory()->get('automatic_updates.settings')->get('ignored_paths');
-    if ($this->getPathMatcher()->matchPath($file_path, $paths)) {
-      return TRUE;
-    }
-    return FALSE;
-  }
-
-  /**
-   * Gets the config factory.
-   *
-   * @return \Drupal\Core\Config\ConfigFactoryInterface
-   *   The config factory.
-   */
-  protected function getConfigFactory() {
-    if (isset($this->configFactory)) {
-      return $this->configFactory;
-    }
-    return \Drupal::configFactory();
-  }
-
-  /**
-   * Get the path matcher service.
-   *
-   * @return \Drupal\Core\Path\PathMatcherInterface
-   *   The path matcher.
-   */
-  protected function getPathMatcher() {
-    if (isset($this->pathMatcher)) {
-      return $this->pathMatcher;
-    }
-    return \Drupal::service('path.matcher');
-  }
-
-}
diff --git a/src/Plugin/DatabaseUpdateHandler/ExecuteUpdates.php b/src/Plugin/DatabaseUpdateHandler/ExecuteUpdates.php
deleted file mode 100644
index 8ce8de571f67517ec0884beb6ccd098cba8ce755..0000000000000000000000000000000000000000
--- a/src/Plugin/DatabaseUpdateHandler/ExecuteUpdates.php
+++ /dev/null
@@ -1,68 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler;
-
-use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase;
-use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Psr\Log\LoggerInterface;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-
-/**
- * Execute database updates.
- *
- * @DatabaseUpdateHandler(
- *   id = "execute_updates",
- *   label = "Execute database updates",
- * )
- */
-class ExecuteUpdates extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface {
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * Constructs a new maintenance mode service.
-   *
-   * @param array $configuration
-   *   A configuration array containing information about the plugin instance.
-   * @param string $plugin_id
-   *   The plugin_id for the plugin instance.
-   * @param mixed $plugin_definition
-   *   The plugin implementation definition.
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) {
-    parent::__construct($configuration, $plugin_id, $plugin_definition);
-    $this->logger = $logger;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
-    return new static(
-      $configuration,
-      $plugin_id,
-      $plugin_definition,
-      $container->get('logger.channel.automatic_updates')
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function execute() {
-    $process = automatic_updates_console_command('updatedb');
-    if ($errors = $process->getErrorOutput()) {
-      $this->logger->error($errors);
-      return FALSE;
-    }
-    return TRUE;
-  }
-
-}
diff --git a/src/Plugin/DatabaseUpdateHandler/IgnoreUpdates.php b/src/Plugin/DatabaseUpdateHandler/IgnoreUpdates.php
deleted file mode 100644
index 3a8325b678113e42d015c72384c6d7f3638fab59..0000000000000000000000000000000000000000
--- a/src/Plugin/DatabaseUpdateHandler/IgnoreUpdates.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler;
-
-use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase;
-use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Psr\Log\LoggerInterface;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-
-/**
- * Ignore database updates.
- *
- * @DatabaseUpdateHandler(
- *   id = "ignore_updates",
- *   label = "Ignore database updates",
- * )
- */
-class IgnoreUpdates extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface {
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * Constructs a new maintenance mode service.
-   *
-   * @param array $configuration
-   *   A configuration array containing information about the plugin instance.
-   * @param string $plugin_id
-   *   The plugin_id for the plugin instance.
-   * @param mixed $plugin_definition
-   *   The plugin implementation definition.
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) {
-    parent::__construct($configuration, $plugin_id, $plugin_definition);
-    $this->logger = $logger;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
-    return new static(
-      $configuration,
-      $plugin_id,
-      $plugin_definition,
-      $container->get('logger.channel.automatic_updates')
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function execute() {
-    $this->logger->notice('Database updates ignored.');
-    // Ignore the updates and hope for the best.
-    return TRUE;
-  }
-
-}
diff --git a/src/Plugin/DatabaseUpdateHandler/MaintenanceModeActivate.php b/src/Plugin/DatabaseUpdateHandler/MaintenanceModeActivate.php
deleted file mode 100644
index 663aafc8a3decea0faf6df40e1ebac94b58b636d..0000000000000000000000000000000000000000
--- a/src/Plugin/DatabaseUpdateHandler/MaintenanceModeActivate.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler;
-
-use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase;
-use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Drupal\Core\State\StateInterface;
-use Psr\Log\LoggerInterface;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-
-/**
- * Put site into maintenance mode if there are database updates.
- *
- * @DatabaseUpdateHandler(
- *   id = "maintenance_mode_activate",
- *   label = "Put site into maintenance mode",
- * )
- */
-class MaintenanceModeActivate extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface {
-
-  /**
-   * The state.
-   *
-   * @var \Drupal\Core\State\StateInterface
-   */
-  protected $state;
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * Constructs a new maintenance mode service.
-   *
-   * @param array $configuration
-   *   A configuration array containing information about the plugin instance.
-   * @param string $plugin_id
-   *   The plugin_id for the plugin instance.
-   * @param mixed $plugin_definition
-   *   The plugin implementation definition.
-   * @param \Drupal\Core\State\StateInterface $state
-   *   The state.
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, StateInterface $state, LoggerInterface $logger) {
-    parent::__construct($configuration, $plugin_id, $plugin_definition);
-    $this->state = $state;
-    $this->logger = $logger;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
-    return new static(
-      $configuration,
-      $plugin_id,
-      $plugin_definition,
-      $container->get('state'),
-      $container->get('logger.channel.automatic_updates')
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function execute() {
-    $this->logger->notice('Maintenance mode activated.');
-    $this->state->set('system.maintenance_mode', TRUE);
-    return TRUE;
-  }
-
-}
diff --git a/src/Plugin/DatabaseUpdateHandler/MaintenanceModeDisactivate.php b/src/Plugin/DatabaseUpdateHandler/MaintenanceModeDisactivate.php
deleted file mode 100644
index 212f71b03412d345a4c4ca10399fe3e253b6567d..0000000000000000000000000000000000000000
--- a/src/Plugin/DatabaseUpdateHandler/MaintenanceModeDisactivate.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler;
-
-use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase;
-use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Drupal\Core\State\StateInterface;
-use Psr\Log\LoggerInterface;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-
-/**
- * Remove site from maintenance mode.
- *
- * @DatabaseUpdateHandler(
- *   id = "maintenance_mode_disactivate",
- *   label = "Remove site from maintenance mode",
- * )
- */
-class MaintenanceModeDisactivate extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface {
-
-  /**
-   * The state.
-   *
-   * @var \Drupal\Core\State\StateInterface
-   */
-  protected $state;
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * Constructs a new maintenance mode service.
-   *
-   * @param array $configuration
-   *   A configuration array containing information about the plugin instance.
-   * @param string $plugin_id
-   *   The plugin_id for the plugin instance.
-   * @param mixed $plugin_definition
-   *   The plugin implementation definition.
-   * @param \Drupal\Core\State\StateInterface $state
-   *   The state.
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, StateInterface $state, LoggerInterface $logger) {
-    parent::__construct($configuration, $plugin_id, $plugin_definition);
-    $this->state = $state;
-    $this->logger = $logger;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
-    return new static(
-      $configuration,
-      $plugin_id,
-      $plugin_definition,
-      $container->get('state'),
-      $container->get('logger.channel.automatic_updates')
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function execute() {
-    $this->logger->notice('Maintenance mode dis-activated.');
-    $this->state->set('system.maintenance_mode', FALSE);
-    return TRUE;
-  }
-
-}
diff --git a/src/Plugin/DatabaseUpdateHandler/RollbackUpdate.php b/src/Plugin/DatabaseUpdateHandler/RollbackUpdate.php
deleted file mode 100644
index b20ec368218fcec31d88ef6ff841941b16eb1177..0000000000000000000000000000000000000000
--- a/src/Plugin/DatabaseUpdateHandler/RollbackUpdate.php
+++ /dev/null
@@ -1,65 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Plugin\DatabaseUpdateHandler;
-
-use Drupal\automatic_updates\DatabaseUpdateHandlerPluginBase;
-use Drupal\Core\Plugin\ContainerFactoryPluginInterface;
-use Psr\Log\LoggerInterface;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-
-/**
- * Rollback database updates.
- *
- * @DatabaseUpdateHandler(
- *   id = "rollback",
- *   label = "Rollback database updates",
- * )
- */
-class RollbackUpdate extends DatabaseUpdateHandlerPluginBase implements ContainerFactoryPluginInterface {
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * Constructs a new maintenance mode service.
-   *
-   * @param array $configuration
-   *   A configuration array containing information about the plugin instance.
-   * @param string $plugin_id
-   *   The plugin_id for the plugin instance.
-   * @param mixed $plugin_definition
-   *   The plugin implementation definition.
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   */
-  public function __construct(array $configuration, $plugin_id, $plugin_definition, LoggerInterface $logger) {
-    parent::__construct($configuration, $plugin_id, $plugin_definition);
-    $this->logger = $logger;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) {
-    return new static(
-      $configuration,
-      $plugin_id,
-      $plugin_definition,
-      $container->get('logger.channel.automatic_updates')
-    );
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function execute() {
-    $this->logger->notice('Rollback initiated due to database updates.');
-    // Simply rollback the update by returning FALSE.
-    return FALSE;
-  }
-
-}
diff --git a/src/ProjectInfoTrait.php b/src/ProjectInfoTrait.php
deleted file mode 100644
index 633f8acaf9a06514be8268e716943ee225113457..0000000000000000000000000000000000000000
--- a/src/ProjectInfoTrait.php
+++ /dev/null
@@ -1,197 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates;
-
-use Drupal\Core\Extension\ExtensionList;
-
-/**
- * Provide a helper to get project info.
- */
-trait ProjectInfoTrait {
-
-  /**
-   * The extension lists.
-   *
-   * @var \Drupal\Core\Extension\ExtensionList[]
-   */
-  protected $extensionLists;
-
-  /**
-   * Get extension list.
-   *
-   * @param string $extension_type
-   *   The extension type.
-   *
-   * @return \Drupal\Core\Extension\ExtensionList
-   *   The extension list service.
-   */
-  protected function getExtensionList($extension_type) {
-    if (isset($this->extensionLists[$extension_type])) {
-      $list = $this->extensionLists[$extension_type];
-    }
-    else {
-      if (!in_array($extension_type, $this->getExtensionsTypes())) {
-        throw new \UnexpectedValueException("Invalid extension type: $extension_type");
-      }
-      $list = \Drupal::service("extension.list.$extension_type");
-    }
-    return $list;
-  }
-
-  /**
-   * Sets the extension lists.
-   *
-   * @param \Drupal\Core\Extension\ExtensionList $module_list
-   *   The module extension list.
-   * @param \Drupal\Core\Extension\ExtensionList $theme_list
-   *   The theme extension list.
-   * @param \Drupal\Core\Extension\ExtensionList $profile_list
-   *   The profile extension list.
-   */
-  protected function setExtensionLists(ExtensionList $module_list, ExtensionList $theme_list, ExtensionList $profile_list) {
-    $this->extensionLists = [
-      'module' => $module_list,
-      'theme' => $theme_list,
-      'profile' => $profile_list,
-    ];
-  }
-
-  /**
-   * Get the extension types.
-   *
-   * @return string[]
-   *   The extension types.
-   */
-  protected function getExtensionsTypes() {
-    return ['module', 'profile', 'theme'];
-  }
-
-  /**
-   * Returns an array of info files information of available extensions.
-   *
-   * @param string $extension_type
-   *   The extension type.
-   *
-   * @return array
-   *   An associative array of extension information arrays, keyed by extension
-   *   name.
-   */
-  protected function getInfos($extension_type) {
-    $file_paths = $this->getExtensionList($extension_type)->getPathnames();
-    $infos = $this->getExtensionList($extension_type)->getAllAvailableInfo();
-    array_walk($infos, function (array &$info, $key) use ($file_paths) {
-      $info['packaged'] = isset($info['datestamp']) ? $info['datestamp'] : FALSE;
-      $info['install path'] = $file_paths[$key] ? dirname($file_paths[$key]) : '';
-      $info['project'] = $this->getProjectName($key, $info);
-      $info['version'] = $this->getExtensionVersion($info);
-    });
-    $system = isset($infos['system']) ? $infos['system'] : NULL;
-    $infos = array_filter($infos, static function (array $info, $project_name) {
-      return $info && $info['project'] === $project_name;
-    }, ARRAY_FILTER_USE_BOTH);
-    if ($system) {
-      $infos['drupal'] = $system;
-      // From 8.8.0 onward, always use packaged for core because non-packaged
-      // will no longer make any sense.
-      if (version_compare(\Drupal::VERSION, '8.8.0', '>=')) {
-        $infos['drupal']['packaged'] = TRUE;
-      }
-
-    }
-    return $infos;
-  }
-
-  /**
-   * Get the extension version.
-   *
-   * @param array $info
-   *   The extension's info.
-   *
-   * @return string|null
-   *   The version or NULL if undefined.
-   */
-  protected function getExtensionVersion(array $info) {
-    $extension_name = $info['project'];
-    if (isset($info['version']) && strpos($info['version'], '-dev') === FALSE) {
-      return $info['version'];
-    }
-    // Handle experimental modules from core.
-    if (strpos($info['install path'], 'core') === 0) {
-      return $this->getExtensionList('module')->get('system')->info['version'];
-    }
-    \Drupal::logger('automatic_updates')->error('Version cannot be located for @extension', ['@extension' => $extension_name]);
-    return NULL;
-  }
-
-  /**
-   * Get the extension's project name.
-   *
-   * @param string $extension_name
-   *   The extension name.
-   * @param array $info
-   *   The extension's info.
-   *
-   * @return string
-   *   The project name or fallback to extension name if project is undefined.
-   */
-  protected function getProjectName($extension_name, array $info) {
-    $project_name = $extension_name;
-    if (isset($info['project'])) {
-      $project_name = $info['project'];
-    }
-    elseif ($composer_json = $this->getComposerJson($extension_name, $info)) {
-      if (isset($composer_json['name'])) {
-        $project_name = $this->getSuffix($composer_json['name'], '/', $extension_name);
-      }
-    }
-    if (strpos($info['install path'], 'core') === 0) {
-      $project_name = 'drupal';
-    }
-    return $project_name;
-  }
-
-  /**
-   * Get string suffix.
-   *
-   * @param string $string
-   *   The string to parse.
-   * @param string $needle
-   *   The needle.
-   * @param string $default
-   *   The default value.
-   *
-   * @return string
-   *   The sub string.
-   */
-  protected function getSuffix($string, $needle, $default) {
-    $pos = strrpos($string, $needle);
-    return $pos === FALSE ? $default : substr($string, ++$pos);
-  }
-
-  /**
-   * Get the composer.json as a JSON array.
-   *
-   * @param string $extension_name
-   *   The extension name.
-   * @param array $info
-   *   The extension's info.
-   *
-   * @return array|null
-   *   The composer.json as an array or NULL.
-   */
-  protected function getComposerJson($extension_name, array $info) {
-    try {
-      if ($directory = drupal_get_path($info['type'], $extension_name)) {
-        $composer_json = $directory . DIRECTORY_SEPARATOR . 'composer.json';
-        if (file_exists($composer_json)) {
-          return json_decode(file_get_contents($composer_json), TRUE);
-        }
-      }
-    }
-    catch (\Throwable $exception) {
-      \Drupal::logger('automatic_updates')->error('Composer.json could not be located for @extension', ['@extension' => $extension_name]);
-    }
-    return NULL;
-  }
-
-}
diff --git a/src/ReadinessChecker/CronFrequency.php b/src/ReadinessChecker/CronFrequency.php
deleted file mode 100644
index a6f33896150d88b3f2edfddd16e8d239d1c1460d..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/CronFrequency.php
+++ /dev/null
@@ -1,61 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\Extension\ModuleHandlerInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\Url;
-
-/**
- * Cron frequency checker.
- */
-class CronFrequency implements ReadinessCheckerInterface {
-  use StringTranslationTrait;
-
-  /**
-   * Minimum cron threshold is 3 hours.
-   */
-  const MINIMUM_CRON_INTERVAL = 10800;
-
-  /**
-   * The config factory.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $configFactory;
-
-  /**
-   * The module handler.
-   *
-   * @var \Drupal\Core\Extension\ModuleHandlerInterface
-   */
-  protected $moduleHandler;
-
-  /**
-   * CronFrequency constructor.
-   *
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The state service.
-   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
-   *   The module handler.
-   */
-  public function __construct(ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler) {
-    $this->configFactory = $config_factory;
-    $this->moduleHandler = $module_handler;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run() {
-    $messages = [];
-    if ($this->moduleHandler->moduleExists('automated_cron') && $this->configFactory->get('automated_cron.settings')->get('interval') > $this::MINIMUM_CRON_INTERVAL) {
-      $messages[] = $this->t('Cron is not set to run frequently enough. <a href="@configure">Configure it</a> to run at least every 3 hours or disable automated cron and run it via an external scheduling system.', [
-        '@configure' => Url::fromRoute('system.cron_settings')->toString(),
-      ]);
-    }
-    return $messages;
-  }
-
-}
diff --git a/src/ReadinessChecker/DiskSpace.php b/src/ReadinessChecker/DiskSpace.php
deleted file mode 100644
index 984031e030c440b2c783ece7d17085c3301b0a34..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/DiskSpace.php
+++ /dev/null
@@ -1,67 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Component\FileSystem\FileSystem as FileSystemComponent;
-
-/**
- * Disk space checker.
- */
-class DiskSpace extends Filesystem {
-
-  /**
-   * Minimum disk space (in bytes) is 10mb.
-   */
-  const MINIMUM_DISK_SPACE = 10000000;
-
-  /**
-   * Megabyte divisor.
-   */
-  const MEGABYTE_DIVISOR = 1000000;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function doCheck() {
-    return $this->diskSpaceCheck();
-  }
-
-  /**
-   * Check if the filesystem has sufficient disk space.
-   *
-   * @return array
-   *   An array of translatable strings if there is not sufficient space.
-   */
-  protected function diskSpaceCheck() {
-    $messages = [];
-    if (!$this->areSameLogicalDisk($this->getRootPath(), $this->getVendorPath())) {
-      if (disk_free_space($this->getRootPath()) < static::MINIMUM_DISK_SPACE) {
-        $messages[] = $this->t('Drupal root filesystem "@root" has insufficient space. There must be at least @space megabytes free.', [
-          '@root' => $this->getRootPath(),
-          '@space' => static::MINIMUM_DISK_SPACE / static::MEGABYTE_DIVISOR,
-        ]);
-      }
-      if (is_dir($this->getVendorPath()) && disk_free_space($this->getVendorPath()) < static::MINIMUM_DISK_SPACE) {
-        $messages[] = $this->t('Vendor filesystem "@vendor" has insufficient space. There must be at least @space megabytes free.', [
-          '@vendor' => $this->getVendorPath(),
-          '@space' => static::MINIMUM_DISK_SPACE / static::MEGABYTE_DIVISOR,
-        ]);
-      }
-    }
-    elseif (disk_free_space($this->getRootPath()) < static::MINIMUM_DISK_SPACE) {
-      $messages[] = $this->t('Logical disk "@root" has insufficient space. There must be at least @space megabytes free.', [
-        '@root' => $this->getRootPath(),
-        '@space' => static::MINIMUM_DISK_SPACE / static::MEGABYTE_DIVISOR,
-      ]);
-    }
-    $temp = FileSystemComponent::getOsTemporaryDirectory();
-    if (disk_free_space($temp) < static::MINIMUM_DISK_SPACE) {
-      $messages[] = $this->t('Directory "@temp" has insufficient space. There must be at least @space megabytes free.', [
-        '@temp' => $temp,
-        '@space' => static::MINIMUM_DISK_SPACE / static::MEGABYTE_DIVISOR,
-      ]);
-    }
-    return $messages;
-  }
-
-}
diff --git a/src/ReadinessChecker/FileOwnership.php b/src/ReadinessChecker/FileOwnership.php
deleted file mode 100644
index d628f74109cf7b7b6ddb8351d790cfa76e5ab655..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/FileOwnership.php
+++ /dev/null
@@ -1,46 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-/**
- * File ownership checker.
- */
-class FileOwnership extends Filesystem {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function doCheck() {
-    $file_path = $this->getRootPath() . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, ['core', 'core.api.php']);
-    return $this->ownerIsScriptUser($file_path);
-  }
-
-  /**
-   * Check if file is owned by the same user as which is running the script.
-   *
-   * Helps identify scenarios when the check is run by web user and the files
-   * are owned by a non-web user.
-   *
-   * @param string $file_path
-   *   The file path to check.
-   *
-   * @return array
-   *   An array of translatable strings if there are file ownership issues.
-   */
-  protected function ownerIsScriptUser($file_path) {
-    $messages = [];
-    if (function_exists('posix_getuid')) {
-      $file_owner_uid = fileowner($file_path);
-      $script_uid = posix_getuid();
-      if ($file_owner_uid !== $script_uid) {
-        $messages[] = $this->t('Files are owned by uid "@owner" but PHP is running as uid "@actual". The file owner and PHP user should be the same during an update.', [
-          '@owner' => $file_owner_uid,
-          '@file' => $file_path,
-          '@actual' => $script_uid,
-        ]);
-      }
-    }
-    return $messages;
-  }
-
-}
diff --git a/src/ReadinessChecker/Filesystem.php b/src/ReadinessChecker/Filesystem.php
deleted file mode 100644
index de0914ff2a350c1b728669daa3c9d0d751ea05a4..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/Filesystem.php
+++ /dev/null
@@ -1,99 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-
-/**
- * Base class for filesystem checkers.
- */
-abstract class Filesystem implements ReadinessCheckerInterface {
-  use StringTranslationTrait;
-
-  /**
-   * The root file path.
-   *
-   * @var string
-   */
-  protected $rootPath;
-
-  /**
-   * The vendor file path.
-   *
-   * @var string
-   */
-  protected $vendorPath;
-
-  /**
-   * Filesystem constructor.
-   *
-   * @param string $app_root
-   *   The app root.
-   */
-  public function __construct($app_root) {
-    $this->rootPath = (string) $app_root;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run() {
-    if (!file_exists($this->getRootPath() . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, ['core', 'core.api.php']))) {
-      return [$this->t('The web root could not be located.')];
-    }
-
-    return $this->doCheck();
-  }
-
-  /**
-   * Perform checks.
-   *
-   * @return array
-   *   An array of translatable strings if any checks fail.
-   */
-  abstract protected function doCheck();
-
-  /**
-   * Get the root file path.
-   *
-   * @return string
-   *   The root file path.
-   */
-  protected function getRootPath() {
-    if (!$this->rootPath) {
-      $this->rootPath = (string) \Drupal::root();
-    }
-    return $this->rootPath;
-  }
-
-  /**
-   * Get the vendor file path.
-   *
-   * @return string
-   *   The vendor file path.
-   */
-  protected function getVendorPath() {
-    if (!$this->vendorPath) {
-      $this->vendorPath = $this->getRootPath() . DIRECTORY_SEPARATOR . 'vendor';
-    }
-    return $this->vendorPath;
-  }
-
-  /**
-   * Determine if the root and vendor file system are the same logical disk.
-   *
-   * @param string $root
-   *   Root file path.
-   * @param string $vendor
-   *   Vendor file path.
-   *
-   * @return bool
-   *   TRUE if same file system, FALSE otherwise.
-   */
-  protected function areSameLogicalDisk($root, $vendor) {
-    $root_statistics = stat($root);
-    $vendor_statistics = stat($vendor);
-    return $root_statistics && $vendor_statistics && $root_statistics['dev'] === $vendor_statistics['dev'];
-  }
-
-}
diff --git a/src/ReadinessChecker/MissingProjectInfo.php b/src/ReadinessChecker/MissingProjectInfo.php
deleted file mode 100644
index 049b603bc8accdcc15e57e05ab40fcade3a17530..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/MissingProjectInfo.php
+++ /dev/null
@@ -1,60 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\automatic_updates\IgnoredPathsTrait;
-use Drupal\automatic_updates\ProjectInfoTrait;
-use Drupal\Core\Extension\ExtensionList;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-
-/**
- * Missing project info checker.
- */
-class MissingProjectInfo implements ReadinessCheckerInterface {
-  use IgnoredPathsTrait;
-  use ProjectInfoTrait;
-  use StringTranslationTrait;
-
-  /**
-   * MissingProjectInfo constructor.
-   *
-   * @param \Drupal\Core\Extension\ExtensionList $modules
-   *   The module extension list.
-   * @param \Drupal\Core\Extension\ExtensionList $profiles
-   *   The profile extension list.
-   * @param \Drupal\Core\Extension\ExtensionList $themes
-   *   The theme extension list.
-   */
-  public function __construct(ExtensionList $modules, ExtensionList $profiles, ExtensionList $themes) {
-    $this->setExtensionLists($modules, $themes, $profiles);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run() {
-    return $this->missingProjectInfoCheck();
-  }
-
-  /**
-   * Check for projects missing project info.
-   *
-   * @return array
-   *   An array of translatable strings if any checks fail.
-   */
-  protected function missingProjectInfoCheck() {
-    $messages = [];
-    foreach ($this->getExtensionsTypes() as $extension_type) {
-      foreach ($this->getInfos($extension_type) as $info) {
-        if ($this->isIgnoredPath($info['install path'])) {
-          continue;
-        }
-        if (!$info['version']) {
-          $messages[] = $this->t('The project "@extension" can not be updated because its version is either undefined or a dev release.', ['@extension' => $info['name']]);
-        }
-      }
-    }
-    return $messages;
-  }
-
-}
diff --git a/src/ReadinessChecker/ModifiedFiles.php b/src/ReadinessChecker/ModifiedFiles.php
deleted file mode 100644
index fd53e9ed44b8e3a3b33501ab86e72dfc5b20502a..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/ModifiedFiles.php
+++ /dev/null
@@ -1,77 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\automatic_updates\IgnoredPathsIteratorFilter;
-use Drupal\automatic_updates\ProjectInfoTrait;
-use Drupal\automatic_updates\Services\ModifiedFilesInterface;
-use Drupal\Core\Extension\ExtensionList;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-
-/**
- * Modified code checker.
- */
-class ModifiedFiles implements ReadinessCheckerInterface {
-  use StringTranslationTrait;
-  use ProjectInfoTrait;
-
-  /**
-   * The modified files service.
-   *
-   * @var \Drupal\automatic_updates\Services\ModifiedFilesInterface
-   */
-  protected $modifiedFiles;
-
-  /**
-   * An array of array of strings of extension paths.
-   *
-   * @var string[]string[]
-   */
-  protected $paths;
-
-  /**
-   * ModifiedFiles constructor.
-   *
-   * @param \Drupal\automatic_updates\Services\ModifiedFilesInterface $modified_files
-   *   The modified files service.
-   *   The config factory.
-   * @param \Drupal\Core\Extension\ExtensionList $modules
-   *   The module extension list.
-   * @param \Drupal\Core\Extension\ExtensionList $profiles
-   *   The profile extension list.
-   * @param \Drupal\Core\Extension\ExtensionList $themes
-   *   The theme extension list.
-   */
-  public function __construct(ModifiedFilesInterface $modified_files, ExtensionList $modules, ExtensionList $profiles, ExtensionList $themes) {
-    $this->modifiedFiles = $modified_files;
-    $this->setExtensionLists($modules, $themes, $profiles);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run() {
-    return $this->modifiedFilesCheck();
-  }
-
-  /**
-   * Check if the site contains any modified code.
-   *
-   * @return array
-   *   An array of translatable strings if any checks fail.
-   */
-  protected function modifiedFilesCheck() {
-    $messages = [];
-    $extensions = [];
-    foreach ($this->getExtensionsTypes() as $extension_type) {
-      $extensions[] = $this->getInfos($extension_type);
-    }
-    $extensions = array_merge(...$extensions);
-    $filtered_modified_files = new IgnoredPathsIteratorFilter($this->modifiedFiles->getModifiedFiles($extensions));
-    foreach ($filtered_modified_files as $file) {
-      $messages[] = $this->t('The hash for @file does not match its original. Updates that include that file will fail and require manual intervention.', ['@file' => $file]);
-    }
-    return $messages;
-  }
-
-}
diff --git a/src/ReadinessChecker/OpcodeCache.php b/src/ReadinessChecker/OpcodeCache.php
deleted file mode 100644
index 2bfe90401c987c023bc4bea6c6c541d72355146c..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/OpcodeCache.php
+++ /dev/null
@@ -1,55 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-
-/**
- * Error if opcode caching is enabled and updates are executed via CLI.
- */
-class OpcodeCache implements ReadinessCheckerInterface {
-  use StringTranslationTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run() {
-    $messages = [];
-    if ($this->isCli() && $this->hasOpcodeFileCache()) {
-      $messages[] = $this->t('Automatic updates cannot run via CLI  when opcode file cache is enabled.');
-    }
-    return $messages;
-  }
-
-  /**
-   * Determine if PHP is running via CLI.
-   *
-   * @return bool
-   *   TRUE if CLI, FALSE otherwise.
-   */
-  protected function isCli() {
-    return PHP_SAPI === 'cli';
-  }
-
-  /**
-   * Determine if opcode cache is enabled.
-   *
-   * If opcache.validate_timestamps is disabled or enabled with
-   * opcache.revalidate_freq greater then 2, then a site is considered to have
-   * opcode caching. The default php.ini setup is
-   * opcache.validate_timestamps=TRUE and opcache.revalidate_freq=2.
-   *
-   * @return bool
-   *   TRUE if opcode file cache is enabled, FALSE otherwise.
-   */
-  protected function hasOpcodeFileCache() {
-    if (!ini_get('opcache.validate_timestamps')) {
-      return TRUE;
-    }
-    if (ini_get('opcache.revalidate_freq') > 2) {
-      return TRUE;
-    }
-    return FALSE;
-  }
-
-}
diff --git a/src/ReadinessChecker/PendingDbUpdates.php b/src/ReadinessChecker/PendingDbUpdates.php
deleted file mode 100644
index e7a311b8c09f79b6a077bd405b414af3c71145f0..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/PendingDbUpdates.php
+++ /dev/null
@@ -1,58 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\Update\UpdateRegistry;
-
-/**
- * Pending database updates checker.
- */
-class PendingDbUpdates implements ReadinessCheckerInterface {
-  use StringTranslationTrait;
-
-  /**
-   * The update registry.
-   *
-   * @var \Drupal\Core\Update\UpdateRegistry
-   */
-  protected $updateRegistry;
-
-  /**
-   * PendingDbUpdates constructor.
-   *
-   * @param \Drupal\Core\Update\UpdateRegistry $update_registry
-   *   The update registry.
-   */
-  public function __construct(UpdateRegistry $update_registry) {
-    $this->updateRegistry = $update_registry;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run() {
-    $messages = [];
-
-    if ($this->areDbUpdatesPending()) {
-      $messages[] = $this->t('There are pending database updates. Please run update.php.');
-    }
-    return $messages;
-  }
-
-  /**
-   * Checks if there are pending database updates.
-   *
-   * @return bool
-   *   TRUE if there are pending updates, otherwise FALSE.
-   */
-  protected function areDbUpdatesPending() {
-    require_once DRUPAL_ROOT . '/core/includes/install.inc';
-    require_once DRUPAL_ROOT . '/core/includes/update.inc';
-    drupal_load_updates();
-    $hook_updates = update_get_update_list();
-    $post_updates = $this->updateRegistry->getPendingUpdateFunctions();
-    return (bool) array_merge($hook_updates, $post_updates);
-  }
-
-}
diff --git a/src/ReadinessChecker/PhpSapi.php b/src/ReadinessChecker/PhpSapi.php
deleted file mode 100644
index 7b9628274b38f1b993cc1d94fe751b0ff089b5f2..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/PhpSapi.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Core\State\StateInterface;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-
-/**
- * Warn if PHP SAPI changes between checker executions.
- */
-class PhpSapi implements ReadinessCheckerInterface {
-  use StringTranslationTrait;
-
-  /**
-   * The state service.
-   *
-   * @var \Drupal\Core\State\StateInterface
-   */
-  protected $state;
-
-  /**
-   * PhpSapi constructor.
-   *
-   * @param \Drupal\Core\State\StateInterface $state
-   *   The state service.
-   */
-  public function __construct(StateInterface $state) {
-    $this->state = $state;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run() {
-    $messages = [];
-    $php_sapi = $this->state->get('automatic_updates.php_sapi', PHP_SAPI);
-    if ($php_sapi !== PHP_SAPI) {
-      $messages[] = $this->t('PHP changed from running as "@previous" to "@current". This can lead to inconsistent and misleading results.', ['@previous' => $php_sapi, '@current' => PHP_SAPI]);
-    }
-    $this->state->set('automatic_updates.php_sapi', PHP_SAPI);
-    return $messages;
-  }
-
-}
diff --git a/src/ReadinessChecker/ReadOnlyFilesystem.php b/src/ReadinessChecker/ReadOnlyFilesystem.php
deleted file mode 100644
index 82cc3545b97ddc8be6a5ab3e0b995e9c8a474288..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/ReadOnlyFilesystem.php
+++ /dev/null
@@ -1,107 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Component\Render\MarkupInterface;
-use Drupal\Core\File\Exception\FileException;
-use Drupal\Core\File\FileSystemInterface;
-use Psr\Log\LoggerInterface;
-
-/**
- * Read only filesystem checker.
- */
-class ReadOnlyFilesystem extends Filesystem {
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * The file system service.
-   *
-   * @var \Drupal\Core\File\FileSystemInterface
-   */
-  protected $fileSystem;
-
-  /**
-   * ReadOnlyFilesystem constructor.
-   *
-   * @param string $app_root
-   *   The app root.
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   * @param \Drupal\Core\File\FileSystemInterface $file_system
-   *   The file system service.
-   */
-  public function __construct($app_root, LoggerInterface $logger, FileSystemInterface $file_system) {
-    parent::__construct($app_root);
-    $this->logger = $logger;
-    $this->fileSystem = $file_system;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function doCheck() {
-    return $this->readOnlyCheck();
-  }
-
-  /**
-   * Check if the filesystem is read only.
-   *
-   * @return array
-   *   An array of translatable strings if any checks fail.
-   */
-  protected function readOnlyCheck() {
-    $messages = [];
-    if ($this->areSameLogicalDisk($this->getRootPath(), $this->getVendorPath())) {
-      $error = $this->t('Logical disk at "@path" is read only. Updates to Drupal cannot be applied against a read only file system.', ['@path' => $this->rootPath]);
-      $this->doReadOnlyCheck($this->getRootPath(), 'core/core.api.php', $messages, $error);
-    }
-    else {
-      $error = $this->t('Drupal core filesystem at "@path" is read only. Updates to Drupal core cannot be applied against a read only file system.', ['@path' => $this->rootPath . '/core']);
-      $this->doReadOnlyCheck($this->getRootPath(), implode(DIRECTORY_SEPARATOR, ['core', 'core.api.php']), $messages, $error);
-      $error = $this->t('Vendor filesystem at "@path" is read only. Updates to the site\'s code base cannot be applied against a read only file system.', ['@path' => $this->vendorPath]);
-      $this->doReadOnlyCheck($this->getVendorPath(), 'composer/LICENSE', $messages, $error);
-    }
-    return $messages;
-  }
-
-  /**
-   * Do the read only check.
-   *
-   * @param string $file_path
-   *   The filesystem to test.
-   * @param string $file
-   *   The file path.
-   * @param array $messages
-   *   The messages array of translatable strings.
-   * @param \Drupal\Component\Render\MarkupInterface $message
-   *   The error message to add if the file is read only.
-   */
-  protected function doReadOnlyCheck($file_path, $file, array &$messages, MarkupInterface $message) {
-    // Ignore check if the path doesn't exit.
-    if (!is_file($file_path . DIRECTORY_SEPARATOR . $file)) {
-      return;
-    }
-    try {
-      // If we can copy and delete a file, then we don't have a read only
-      // file system.
-      if ($this->fileSystem->copy($file_path . DIRECTORY_SEPARATOR . $file, $file_path . DIRECTORY_SEPARATOR . "$file.automatic_updates", FileSystemInterface::EXISTS_REPLACE)) {
-        // Delete it after copying.
-        $this->fileSystem->delete($file_path . DIRECTORY_SEPARATOR . "$file.automatic_updates");
-      }
-      else {
-        $this->logger->error($message);
-        $messages[] = $message;
-      }
-    }
-    catch (FileException $exception) {
-      $messages[] = $message;
-    }
-  }
-
-}
diff --git a/src/ReadinessChecker/ReadinessCheckerInterface.php b/src/ReadinessChecker/ReadinessCheckerInterface.php
deleted file mode 100644
index 11fe60ae395bb1a690ca172362a0745a4f9c7ef1..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/ReadinessCheckerInterface.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-/**
- * Interface for objects capable of readiness checking.
- */
-interface ReadinessCheckerInterface {
-
-  /**
-   * Run check.
-   *
-   * @return array
-   *   An array of translatable strings.
-   */
-  public function run();
-
-}
diff --git a/src/ReadinessChecker/ReadinessCheckerManager.php b/src/ReadinessChecker/ReadinessCheckerManager.php
deleted file mode 100644
index 16f9436079d3fc2cdfb4f9bfa3b510d698d5bc56..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/ReadinessCheckerManager.php
+++ /dev/null
@@ -1,136 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
-
-/**
- * Defines a chained readiness checker implementation combining multiple checks.
- */
-class ReadinessCheckerManager implements ReadinessCheckerManagerInterface {
-
-  /**
-   * The key/value storage.
-   *
-   * @var \Drupal\Core\KeyValueStore\KeyValueStoreInterface
-   */
-  protected $keyValue;
-
-  /**
-   * The config factory.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $configFactory;
-
-  /**
-   * An unsorted array of active checkers.
-   *
-   * The keys are category, next level is integers that indicate priority.
-   * Values are arrays of ReadinessCheckerInterface objects.
-   *
-   * @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerInterface[][][]
-   */
-  protected $checkers = [];
-
-  /**
-   * ReadinessCheckerManager constructor.
-   *
-   * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value
-   *   The key/value service.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory.
-   */
-  public function __construct(KeyValueFactoryInterface $key_value, ConfigFactoryInterface $config_factory) {
-    $this->keyValue = $key_value->get('automatic_updates');
-    $this->configFactory = $config_factory;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function addChecker(ReadinessCheckerInterface $checker, $category = 'warning', $priority = 0) {
-    if (!in_array($category, $this->getCategories(), TRUE)) {
-      throw new \InvalidArgumentException(sprintf('Readiness checker category "%s" is invalid. Use "%s" instead.', $category, implode('" or "', $this->getCategories())));
-    }
-    $this->checkers[$category][$priority][] = $checker;
-    return $this;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function run($category) {
-    $messages = [];
-    if (!$this->isEnabled()) {
-      return $messages;
-    }
-    if (!isset($this->getSortedCheckers()[$category])) {
-      throw new \InvalidArgumentException(sprintf('No readiness checkers exist of category "%s"', $category));
-    }
-
-    foreach ($this->getSortedCheckers()[$category] as $checker) {
-      $messages[] = $checker->run();
-    }
-    $messages = array_merge(...$messages);
-    $this->keyValue->set("readiness_check_results.$category", $messages);
-    $this->keyValue->set('readiness_check_timestamp', \Drupal::time()->getRequestTime());
-    return $messages;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getResults($category) {
-    $results = [];
-    if ($this->isEnabled()) {
-      $results = $this->keyValue->get("readiness_check_results.$category", []);
-    }
-    return $results;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function timestamp() {
-    $last_check_timestamp = $this->keyValue->get('readiness_check_timestamp');
-    if (!is_numeric($last_check_timestamp)) {
-      $last_check_timestamp = \Drupal::state()->get('install_time', 0);
-    }
-    return $last_check_timestamp;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function isEnabled() {
-    return $this->configFactory->get('automatic_updates.settings')->get('enable_readiness_checks');
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getCategories() {
-    return [self::ERROR, self::WARNING];
-  }
-
-  /**
-   * Sorts checkers according to priority.
-   *
-   * @return \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerInterface[]
-   *   A sorted array of checker objects.
-   */
-  protected function getSortedCheckers() {
-    $sorted = [];
-    foreach ($this->checkers as $category => $priorities) {
-      foreach ($priorities as $checkers) {
-        krsort($checkers);
-        $sorted[$category][] = $checkers;
-      }
-      $sorted[$category] = array_merge(...$sorted[$category]);
-    }
-    return $sorted;
-  }
-
-}
diff --git a/src/ReadinessChecker/ReadinessCheckerManagerInterface.php b/src/ReadinessChecker/ReadinessCheckerManagerInterface.php
deleted file mode 100644
index 09523eafac7d15eae8d37f298becaa3e03394a8e..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/ReadinessCheckerManagerInterface.php
+++ /dev/null
@@ -1,85 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-/**
- * Readiness checker manager interface.
- */
-interface ReadinessCheckerManagerInterface {
-
-  /**
-   * Error category.
-   */
-  const ERROR = 'error';
-
-  /**
-   * Warning category.
-   */
-  const WARNING = 'warning';
-
-  /**
-   * Last checked ago warning (in seconds).
-   */
-  const LAST_CHECKED_WARNING = 3600 * 24;
-
-  /**
-   * Appends a checker to the checker chain.
-   *
-   * @param \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerInterface $checker
-   *   The checker interface to be appended to the checker chain.
-   * @param string $category
-   *   (optional) The category of check.
-   * @param int $priority
-   *   (optional) The priority of the checker being added.
-   *
-   * @return $this
-   */
-  public function addChecker(ReadinessCheckerInterface $checker, $category = 'warning', $priority = 0);
-
-  /**
-   * Run checks.
-   *
-   * @param string $category
-   *   The category of check.
-   *
-   * @return array
-   *   An array of translatable strings.
-   */
-  public function run($category);
-
-  /**
-   * Get results of most recent run.
-   *
-   * @param string $category
-   *   The category of check.
-   *
-   * @return array
-   *   An array of translatable strings.
-   */
-  public function getResults($category);
-
-  /**
-   * Get timestamp of most recent run.
-   *
-   * @return int
-   *   A unix timestamp of most recent completed run.
-   */
-  public function timestamp();
-
-  /**
-   * Determine if readiness checks is enabled.
-   *
-   * @return bool
-   *   TRUE if enabled, otherwise FALSE.
-   */
-  public function isEnabled();
-
-  /**
-   * Get the categories of checkers.
-   *
-   * @return array
-   *   The categories of checkers.
-   */
-  public function getCategories();
-
-}
diff --git a/src/ReadinessChecker/Vendor.php b/src/ReadinessChecker/Vendor.php
deleted file mode 100644
index 3529b72c0e7ee039d4aa639f255f1c14f4c52f0b..0000000000000000000000000000000000000000
--- a/src/ReadinessChecker/Vendor.php
+++ /dev/null
@@ -1,20 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\ReadinessChecker;
-
-/**
- * Error if site is managed via composer instead of via tarballs.
- */
-class Vendor extends Filesystem {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function doCheck() {
-    if (!file_exists($this->getVendorPath() . DIRECTORY_SEPARATOR . 'autoload.php')) {
-      return [$this->t('The vendor folder could not be located.')];
-    }
-    return [];
-  }
-
-}
diff --git a/src/Services/AutomaticUpdatesPsa.php b/src/Services/AutomaticUpdatesPsa.php
deleted file mode 100644
index 68585d0dbfa0020554e537da1d4919290c6e8e23..0000000000000000000000000000000000000000
--- a/src/Services/AutomaticUpdatesPsa.php
+++ /dev/null
@@ -1,237 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Services;
-
-use Composer\Semver\VersionParser;
-use Drupal\automatic_updates\ProjectInfoTrait;
-use Drupal\Component\Datetime\TimeInterface;
-use Drupal\Component\Render\FormattableMarkup;
-use Drupal\Core\Cache\CacheBackendInterface;
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\DependencyInjection\DependencySerializationTrait;
-use Drupal\Core\Extension\ExtensionList;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\TransferException;
-use Psr\Log\LoggerInterface;
-
-/**
- * Class AutomaticUpdatesPsa.
- */
-class AutomaticUpdatesPsa implements AutomaticUpdatesPsaInterface {
-  use StringTranslationTrait;
-  use DependencySerializationTrait;
-  use ProjectInfoTrait;
-
-  /**
-   * This module's configuration.
-   *
-   * @var \Drupal\Core\Config\ImmutableConfig
-   */
-  protected $config;
-
-  /**
-   * The http client.
-   *
-   * @var \GuzzleHttp\Client
-   */
-  protected $httpClient;
-
-  /**
-   * The cache backend.
-   *
-   * @var \Drupal\Core\Cache\CacheBackendInterface
-   */
-  protected $cache;
-
-  /**
-   * The time service.
-   *
-   * @var \Drupal\Component\Datetime\TimeInterface
-   */
-  protected $time;
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * AutomaticUpdatesPsa constructor.
-   *
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory.
-   * @param \Drupal\Core\Cache\CacheBackendInterface $cache
-   *   The cache backend.
-   * @param \Drupal\Component\Datetime\TimeInterface $time
-   *   The time service.
-   * @param \GuzzleHttp\Client $client
-   *   The HTTP client.
-   * @param \Drupal\Core\Extension\ExtensionList $moduleList
-   *   The module extension list.
-   * @param \Drupal\Core\Extension\ExtensionList $profileList
-   *   The profile extension list.
-   * @param \Drupal\Core\Extension\ExtensionList $themeList
-   *   The theme extension list.
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   */
-  public function __construct(ConfigFactoryInterface $config_factory, CacheBackendInterface $cache, TimeInterface $time, Client $client, ExtensionList $moduleList, ExtensionList $profileList, ExtensionList $themeList, LoggerInterface $logger) {
-    $this->config = $config_factory->get('automatic_updates.settings');
-    $this->cache = $cache;
-    $this->time = $time;
-    $this->httpClient = $client;
-    $this->logger = $logger;
-    $this->setExtensionLists($moduleList, $themeList, $profileList);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getPublicServiceMessages() {
-    $messages = [];
-    if (!$this->config->get('enable_psa')) {
-      return $messages;
-    }
-
-    if ($cache = $this->cache->get('automatic_updates_psa')) {
-      $response = $cache->data;
-    }
-    else {
-      $psa_endpoint = $this->config->get('psa_endpoint');
-      try {
-        $response = $this->httpClient->get($psa_endpoint)
-          ->getBody()
-          ->getContents();
-        $this->cache->set('automatic_updates_psa', $response, $this->time->getCurrentTime() + $this->config->get('check_frequency'));
-      }
-      catch (TransferException $exception) {
-        $this->logger->error($exception->getMessage());
-        return [$this->t('Drupal PSA endpoint :url is unreachable.', [':url' => $psa_endpoint])];
-      }
-    }
-
-    try {
-      $json_payload = json_decode($response, FALSE);
-      if ($json_payload !== NULL) {
-        foreach ($json_payload as $json) {
-          if ($json->is_psa && ($json->type === 'core' || $this->isValidExtension($json->type, $json->project))) {
-            $messages[] = $this->message($json->title, $json->link);
-          }
-          elseif ($json->type === 'core') {
-            $this->parseConstraints($messages, $json, \Drupal::VERSION);
-          }
-          elseif ($this->isValidExtension($json->type, $json->project)) {
-            $this->contribParser($messages, $json);
-          }
-        }
-      }
-      else {
-        $this->logger->error('Drupal PSA JSON is malformed: @response', ['@response' => $response]);
-        $messages[] = $this->t('Drupal PSA JSON is malformed.');
-      }
-
-    }
-    catch (\UnexpectedValueException $exception) {
-      $this->logger->error($exception->getMessage());
-      $messages[] = $this->t('Drupal PSA endpoint service is malformed.');
-    }
-
-    return $messages;
-  }
-
-  /**
-   * Determine if extension exists and has a version string.
-   *
-   * @param string $extension_type
-   *   The extension type i.e. module, theme, profile.
-   * @param string $project_name
-   *   The project.
-   *
-   * @return bool
-   *   TRUE if extension exists, else FALSE.
-   */
-  protected function isValidExtension($extension_type, $project_name) {
-    try {
-      $extension_list = $this->getExtensionList($extension_type);
-      return $extension_list->exists($project_name) && !empty($extension_list->getAllAvailableInfo()[$project_name]['version']);
-    }
-    catch (\UnexpectedValueException $exception) {
-      $this->logger->error($exception->getMessage());
-      return FALSE;
-    }
-  }
-
-  /**
-   * Parse contrib project JSON version strings.
-   *
-   * @param array $messages
-   *   The messages array.
-   * @param object $json
-   *   The JSON object.
-   */
-  protected function contribParser(array &$messages, $json) {
-    $extension_version = $this->getExtensionList($json->type)->getAllAvailableInfo()[$json->project]['version'];
-    $json->insecure = array_filter(array_map(static function ($version) {
-      $version_array = explode('-', $version, 2);
-      if ($version_array && $version_array[0] === \Drupal::CORE_COMPATIBILITY) {
-        return isset($version_array[1]) ? $version_array[1] : NULL;
-      }
-      if (count($version_array) === 1) {
-        return $version_array[0];
-      }
-      if (count($version_array) === 2 && $version_array[1] === 'dev') {
-        return $version;
-      }
-    }, $json->insecure));
-    $version_array = explode('-', $extension_version, 2);
-    $extension_version = isset($version_array[1]) && $version_array[1] !== 'dev' ? $version_array[1] : $extension_version;
-    $this->parseConstraints($messages, $json, $extension_version);
-  }
-
-  /**
-   * Compare versions and add a message, if appropriate.
-   *
-   * @param array $messages
-   *   The messages array.
-   * @param object $json
-   *   The JSON object.
-   * @param string $current_version
-   *   The current extension version.
-   *
-   * @throws \UnexpectedValueException
-   */
-  protected function parseConstraints(array &$messages, $json, $current_version) {
-    $version_string = implode('||', $json->insecure);
-    if (empty($version_string)) {
-      return;
-    }
-    $parser = new VersionParser();
-    $psa_constraint = $parser->parseConstraints($version_string);
-    $contrib_constraint = $parser->parseConstraints($current_version);
-    if ($psa_constraint->matches($contrib_constraint)) {
-      $messages[] = $this->message($json->title, $json->link);
-    }
-  }
-
-  /**
-   * Return a message.
-   *
-   * @param string $title
-   *   The title.
-   * @param string $link
-   *   The link.
-   *
-   * @return \Drupal\Component\Render\FormattableMarkup
-   *   The PSA or SA message.
-   */
-  protected function message($title, $link) {
-    return new FormattableMarkup('<a href=":url">:message</a>', [
-      ':message' => $title,
-      ':url' => $link,
-    ]);
-  }
-
-}
diff --git a/src/Services/AutomaticUpdatesPsaInterface.php b/src/Services/AutomaticUpdatesPsaInterface.php
deleted file mode 100644
index 67ce4f2f1b98e34cf3fae96275c65aa04c5983ed..0000000000000000000000000000000000000000
--- a/src/Services/AutomaticUpdatesPsaInterface.php
+++ /dev/null
@@ -1,18 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Services;
-
-/**
- * Interface AutomaticUpdatesPsaInterface.
- */
-interface AutomaticUpdatesPsaInterface {
-
-  /**
-   * Get public service messages.
-   *
-   * @return array
-   *   A return of translatable strings.
-   */
-  public function getPublicServiceMessages();
-
-}
diff --git a/src/Services/InPlaceUpdate.php b/src/Services/InPlaceUpdate.php
deleted file mode 100644
index 1b6ee8ac1be64818a5aa9c3da3e0ce11343d9198..0000000000000000000000000000000000000000
--- a/src/Services/InPlaceUpdate.php
+++ /dev/null
@@ -1,665 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Services;
-
-use Drupal\automatic_updates\Event\PostUpdateEvent;
-use Drupal\automatic_updates\Event\UpdateEvents;
-use Drupal\automatic_updates\ProjectInfoTrait;
-use Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface;
-use Drupal\automatic_updates\UpdateMetadata;
-use Drupal\Component\FileSystem\FileSystem;
-use Drupal\Core\Archiver\ArchiverInterface;
-use Drupal\Core\Archiver\ArchiverManager;
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\File\Exception\FileException;
-use Drupal\Core\File\FileSystemInterface;
-use Drupal\Core\Url;
-use Drupal\Signify\ChecksumList;
-use Drupal\Signify\FailedCheckumFilter;
-use Drupal\Signify\Verifier;
-use GuzzleHttp\ClientInterface;
-use GuzzleHttp\Exception\RequestException;
-use Psr\Log\LoggerInterface;
-
-/**
- * Class to apply in-place updates.
- */
-class InPlaceUpdate implements UpdateInterface {
-  use ProjectInfoTrait;
-
-  /**
-   * The manifest file that lists all file deletions.
-   */
-  const DELETION_MANIFEST = 'DELETION_MANIFEST.txt';
-
-  /**
-   * The directory inside the archive for file additions and modifications.
-   */
-  const ARCHIVE_DIRECTORY = 'files/';
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * The archive manager.
-   *
-   * @var \Drupal\Core\Archiver\ArchiverManager
-   */
-  protected $archiveManager;
-
-  /**
-   * The config factory.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $configFactory;
-
-  /**
-   * The file system service.
-   *
-   * @var \Drupal\Core\File\FileSystemInterface
-   */
-  protected $fileSystem;
-
-  /**
-   * The HTTP client service.
-   *
-   * @var \GuzzleHttp\Client
-   */
-  protected $httpClient;
-
-  /**
-   * The root file path.
-   *
-   * @var string
-   */
-  protected $rootPath;
-
-  /**
-   * The vendor file path.
-   *
-   * @var string
-   */
-  protected $vendorPath;
-
-  /**
-   * The folder where files are backed up.
-   *
-   * @var string
-   */
-  protected $backup;
-
-  /**
-   * The temporary extract directory.
-   *
-   * @var string
-   */
-  protected $tempDirectory;
-
-  /**
-   * Constructs an InPlaceUpdate.
-   *
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   * @param \Drupal\Core\Archiver\ArchiverManager $archive_manager
-   *   The archive manager.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory.
-   * @param \Drupal\Core\File\FileSystemInterface $file_system
-   *   The filesystem service.
-   * @param \GuzzleHttp\ClientInterface $http_client
-   *   The HTTP client service.
-   * @param string $app_root
-   *   The app root.
-   */
-  public function __construct(LoggerInterface $logger, ArchiverManager $archive_manager, ConfigFactoryInterface $config_factory, FileSystemInterface $file_system, ClientInterface $http_client, $app_root) {
-    $this->logger = $logger;
-    $this->archiveManager = $archive_manager;
-    $this->configFactory = $config_factory;
-    $this->fileSystem = $file_system;
-    $this->httpClient = $http_client;
-    $this->rootPath = (string) $app_root;
-    $this->vendorPath = $this->rootPath . DIRECTORY_SEPARATOR . 'vendor' . DIRECTORY_SEPARATOR;
-    $project_root = drupal_get_path('module', 'automatic_updates');
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function update(UpdateMetadata $metadata) {
-    // Bail immediately on updates if error category checks fail.
-    /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $readiness_check_manager */
-    $checker = \Drupal::service('automatic_updates.readiness_checker');
-    if ($checker->run(ReadinessCheckerManagerInterface::ERROR)) {
-      return FALSE;
-    }
-    $success = FALSE;
-    if ($metadata->getProjectName() === 'drupal') {
-      $project_root = $this->rootPath;
-    }
-    else {
-      $project_root = drupal_get_path($metadata->getProjectType(), $metadata->getProjectName());
-    }
-    if ($archive = $this->getArchive($metadata)) {
-      $modified = $this->checkModifiedFiles($metadata, $archive);
-      if (!$modified && $this->backup($archive, $project_root)) {
-        $this->logger->info('In place update has started.');
-        try {
-          $success = $this->processUpdate($archive, $project_root);
-          $this->logger->info('In place update has finished.');
-        }
-        catch (\Throwable $throwable) {
-          $this->logger->info('In place update failed.');
-          watchdog_exception($throwable);
-        }
-        catch (\Exception $exception) {
-          $this->logger->info('In place update failed.');
-          watchdog_exception($exception);
-        }
-        if ($success) {
-          $process = automatic_updates_console_command('updatedb:status');
-          if ($success && $process->getOutput()) {
-            $this->logger->info('Database update handling has started.');
-            $success = $this->handleDatabaseUpdates();
-            $this->logger->info('Database update handling has finished.');
-          }
-        }
-        if (!$success) {
-          $this->logger->info('Rollback has started.');
-          $this->rollback($project_root);
-          $this->logger->info('Rollback has finished.');
-        }
-        if ($success) {
-          $this->logger->info('Cache clear has started.');
-          $this->cacheRebuild();
-          $this->logger->info('Cache clear has finished.');
-        }
-      }
-    }
-    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher */
-    $event_dispatcher = \Drupal::service('event_dispatcher');
-    $event = new PostUpdateEvent($metadata, $success);
-    $event_dispatcher->dispatch(UpdateEvents::POST_UPDATE, $event);
-
-    return $success;
-  }
-
-  /**
-   * Get an archive with the quasi-patch contents.
-   *
-   * @param \Drupal\automatic_updates\UpdateMetadata $metadata
-   *   The update metadata.
-   *
-   * @return \Drupal\Core\Archiver\ArchiverInterface|null
-   *   The archive or NULL if download fails.
-   *
-   * @throws \SodiumException
-   */
-  protected function getArchive(UpdateMetadata $metadata) {
-    $quasi_patch = $this->getQuasiPatchFileName($metadata);
-    $url = $this->buildUrl($metadata->getProjectName(), $quasi_patch);
-    $temp_directory = FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR;
-    $destination = $this->fileSystem->getDestinationFilename($temp_directory . $quasi_patch, FileSystemInterface::EXISTS_REPLACE);
-    $this->doGetResource($url, $destination);
-    $csig_file = $quasi_patch . '.csig';
-    $csig_url = $this->buildUrl($metadata->getProjectName(), $csig_file);
-    $csig_destination = $this->fileSystem->getDestinationFilename(FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . $csig_file, FileSystemInterface::EXISTS_REPLACE);
-    $this->doGetResource($csig_url, $csig_destination);
-    $csig = file_get_contents($csig_destination);
-    $this->validateArchive($temp_directory, $csig);
-    return $this->archiveManager->getInstance(['filepath' => $destination]);
-  }
-
-  /**
-   * Check if files are modified before applying updates.
-   *
-   * @param \Drupal\automatic_updates\UpdateMetadata $metadata
-   *   The update metadata.
-   * @param \Drupal\Core\Archiver\ArchiverInterface $archive
-   *   The archive.
-   *
-   * @return bool
-   *   Return TRUE if modified files exist, FALSE otherwise.
-   */
-  protected function checkModifiedFiles(UpdateMetadata $metadata, ArchiverInterface $archive) {
-    if ($metadata->getProjectType() === 'core') {
-      $metadata->setProjectType('module');
-    }
-    $extensions = $this->getInfos($metadata->getProjectType());
-    /** @var \Drupal\automatic_updates\Services\ModifiedFilesInterface $modified_files */
-    $modified_files = \Drupal::service('automatic_updates.modified_files');
-    try {
-      $files = iterator_to_array($modified_files->getModifiedFiles([$extensions[$metadata->getProjectName()]]));
-    }
-    catch (RequestException $exception) {
-      // While not strictly true that there are modified files, we can't be sure
-      // there aren't any. So assume the worst.
-      return TRUE;
-    }
-    $files = array_unique($files);
-    // TODO: remove this logic once composer support is more fully finished.
-    // These files are always modified when composer update/require is run.
-    $files = array_diff($files, [
-      'composer.lock',
-      'vendor/composer/autoload_classmap.php',
-      'vendor/composer/autoload_files.php',
-      'vendor/composer/autoload_namespaces.php',
-      'vendor/composer/autoload_psr4.php',
-      'vendor/composer/autoload_real.php',
-      'vendor/composer/autoload_static.php',
-      'vendor/composer/include_paths.php',
-      'vendor/composer/installed.json',
-    ]);
-    $archive_files = $archive->listContents();
-    foreach ($archive_files as $index => &$archive_file) {
-      $skipped_files = [
-        self::DELETION_MANIFEST,
-      ];
-      // Skip certain files and all directories.
-      if (in_array($archive_file, $skipped_files, TRUE) || substr($archive_file, -1) === '/') {
-        unset($archive_files[$index]);
-        continue;
-      }
-      $this->stripFileDirectoryPath($archive_file);
-    }
-    unset($archive_file);
-    if ($intersection = array_intersect($files, $archive_files)) {
-      $this->logger->error('Can not update because %count files are modified: %paths', [
-        '%count' => count($intersection),
-        '%paths' => implode(', ', $intersection),
-      ]);
-      return TRUE;
-    }
-    return FALSE;
-  }
-
-  /**
-   * Perform retrieval of archive, with delay if archive is still being created.
-   *
-   * @param string $url
-   *   The URL to retrieve.
-   * @param string $destination
-   *   The destination to download the archive.
-   * @param null|int $delay
-   *   The delay, defaults to NULL.
-   */
-  protected function doGetResource($url, $destination, $delay = NULL) {
-    try {
-      $this->httpClient->get($url, [
-        'sink' => $destination,
-        'delay' => $delay,
-        // Some of the core quasi-patch zip files are large, increase timeout.
-        'timeout' => 120,
-      ]);
-    }
-    catch (RequestException $exception) {
-      $response = $exception->getResponse();
-      if ($response && $response->getStatusCode() === 429) {
-        $delay = 1000 * (isset($response->getHeader('Retry-After')[0]) ? $response->getHeader('Retry-After')[0] : 10);
-        $this->doGetResource($url, $destination, $delay);
-      }
-      else {
-        $this->logger->error('Retrieval of "@url" failed with: @message', [
-          '@url' => $exception->getRequest()->getUri(),
-          '@message' => $exception->getMessage(),
-        ]);
-        throw $exception;
-      }
-    }
-  }
-
-  /**
-   * Process update.
-   *
-   * @param \Drupal\Core\Archiver\ArchiverInterface $archive
-   *   The archive.
-   * @param string $project_root
-   *   The project root directory.
-   *
-   * @return bool
-   *   Return TRUE if update succeeds, FALSE otherwise.
-   */
-  protected function processUpdate(ArchiverInterface $archive, $project_root) {
-    $archive->extract($this->getTempDirectory());
-    foreach ($this->getFilesList($this->getTempDirectory()) as $file) {
-      $file_real_path = $this->getFileRealPath($file);
-      $file_path = substr($file_real_path, strlen($this->getTempDirectory() . self::ARCHIVE_DIRECTORY));
-      $project_real_path = $this->getProjectRealPath($file_path, $project_root);
-      try {
-        $directory = dirname($project_real_path);
-        $this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
-        $this->fileSystem->copy($file_real_path, $project_real_path, FileSystemInterface::EXISTS_REPLACE);
-        $this->logger->info('"@file" was updated.', ['@file' => $project_real_path]);
-      }
-      catch (FileException $exception) {
-        return FALSE;
-      }
-    }
-    foreach ($this->getDeletions() as $deletion) {
-      try {
-        $file_deletion = $this->getProjectRealPath($deletion, $project_root);
-        $this->fileSystem->delete($file_deletion);
-        $this->logger->info('"@file" was deleted.', ['@file' => $file_deletion]);
-      }
-      catch (FileException $exception) {
-        return FALSE;
-      }
-    }
-    return TRUE;
-  }
-
-  /**
-   * Validate the downloaded archive.
-   *
-   * @param string $directory
-   *   The location of the downloaded archive.
-   * @param string $csig
-   *   The CSIG contents.
-   *
-   * @throws \SodiumException
-   */
-  protected function validateArchive($directory, $csig) {
-    $module_path = drupal_get_path('module', 'automatic_updates');
-    $key = file_get_contents($module_path . '/artifacts/keys/root.pub');
-    $verifier = new Verifier($key);
-    $files = $verifier->verifyCsigMessage($csig);
-    $checksums = new ChecksumList($files, TRUE);
-    $failed_checksums = new FailedCheckumFilter($checksums, $directory);
-    if (iterator_count($failed_checksums)) {
-      throw new \RuntimeException('The downloaded files did not match what was expected.');
-    }
-  }
-
-  /**
-   * Backup before an update.
-   *
-   * @param \Drupal\Core\Archiver\ArchiverInterface $archive
-   *   The archive.
-   * @param string $project_root
-   *   The project root directory.
-   *
-   * @return bool
-   *   Return TRUE if backup succeeds, FALSE otherwise.
-   */
-  protected function backup(ArchiverInterface $archive, $project_root) {
-    $backup = $this->fileSystem->createFilename('automatic_updates-backup', 'temporary://');
-    $this->fileSystem->prepareDirectory($backup, FileSystemInterface::CREATE_DIRECTORY);
-    $this->backup = $this->fileSystem->realpath($backup) . DIRECTORY_SEPARATOR;
-    if (!$this->backup) {
-      return FALSE;
-    }
-    foreach ($archive->listContents() as $file) {
-      // Ignore files that aren't in the files directory.
-      if (!$this->stripFileDirectoryPath($file)) {
-        continue;
-      }
-      $success = $this->doBackup($file, $project_root);
-      if (!$success) {
-        return FALSE;
-      }
-    }
-    $archive->extract($this->getTempDirectory(), [self::DELETION_MANIFEST]);
-    foreach ($this->getDeletions() as $deletion) {
-      $success = $this->doBackup($deletion, $project_root);
-      if (!$success) {
-        return FALSE;
-      }
-    }
-    return TRUE;
-  }
-
-  /**
-   * Remove the files directory path from files from the archive.
-   *
-   * @param string $file
-   *   The file path.
-   *
-   * @return bool
-   *   TRUE if path was removed, else FALSE.
-   */
-  protected function stripFileDirectoryPath(&$file) {
-    if (strpos($file, self::ARCHIVE_DIRECTORY) === 0) {
-      $file = substr($file, 6);
-      return TRUE;
-    }
-    return FALSE;
-  }
-
-  /**
-   * Execute file backup.
-   *
-   * @param string $file
-   *   The file to backup.
-   * @param string $project_root
-   *   The project root directory.
-   *
-   * @return bool
-   *   Return TRUE if backup succeeds, FALSE otherwise.
-   */
-  protected function doBackup($file, $project_root) {
-    $directory = $this->backup . dirname($file);
-    if (!file_exists($directory) && !$this->fileSystem->mkdir($directory, NULL, TRUE)) {
-      return FALSE;
-    }
-    $project_real_path = $this->getProjectRealPath($file, $project_root);
-    if (file_exists($project_real_path) && !is_dir($project_real_path)) {
-      try {
-        $this->fileSystem->copy($project_real_path, $this->backup . $file, FileSystemInterface::EXISTS_REPLACE);
-        $this->logger->info('"@file" was backed up in preparation for an update.', ['@file' => $project_real_path]);
-      }
-      catch (FileException $exception) {
-        return FALSE;
-      }
-    }
-    return TRUE;
-  }
-
-  /**
-   * Rollback after a failed update.
-   *
-   * @param string $project_root
-   *   The project root directory.
-   */
-  protected function rollback($project_root) {
-    if (!$this->backup) {
-      return;
-    }
-    foreach ($this->getFilesList($this->getTempDirectory()) as $file) {
-      $file_real_path = $this->getFileRealPath($file);
-      $file_path = substr($file_real_path, strlen($this->getTempDirectory() . self::ARCHIVE_DIRECTORY));
-      $project_real_path = $this->getProjectRealPath($file_path, $project_root);
-      try {
-        $this->fileSystem->delete($project_real_path);
-        $this->logger->info('"@file" was successfully removed during rollback.', ['@file' => $project_real_path]);
-      }
-      catch (FileException $exception) {
-        $this->logger->error('"@file" failed removal on rollback.', ['@file' => $project_real_path]);
-      }
-    }
-    foreach ($this->getFilesList($this->backup) as $file) {
-      $this->doRestore($file, $project_root);
-    }
-  }
-
-  /**
-   * Do restore.
-   *
-   * @param \SplFileInfo $file
-   *   File to restore.
-   * @param string $project_root
-   *   The project root directory.
-   */
-  protected function doRestore(\SplFileInfo $file, $project_root) {
-    $file_real_path = $this->getFileRealPath($file);
-    $file_path = substr($file_real_path, strlen($this->backup));
-    try {
-      $this->fileSystem->copy($file_real_path, $this->getProjectRealPath($file_path, $project_root), FileSystemInterface::EXISTS_REPLACE);
-      $this->logger->info('"@file" was successfully restored.', ['@file' => $file_path]);
-    }
-    catch (FileException $exception) {
-      $this->logger->error('"@file" failed restoration during rollback.', ['@file' => $file_real_path]);
-    }
-  }
-
-  /**
-   * Provide a recursive list of files, excluding directories.
-   *
-   * @param string $directory
-   *   The directory to recurse for files.
-   *
-   * @return \RecursiveIteratorIterator|\SplFileInfo[]
-   *   The iterator of SplFileInfos.
-   */
-  protected function getFilesList($directory) {
-    $filter = static function ($file, $file_name, $iterator) {
-      /** @var \SplFileInfo $file */
-      /** @var string $file_name */
-      /** @var \RecursiveDirectoryIterator $iterator */
-      if ($iterator->hasChildren() && $file->getFilename() !== '.git') {
-        return TRUE;
-      }
-      $skipped_files = [
-        self::DELETION_MANIFEST,
-      ];
-      return $file->isFile() && !in_array($file->getFilename(), $skipped_files, TRUE);
-    };
-
-    $innerIterator = new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS);
-    return new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator($innerIterator, $filter));
-  }
-
-  /**
-   * Build a project quasi-patch download URL.
-   *
-   * @param string $project_name
-   *   The project name.
-   * @param string $file_name
-   *   The file name.
-   *
-   * @return string
-   *   The URL endpoint with for an extension.
-   */
-  protected function buildUrl($project_name, $file_name) {
-    $uri = $this->configFactory->get('automatic_updates.settings')->get('download_uri');
-    return Url::fromUri("$uri/$project_name/$file_name")->toString();
-  }
-
-  /**
-   * Get the quasi-patch file name.
-   *
-   * @param \Drupal\automatic_updates\UpdateMetadata $metadata
-   *   The update metadata.
-   *
-   * @return string
-   *   The quasi-patch file name.
-   */
-  protected function getQuasiPatchFileName(UpdateMetadata $metadata) {
-    return "{$metadata->getProjectName()}-{$metadata->getFromVersion()}-to-{$metadata->getToVersion()}.zip";
-  }
-
-  /**
-   * Get file real path.
-   *
-   * @param \SplFileInfo $file
-   *   The file to retrieve the real path.
-   *
-   * @return string
-   *   The file real path.
-   */
-  protected function getFileRealPath(\SplFileInfo $file) {
-    $real_path = $file->getRealPath();
-    if (!$real_path) {
-      throw new FileException(sprintf('Could not get real path for "%s"', $file->getFilename()));
-    }
-    return $real_path;
-  }
-
-  /**
-   * Get the real path of a file.
-   *
-   * @param string $file_path
-   *   The file path.
-   * @param string $project_root
-   *   The project root directory.
-   *
-   * @return string
-   *   The real path of a file.
-   */
-  protected function getProjectRealPath($file_path, $project_root) {
-    if (strpos($file_path, 'vendor' . DIRECTORY_SEPARATOR) === 0) {
-      return $this->vendorPath . substr($file_path, 7);
-    }
-    return rtrim($project_root, '/\\') . DIRECTORY_SEPARATOR . $file_path;
-  }
-
-  /**
-   * Provides the temporary extraction directory.
-   *
-   * @return string
-   *   The temporary directory.
-   */
-  protected function getTempDirectory() {
-    if (!$this->tempDirectory) {
-      $this->tempDirectory = $this->fileSystem->createFilename('automatic_updates-update', FileSystem::getOsTemporaryDirectory());
-      $this->fileSystem->prepareDirectory($this->tempDirectory, FileSystemInterface::CREATE_DIRECTORY);
-      $this->tempDirectory .= DIRECTORY_SEPARATOR;
-    }
-    return $this->tempDirectory;
-  }
-
-  /**
-   * Get an iterator of files to delete.
-   *
-   * @return \ArrayIterator
-   *   Iterator of files to delete.
-   */
-  protected function getDeletions() {
-    $deletions = [];
-    if (!file_exists($this->getTempDirectory() . self::DELETION_MANIFEST)) {
-      return new \ArrayIterator($deletions);
-    }
-    $handle = fopen($this->getTempDirectory() . self::DELETION_MANIFEST, 'r');
-    if ($handle) {
-      while (($deletion = fgets($handle)) !== FALSE) {
-        if ($result = trim($deletion)) {
-          $deletions[] = $result;
-        }
-      }
-      fclose($handle);
-    }
-    return new \ArrayIterator($deletions);
-  }
-
-  /**
-   * Clear cache on successful update.
-   */
-  protected function cacheRebuild() {
-    if (function_exists('opcache_reset')) {
-      opcache_reset();
-    }
-    automatic_updates_console_command('cache:rebuild');
-  }
-
-  /**
-   * Handle database updates.
-   *
-   * @return bool
-   *   TRUE if database updates were handled successfully. FALSE otherwise.
-   *
-   * @throws \Drupal\Component\Plugin\Exception\PluginException
-   */
-  protected function handleDatabaseUpdates() {
-    $result = TRUE;
-    /** @var \Drupal\Component\Plugin\PluginManagerInterface $database_update_handler */
-    $database_update_handler = \Drupal::service('plugin.manager.database_update_handler');
-    foreach ($this->configFactory->get('automatic_updates.settings')->get('database_update_handling') as $plugin_id) {
-      $result = $result && $database_update_handler->createInstance($plugin_id)->execute();
-    }
-    return $result;
-  }
-
-}
diff --git a/src/Services/ModifiedFiles.php b/src/Services/ModifiedFiles.php
deleted file mode 100644
index 329cc14abcd044ecd625cc24f009626f38fa5641..0000000000000000000000000000000000000000
--- a/src/Services/ModifiedFiles.php
+++ /dev/null
@@ -1,220 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Services;
-
-use Drupal\automatic_updates\IgnoredPathsTrait;
-use Drupal\automatic_updates\ProjectInfoTrait;
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\Logger\RfcLogLevel;
-use Drupal\Core\Url;
-use Drupal\Signify\ChecksumList;
-use Drupal\Signify\FailedCheckumFilter;
-use Drupal\Signify\Verifier;
-use GuzzleHttp\ClientInterface;
-use GuzzleHttp\Exception\RequestException;
-use GuzzleHttp\Promise\EachPromise;
-use Psr\Http\Message\ResponseInterface;
-use Psr\Log\LoggerInterface;
-
-/**
- * Modified files service.
- */
-class ModifiedFiles implements ModifiedFilesInterface {
-  use IgnoredPathsTrait;
-  use ProjectInfoTrait;
-
-  /**
-   * The logger.
-   *
-   * @var \Psr\Log\LoggerInterface
-   */
-  protected $logger;
-
-  /**
-   * The HTTP client.
-   *
-   * @var \GuzzleHttp\ClientInterface
-   */
-  protected $httpClient;
-
-  /**
-   * The config factory.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $configFactory;
-
-  /**
-   * ModifiedFiles constructor.
-   *
-   * @param \Psr\Log\LoggerInterface $logger
-   *   The logger.
-   * @param \GuzzleHttp\ClientInterface $http_client
-   *   The HTTP client.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory.
-   */
-  public function __construct(LoggerInterface $logger, ClientInterface $http_client, ConfigFactoryInterface $config_factory) {
-    $this->logger = $logger;
-    $this->httpClient = $http_client;
-    $this->configFactory = $config_factory;
-    $project_root = drupal_get_path('module', 'automatic_updates');
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getModifiedFiles(array $extensions = []) {
-    $modified_files = new \ArrayIterator();
-    /** @var \GuzzleHttp\Promise\PromiseInterface[] $promises */
-    $promises = $this->getHashRequests($extensions);
-    // Wait until all the requests are finished.
-    (new EachPromise($promises, [
-      'concurrency' => 4,
-      'fulfilled' => function (array $resource) use ($modified_files) {
-        $this->processHashes($resource, $modified_files);
-      },
-      'rejected' => function (RequestException $exception) {
-        $this->processFailures($exception);
-      },
-    ]))->promise()->wait();
-    return $modified_files;
-  }
-
-  /**
-   * Process checking hashes of files from external URL.
-   *
-   * @param array $hash
-   *   An array of http response and project info.
-   * @param \ArrayIterator $modified_files
-   *   The list of modified files.
-   *
-   * @throws \SodiumException
-   */
-  protected function processHashes(array $hash, \ArrayIterator $modified_files) {
-    $contents = $hash['contents'];
-    $info = $hash['info'];
-    $directory_root = $info['install path'];
-    if ($info['project'] === 'drupal') {
-      $directory_root = '';
-    }
-    $module_path = drupal_get_path('module', 'automatic_updates');
-    $key = file_get_contents($module_path . '/artifacts/keys/root.pub');
-    $verifier = new Verifier($key);
-    $files = $verifier->verifyCsigMessage($contents);
-    $checksums = new ChecksumList($files, TRUE);
-    foreach (new FailedCheckumFilter($checksums, $directory_root) as $failed_checksum) {
-      $file_path = implode(DIRECTORY_SEPARATOR, array_filter([
-        $directory_root,
-        $failed_checksum->filename,
-      ]));
-      if (!file_exists($file_path)) {
-        $modified_files->append($file_path);
-        continue;
-      }
-      $actual_hash = @hash_file(strtolower($failed_checksum->algorithm), $file_path);
-      if ($actual_hash === FALSE || empty($actual_hash) || strlen($actual_hash) < 64 || strcmp($actual_hash, $failed_checksum->hex_hash) !== 0) {
-        $modified_files->append($file_path);
-      }
-    }
-  }
-
-  /**
-   * Handle HTTP failures.
-   *
-   * @param \GuzzleHttp\Exception\RequestException $exception
-   *   The request exception.
-   */
-  protected function processFailures(RequestException $exception) {
-    // Log all the exceptions, even modules that aren't the main project.
-    watchdog_exception('automatic_updates', $exception, NULL, [], RfcLogLevel::INFO);
-    // HTTP 404 is expected for modules that aren't the main project. But
-    // other error codes should complain loudly.
-    if ($exception->getCode() !== 404) {
-      throw $exception;
-    }
-  }
-
-  /**
-   * Get an iterator of promises that return a resource stream.
-   *
-   * @param array $extensions
-   *   The list of extensions, keyed by extension name and value the info array.
-   *
-   * @codingStandardsIgnoreStart
-   *
-   * @return \Generator
-   *
-   * @@codingStandardsIgnoreEnd
-   */
-  protected function getHashRequests(array $extensions) {
-    foreach ($extensions as $info) {
-      // We can't check for modifications if we don't know the version.
-      if (!($info['version'])) {
-        continue;
-      }
-      $url = $this->buildUrl($info);
-      yield $this->getPromise($url, $info);
-    }
-  }
-
-  /**
-   * Get a promise.
-   *
-   * @param string $url
-   *   The URL.
-   * @param array $info
-   *   The extension's info.
-   *
-   * @return \GuzzleHttp\Promise\PromiseInterface
-   *   The promise.
-   */
-  protected function getPromise($url, array $info) {
-    return $this->httpClient->requestAsync('GET', $url, [
-      'stream' => TRUE,
-      'read_timeout' => 30,
-    ])->then(
-      static function (ResponseInterface $response) use ($info) {
-        return [
-          'contents' => $response->getBody()->getContents(),
-          'info' => $info,
-        ];
-      }
-    );
-  }
-
-  /**
-   * Build an extension's hash file URL.
-   *
-   * @param array $info
-   *   The extension's info.
-   *
-   * @return string
-   *   The URL endpoint with for an extension.
-   */
-  protected function buildUrl(array $info) {
-    $version = $info['version'];
-    $project_name = $info['project'];
-    $hash_name = $this->getHashName($info);
-    $uri = ltrim($this->configFactory->get('automatic_updates.settings')->get('hashes_uri'), '/');
-    return Url::fromUri("$uri/$project_name/$version/$hash_name")->toString();
-  }
-
-  /**
-   * Get the hash file name.
-   *
-   * @param array $info
-   *   The extension's info.
-   *
-   * @return string|null
-   *   The hash name.
-   */
-  protected function getHashName(array $info) {
-    $hash_name = 'contents-sha256sums';
-    if ($info['packaged']) {
-      $hash_name .= '-packaged';
-    }
-    return $hash_name . '.csig';
-  }
-
-}
diff --git a/src/Services/ModifiedFilesInterface.php b/src/Services/ModifiedFilesInterface.php
deleted file mode 100644
index ba46f254ee7d6c7c6eb6203118d3f3a1906326b5..0000000000000000000000000000000000000000
--- a/src/Services/ModifiedFilesInterface.php
+++ /dev/null
@@ -1,22 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Services;
-
-/**
- * Modified files service interface.
- */
-interface ModifiedFilesInterface {
-
-  /**
-   * Get list of modified files.
-   *
-   * @param array $extensions
-   *   The list of extensions, keyed by extension name with values an info
-   *   array.
-   *
-   * @return \Iterator
-   *   The modified files.
-   */
-  public function getModifiedFiles(array $extensions = []);
-
-}
diff --git a/src/Services/Notify.php b/src/Services/Notify.php
deleted file mode 100644
index 87f312fbd78461bb645ee7c25441f2f370a3a27f..0000000000000000000000000000000000000000
--- a/src/Services/Notify.php
+++ /dev/null
@@ -1,167 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Services;
-
-use Drupal\Component\Datetime\TimeInterface;
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\Entity\EntityTypeManagerInterface;
-use Drupal\Core\Language\LanguageManagerInterface;
-use Drupal\Core\Mail\MailManagerInterface;
-use Drupal\Core\State\StateInterface;
-use Drupal\Core\StringTranslation\PluralTranslatableMarkup;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\StringTranslation\TranslationInterface;
-
-/**
- * Class EmailNotify.
- */
-class Notify implements NotifyInterface {
-  use StringTranslationTrait;
-
-  /**
-   * Mail manager.
-   *
-   * @var \Drupal\Core\Mail\MailManagerInterface
-   */
-  protected $mailManager;
-
-  /**
-   * The automatic updates service.
-   *
-   * @var \Drupal\automatic_updates\Services\AutomaticUpdatesPsaInterface
-   */
-  protected $automaticUpdatesPsa;
-
-  /**
-   * The config factory.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $configFactory;
-
-  /**
-   * The language manager.
-   *
-   * @var \Drupal\Core\Language\LanguageManagerInterface
-   */
-  protected $languageManager;
-
-  /**
-   * The state service.
-   *
-   * @var \Drupal\Core\State\StateInterface
-   */
-  protected $state;
-
-  /**
-   * The time service.
-   *
-   * @var \Drupal\Component\Datetime\TimeInterface
-   */
-  protected $time;
-
-  /**
-   * Entity type manager.
-   *
-   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
-   */
-  protected $entityTypeManager;
-
-  /**
-   * Event dispatcher.
-   *
-   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
-   */
-  protected $eventDispatcher;
-
-  /**
-   * EmailNotify constructor.
-   *
-   * @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
-   *   The mail manager.
-   * @param \Drupal\automatic_updates\Services\AutomaticUpdatesPsaInterface $automatic_updates_psa
-   *   The automatic updates service.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory.
-   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
-   *   The language manager.
-   * @param \Drupal\Core\State\StateInterface $state
-   *   The state service.
-   * @param \Drupal\Component\Datetime\TimeInterface $time
-   *   The time service.
-   * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
-   *   Entity type manager.
-   * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
-   *   The string translation service.
-   */
-  public function __construct(MailManagerInterface $mail_manager, AutomaticUpdatesPsaInterface $automatic_updates_psa, ConfigFactoryInterface $config_factory, LanguageManagerInterface $language_manager, StateInterface $state, TimeInterface $time, EntityTypeManagerInterface $entity_type_manager, TranslationInterface $string_translation) {
-    $this->mailManager = $mail_manager;
-    $this->automaticUpdatesPsa = $automatic_updates_psa;
-    $this->configFactory = $config_factory;
-    $this->languageManager = $language_manager;
-    $this->state = $state;
-    $this->time = $time;
-    $this->entityTypeManager = $entity_type_manager;
-    $this->stringTranslation = $string_translation;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function send() {
-    // Don't send mail if notifications are disabled.
-    if (!$this->configFactory->get('automatic_updates.settings')->get('notify')) {
-      return;
-    }
-    $messages = $this->automaticUpdatesPsa->getPublicServiceMessages();
-    if (!$messages) {
-      return;
-    }
-    $notify_list = $this->configFactory->get('update.settings')->get('notification.emails');
-    if (!empty($notify_list)) {
-      $frequency = $this->configFactory->get('automatic_updates.settings')->get('check_frequency');
-      $last_check = $this->state->get('automatic_updates.notify_last_check') ?: 0;
-      if (($this->time->getRequestTime() - $last_check) > $frequency) {
-        $this->state->set('automatic_updates.notify_last_check', $this->time->getRequestTime());
-
-        $params['subject'] = new PluralTranslatableMarkup(
-          count($messages),
-          '@count urgent Drupal announcement requires your attention for @site_name',
-          '@count urgent Drupal announcements require your attention for @site_name',
-          ['@site_name' => $this->configFactory->get('system.site')->get('name')]
-        );
-        $params['body'] = [
-          '#theme' => 'automatic_updates_psa_notify',
-          '#messages' => $messages,
-        ];
-        $default_langcode = $this->languageManager->getDefaultLanguage()->getId();
-        $params['langcode'] = $default_langcode;
-        foreach ($notify_list as $to) {
-          $this->doSend($to, $params);
-        }
-      }
-    }
-  }
-
-  /**
-   * Composes and send the email message.
-   *
-   * @param string $to
-   *   The email address where the message will be sent.
-   * @param array $params
-   *   Parameters to build the email.
-   *
-   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
-   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
-   */
-  protected function doSend($to, array $params) {
-    $users = $this->entityTypeManager->getStorage('user')
-      ->loadByProperties(['mail' => $to]);
-    foreach ($users as $user) {
-      $to_user = reset($users);
-      $params['langcode'] = $to_user->getPreferredLangcode();
-      $this->mailManager->mail('automatic_updates', 'notify', $to, $params['langcode'], $params);
-    }
-  }
-
-}
diff --git a/src/Services/NotifyInterface.php b/src/Services/NotifyInterface.php
deleted file mode 100644
index aee5b3ba8745b531887134f34bd0a0789aa23b8b..0000000000000000000000000000000000000000
--- a/src/Services/NotifyInterface.php
+++ /dev/null
@@ -1,15 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Services;
-
-/**
- * Interface NotifyInterface.
- */
-interface NotifyInterface {
-
-  /**
-   * Send notification when PSAs are available.
-   */
-  public function send();
-
-}
diff --git a/src/Services/UpdateInterface.php b/src/Services/UpdateInterface.php
deleted file mode 100644
index 71d26b9bba4330960efa80561bfbd3592d26f3fa..0000000000000000000000000000000000000000
--- a/src/Services/UpdateInterface.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates\Services;
-
-use Drupal\automatic_updates\UpdateMetadata;
-
-/**
- * Interface UpdateInterface.
- */
-interface UpdateInterface {
-
-  /**
-   * Update a project to the next release.
-   *
-   * @param \Drupal\automatic_updates\UpdateMetadata $metadata
-   *   The update metadata.
-   *
-   * @return bool
-   *   TRUE if project was successfully updated, FALSE otherwise.
-   */
-  public function update(UpdateMetadata $metadata);
-
-}
diff --git a/src/UpdateMetadata.php b/src/UpdateMetadata.php
deleted file mode 100644
index f54972cfe82000f1e20768d8d5da554a96a5a3bc..0000000000000000000000000000000000000000
--- a/src/UpdateMetadata.php
+++ /dev/null
@@ -1,153 +0,0 @@
-<?php
-
-namespace Drupal\automatic_updates;
-
-/**
- * Transfer object to encapsulate the details for an update.
- */
-final class UpdateMetadata {
-
-  /**
-   * The project name.
-   *
-   * @var string
-   */
-  protected $projectName;
-
-  /**
-   * The project type.
-   *
-   * @var string
-   */
-  protected $projectType;
-
-  /**
-   * The current project version.
-   *
-   * @var string
-   */
-  protected $fromVersion;
-
-  /**
-   * The desired next project version.
-   *
-   * @var string
-   */
-  protected $toVersion;
-
-  /**
-   * UpdateMetadata constructor.
-   *
-   * @param string $project_name
-   *   The project name.
-   * @param string $project_type
-   *   The project type.
-   * @param string $from_version
-   *   The current project version.
-   * @param string $to_version
-   *   The desired next project version.
-   */
-  public function __construct($project_name, $project_type, $from_version, $to_version) {
-    $this->projectName = $project_name;
-    $this->projectType = $project_type;
-    $this->fromVersion = $from_version;
-    $this->toVersion = $to_version;
-  }
-
-  /**
-   * Get project name.
-   *
-   * @return string
-   *   The project nam.
-   */
-  public function getProjectName() {
-    return $this->projectName;
-  }
-
-  /**
-   * Set the project name.
-   *
-   * @param string $projectName
-   *   The project name.
-   *
-   * @return \Drupal\automatic_updates\UpdateMetadata
-   *   The update metadata.
-   */
-  public function setProjectName($projectName) {
-    $this->projectName = $projectName;
-    return $this;
-  }
-
-  /**
-   * Get the project type.
-   *
-   * @return string
-   *   The project type.
-   */
-  public function getProjectType() {
-    return $this->projectType;
-  }
-
-  /**
-   * Set the project type.
-   *
-   * @param string $projectType
-   *   The project type.
-   *
-   * @return \Drupal\automatic_updates\UpdateMetadata
-   *   The update metadata.
-   */
-  public function setProjectType($projectType) {
-    $this->projectType = $projectType;
-    return $this;
-  }
-
-  /**
-   * Get the current project version.
-   *
-   * @return string
-   *   The current project version.
-   */
-  public function getFromVersion() {
-    return $this->fromVersion;
-  }
-
-  /**
-   * Set the current project version.
-   *
-   * @param string $fromVersion
-   *   The current project version.
-   *
-   * @return \Drupal\automatic_updates\UpdateMetadata
-   *   The update metadata.
-   */
-  public function setFromVersion($fromVersion) {
-    $this->fromVersion = $fromVersion;
-    return $this;
-  }
-
-  /**
-   * Get the desired next project version.
-   *
-   * @return string
-   *   The desired next project version.
-   */
-  public function getToVersion() {
-    return $this->toVersion;
-  }
-
-  /**
-   * Set the desired next project version.
-   *
-   * @param string $toVersion
-   *   The desired next project version.
-   *
-   * @return \Drupal\automatic_updates\UpdateMetadata
-   *   The update metadata.
-   */
-  public function setToVersion($toVersion) {
-    $this->toVersion = $toVersion;
-    return $this;
-  }
-
-}
diff --git a/src/UpdateRecommender.php b/src/UpdateRecommender.php
new file mode 100644
index 0000000000000000000000000000000000000000..5732b4b40085ad5c39a328d490356d8d7618ad96
--- /dev/null
+++ b/src/UpdateRecommender.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Drupal\automatic_updates;
+
+use Drupal\update\UpdateManagerInterface;
+use Drupal\update\UpdateProcessorInterface;
+
+/**
+ * Determines the recommended version of a package.
+ */
+class UpdateRecommender {
+
+  /**
+   * The update manager service.
+   *
+   * @var \Drupal\update\UpdateManagerInterface
+   */
+  protected $updateManager;
+
+  /**
+   * The update processor service.
+   *
+   * @var \Drupal\update\UpdateProcessorInterface
+   */
+  protected $updateProcessor;
+
+  /**
+   * UpdateRecommender constructor.
+   *
+   * @param \Drupal\update\UpdateManagerInterface $update_manager
+   *   The update manager service.
+   * @param \Drupal\update\UpdateProcessorInterface $update_processor
+   *   The update processor service.
+   */
+  public function __construct(UpdateManagerInterface $update_manager, UpdateProcessorInterface $update_processor) {
+    $this->updateManager = $update_manager;
+    $this->updateProcessor = $update_processor;
+  }
+
+  /**
+   * Returns the recommended update version of a project.
+   *
+   * @param string $project
+   *   The name of the project.
+   *
+   * @return string
+   *   The version that we recommend the site update to.
+   */
+  public function getRecommendedUpdateVersion(string $project) {
+    // Hard code for now
+    return '9.2.0';
+    // From https://www.drupal.org/project/drupal/issues/3111767
+    $this->updateManager->refreshUpdateData();
+    $this->updateProcessor->fetchData();
+    $available = update_get_available(TRUE);
+    $projects = update_calculate_project_data($available);
+    $not_recommended_version = $projects[$project]['status'] !== UpdateManagerInterface::CURRENT;
+    $security_update = in_array($projects['drupal']['status'], [UpdateManagerInterface::NOT_SECURE, UpdateManagerInterface::REVOKED], TRUE);
+    $recommended_release = isset($projects['drupal']['releases'][$projects['drupal']['recommended']]) ? $projects['drupal']['releases'][$projects['drupal']['recommended']] : NULL;
+    $existing_minor_version = explode('.', \Drupal::VERSION, -1);
+    $recommended_minor_version = explode('.', $recommended_release['version'], -1);
+    $major_upgrade = $existing_minor_version !== $recommended_minor_version;
+  }
+
+}
diff --git a/src/Updater.php b/src/Updater.php
new file mode 100644
index 0000000000000000000000000000000000000000..2d6a428f08d0d2f92d7e8025535c48afdf9cd138
--- /dev/null
+++ b/src/Updater.php
@@ -0,0 +1,342 @@
+<?php
+
+namespace Drupal\automatic_updates;
+
+use Composer\Autoload\ClassLoader;
+use Drupal\automatic_updates\Event\UpdateEvent;
+use Drupal\automatic_updates\Exception\UpdateException;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\system\SystemManager;
+use PhpTuf\ComposerStager\Domain\BeginnerInterface;
+use PhpTuf\ComposerStager\Domain\CleanerInterface;
+use PhpTuf\ComposerStager\Domain\CommitterInterface;
+use PhpTuf\ComposerStager\Domain\StagerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
+
+/**
+ * Defines a service to perform updates.
+ */
+class Updater {
+
+  use StringTranslationTrait;
+
+  /**
+   * The state key in which to store the status of the update.
+   *
+   * @var string
+   */
+  public const STATE_KEY = 'AUTOMATIC_UPDATES_CURRENT';
+
+  /**
+   * The composer_stager beginner service.
+   *
+   * @var \Drupal\automatic_updates\ComposerStager\Beginner
+   */
+  protected $beginner;
+
+  /**
+   * The composer_stager stager service.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\StagerInterface
+   */
+  protected $stager;
+
+  /**
+   * The composer_stager cleaner service.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\CleanerInterface
+   */
+  protected $cleaner;
+
+  /**
+   * The composer_stager committer service.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\CommitterInterface
+   */
+  protected $committer;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The event dispatcher service.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   */
+  protected $eventDispatcher;
+
+  /**
+   * Updater constructor.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The string translation service.
+   * @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner
+   *   The Composer Stager's beginner service.
+   * @param \PhpTuf\ComposerStager\Domain\StagerInterface $stager
+   *   The Composer Stager's stager service.
+   * @param \PhpTuf\ComposerStager\Domain\CleanerInterface $cleaner
+   *   The Composer Stager's cleaner service.
+   * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer
+   *   The Composer Stager's committer service.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
+   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
+   *   The event dispatcher service.
+   */
+  public function __construct(StateInterface $state, TranslationInterface $translation, BeginnerInterface $beginner, StagerInterface $stager, CleanerInterface $cleaner, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher) {
+    $this->state = $state;
+    $this->beginner = $beginner;
+    $this->stager = $stager;
+    $this->cleaner = $cleaner;
+    $this->committer = $committer;
+    $this->setStringTranslation($translation);
+    $this->fileSystem = $file_system;
+    $this->eventDispatcher = $event_dispatcher;
+  }
+
+  /**
+   * Gets the vendor directory.
+   *
+   * @return string
+   *   The absolute path for vendor directory.
+   */
+  private static function getVendorDirectory(): string {
+    $class_loader_reflection = new \ReflectionClass(ClassLoader::class);
+    return dirname($class_loader_reflection->getFileName(), 2);
+  }
+
+  /**
+   * Gets the stage directory.
+   *
+   * @return string
+   *   The absolute path for stage directory.
+   */
+  public function getStageDirectory(): string {
+    return realpath(static::getVendorDirectory() . '/..') . '/.automatic_updates_stage';
+  }
+
+  /**
+   * Determines if there is an active update in progress.
+   *
+   * @return bool
+   *   TRUE if there is active update, otherwise FALSE.
+   */
+  public function hasActiveUpdate(): bool {
+    $staged_dir = $this->getStageDirectory();
+    if (is_dir($staged_dir) || $this->state->get(static::STATE_KEY)) {
+      return TRUE;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Gets the active directory.
+   *
+   * @return string
+   *   The absolute path for active directory.
+   */
+  public function getActiveDirectory(): string {
+    return realpath(static::getVendorDirectory() . '/..');
+  }
+
+  /**
+   * Begins the update.
+   *
+   * @return string
+   *   A key for this stage update process.
+   *
+   * @throws \Drupal\automatic_updates\Exception\UpdateException
+   *   Thrown if any readiness checkers return results with an error. Warnings
+   *   from readiness checker will not stop an update.
+   */
+  public function begin(): string {
+    $this->dispatchUpdateEvent(AutomaticUpdatesEvents::PRE_START);
+    $stage_key = $this->createActiveStage();
+    $this->beginner->begin(static::getActiveDirectory(), static::getStageDirectory(), NULL, 120, $this->getExclusions());
+    return $stage_key;
+  }
+
+  /**
+   * Gets directories that should be excluded from the staging area.
+   *
+   * @return string[]
+   *   The absolute paths of directories to exclude from the staging area.
+   */
+  private function getExclusions(): array {
+    $directories = [];
+    $make_relative = function ($path) {
+      return str_replace(static::getActiveDirectory() . '/', '', $path);
+    };
+    if ($public = $this->fileSystem->realpath('public://')) {
+      $directories[] = $make_relative($public);
+    }
+    if ($private = $this->fileSystem->realpath('private://')) {
+      $directories[] = $make_relative($private);
+    }
+    /** @var \Drupal\Core\Extension\ModuleHandlerInterface $module_handler */
+    $module_handler = \Drupal::service('module_handler');
+    $module_path = $this->fileSystem->realpath($module_handler->getModule('automatic_updates')->getPath());
+    if (is_dir("$module_path/.git")) {
+      // If the current module is git clone. Don't copy it.
+      $directories[] = $make_relative($module_path);
+    }
+    return $directories;
+  }
+
+  /**
+   * Adds specific project versions to the staging area.
+   *
+   * @param string[] $project_versions
+   *   The project versions to add to the staging area, keyed by package name.
+   */
+  public function stageVersions(array $project_versions): void {
+    $packages = [];
+    foreach ($project_versions as $project => $project_version) {
+      if ($project === 'drupal') {
+        // @todo Determine when to use drupal/core-recommended and when to use
+        //   drupal/core
+        $packages[] = "drupal/core:$project_version";
+      }
+      else {
+        $packages[] = "drupal/$project:$project_version";
+      }
+    }
+    $this->stagePackages($packages);
+  }
+
+  /**
+   * Installs Composer packages in the staging area.
+   *
+   * @param string[] $packages
+   *   The versions of the packages to stage, keyed by package name.
+   */
+  protected function stagePackages(array $packages): void {
+    $command = array_merge(['require'], $packages);
+    $command[] = '--update-with-all-dependencies';
+    $this->stageCommand($command);
+    // Store the expected packages to confirm no other Drupal packages were
+    // updated.
+    $current = $this->state->get(static::STATE_KEY);
+    $current['packages'] = $packages;
+    $this->state->set(self::STATE_KEY, $current);
+  }
+
+  /**
+   * Commits the current update.
+   */
+  public function commit(): void {
+    $this->dispatchUpdateEvent(AutomaticUpdatesEvents::PRE_COMMIT);
+    $this->committer->commit($this->getStageDirectory(), static::getActiveDirectory());
+  }
+
+  /**
+   * Cleans the current update.
+   */
+  public function clean(): void {
+    if (is_dir($this->getStageDirectory())) {
+      $this->cleaner->clean($this->getStageDirectory());
+    }
+    $this->state->delete(static::STATE_KEY);
+  }
+
+  /**
+   * Stages a Composer command.
+   *
+   * @param string[] $command
+   *   The command array as expected by
+   *   \PhpTuf\ComposerStager\Domain\StagerInterface::stage().
+   *
+   * @see \PhpTuf\ComposerStager\Domain\StagerInterface::stage()
+   */
+  protected function stageCommand(array $command): void {
+    $this->setEnv('PATH', $this->getEnv('PATH') . ":/usr/local/bin");
+    $this->stager->stage($command, $this->getStageDirectory());
+  }
+
+  /**
+   * 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);
+  }
+
+  /**
+   * Sets the value of an environment variable.
+   *
+   * @param string $variable
+   *   The name of the variable.
+   * @param mixed $value
+   *   The value to set.
+   */
+  private function setEnv(string $variable, $value): void {
+    if (function_exists('apache_setenv')) {
+      apache_setenv($variable, $value);
+    }
+    else {
+      putenv("$variable=$value");
+    }
+  }
+
+  /**
+   * Initializes an active update and returns its ID.
+   *
+   * @return string
+   *   The active update ID.
+   */
+  private function createActiveStage(): string {
+    $value = static::STATE_KEY . microtime();
+    $this->state->set(static::STATE_KEY, ['id' => $value]);
+    return $value;
+  }
+
+  /**
+   * Validates that an update was performed as expected.
+   */
+  public function validateStaged():void {
+    $this->dispatchUpdateEvent(AutomaticUpdatesEvents::PRE_COMMIT);
+  }
+
+  /**
+   * Dispatches an update event.
+   *
+   * @param string $event_name
+   *   The name of the event to dispatch.
+   *
+   * @throws \Drupal\automatic_updates\Exception\UpdateException
+   *   If any of the event subscribers adds a validation error.
+   */
+  public function dispatchUpdateEvent(string $event_name): void {
+    $event = new UpdateEvent();
+    $this->eventDispatcher->dispatch($event, $event_name);
+    if ($checker_results = $event->getResults(SystemManager::REQUIREMENT_ERROR)) {
+      throw new UpdateException($checker_results,
+        "Unable to complete the update because of errors.");
+    }
+  }
+
+}
diff --git a/src/Validation/AdminReadinessMessages.php b/src/Validation/AdminReadinessMessages.php
new file mode 100644
index 0000000000000000000000000000000000000000..6dd6b10c28ae894d4aed5ad4969d69a654c8856b
--- /dev/null
+++ b/src/Validation/AdminReadinessMessages.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Drupal\automatic_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\automatic_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\automatic_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('automatic_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('automatic_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/src/Validation/ReadinessRequirements.php b/src/Validation/ReadinessRequirements.php
new file mode 100644
index 0000000000000000000000000000000000000000..89e41526bc34b19d0e51c64ac488f3feaa8389e4
--- /dev/null
+++ b/src/Validation/ReadinessRequirements.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Drupal\automatic_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 automatic_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\automatic_updates\Validation\ReadinessValidationManager
+   */
+  protected $readinessCheckerManager;
+
+  /**
+   * The date formatter service.
+   *
+   * @var \Drupal\Core\Datetime\DateFormatterInterface
+   */
+  protected $dateFormatter;
+
+  /**
+   * Constructor ReadinessRequirement object.
+   *
+   * @param \Drupal\automatic_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('automatic_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 {
+    $run_link = $this->createRunLink();
+
+    $last_check_timestamp = $this->readinessCheckerManager->getLastRunTime();
+    if ($last_check_timestamp === NULL) {
+      $requirement['title'] = $this->t('Update readiness checks');
+      $requirement['severity'] = SystemManager::REQUIREMENT_WARNING;
+      // @todo Link "automatic updates" to documentation in
+      //   https://www.drupal.org/node/3168405.
+      $requirement['value'] = $this->t('Your site has never checked if it is ready to apply automatic updates.');
+      if ($run_link) {
+        $requirement['description'] = $run_link;
+      }
+      return ['automatic_updates_readiness' => $requirement];
+    }
+    else {
+      $results = $this->readinessCheckerManager->runIfNoStoredResults()->getResults();
+      $requirements = [];
+      if (empty($results)) {
+        $requirements['automatic_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.'),
+        ];
+        if ($run_link) {
+          $requirements['automatic_updates_readiness']['description'] = $run_link;
+        }
+      }
+      else {
+        foreach ([SystemManager::REQUIREMENT_WARNING, SystemManager::REQUIREMENT_ERROR] as $severity) {
+          if ($requirement = $this->createRequirementForSeverity($severity)) {
+            $requirements["automatic_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' => $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('automatic_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/src/Validation/ReadinessTrait.php b/src/Validation/ReadinessTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..bbcd611678ffb6ae90478cb041d5ca5f4b5d577f
--- /dev/null
+++ b/src/Validation/ReadinessTrait.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Drupal\automatic_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/src/Validation/ReadinessValidationManager.php b/src/Validation/ReadinessValidationManager.php
new file mode 100644
index 0000000000000000000000000000000000000000..7ce741084eeb89822d2738c1914b247a76e33868
--- /dev/null
+++ b/src/Validation/ReadinessValidationManager.php
@@ -0,0 +1,167 @@
+<?php
+
+namespace Drupal\automatic_updates\Validation;
+
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates\Event\UpdateEvent;
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
+
+/**
+ * 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 number of hours to store results.
+   *
+   * @var int
+   */
+  protected $resultsTimeToLive;
+
+  /**
+   * 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 int $results_time_to_live
+   *   The number of hours to store results.
+   */
+  public function __construct(KeyValueExpirableFactoryInterface $key_value_expirable_factory, TimeInterface $time, int $results_time_to_live) {
+    $this->keyValueExpirable = $key_value_expirable_factory->get('automatic_updates');
+    $this->time = $time;
+    $this->resultsTimeToLive = $results_time_to_live;
+  }
+
+  /**
+   * Dispatches the readiness check event and stores the results.
+   *
+   * @return $this
+   */
+  public function run(): self {
+    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher */
+    $dispatcher = \Drupal::service('event_dispatcher');
+    $event = new UpdateEvent();
+    $dispatcher->dispatch($event, AutomaticUpdatesEvents::READINESS_CHECK);
+    $results = $event->getResults();
+    $this->keyValueExpirable->setWithExpire(
+      'readiness_validation_last_run',
+      [
+        'results' => $results,
+        'listeners' => $this->getListenersAsString(AutomaticUpdatesEvents::READINESS_CHECK),
+      ],
+      $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 {
+    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher */
+    $dispatcher = \Drupal::service('event_dispatcher');
+    $listeners = $dispatcher->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\automatic_updates\Validation\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\automatic_updates\Validation\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(AutomaticUpdatesEvents::READINESS_CHECK)) {
+      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/src/Validation/StagedProjectsValidation.php b/src/Validation/StagedProjectsValidation.php
new file mode 100644
index 0000000000000000000000000000000000000000..adbdbbbb8d9e033ef7787573f71d0e41f620cc59
--- /dev/null
+++ b/src/Validation/StagedProjectsValidation.php
@@ -0,0 +1,182 @@
+<?php
+
+namespace Drupal\automatic_updates\Validation;
+
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates\Event\UpdateEvent;
+use Drupal\automatic_updates\Exception\UpdateException;
+use Drupal\automatic_updates\Updater;
+use Drupal\Component\Serialization\Json;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * An event subscriber that validates staged Drupal projects.
+ */
+final class StagedProjectsValidation implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The updater service.
+   *
+   * @var \Drupal\automatic_updates\Updater
+   */
+  protected $updater;
+
+  /**
+   * Constructs a StagedProjectsValidation object.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
+   *   The translation service.
+   * @param \Drupal\automatic_updates\Updater $updater
+   *   The updater service.
+   */
+  public function __construct(TranslationInterface $translation, Updater $updater) {
+    $this->setStringTranslation($translation);
+    $this->updater = $updater;
+  }
+
+  /**
+   * Gets the Drupal packages in a composer.lock file.
+   *
+   * @param string $composer_lock_file
+   *   The composer.lock file location.
+   *
+   * @return array[]
+   *   The Drupal packages' information, as stored in the lock file, keyed by
+   *   package name.
+   */
+  private function getDrupalPackagesFromLockFile(string $composer_lock_file): array {
+    if (!file_exists($composer_lock_file)) {
+      $result = ValidationResult::createError([
+        $this->t("composer.lock file '@lock_file' not found.", ['@lock_file' => $composer_lock_file]),
+      ]);
+      throw new UpdateException(
+        [$result],
+        'The staged packages could not be evaluated because composer.lock file not found.'
+      );
+    }
+    $composer_lock = file_get_contents($composer_lock_file);
+    $drupal_packages = [];
+    $data = Json::decode($composer_lock);
+    $drupal_package_types = [
+      'drupal-module',
+      'drupal-theme',
+      'drupal-custom-module',
+      'drupal-custom-theme',
+    ];
+    $packages = $data['packages'] ?? [];
+    $packages = array_merge($packages, $data['packages-dev'] ?? []);
+    foreach ($packages as $package) {
+      if (in_array($package['type'], $drupal_package_types, TRUE)) {
+        $drupal_packages[$package['name']] = $package;
+      }
+    }
+
+    return $drupal_packages;
+  }
+
+  /**
+   * Validates the staged packages.
+   *
+   * @param \Drupal\automatic_updates\Event\UpdateEvent $event
+   *   The update event.
+   */
+  public function validateStagedProjects(UpdateEvent $event): void {
+    try {
+      $active_packages = $this->getDrupalPackagesFromLockFile($this->updater->getActiveDirectory() . "/composer.lock");
+      $staged_packages = $this->getDrupalPackagesFromLockFile($this->updater->getStageDirectory() . "/composer.lock");
+    }
+    catch (UpdateException $e) {
+      foreach ($e->getValidationResults() as $result) {
+        $event->addValidationResult($result);
+      }
+      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['type']],
+            '@name' => $new_package['name'],
+          ]
+        );
+      }
+      $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->addValidationResult(ValidationResult::createError($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['type']],
+            '@name' => $removed_package['name'],
+          ]
+        );
+      }
+      $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->addValidationResult(ValidationResult::createError($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['version'] !== $active_package['version']) {
+          $version_change_messages[] = $this->t(
+            "@type '@name' from @active_version to  @staged_version.",
+            [
+              '@type' => $type_map[$active_package['type']],
+              '@name' => $active_package['name'],
+              '@staged_version' => $staged_existing_package['version'],
+              '@active_version' => $active_package['version'],
+            ]
+          );
+        }
+      }
+      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->addValidationResult(ValidationResult::createError($version_change_messages, $version_change_summary));
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $events[AutomaticUpdatesEvents::PRE_COMMIT][] = ['validateStagedProjects'];
+    return $events;
+  }
+
+}
diff --git a/src/Validation/ValidationResult.php b/src/Validation/ValidationResult.php
new file mode 100644
index 0000000000000000000000000000000000000000..5e730a70b9bbd62fbb1210f023d98bc50f6456a0
--- /dev/null
+++ b/src/Validation/ValidationResult.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\automatic_updates\Validation;
+
+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/templates/automatic-updates-post-update.html.twig b/templates/automatic-updates-post-update.html.twig
deleted file mode 100644
index c1f1d9171c2a967a8f475d9c32e1df86be065cf9..0000000000000000000000000000000000000000
--- a/templates/automatic-updates-post-update.html.twig
+++ /dev/null
@@ -1,33 +0,0 @@
-{#
-/**
- * @file
- * Template for the post update email notification.
- *
- * Available variables:
- * - success: The update success status
- * - metadata: The update metadata
- *
- * @ingroup themeable
- */
-#}
-<p>
-  {% if success %}
-  {{ 'The project "@project" was updated from "@from_version" to "@to_version" with success.'|t({
-    '@project': metadata.getProjectName,
-    '@from_version': metadata.getFromVersion,
-    '@to_version': metadata.getToVersion,
-  }) }}
-  {% else %}
-  {{ 'The project "@project" was updated from "@from_version" to "@to_version" with failures.'|t({
-    '@project': metadata.getProjectName,
-    '@from_version': metadata.getFromVersion,
-    '@to_version': metadata.getToVersion,
-  }) }}
-  {% endif %}
-</p>
-<p>
-  {% set status_report = path('system.status') %}
-  {% trans %}
-    See the <a href="{{ status_report }}">site status report page</a> and any logs for more information.
-  {% endtrans %}
-</p>
diff --git a/templates/automatic-updates-psa-notify.html.twig b/templates/automatic-updates-psa-notify.html.twig
deleted file mode 100644
index f6f5d05ced0771a8dd6591904625607099544920..0000000000000000000000000000000000000000
--- a/templates/automatic-updates-psa-notify.html.twig
+++ /dev/null
@@ -1,38 +0,0 @@
-{#
-/**
- * @file
- * Template for the public service announcements email notification.
- *
- * Available variables:
- * - messages: The messages array
- *
- * @ingroup themeable
- */
-#}
-
-<p>
-  {% trans %}
-    A security update will be made available soon for your Drupal site. To ensure the security of the site, you should prepare the site to immediately apply the update once it is released!
-  {% endtrans %}
-</p>
-<p>
-  {% set status_report = path('system.status') %}
-  {% trans %}
-    See the <a href="{{ status_report }}">site status report page</a> for more information.
-  {% endtrans %}
-</p>
-<p>{{ 'Public service announcements:'|t }}</p>
-<ul>
-  {% for message in messages %}
-    <li>{{ message }}</li>
-  {% endfor %}
-</ul>
-<p>
-  {{ 'To see all PSAs, visit <a href="@uri">@uri</a>.'|t({'@uri': 'https://www.drupal.org/security/psa'}) }}
-</p>
-<p>
-  {% set settings_link = path('automatic_updates.settings') %}
-  {% trans %}
-    Your site is currently configured to send these emails when a security update will be made available soon. To change how you are notified, you may <a href="{{ settings_link }}">configure email notifications</a>.
-  {% endtrans %}
-</p>
diff --git a/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock b/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..94120e7e010dc5a3836ce092a68c5a43b7cee69e
--- /dev/null
+++ b/tests/fixtures/project_staged_validation/new_project_added/active/composer.lock
@@ -0,0 +1,29 @@
+{
+  "packages": [
+    {
+      "name": "drupal/testmodule",
+      "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-testmodule",
+      "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/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock b/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..4f13ff8c3459d1799f89f9bccf5ff90c2afd9c3c
--- /dev/null
+++ b/tests/fixtures/project_staged_validation/new_project_added/staged/composer.lock
@@ -0,0 +1,38 @@
+{
+  "packages": [
+    {
+      "name": "drupal/testmodule",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/testmodule2",
+      "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-testmodule",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/dev-testmodule2",
+      "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/tests/fixtures/project_staged_validation/no_errors/active/composer.lock b/tests/fixtures/project_staged_validation/no_errors/active/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..9130122783c74ee3c1018879419e60df9a1127b4
--- /dev/null
+++ b/tests/fixtures/project_staged_validation/no_errors/active/composer.lock
@@ -0,0 +1,40 @@
+{
+  "packages": [
+    {
+      "name": "drupal/testmodule",
+      "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-testmodule",
+      "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/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock b/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..07411d7f7d27c917f890a2d652c72ef884f6a0e3
--- /dev/null
+++ b/tests/fixtures/project_staged_validation/no_errors/staged/composer.lock
@@ -0,0 +1,40 @@
+{
+  "packages": [
+    {
+      "name": "drupal/testmodule",
+      "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-testmodule",
+      "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/tests/fixtures/project_staged_validation/project_removed/active/composer.lock b/tests/fixtures/project_staged_validation/project_removed/active/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..d42425d52dddda11d818dfb6ae01479a74d77aed
--- /dev/null
+++ b/tests/fixtures/project_staged_validation/project_removed/active/composer.lock
@@ -0,0 +1,38 @@
+{
+  "packages": [
+    {
+      "name": "drupal/test_theme",
+      "version": "1.3.0",
+      "type": "drupal-theme"
+    },
+    {
+      "name": "drupal/testmodule2",
+      "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-testmodule2",
+      "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/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock b/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..49ef1a451efe8337bb533657f86271e1014a7064
--- /dev/null
+++ b/tests/fixtures/project_staged_validation/project_removed/staged/composer.lock
@@ -0,0 +1,16 @@
+{
+  "packages": [
+    {
+      "name": "drupal/testmodule2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    }
+  ],
+  "packages-dev": [
+    {
+      "name": "drupal/dev-testmodule2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    }
+  ]
+}
diff --git a/tests/fixtures/project_staged_validation/version_changed/active/composer.lock b/tests/fixtures/project_staged_validation/version_changed/active/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..ed424c59307f92489fb2a03d1bc84e08c4585e31
--- /dev/null
+++ b/tests/fixtures/project_staged_validation/version_changed/active/composer.lock
@@ -0,0 +1,28 @@
+{
+  "packages": [
+    {
+      "name": "drupal/testmodule",
+      "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-testmodule",
+      "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/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock b/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock
new file mode 100644
index 0000000000000000000000000000000000000000..033fbec94d721230b7ff328a7959345544b0ef0a
--- /dev/null
+++ b/tests/fixtures/project_staged_validation/version_changed/staged/composer.lock
@@ -0,0 +1,28 @@
+{
+  "packages": [
+    {
+      "name": "drupal/testmodule",
+      "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-testmodule",
+      "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/tests/fixtures/release-history/drupal.0.0.xml b/tests/fixtures/release-history/drupal.0.0.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4d5268378ef31b4e9c59766f2572dd76c30c730e
--- /dev/null
+++ b/tests/fixtures/release-history/drupal.0.0.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/tests/modules/automatic_updates_test/automatic_updates_test.info.yml b/tests/modules/automatic_updates_test/automatic_updates_test.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..eee46d1e2aae41883f2a94314afa10209b1ea3e3
--- /dev/null
+++ b/tests/modules/automatic_updates_test/automatic_updates_test.info.yml
@@ -0,0 +1,7 @@
+name: 'Automatic Updates Test module 1'
+type: module
+description: 'Module for testing Automatic Updates.'
+package: Testing
+version: VERSION
+dependencies:
+  - drupal:automatic_updates
diff --git a/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml b/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..3f4200e4f9f8b6ec25cec6a43345053751e344d9
--- /dev/null
+++ b/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml
@@ -0,0 +1,7 @@
+automatic_updates_test.update_test:
+  path: '/automatic-update-test/{project_name}/{version}'
+  defaults:
+    _title: 'Update test'
+    _controller: '\Drupal\automatic_updates_test\MetadataController::updateTest'
+  requirements:
+    _access: 'TRUE'
diff --git a/tests/modules/automatic_updates_test/automatic_updates_test.services.yml b/tests/modules/automatic_updates_test/automatic_updates_test.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..070fcd06ac7944d9f769cf2601bd11172b58be7d
--- /dev/null
+++ b/tests/modules/automatic_updates_test/automatic_updates_test.services.yml
@@ -0,0 +1,9 @@
+services:
+  automatic_updates_test.checker:
+    class: Drupal\automatic_updates_test\ReadinessChecker\TestChecker1
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@state']
+  datetime.time:
+    class: Drupal\automatic_updates_test\Datetime\TestTime
+    arguments: ['@request_stack']
diff --git a/tests/modules/automatic_updates_test/src/AutomaticUpdatesTestServiceProvider.php b/tests/modules/automatic_updates_test/src/AutomaticUpdatesTestServiceProvider.php
new file mode 100644
index 0000000000000000000000000000000000000000..5817b28556a7bc59396414882ee0695f9a9d061a
--- /dev/null
+++ b/tests/modules/automatic_updates_test/src/AutomaticUpdatesTestServiceProvider.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\automatic_updates_test;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+
+/**
+ * Defines a service provider for testing automatic updates.
+ */
+class AutomaticUpdatesTestServiceProvider extends ServiceProviderBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    parent::alter($container);
+
+    $modules = $container->getParameter('container.modules');
+    if (isset($modules['automatic_updates'])) {
+      // Swap in our special updater implementation, which can be rigged to
+      // throw errors during various points in the update process in order to
+      // test error handling during updates.
+      $container->getDefinition('automatic_updates.updater')
+        ->setClass(TestUpdater::class);
+    }
+  }
+
+}
diff --git a/tests/modules/automatic_updates_test/src/Datetime/TestTime.php b/tests/modules/automatic_updates_test/src/Datetime/TestTime.php
new file mode 100644
index 0000000000000000000000000000000000000000..4b43a226f19fe0b56732de997d0a33790a0348b0
--- /dev/null
+++ b/tests/modules/automatic_updates_test/src/Datetime/TestTime.php
@@ -0,0 +1,33 @@
+<?php
+
+namespace Drupal\automatic_updates_test\Datetime;
+
+use Drupal\Component\Datetime\Time;
+
+/**
+ * Test service for altering the request time.
+ */
+class TestTime extends Time {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getRequestTime(): int {
+    if ($faked_date = \Drupal::state()->get('automatic_updates_test.fake_date_time')) {
+      return \DateTime::createFromFormat('U', $faked_date)->getTimestamp();
+    }
+    return parent::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('automatic_updates_test.fake_date_time', $fake_time);
+  }
+
+}
diff --git a/tests/modules/automatic_updates_test/src/MetadataController.php b/tests/modules/automatic_updates_test/src/MetadataController.php
new file mode 100644
index 0000000000000000000000000000000000000000..8c8e480b0560ee4d508fc4543e8a82f46a2bf77c
--- /dev/null
+++ b/tests/modules/automatic_updates_test/src/MetadataController.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\automatic_updates_test;
+
+use Drupal\Core\Controller\ControllerBase;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\Response;
+
+class MetadataController extends ControllerBase {
+
+  /**
+   * 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 updateTest($project_name = 'drupal', $version = NULL) {
+    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/tests/modules/automatic_updates_test/src/ReadinessChecker/TestChecker1.php b/tests/modules/automatic_updates_test/src/ReadinessChecker/TestChecker1.php
new file mode 100644
index 0000000000000000000000000000000000000000..ada93a7ed5b8fb1542a95fb6fb81dcdab66484c8
--- /dev/null
+++ b/tests/modules/automatic_updates_test/src/ReadinessChecker/TestChecker1.php
@@ -0,0 +1,109 @@
+<?php
+
+namespace Drupal\automatic_updates_test\ReadinessChecker;
+
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates\Event\UpdateEvent;
+use Drupal\Core\State\StateInterface;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * A test readiness checker.
+ */
+class TestChecker1 implements EventSubscriberInterface {
+
+  /**
+   * The key to use store the test results.
+   */
+  protected const STATE_KEY = 'automatic_updates_test.checker_results';
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * Creates a TestChecker object.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   */
+  public function __construct(StateInterface $state) {
+    $this->state = $state;
+  }
+
+  /**
+   * Sets messages for this readiness checker.
+   *
+   * This method is static to enable setting the expected messages before the
+   * test module is enabled.
+   *
+   * @param \Drupal\automatic_updates\Validation\ValidationResult[] $checker_results
+   *   The test validation result.
+   * @param string $event_name
+   *   (optional )The event name. Defaults to
+   *   AutomaticUpdatesEvents::READINESS_CHECK.
+   */
+  public static function setTestResult(array $checker_results, string $event_name = AutomaticUpdatesEvents::READINESS_CHECK): void {
+    \Drupal::state()->set(static::STATE_KEY . ".$event_name", $checker_results);
+  }
+
+  /**
+   * Adds test result to an update event from a state setting.
+   *
+   * @param \Drupal\automatic_updates\Event\UpdateEvent $event
+   *   The update event.
+   * @param string $state_key
+   *   The state key.
+   */
+  protected function addResults(UpdateEvent $event, string $state_key): void {
+    $results = $this->state->get($state_key, []);
+    foreach ($results as $result) {
+      $event->addValidationResult($result);
+    }
+  }
+
+  /**
+   * Adds test results for the readiness check event.
+   *
+   * @param \Drupal\automatic_updates\Event\UpdateEvent $event
+   *   The update event.
+   */
+  public function runPreChecks(UpdateEvent $event): void {
+    $this->addResults($event, static::STATE_KEY . "." . AutomaticUpdatesEvents::READINESS_CHECK);
+  }
+
+  /**
+   * Adds test results for the pre-commit event.
+   *
+   * @param \Drupal\automatic_updates\Event\UpdateEvent $event
+   *   The update event.
+   */
+  public function runPreCommitChecks(UpdateEvent $event): void {
+    $this->addResults($event, static::STATE_KEY . "." . AutomaticUpdatesEvents::PRE_COMMIT);
+  }
+
+  /**
+   * Adds test results for the pre-start event.
+   *
+   * @param \Drupal\automatic_updates\Event\UpdateEvent $event
+   *   The update event.
+   */
+  public function runStartChecks(UpdateEvent $event): void {
+    $this->addResults($event, static::STATE_KEY . "." . AutomaticUpdatesEvents::PRE_START);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    $priority = defined('AUTOMATIC_UPDATES_TEST_SET_PRIORITY') ? AUTOMATIC_UPDATES_TEST_SET_PRIORITY : 5;
+    $events[AutomaticUpdatesEvents::READINESS_CHECK][] = ['runPreChecks', $priority];
+    $events[AutomaticUpdatesEvents::PRE_START][] = ['runStartChecks', $priority];
+    $events[AutomaticUpdatesEvents::PRE_COMMIT][] = ['runPreCommitChecks', $priority];
+    return $events;
+  }
+
+}
diff --git a/tests/modules/automatic_updates_test/src/TestUpdater.php b/tests/modules/automatic_updates_test/src/TestUpdater.php
new file mode 100644
index 0000000000000000000000000000000000000000..14cd9c06fc787c22fa0b17370b527148250cf2e0
--- /dev/null
+++ b/tests/modules/automatic_updates_test/src/TestUpdater.php
@@ -0,0 +1,36 @@
+<?php
+
+namespace Drupal\automatic_updates_test;
+
+use Drupal\automatic_updates\Exception\UpdateException;
+use Drupal\automatic_updates\Updater;
+
+/**
+ * A test-only updater which can throw errors during the update process.
+ */
+class TestUpdater extends Updater {
+
+  /**
+   * Sets the errors to be thrown during the begin() method.
+   *
+   * @param \Drupal\automatic_updates\Validation\ValidationResult[] $errors
+   *   The validation errors that should be thrown.
+   */
+  public static function setBeginErrors(array $errors): void {
+    \Drupal::state()->set('automatic_updates_test.updater_errors', [
+      'begin' => $errors,
+    ]);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function begin(): string {
+    $errors = $this->state->get('automatic_updates_test.updater_errors', []);
+    if (isset($errors['begin'])) {
+      throw new UpdateException($errors['begin'], reset($errors['begin'])->getSummary());
+    }
+    return parent::begin();
+  }
+
+}
diff --git a/tests/modules/automatic_updates_test2/automatic_updates_test2.info.yml b/tests/modules/automatic_updates_test2/automatic_updates_test2.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c6029d2b96031b7e6073421e556b9256f57d46ee
--- /dev/null
+++ b/tests/modules/automatic_updates_test2/automatic_updates_test2.info.yml
@@ -0,0 +1,7 @@
+name: 'Automatic Updates Test module 2'
+type: module
+description: 'Test module to provide an additional readiness checker.'
+package: Testing
+version: VERSION
+dependencies:
+  - drupal:automatic_updates_test
diff --git a/tests/modules/automatic_updates_test2/automatic_updates_test2.services.yml b/tests/modules/automatic_updates_test2/automatic_updates_test2.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..aeb56d03e7c24663a172834717fb6466cb7e0013
--- /dev/null
+++ b/tests/modules/automatic_updates_test2/automatic_updates_test2.services.yml
@@ -0,0 +1,6 @@
+services:
+  automatic_updates_test2.checker:
+    class: Drupal\automatic_updates_test2\ReadinessChecker\TestChecker2
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@state']
diff --git a/tests/modules/automatic_updates_test2/src/ReadinessChecker/TestChecker2.php b/tests/modules/automatic_updates_test2/src/ReadinessChecker/TestChecker2.php
new file mode 100644
index 0000000000000000000000000000000000000000..da0b88c7364d1d147169d7aed194bbdfc97fd3ac
--- /dev/null
+++ b/tests/modules/automatic_updates_test2/src/ReadinessChecker/TestChecker2.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Drupal\automatic_updates_test2\ReadinessChecker;
+
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates_test\ReadinessChecker\TestChecker1;
+
+/**
+ * A test readiness checker.
+ */
+class TestChecker2 extends TestChecker1 {
+
+  protected const STATE_KEY = 'automatic_updates_test2.checker_results';
+
+  public static function getSubscribedEvents() {
+    $events[AutomaticUpdatesEvents::READINESS_CHECK][] = ['runPreChecks', 4];
+    $events[AutomaticUpdatesEvents::PRE_START][] = ['runStartChecks', 4];
+
+    return $events;
+  }
+
+}
diff --git a/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php b/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php
deleted file mode 100644
index 4bf564784a6427c0f5ef1a73e1609cc3ed8ff5bb..0000000000000000000000000000000000000000
--- a/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-
-namespace Drupal\test_automatic_updates\Controller;
-
-use Drupal\automatic_updates\Services\UpdateInterface;
-use Drupal\automatic_updates\UpdateMetadata;
-use Drupal\Core\Controller\ControllerBase;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-
-/**
- * Returns responses for Test Automatic Updates routes.
- */
-class InPlaceUpdateController extends ControllerBase {
-
-  /**
-   * Updater service.
-   *
-   * @var \Drupal\automatic_updates\Services\UpdateInterface
-   */
-  protected $updater;
-
-  /**
-   * InPlaceUpdateController constructor.
-   *
-   * @param \Drupal\automatic_updates\Services\UpdateInterface $updater
-   *   The updater service.
-   */
-  public function __construct(UpdateInterface $updater) {
-    $this->updater = $updater;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container) {
-    return new static(
-      $container->get('automatic_updates.update')
-    );
-  }
-
-  /**
-   * Builds the response.
-   */
-  public function update($project, $type, $from, $to) {
-    $metadata = new UpdateMetadata($project, $type, $from, $to);
-    $updated = $this->updater->update($metadata);
-    return [
-      '#markup' => $updated ? $this->t('Update successful') : $this->t('Update Failed'),
-    ];
-  }
-
-}
diff --git a/tests/modules/test_automatic_updates/src/Controller/JsonTestController.php b/tests/modules/test_automatic_updates/src/Controller/JsonTestController.php
deleted file mode 100644
index 7f4b91edd9ff25cfe1ee7d7fb6f37e6a8d7ea20a..0000000000000000000000000000000000000000
--- a/tests/modules/test_automatic_updates/src/Controller/JsonTestController.php
+++ /dev/null
@@ -1,101 +0,0 @@
-<?php
-
-namespace Drupal\test_automatic_updates\Controller;
-
-use Drupal\Core\Controller\ControllerBase;
-use Symfony\Component\HttpFoundation\JsonResponse;
-
-/**
- * Class JsonTestController.
- */
-class JsonTestController extends ControllerBase {
-
-  /**
-   * Test JSON controller.
-   *
-   * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   Return JSON feed response.
-   */
-  public function json() {
-    $feed = [];
-    $feed[] = [
-      'title' => 'Critical Release - SA-2019-02-19',
-      'link' => 'https://www.drupal.org/sa-2019-02-19',
-      'project' => 'drupal',
-      'type' => 'core',
-      'insecure' => [
-        '7.65',
-        '8.5.14',
-        '8.5.14',
-        '8.6.13',
-        '8.7.0-alpha2',
-        '8.7.0-beta1',
-        '8.7.0-beta2',
-        '8.6.14',
-        '8.6.15',
-        '8.6.15',
-        '8.5.15',
-        '8.5.15',
-        '7.66',
-        '8.7.0',
-        \Drupal::VERSION,
-      ],
-      'is_psa' => '0',
-      'pubDate' => 'Tue, 19 Feb 2019 14:11:01 +0000',
-    ];
-    $feed[] = [
-      'title' => 'Critical Release - PSA-Really Old',
-      'link' => 'https://www.drupal.org/psa',
-      'project' => 'drupal',
-      'type' => 'core',
-      'is_psa' => '1',
-      'insecure' => [],
-      'pubDate' => 'Tue, 19 Feb 2019 14:11:01 +0000',
-    ];
-    $feed[] = [
-      'title' => 'Seven - Moderately critical - Access bypass - SA-CONTRIB-2019',
-      'link' => 'https://www.drupal.org/sa-contrib-2019',
-      'project' => 'seven',
-      'type' => 'theme',
-      'is_psa' => '0',
-      'insecure' => ['8.x-8.7.0', '8.x-' . \Drupal::VERSION],
-      'pubDate' => 'Tue, 19 Mar 2019 12:50:00 +0000',
-    ];
-    $feed[] = [
-      'title' => 'Foobar - Moderately critical - Access bypass - SA-CONTRIB-2019',
-      'link' => 'https://www.drupal.org/sa-contrib-2019',
-      'project' => 'foobar',
-      'type' => 'foobar',
-      'is_psa' => '1',
-      'insecure' => [],
-      'pubDate' => 'Tue, 19 Mar 2019 12:50:00 +0000',
-    ];
-    $feed[] = [
-      'title' => 'Token - Moderately critical - Access bypass - SA-CONTRIB-2019',
-      'link' => 'https://www.drupal.org/sa-contrib-2019',
-      'project' => 'token',
-      'type' => 'module',
-      'is_psa' => '0',
-      'insecure' => ['7.x-1.7', '8.x-1.4'],
-      'pubDate' => 'Tue, 19 Mar 2019 12:50:00 +0000',
-    ];
-    $feed[] = [
-      'title' => 'Views - Moderately critical - Access bypass - SA-CONTRIB-2019',
-      'link' => 'https://www.drupal.org/sa-contrib-2019',
-      'project' => 'views',
-      'type' => 'module',
-      'insecure' => [
-        '7.x-3.16',
-        '7.x-3.17',
-        '7.x-3.18',
-        '7.x-3.19',
-        '7.x-3.19',
-        '8.x-8.7.0',
-      ],
-      'is_psa' => '0',
-      'pubDate' => 'Tue, 19 Mar 2019 12:50:00 +0000',
-    ];
-    return new JsonResponse($feed);
-  }
-
-}
diff --git a/tests/modules/test_automatic_updates/src/Controller/ModifiedFilesController.php b/tests/modules/test_automatic_updates/src/Controller/ModifiedFilesController.php
deleted file mode 100644
index 87874b7132919b1ceb46b58f1af30a5080a4cb1a..0000000000000000000000000000000000000000
--- a/tests/modules/test_automatic_updates/src/Controller/ModifiedFilesController.php
+++ /dev/null
@@ -1,79 +0,0 @@
-<?php
-
-namespace Drupal\test_automatic_updates\Controller;
-
-use Drupal\automatic_updates\IgnoredPathsIteratorFilter;
-use Drupal\automatic_updates\ProjectInfoTrait;
-use Drupal\automatic_updates\Services\ModifiedFilesInterface;
-use Drupal\Core\Controller\ControllerBase;
-use Symfony\Component\DependencyInjection\ContainerInterface;
-use Symfony\Component\HttpFoundation\Response;
-
-/**
- * Class ModifiedFilesController.
- */
-class ModifiedFilesController extends ControllerBase {
-  use ProjectInfoTrait;
-
-  /**
-   * The modified files service.
-   *
-   * @var \Drupal\automatic_updates\Services\ModifiedFilesInterface
-   */
-  protected $modifiedFiles;
-
-  /**
-   * ModifiedFilesController constructor.
-   *
-   * @param \Drupal\automatic_updates\Services\ModifiedFilesInterface $modified_files
-   *   The modified files service.
-   */
-  public function __construct(ModifiedFilesInterface $modified_files) {
-    $this->modifiedFiles = $modified_files;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function create(ContainerInterface $container) {
-    return new static(
-      $container->get('automatic_updates.modified_files')
-    );
-  }
-
-  /**
-   * Test modified files service.
-   *
-   * @param string $project_type
-   *   The project type.
-   * @param string $extension
-   *   The extension name.
-   *
-   * @return \Symfony\Component\HttpFoundation\Response
-   *   A status message of modified files .
-   */
-  public function modified($project_type, $extension) {
-    // Special edge case for core.
-    if ($project_type === 'core') {
-      $infos = $this->getInfos('module');
-      $extensions = array_filter($infos, static function (array $info) {
-        return $info['project'] === 'drupal';
-      });
-    }
-    // Filter for the main project.
-    else {
-      $infos = $this->getInfos($project_type);
-      $extensions = array_filter($infos, static function (array $info) use ($extension, $project_type) {
-        return $info['install path'] === "{$project_type}s/contrib/$extension";
-      });
-    }
-
-    $response = Response::create('No modified files!');
-    $filtered_modified_files = new IgnoredPathsIteratorFilter($this->modifiedFiles->getModifiedFiles($extensions));
-    if (iterator_count($filtered_modified_files)) {
-      $response->setContent('Modified files include: ' . implode(', ', iterator_to_array($filtered_modified_files)));
-    }
-    return $response;
-  }
-
-}
diff --git a/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php b/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php
deleted file mode 100644
index d8222b85a9c11f1a8f433885baa49edfb9c95884..0000000000000000000000000000000000000000
--- a/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php
+++ /dev/null
@@ -1,23 +0,0 @@
-<?php
-
-namespace Drupal\test_automatic_updates\EventSubscriber;
-
-use Drupal\Core\Routing\RouteSubscriberBase;
-use Symfony\Component\Routing\RouteCollection;
-
-/**
- * Disable theme CSRF route subscriber.
- */
-class DisableThemeCsrfRouteSubscriber extends RouteSubscriberBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function alterRoutes(RouteCollection $collection) {
-    // Disable CSRF so we can easily enable themes in tests.
-    if ($route = $collection->get('system.theme_set_default')) {
-      $route->setRequirements(['_permission' => 'administer themes']);
-    }
-  }
-
-}
diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.info.yml b/tests/modules/test_automatic_updates/test_automatic_updates.info.yml
deleted file mode 100644
index edee07ac0456d28a5b19d1d1029cf1c233b026f6..0000000000000000000000000000000000000000
--- a/tests/modules/test_automatic_updates/test_automatic_updates.info.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-name: 'Test Automatic Updates'
-type: module
-description: 'Tests for Automatic Updates'
-package: Testing
-core: 8.x
-core_version_requirement: ^8 || ^9
-dependencies:
-  - automatic_updates:automatic_updates
diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml
deleted file mode 100644
index e502219e0dcecd77c5a7d71b98649d9cbed062b7..0000000000000000000000000000000000000000
--- a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-test_automatic_updates.json_test_controller:
-  path: '/automatic_updates/test-json'
-  defaults:
-    _controller: '\Drupal\test_automatic_updates\Controller\JsonTestController::json'
-    _title: 'JSON'
-  requirements:
-    _access: 'TRUE'
-test_automatic_updates.json_test_denied_controller:
-  path: '/automatic_updates/test-json-denied'
-  defaults:
-    _controller: '\Drupal\test_automatic_updates\Controller\JsonTestController::json'
-    _title: 'JSON'
-  requirements:
-    _access: 'FALSE'
-test_automatic_updates.modified_files:
-  path: '/automatic_updates/modified-files/{project_type}/{extension}'
-  defaults:
-    _controller: '\Drupal\test_automatic_updates\Controller\ModifiedFilesController::modified'
-    _title: 'Modified Files'
-  requirements:
-    _access: 'TRUE'
-test_automatic_updates.inplace-update:
-  path: '/test_automatic_updates/in-place-update/{project}/{type}/{from}/{to}'
-  defaults:
-    _title: 'Update'
-    _controller: '\Drupal\test_automatic_updates\Controller\InPlaceUpdateController::update'
-  requirements:
-    _access: 'TRUE'
-  options:
-    no_cache: 'TRUE'
diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.services.yml b/tests/modules/test_automatic_updates/test_automatic_updates.services.yml
deleted file mode 100644
index da4b2779014a29aa173876e5289b2fc0b4b08df3..0000000000000000000000000000000000000000
--- a/tests/modules/test_automatic_updates/test_automatic_updates.services.yml
+++ /dev/null
@@ -1,5 +0,0 @@
-services:
-  test_automatic_updates.route_subscriber:
-    class: Drupal\test_automatic_updates\EventSubscriber\DisableThemeCsrfRouteSubscriber
-    tags:
-      - { name: event_subscriber }
diff --git a/tests/src/Build/AttendedCoreUpdateTest.php b/tests/src/Build/AttendedCoreUpdateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..64fd82758a1858764a6f8ddbd4fd6325d4c3477b
--- /dev/null
+++ b/tests/src/Build/AttendedCoreUpdateTest.php
@@ -0,0 +1,134 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Build;
+
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * Tests an end-to-end update of Drupal core within the UI.
+ *
+ * @group automatic_updates
+ */
+class AttendedCoreUpdateTest extends AttendedUpdateTestBase {
+
+  /**
+   * A directory containing a fake version of core that we will update to.
+   *
+   * @var string
+   */
+  private $coreDir;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    if ($this->destroyBuild && $this->coreDir) {
+      (new Filesystem())->remove($this->coreDir);
+    }
+    parent::tearDown();
+  }
+
+  /**
+   * Creates a Drupal core code base and assigns it an arbitrary version number.
+   *
+   * @param string $version
+   *   The version number that the Drupal core code base should have.
+   *
+   * @return string
+   *   The path of the code base.
+   */
+  protected function createTargetCorePackage(string $version): string {
+    $dir = $this->getWorkspaceDirectory();
+    $source = "$dir/core";
+    $this->assertDirectoryExists($source);
+    $destination = $dir . uniqid('_core_');
+    $this->assertDirectoryDoesNotExist($destination);
+
+    $fs = new Filesystem();
+    $fs->mirror($source, $destination);
+
+    $this->setCoreVersion($destination, $version);
+    // This is for us to be certain that we actually update to our local, fake
+    // version of Drupal core.
+    file_put_contents($destination . '/README.txt', "Placeholder for Drupal core $version.");
+    return $destination;
+  }
+
+  /**
+   * 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 {
+    $composer = "$dir/composer.json";
+    $data = $this->readJson($composer);
+    $data['version'] = $version;
+    $this->writeJson($composer, $data);
+
+    $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);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createTestSite(): void {
+    parent::createTestSite();
+    $this->setCoreVersion($this->getWorkspaceDirectory() . '/core', '9.8.0');
+  }
+
+  /**
+   * Tests an end-to-end core update.
+   */
+  public function test(): void {
+    $this->createTestSite();
+    $this->coreDir = $this->createTargetCorePackage('9.8.1');
+
+    $composer = $this->getWorkspaceDirectory() . "/composer.json";
+    $data = $this->readJson($composer);
+    $data['repositories']['drupal/core'] = [
+      'type' => 'path',
+      'url' => $this->coreDir,
+      'options' => [
+        'symlink' => FALSE,
+      ],
+    ];
+    $this->writeJson($composer, $data);
+
+    $this->installQuickStart('minimal');
+    $this->setReleaseMetadata(['drupal' => '0.0']);
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->installModules([
+      'automatic_updates',
+      'automatic_updates_test',
+      'update_test',
+    ]);
+
+    $mink = $this->getMink();
+    $page = $mink->getSession()->getPage();
+    $assert_session = $mink->assertSession();
+
+    $this->assertCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $this->visit('/admin/automatic-update');
+    $assert_session->pageTextContains('9.8.1');
+    $page->pressButton('Download these updates');
+    $this->waitForBatchJob();
+    $assert_session->pageTextContains('Ready to update');
+    $page->pressButton('Continue');
+    // @todo This message isn't showing up, for some reason. Figure out what the
+    // eff is going on.
+    // $assert_session->pageTextContains('Update complete!');
+    $this->assertCoreVersion('9.8.1');
+
+    $placeholder = file_get_contents($this->getWorkspaceDirectory() . '/core/README.txt');
+    $this->assertSame('Placeholder for Drupal core 9.8.1.', $placeholder);
+  }
+
+}
diff --git a/tests/src/Build/AttendedUpdateTestBase.php b/tests/src/Build/AttendedUpdateTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..1f0b3633c825d5e6fec741195bf83d8915b24e6b
--- /dev/null
+++ b/tests/src/Build/AttendedUpdateTestBase.php
@@ -0,0 +1,250 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Build;
+
+use Drupal\BuildTests\QuickStart\QuickStartTestBase;
+use Drupal\Component\Utility\Html;
+use Drupal\Tests\automatic_updates\Traits\LocalPackagesTrait;
+use Drupal\Tests\automatic_updates\Traits\SettingsTrait;
+
+/**
+ * Base class for tests that perform in-place attended updates via the UI.
+ */
+abstract class AttendedUpdateTestBase extends QuickStartTestBase {
+
+  use LocalPackagesTrait {
+    getPackagePath as traitGetPackagePath;
+  }
+  use SettingsTrait;
+
+  /**
+   * A secondary server instance, to serve XML metadata about available updates.
+   *
+   * @var \Symfony\Component\Process\Process
+   */
+  private $metadataServer;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    if ($this->metadataServer) {
+      $this->metadataServer->stop();
+    }
+    parent::tearDown();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getPackagePath(array $package): string {
+    return $package['name'] === 'drupal/core'
+      ? 'core'
+      : $this->traitGetPackagePath($package);
+  }
+
+  /**
+   * 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\automatic_updates_test\MetadataController::updateTest()
+   */
+  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);
+      $code .= <<<END
+\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test';
+END;
+    }
+    $this->addSettings($code, $this->getWorkspaceDirectory());
+  }
+
+  /**
+   * Runs a Composer command and asserts that it succeeded.
+   *
+   * @param string $command
+   *   The command to run, excluding the 'composer' prefix.
+   */
+  protected function runComposer(string $command): void {
+    $this->executeCommand("composer $command");
+    $this->assertCommandSuccessful();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function installQuickStart($profile, $working_dir = NULL) {
+    parent::installQuickStart($profile, $working_dir);
+
+    // 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->getWorkspaceDirectory());
+  }
+
+  /**
+   * Uses our already-installed dependencies to build a test site to update.
+   */
+  protected function createTestSite(): void {
+    $composer = $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . 'composer.json';
+    $this->writeJson($composer, $this->getComposerConfiguration());
+    $this->runComposer('update');
+  }
+
+  /**
+   * Returns the data to write to the test site's composer.json.
+   *
+   * @return array
+   *   The data that should be written to the test site's composer.json.
+   */
+  protected function getComposerConfiguration(): array {
+    $core_constraint = preg_replace('/\.[0-9]+-dev$/', '.x-dev', \Drupal::VERSION);
+
+    $drupal_root = $this->getDrupalRoot();
+    $repositories = [
+      'drupal/core-composer-scaffold' => [
+        'type' => 'path',
+        'url' => implode(DIRECTORY_SEPARATOR, [
+          $drupal_root,
+          'composer',
+          'Plugin',
+          'Scaffold',
+        ]),
+      ],
+      'drupal/automatic_updates' => [
+        'type' => 'path',
+        'url' => __DIR__ . '/../../..',
+      ],
+    ];
+    $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;
+
+    return [
+      'require' => [
+        // Allow packages to be placed in their right Drupal-findable places.
+        'composer/installers' => '^1.9',
+        // Use whatever the current branch of automatic_updates is.
+        'drupal/automatic_updates' => '*',
+        // Ensure we have all files that the test site needs.
+        'drupal/core-composer-scaffold' => '*',
+        // Require the current version of core, to install its dependencies.
+        'drupal/core' => $core_constraint,
+      ],
+      // 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.
+      'replace' => [
+        'symfony/polyfill-php72' => '*',
+        'symfony/polyfill-php73' => '*',
+      ],
+      'repositories' => $repositories,
+      'extra' => [
+        'installer-paths' => [
+          'core' => [
+            'type:drupal-core',
+          ],
+          'modules/{$name}' => [
+            'type:drupal-module',
+          ],
+        ],
+      ],
+      'minimum-stability' => 'dev',
+      'prefer-stable' => TRUE,
+    ];
+  }
+
+  /**
+   * 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/tests/src/Build/InPlaceUpdateTest.php b/tests/src/Build/InPlaceUpdateTest.php
deleted file mode 100644
index b241f41d6aaa218691ae72f48e5388b1546ca6ec..0000000000000000000000000000000000000000
--- a/tests/src/Build/InPlaceUpdateTest.php
+++ /dev/null
@@ -1,351 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Build;
-
-use Drupal\automatic_updates\Services\InPlaceUpdate;
-use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem;
-use Drupal\Tests\automatic_updates\Build\QuickStart\QuickStartTestBase;
-use Drupal\Tests\automatic_updates\Traits\InstallTestTrait;
-use GuzzleHttp\Client;
-use GuzzleHttp\Exception\RequestException;
-use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
-use Symfony\Component\Finder\Finder;
-
-/**
- * @coversDefaultClass \Drupal\automatic_updates\Services\InPlaceUpdate
- *
- * @group Build
- * @group Update
- *
- * @requires externalCommand composer
- * @requires externalCommand curl
- * @requires externalCommand git
- * @requires externalCommand tar
- */
-class InPlaceUpdateTest extends QuickStartTestBase {
-  use InstallTestTrait;
-
-  /**
-   * The files which are candidates for deletion during an upgrade.
-   *
-   * @var string[]
-   */
-  protected $deletions;
-
-  /**
-   * The directory where the deletion manifest is extracted.
-   *
-   * @var string
-   */
-  protected $deletionsDestination;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function tearDown() {
-    parent::tearDown();
-    $fs = new SymfonyFilesystem();
-    $fs->remove($this->deletionsDestination);
-  }
-
-  /**
-   * @covers ::update
-   * @dataProvider coreVersionsSuccessProvider
-   */
-  public function testCoreUpdate($from_version, $to_version) {
-    $this->installCore($from_version);
-    $this->assertCoreUpgradeSuccess($from_version, $to_version);
-  }
-
-  /**
-   * @covers ::update
-   */
-  public function testCoreRollbackUpdate() {
-    $from_version = '8.7.0';
-    $to_version = '8.8.5';
-    $this->installCore($from_version);
-
-    // Configure module to have db updates cause a rollback.
-    $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php';
-    $fs = new SymfonyFilesystem();
-    $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0755);
-    $fs->chmod($settings_php, 0640);
-    $fs->appendToFile($settings_php, PHP_EOL . '$config[\'automatic_updates.settings\'][\'database_update_handling\'] = [\'rollback\'];' . PHP_EOL);
-
-    $this->assertCoreUpgradeFailed($from_version, $to_version);
-  }
-
-  /**
-   * @covers ::update
-   * @dataProvider contribProjectsProvider
-   */
-  public function testContribUpdate($project, $project_type, $from_version, $to_version) {
-    $this->markTestSkipped('Contrib updates are not currently supported');
-    $this->copyCodebase();
-    $fs = new SymfonyFilesystem();
-    $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700);
-    $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction');
-    $this->assertErrorOutputContains('Generating autoload files');
-    $this->installQuickStart('standard');
-
-    // Download the project.
-    $fs->mkdir($this->getWorkspaceDirectory() . "/{$project_type}s/contrib/$project");
-    $this->executeCommand("curl -fsSL https://ftp.drupal.org/files/projects/$project-$from_version.tar.gz | tar xvz -C {$project_type}s/contrib/$project --strip 1");
-    $this->assertCommandSuccessful();
-    $finder = new Finder();
-    $finder->files()->in($this->getWorkspaceDirectory())->path("{$project_type}s/contrib/$project/$project.info.yml");
-    $finder->contains("/version: '$from_version'/");
-    $this->assertTrue($finder->hasResults(), "Expected version $from_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php");
-
-    // Assert files slated for deletion still exist.
-    foreach ($this->getDeletions($project, $from_version, $to_version) as $deletion) {
-      $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
-    }
-
-    // Currently, this test has to use extension_discovery_scan_tests so we can
-    // install test modules.
-    $fs = new SymfonyFilesystem();
-    $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php';
-    $fs->chmod($settings_php, 0640);
-    $fs->appendToFile($settings_php, '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL);
-
-    // Log in so that we can install projects.
-    $this->formLogin($this->adminUsername, $this->adminPassword);
-    $this->moduleInstall('update');
-    $this->moduleInstall('automatic_updates');
-    $this->moduleInstall('test_automatic_updates');
-    $this->{"{$project_type}Install"}($project);
-
-    // Assert that the site is functional before updating.
-    $this->visit();
-    $this->assertDrupalVisit();
-
-    // Update the contrib project.
-    $assert = $this->visit("/test_automatic_updates/in-place-update/$project/$project_type/$from_version/$to_version")
-      ->assertSession();
-    $assert->statusCodeEquals(200);
-    $this->assertDrupalVisit();
-
-    // Assert that the update worked.
-    $assert->pageTextContains('Update successful');
-    $finder = new Finder();
-    $finder->files()->in($this->getWorkspaceDirectory())->path("{$project_type}s/contrib/$project/$project.info.yml");
-    $finder->contains("/version: '$to_version'/");
-    $this->assertTrue($finder->hasResults(), "Expected version $to_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php");
-    $this->assertDrupalVisit();
-
-    // Assert files slated for deletion are now gone.
-    foreach ($this->getDeletions($project, $from_version, $to_version) as $deletion) {
-      $this->assertFileNotExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
-    }
-  }
-
-  /**
-   * Test in-place update via cron run.
-   *
-   * @covers ::update
-   * @see automatic_updates_cron()
-   */
-  public function testCronCoreUpdate() {
-    $this->installCore('8.8.0');
-    $filesystem = new SymfonyFilesystem();
-    $filesystem->chmod($this->getWorkspaceDirectory() . '/sites/default', 0750);
-    $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php';
-    $filesystem->chmod($settings_php, 0640);
-    $filesystem->appendToFile($settings_php, PHP_EOL . '$config[\'automatic_updates.settings\'][\'enable_cron_updates\'] = TRUE;' . PHP_EOL);
-    $mink = $this->visit('/admin/config/system/cron');
-    $mink->getSession()->getPage()->findButton('Run cron')->submit();
-    $mink->assertSession()->pageTextContains('Cron ran successfully.');
-
-    // Assert that the update worked.
-    $this->assertDrupalVisit();
-    $finder = new Finder();
-    $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php');
-    $finder->notContains("/const VERSION = '8.8.0'/");
-    $finder->contains("/const VERSION = '8.8./");
-    $this->assertTrue($finder->hasResults(), "Expected version 8.8.{x} does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php");
-  }
-
-  /**
-   * Core versions data provider resulting in a successful upgrade.
-   */
-  public function coreVersionsSuccessProvider() {
-    $datum[] = [
-      'from' => '8.7.2',
-      'to' => '8.7.4',
-    ];
-    $datum[] = [
-      'from' => '8.7.0',
-      'to' => '8.7.1',
-    ];
-    $datum[] = [
-      'from' => '8.7.2',
-      'to' => '8.7.10',
-    ];
-    $datum[] = [
-      'from' => '8.7.6',
-      'to' => '8.7.7',
-    ];
-    $datum[] = [
-      'from' => '8.9.0-beta1',
-      'to' => '8.9.0-beta2',
-    ];
-    return $datum;
-  }
-
-  /**
-   * Contrib project data provider.
-   */
-  public function contribProjectsProvider() {
-    $datum[] = [
-      'project' => 'bootstrap',
-      'type' => 'theme',
-      'from' => '8.x-3.19',
-      'to' => '8.x-3.20',
-    ];
-    $datum[] = [
-      'project' => 'token',
-      'type' => 'module',
-      'from' => '8.x-1.4',
-      'to' => '8.x-1.5',
-    ];
-    return $datum;
-  }
-
-  /**
-   * Helper method to retrieve files slated for deletion.
-   */
-  protected function getDeletions($project, $from_version, $to_version) {
-    if (isset($this->deletions)) {
-      return $this->deletions;
-    }
-    $this->deletions = [];
-    $filesystem = new SymfonyFilesystem();
-    $this->deletionsDestination = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . "$project-" . mt_rand(10000, 99999) . microtime(TRUE);
-    $filesystem->mkdir($this->deletionsDestination);
-    $file_name = "$project-$from_version-to-$to_version.zip";
-    $zip_file = $this->deletionsDestination . DIRECTORY_SEPARATOR . $file_name;
-    $this->doGetArchive($project, $file_name, $zip_file);
-    $zip = new \ZipArchive();
-    $zip->open($zip_file);
-    $zip->extractTo($this->deletionsDestination, [InPlaceUpdate::DELETION_MANIFEST]);
-    $handle = fopen($this->deletionsDestination . DIRECTORY_SEPARATOR . InPlaceUpdate::DELETION_MANIFEST, 'r');
-    if ($handle) {
-      while (($deletion = fgets($handle)) !== FALSE) {
-        if ($result = trim($deletion)) {
-          $this->deletions[] = $result;
-        }
-      }
-      fclose($handle);
-    }
-    return $this->deletions;
-  }
-
-  /**
-   * Get the archive with protection against 429s.
-   *
-   * @param string $project
-   *   The project.
-   * @param string $file_name
-   *   The filename.
-   * @param string $zip_file
-   *   The zip file path.
-   * @param int $delay
-   *   (optional) The delay.
-   */
-  protected function doGetArchive($project, $file_name, $zip_file, $delay = 0) {
-    try {
-      sleep($delay);
-      $http_client = new Client();
-      $http_client->get("https://www.drupal.org/in-place-updates/$project/$file_name", ['sink' => $zip_file]);
-    }
-    catch (RequestException $exception) {
-      $response = $exception->getResponse();
-      if ($response && $response->getStatusCode() === 429) {
-        $this->doGetArchive($project, $file_name, $zip_file, 10);
-      }
-      else {
-        throw $exception;
-      }
-    }
-  }
-
-  /**
-   * Assert an upgrade succeeded.
-   *
-   * @param string $from_version
-   *   The version from which to upgrade.
-   * @param string $to_version
-   *   The version to which to upgrade.
-   *
-   * @throws \Behat\Mink\Exception\ExpectationException
-   * @throws \Behat\Mink\Exception\ResponseTextException
-   */
-  public function assertCoreUpgradeSuccess($from_version, $to_version) {
-    // Assert files slated for deletion still exist.
-    foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) {
-      $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
-    }
-
-    // Update the site.
-    $assert = $this->visit("/test_automatic_updates/in-place-update/drupal/core/$from_version/$to_version")
-      ->assertSession();
-    $assert->statusCodeEquals(200);
-    $this->assertDrupalVisit();
-
-    // Assert that the update worked.
-    $finder = new Finder();
-    $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php');
-    $finder->contains("/const VERSION = '$to_version'/");
-    $this->assertTrue($finder->hasResults(), "Expected version $to_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php");
-    $assert->pageTextContains('Update successful');
-    $this->visit('/admin/reports/status');
-    $assert->pageTextContains("Drupal Version $to_version");
-
-    // Assert files slated for deletion are now gone.
-    foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) {
-      $this->assertFileNotExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
-    }
-
-    // Validate that all DB updates are processed.
-    $this->visit('/update.php/selection');
-    $assert->pageTextContains('No pending updates.');
-  }
-
-  /**
-   * Assert an upgraded failed and was handle appropriately.
-   *
-   * @param string $from_version
-   *   The version from which to upgrade.
-   * @param string $to_version
-   *   The version to which to upgrade.
-   *
-   * @throws \Behat\Mink\Exception\ResponseTextException
-   */
-  public function assertCoreUpgradeFailed($from_version, $to_version) {
-    // Assert files slated for deletion still exist.
-    foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) {
-      $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
-    }
-
-    // Update the site.
-    $assert = $this->visit("/test_automatic_updates/in-place-update/drupal/core/$from_version/$to_version")
-      ->assertSession();
-    $assert->statusCodeEquals(200);
-
-    // Assert that the update failed.
-    $finder = new Finder();
-    $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php');
-    $finder->contains("/const VERSION = '$from_version'/");
-    $this->assertTrue($finder->hasResults(), "Expected version $from_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php");
-    $assert->pageTextContains('Update Failed');
-    $this->visit('/admin/reports/status');
-    $assert->pageTextContains("Drupal Version $from_version");
-
-    // Assert files slated for deletion are restored.
-    foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) {
-      $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
-    }
-  }
-
-}
diff --git a/tests/src/Build/ModifiedFilesTest.php b/tests/src/Build/ModifiedFilesTest.php
deleted file mode 100644
index b404826f371bd97b090fe771a24b34b7dbe5b901..0000000000000000000000000000000000000000
--- a/tests/src/Build/ModifiedFilesTest.php
+++ /dev/null
@@ -1,132 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Build;
-
-use Drupal\Tests\automatic_updates\Build\QuickStart\QuickStartTestBase;
-use Drupal\Tests\automatic_updates\Traits\InstallTestTrait;
-use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
-
-/**
- * @coversDefaultClass \Drupal\automatic_updates\Services\ModifiedFiles
- *
- * @group Update
- *
- * @requires externalCommand composer
- * @requires externalCommand curl
- * @requires externalCommand git
- * @requires externalCommand tar
- */
-class ModifiedFilesTest extends QuickStartTestBase {
-  use InstallTestTrait;
-
-  /**
-   * Symfony file system.
-   *
-   * @var \Symfony\Component\Filesystem\Filesystem
-   */
-  protected $symfonyFileSystem;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-    $this->symfonyFileSystem = new SymfonyFilesystem();
-  }
-
-  /**
-   * @covers ::getModifiedFiles
-   * @dataProvider coreProjectProvider
-   */
-  public function testCoreModified($version, array $modifications = []) {
-    $this->installCore($version);
-
-    // Assert modifications.
-    $this->assertModifications('core', 'drupal', $modifications);
-  }
-
-  /**
-   * @covers ::getModifiedFiles
-   * @dataProvider contribProjectsProvider
-   */
-  public function testContribModified($project, $project_type, $version, array $modifications = []) {
-    $this->markTestSkipped('Contrib updates are not currently supported');
-    $this->copyCodebase();
-
-    // Download the project.
-    $this->symfonyFileSystem->mkdir($this->getWorkspaceDirectory() . "/{$project_type}s/contrib/$project");
-    $this->executeCommand("curl -fsSL https://ftp.drupal.org/files/projects/$project-$version.tar.gz | tar xvz -C {$project_type}s/contrib/$project --strip 1");
-    $this->assertCommandSuccessful();
-
-    // Assert modifications.
-    $this->assertModifications($project_type, $project, $modifications);
-  }
-
-  /**
-   * Core project data provider.
-   */
-  public function coreProjectProvider() {
-    $datum[] = [
-      'version' => '8.7.3',
-      'modifications' => [
-        'core/LICENSE.txt',
-      ],
-    ];
-    return $datum;
-  }
-
-  /**
-   * Contrib project data provider.
-   */
-  public function contribProjectsProvider() {
-    $datum[] = [
-      'project' => 'bootstrap',
-      'project_type' => 'theme',
-      'version' => '8.x-3.20',
-      'modifications' => [
-        'themes/contrib/bootstrap/LICENSE.txt',
-      ],
-    ];
-    $datum[] = [
-      'project' => 'token',
-      'project_type' => 'module',
-      'version' => '8.x-1.5',
-      'modifications' => [
-        'modules/contrib/token/LICENSE.txt',
-      ],
-    ];
-    return $datum;
-  }
-
-  /**
-   * Assert modified files.
-   *
-   * @param string $project_type
-   *   The project type.
-   * @param string $project
-   *   The project to assert.
-   * @param array $modifications
-   *   The modified files to assert.
-   */
-  protected function assertModifications($project_type, $project, array $modifications) {
-    // Validate project is not modified.
-    $this->visit("/automatic_updates/modified-files/$project_type/$project");
-    $assert = $this->getMink()->assertSession();
-    $assert->statusCodeEquals(200);
-    $assert->pageTextContains('No modified files!');
-
-    // Assert modifications.
-    $this->assertNotEmpty($modifications);
-    foreach ($modifications as $modification) {
-      $file = $this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $modification;
-      $this->fileExists($file);
-      $this->symfonyFileSystem->appendToFile($file, PHP_EOL . '// file is modified' . PHP_EOL);
-    }
-    $this->visit("/automatic_updates/modified-files/$project_type/$project");
-    $assert->pageTextContains('Modified files include:');
-    foreach ($modifications as $modification) {
-      $assert->pageTextContains($modification);
-    }
-  }
-
-}
diff --git a/tests/src/Build/QuickStart/QuickStartTestBase.php b/tests/src/Build/QuickStart/QuickStartTestBase.php
deleted file mode 100644
index af99c15301ff84debfce10857efd08c39a4eef2f..0000000000000000000000000000000000000000
--- a/tests/src/Build/QuickStart/QuickStartTestBase.php
+++ /dev/null
@@ -1,131 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Build\QuickStart;
-
-use Drupal\BuildTests\Framework\BuildTestBase;
-use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem;
-use Drupal\Core\Archiver\Zip;
-use GuzzleHttp\Client;
-use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
-use Symfony\Component\Finder\Finder;
-use Symfony\Component\Process\PhpExecutableFinder;
-
-/**
- * Helper methods for using the quickstart feature of Drupal.
- */
-abstract class QuickStartTestBase extends BuildTestBase {
-
-  /**
-   * User name of the admin account generated during install.
-   *
-   * @var string
-   */
-  protected $adminUsername;
-
-  /**
-   * Password of the admin account generated during install.
-   *
-   * @var string
-   */
-  protected $adminPassword;
-
-  /**
-   * Install a Drupal site using the quick start feature.
-   *
-   * @param string $profile
-   *   Drupal profile to install.
-   * @param string $working_dir
-   *   (optional) A working directory relative to the workspace, within which to
-   *   execute the command. Defaults to the workspace directory.
-   */
-  public function installQuickStart($profile, $working_dir = NULL) {
-    $finder = new PhpExecutableFinder();
-    $process = $this->executeCommand($finder->find() . ' ./core/scripts/drupal install ' . $profile, $working_dir);
-    $this->assertCommandSuccessful();
-    $this->assertCommandOutputContains('Username:');
-    preg_match('/Username: (.+)\vPassword: (.+)/', $process->getOutput(), $matches);
-    $this->assertNotEmpty($this->adminUsername = $matches[1]);
-    $this->assertNotEmpty($this->adminPassword = $matches[2]);
-  }
-
-  /**
-   * Prepare core for testing.
-   *
-   * @param string $starting_version
-   *   The starting version.
-   */
-  protected function installCore($starting_version) {
-    // Get tarball of drupal core.
-    $drupal_tarball = "drupal-$starting_version.zip";
-    $destination = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . 'drupal-' . random_int(10000, 99999) . microtime(TRUE);
-    $fs = new SymfonyFilesystem();
-    $fs->mkdir($destination);
-    $http_client = new Client();
-    $http_client->get("https://ftp.drupal.org/files/projects/$drupal_tarball", ['sink' => $destination . DIRECTORY_SEPARATOR . $drupal_tarball]);
-    $zip = new Zip($destination . DIRECTORY_SEPARATOR . $drupal_tarball);
-    $zip->extract($destination);
-    // Move the tarball codebase over to the test workspace.
-    $finder = new Finder();
-    $finder->files()
-      ->ignoreUnreadableDirs()
-      ->ignoreDotFiles(FALSE)
-      ->in("$destination/drupal-$starting_version");
-    $options = ['override' => TRUE, 'delete' => FALSE];
-    $fs->mirror("$destination/drupal-$starting_version", $this->getWorkingPath(), $finder->getIterator(), $options);
-    $fs->remove("$destination/drupal-$starting_version");
-    // Copy in this module from the original code base.
-    $finder = new Finder();
-    $finder->files()
-      ->ignoreUnreadableDirs()
-      ->in($this->getDrupalRoot())
-      ->path('automatic_updates');
-    $this->copyCodebase($finder->getIterator());
-
-    $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700);
-    $this->installQuickStart('minimal');
-
-    // Currently, this test has to use extension_discovery_scan_tests so we can
-    // install test modules.
-    $settings_php = $this->getWorkspaceDirectory() . '/sites/default/settings.php';
-    $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0755);
-    $fs->chmod($settings_php, 0640);
-    $fs->appendToFile($settings_php, '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL);
-
-    // Log in so that we can install modules.
-    $this->formLogin($this->adminUsername, $this->adminPassword);
-    $this->moduleInstall('update');
-    $this->moduleInstall('automatic_updates');
-    $this->moduleInstall('test_automatic_updates');
-
-    // Confirm we are running correct Drupal version.
-    $finder = new Finder();
-    $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php');
-    $finder->contains("/const VERSION = '$starting_version'/");
-    $this->assertTrue($finder->hasResults(), "Expected version $starting_version does not exist in {$this->getWorkspaceDirectory()}/core/lib/Drupal.php");
-
-    // Assert that the site is functional after install.
-    $this->visit();
-    $this->assertDrupalVisit();
-  }
-
-  /**
-   * Helper that uses Drupal's user/login form to log in.
-   *
-   * @param string $username
-   *   Username.
-   * @param string $password
-   *   Password.
-   * @param string $working_dir
-   *   (optional) A working directory within which to login. Defaults to the
-   *   workspace directory.
-   */
-  public function formLogin($username, $password, $working_dir = NULL) {
-    $mink = $this->visit('/user/login', $working_dir);
-    $this->assertEquals(200, $mink->getSession()->getStatusCode());
-    $assert = $mink->assertSession();
-    $assert->fieldExists('edit-name')->setValue($username);
-    $assert->fieldExists('edit-pass')->setValue($password);
-    $mink->getSession()->getPage()->findButton('Log in')->submit();
-  }
-
-}
diff --git a/tests/src/Functional/AutomaticUpdatesTest.php b/tests/src/Functional/AutomaticUpdatesTest.php
deleted file mode 100644
index 0a4ad81f3c87a13649055edec2f2f71406059298..0000000000000000000000000000000000000000
--- a/tests/src/Functional/AutomaticUpdatesTest.php
+++ /dev/null
@@ -1,115 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Functional;
-
-use Drupal\Core\Url;
-use Drupal\Tests\BrowserTestBase;
-
-/**
- * Tests of automatic updates.
- *
- * @group automatic_updates
- */
-class AutomaticUpdatesTest extends BrowserTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $defaultTheme = 'stark';
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'automatic_updates',
-    'test_automatic_updates',
-    'update',
-  ];
-
-  /**
-   * A user with permission to administer site configuration.
-   *
-   * @var \Drupal\user\UserInterface
-   */
-  protected $user;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-    $this->user = $this->drupalCreateUser([
-      'access administration pages',
-      'administer site configuration',
-      'administer software updates',
-    ]);
-    $this->drupalLogin($this->user);
-  }
-
-  /**
-   * Tests that a PSA is displayed.
-   */
-  public function testPsa() {
-    // Setup test PSA endpoint.
-    $end_point = $this->buildUrl(Url::fromRoute('test_automatic_updates.json_test_controller'));
-    $this->config('automatic_updates.settings')
-      ->set('psa_endpoint', $end_point)
-      ->save();
-    $this->drupalGet(Url::fromRoute('system.admin'));
-    $this->assertSession()->pageTextContains('Critical Release - SA-2019-02-19');
-    $this->assertSession()->pageTextContains('Critical Release - PSA-Really Old');
-    $this->assertSession()->pageTextNotContains('Node - Moderately critical - Access bypass - SA-CONTRIB-2019');
-    $this->assertSession()->pageTextNotContains('Views - Moderately critical - Access bypass - SA-CONTRIB-2019');
-
-    // Test site status report.
-    $this->drupalGet(Url::fromRoute('system.status'));
-    $this->assertSession()->pageTextContains('3 urgent announcements require your attention:');
-
-    // Test cache.
-    $end_point = $this->buildUrl(Url::fromRoute('test_automatic_updates.json_test_denied_controller'));
-    $this->config('automatic_updates.settings')
-      ->set('psa_endpoint', $end_point)
-      ->save();
-    $this->drupalGet(Url::fromRoute('system.admin'));
-    $this->assertSession()->pageTextContains('Critical Release - SA-2019-02-19');
-
-    // Test transmit errors with JSON endpoint.
-    drupal_flush_all_caches();
-    $this->drupalGet(Url::fromRoute('system.admin'));
-    $this->assertSession()->pageTextContains("Drupal PSA endpoint $end_point is unreachable.");
-
-    // Test disabling PSAs.
-    $end_point = $this->buildUrl(Url::fromRoute('test_automatic_updates.json_test_controller'));
-    $this->config('automatic_updates.settings')
-      ->set('psa_endpoint', $end_point)
-      ->set('enable_psa', FALSE)
-      ->save();
-    drupal_flush_all_caches();
-    $this->drupalGet(Url::fromRoute('system.admin'));
-    $this->assertSession()->pageTextNotContains('Critical Release - PSA-2019-02-19');
-    $this->drupalGet(Url::fromRoute('system.status'));
-    $this->assertSession()->pageTextNotContains('urgent announcements require your attention');
-  }
-
-  /**
-   * Tests manually running readiness checks.
-   */
-  public function testReadinessChecks() {
-    $ignore_paths = "modules/custom/*\nthemes/custom/*\nprofiles/custom/*";
-    $this->config('automatic_updates.settings')->set('ignored_paths', $ignore_paths)
-      ->save();
-    // Test manually running readiness checks. A few warnings will occur.
-    $this->drupalGet(Url::fromRoute('automatic_updates.settings'));
-    $this->clickLink('run the readiness checks');
-    $this->assertSession()->pageTextContains('Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might effect the eligibility for automatic updates.');
-
-    // Ignore specific file paths to see no readiness issues.
-    $ignore_paths = "vendor/*\ncore/*\nmodules/*\nthemes/*\nprofiles/*\ncomposer.*\nautoload.php\nLICENSE.txt";
-    $this->config('automatic_updates.settings')->set('ignored_paths', $ignore_paths)
-      ->save();
-    $this->drupalGet(Url::fromRoute('automatic_updates.settings'));
-    $this->clickLink('run the readiness checks');
-    $this->assertSession()->pageTextContains('No issues found. Your site is ready for automatic updates.');
-  }
-
-}
diff --git a/tests/src/Functional/LogPageTest.php b/tests/src/Functional/LogPageTest.php
deleted file mode 100644
index 75e328d6411e80337d6473c8ae2041b4bf76513a..0000000000000000000000000000000000000000
--- a/tests/src/Functional/LogPageTest.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Functional;
-
-use Drupal\Tests\BrowserTestBase;
-
-/**
- * Tests access permission to log page.
- *
- * @group automatic_updates
- */
-class LogPageTest extends BrowserTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $defaultTheme = 'stark';
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'views',
-    'dblog',
-    'automatic_updates',
-  ];
-
-  /**
-   * A user with permission to administer software updates.
-   *
-   * @var \Drupal\user\UserInterface
-   */
-  protected $user;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-    $this->user = $this->drupalCreateUser([
-      'access administration pages',
-      'administer site configuration',
-      'administer software updates',
-    ]);
-    $this->drupalLogin($this->user);
-  }
-
-  /**
-   * Tests that the log page is displayed.
-   */
-  public function testLogPageExists() {
-    $this->drupalGet('admin/reports/automatic_updates_log');
-
-    $this->assertSession()->statusCodeEquals(200);
-  }
-
-}
diff --git a/tests/src/Functional/NotifyTest.php b/tests/src/Functional/NotifyTest.php
deleted file mode 100644
index 783f9222b1fb24124e8a13eb665330af3d90da8b..0000000000000000000000000000000000000000
--- a/tests/src/Functional/NotifyTest.php
+++ /dev/null
@@ -1,112 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Functional;
-
-use Drupal\automatic_updates\Event\PostUpdateEvent;
-use Drupal\automatic_updates\UpdateMetadata;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\Test\AssertMailTrait;
-use Drupal\Core\Url;
-use Drupal\Tests\BrowserTestBase;
-
-/**
- * Tests notification emails for PSAs.
- *
- * @group automatic_updates
- */
-class NotifyTest extends BrowserTestBase {
-  use AssertMailTrait;
-  use StringTranslationTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected $defaultTheme = 'stark';
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'automatic_updates',
-    'test_automatic_updates',
-    'update',
-  ];
-
-  /**
-   * A user with permission to administer site configuration.
-   *
-   * @var \Drupal\user\UserInterface
-   */
-  protected $user;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp() {
-    parent::setUp();
-    // Setup test PSA endpoint.
-    $end_point = $this->buildUrl(Url::fromRoute('test_automatic_updates.json_test_controller'));
-    $this->config('automatic_updates.settings')
-      ->set('psa_endpoint', $end_point)
-      ->save();
-    // Setup a default destination email address.
-    $this->config('update.settings')
-      ->set('notification.emails', ['admin@example.com'])
-      ->save();
-
-    $this->user = $this->drupalCreateUser([
-      'administer site configuration',
-      'access administration pages',
-    ]);
-    $this->drupalLogin($this->user);
-  }
-
-  /**
-   * Tests sending PSA email notifications.
-   */
-  public function testPsaMail() {
-    // Test PSAs on admin pages.
-    $this->drupalGet(Url::fromRoute('system.admin'));
-    $this->assertSession()->pageTextContains('Critical Release - SA-2019-02-19');
-
-    // Email should be sent.
-    $notify = $this->container->get('automatic_updates.psa_notify');
-    $notify->send();
-    $this->assertCount(1, $this->getMails());
-    $this->assertMailString('subject', '3 urgent Drupal announcements require your attention', 1);
-    $this->assertMailString('body', 'Critical Release - SA-2019-02-19', 1);
-
-    // No email should be sent if PSA's are disabled.
-    $this->container->get('state')->set('system.test_mail_collector', []);
-    $this->container->get('state')->delete('automatic_updates.last_check');
-    $this->config('automatic_updates.settings')
-      ->set('enable_psa', FALSE)
-      ->save();
-    $notify->send();
-    $this->assertCount(0, $this->getMails());
-  }
-
-  /**
-   * Tests sending post update email notifications.
-   */
-  public function testPostUpdateMail() {
-    // Success email.
-    $metadata = new UpdateMetadata('drupal', 'core', '8.7.0', '8.8.0');
-    $post_update = new PostUpdateEvent($metadata, TRUE);
-    $notify = $this->container->get('automatic_updates.post_update_subscriber');
-    $notify->onPostUpdate($post_update);
-    $this->assertCount(1, $this->getMails());
-    $this->assertMailString('subject', 'Automatic update of "drupal" succeeded', 1);
-    $this->assertMailString('body', 'The project "drupal" was updated from "8.7.0" to "8.8.0" with success.', 1);
-
-    // Failure email.
-    $this->container->get('state')->set('system.test_mail_collector', []);
-    $post_update = new PostUpdateEvent($metadata, FALSE);
-    $notify = $this->container->get('automatic_updates.post_update_subscriber');
-    $notify->onPostUpdate($post_update);
-    $this->assertCount(1, $this->getMails());
-    $this->assertMailString('subject', 'Automatic update of "drupal" failed', 1);
-    $this->assertMailString('body', 'The project "drupal" was updated from "8.7.0" to "8.8.0" with failures.', 1);
-  }
-
-}
diff --git a/tests/src/Functional/ReadinessValidationTest.php b/tests/src/Functional/ReadinessValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..feb01de72df1f9a69efdbb4caef48e9ce8fb1082
--- /dev/null
+++ b/tests/src/Functional/ReadinessValidationTest.php
@@ -0,0 +1,407 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Functional;
+
+use Drupal\automatic_updates_test\Datetime\TestTime;
+use Drupal\automatic_updates_test\ReadinessChecker\TestChecker1;
+use Drupal\automatic_updates_test2\ReadinessChecker\TestChecker2;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\system\SystemManager;
+use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
+use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\Traits\Core\CronRunTrait;
+
+/**
+ * Tests readiness validation.
+ *
+ * @group automatic_updates
+ */
+class ReadinessValidationTest extends BrowserTestBase {
+
+  use StringTranslationTrait;
+  use CronRunTrait;
+  use ValidationTestTrait;
+
+  /**
+   * Expected explanation text when readiness checkers return error messages.
+   */
+  const ERRORS_EXPLANATION = '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.
+   */
+  const WARNINGS_EXPLANATION = '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.';
+
+  /**
+   * {@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\automatic_updates_test\ReadinessChecker\TestChecker1
+   */
+  protected $testChecker;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $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 automatic_updates. This
+    // ensures we are testing that automatic_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(['automatic_updates', 'automatic_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->drupalGet('admin/reports/status');
+    $this->assertReadinessReportMatches('Your site is ready for automatic updates.', 'checked', FALSE);
+    $this->drupalLogin($this->checkerRunnerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertReadinessReportMatches('Your site is ready for automatic updates. Run readiness checks now.', 'checked', FALSE);
+
+    // 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('automatic_updates');
+    $key_value->delete('readiness_validation_last_run');
+    $this->drupalLogin($this->reportViewerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertReadinessReportMatches('Your site is ready for automatic updates.', 'checked', FALSE);
+    $this->drupalLogin($this->checkerRunnerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertReadinessReportMatches('Your site is ready for automatic updates. Run readiness checks now.', 'checked', FALSE);
+
+    // 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->assertReadinessReportMatches('Your site is ready for automatic updates.', 'checked', FALSE);
+    $this->drupalLogin($this->checkerRunnerUser);
+    $this->drupalGet('admin/reports/status');
+    $this->assertReadinessReportMatches('Your site is ready for automatic updates. Run readiness checks now.', 'checked', FALSE);
+    /** @var \Drupal\automatic_updates\Validation\ValidationResult[] $expected_results */
+    $expected_results = $this->testResults['checker_1']['1 error'];
+    TestChecker1::setTestResult($expected_results);
+
+    // 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::ERRORS_EXPLANATION) . '/');
+    $this->assertReadinessReportMatches($expected_results[0]->getMessages()[0] . 'Run readiness checks now.', 'error', static::ERRORS_EXPLANATION);
+
+    // @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->assertReadinessReportMatches($expected_results[0]->getMessages()[0], 'error', static::ERRORS_EXPLANATION);
+
+    $expected_results = $this->testResults['checker_1']['1 error 1 warning'];
+    TestChecker1::setTestResult($expected_results);
+    $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->assertReadinessReportMatches($expected_results['1:error']->getMessages()[0], 'error', static::ERRORS_EXPLANATION);
+    $this->assertReadinessReportMatches($expected_results['1:warning']->getMessages()[0], 'warning', static::WARNINGS_EXPLANATION);
+    $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);
+    $this->drupalGet('admin/reports/status');
+    // Confirm that both messages and summaries will be displayed on status
+    // report when there multiple messages.
+    $this->assertReadinessReportMatches($expected_results['1:errors']->getSummary() . ' ' . implode('', $expected_results['1:errors']->getMessages()), 'error', static::ERRORS_EXPLANATION);
+    $this->assertReadinessReportMatches($expected_results['1:warnings']->getSummary() . ' ' . implode('', $expected_results['1:warnings']->getMessages()), 'warning', static::WARNINGS_EXPLANATION);
+
+    $key_value->delete('readiness_validation_last_run');
+    $expected_results = $this->testResults['checker_1']['2 warnings'];
+    TestChecker1::setTestResult($expected_results);
+    $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->assertReadinessReportMatches($expected_results[0]->getSummary() . ' ' . implode('', $expected_results[0]->getMessages()), 'warning', static::WARNINGS_EXPLANATION);
+
+    $key_value->delete('readiness_validation_last_run');
+    $expected_results = $this->testResults['checker_1']['1 warning'];
+    TestChecker1::setTestResult($expected_results);
+    $this->drupalGet('admin/reports/status');
+    $assert->pageTextContainsOnce('Update readiness checks');
+    $this->assertReadinessReportMatches($expected_results[0]->getMessages()[0], 'warning', static::WARNINGS_EXPLANATION);
+  }
+
+  /**
+   * 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 automatic_updates. This
+    // ensures we are testing that automatic_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(['automatic_updates', 'automatic_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->assertReadinessReportMatches('Your site is ready for automatic updates.', 'checked', FALSE);
+    $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);
+    // @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('automatic_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);
+    // 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::ERRORS_EXPLANATION);
+    // 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);
+    $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::ERRORS_EXPLANATION);
+    // 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);
+    $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::ERRORS_EXPLANATION);
+    $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::WARNINGS_EXPLANATION);
+    $assert->pageTextContainsOnce($expected_results[0]->getSummary());
+
+    $expected_results = $this->testResults['checker_1']['1 warning'];
+    TestChecker1::setTestResult($expected_results);
+    $this->delayRequestTime();
+    $this->cronRun();
+    $this->drupalGet('admin/structure');
+    $assert->pageTextNotContains(static::ERRORS_EXPLANATION);
+    // 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::WARNINGS_EXPLANATION);
+    $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results[0]->getSummary());
+  }
+
+  /**
+   * Tests installing a module with a checker before installing automatic_updates.
+   */
+  public function testReadinessCheckAfterInstall(): void {
+    $assert = $this->assertSession();
+    $this->drupalLogin($this->checkerRunnerUser);
+
+    $this->drupalGet('admin/reports/status');
+    $assert->pageTextNotContains('Update readiness checks');
+
+    $this->container->get('module_installer')->install(['automatic_updates']);
+    $this->drupalGet('admin/reports/status');
+    $this->assertReadinessReportMatches('Your site is ready for automatic updates. Run readiness checks now.', 'checked');
+
+    $expected_results = $this->testResults['checker_1']['1 error'];
+    TestChecker1::setTestResult($expected_results);
+    $this->container->get('module_installer')->install(['automatic_updates_test']);
+    $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'];
+    TestChecker1::setTestResult($unexpected_results);
+    $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);
+    $expected_results_2 = $this->testResults['checker_2']['1 error'];
+    TestChecker2::setTestResult($expected_results_2);
+    $this->container->get('module_installer')->install([
+      'automatic_updates',
+      'automatic_updates_test',
+      'automatic_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(['automatic_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(['automatic_updates_test']);
+    $this->drupalGet('admin/structure');
+    $assert->pageTextNotContains($expected_results_2[0]->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results_1[0]->getMessages()[0]);
+  }
+
+  /**
+   * Asserts status report readiness report item matches a format.
+   *
+   * @param string $format
+   *   The string to match.
+   * @param string $section
+   *   The section of the status report in which the string should appear.
+   * @param string $message_prefix
+   *   The prefix for before the string.
+   */
+  private function assertReadinessReportMatches(string $format, string $section = 'error', string $message_prefix = ''): void {
+    $format = 'Update readiness checks ' . ($message_prefix ? "$message_prefix " : '') . $format;
+
+    $text = $this->getSession()->getPage()->find(
+      'css',
+      "h3#$section ~ details.system-status-report__entry:contains('Update readiness checks')",
+    )->getText();
+    $this->assertStringMatchesFormat($format, $text);
+  }
+
+  /**
+   * 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/tests/src/Functional/UpdaterFormTest.php b/tests/src/Functional/UpdaterFormTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..abe90381ebb1b875dc15af93b1199ae0d7ea1083
--- /dev/null
+++ b/tests/src/Functional/UpdaterFormTest.php
@@ -0,0 +1,177 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Functional;
+
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates\Validation\ValidationResult;
+use Drupal\automatic_updates_test\ReadinessChecker\TestChecker1;
+use Drupal\automatic_updates_test\TestUpdater;
+use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * @covers \Drupal\automatic_updates\Form\UpdaterForm
+ *
+ * @group automatic_updates
+ */
+class UpdaterFormTest extends BrowserTestBase {
+
+  use ValidationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'automatic_updates',
+    'automatic_updates_test',
+    'update_test',
+  ];
+
+  /**
+   * Sets the running version of core, as known to the Update module.
+   *
+   * @param string $version
+   *   The version of core to set. When checking for updates, this is what the
+   *   Update module will think the running version of core is.
+   */
+  private function setCoreVersion(string $version): void {
+    $this->config('update_test.settings')
+      ->set('system_info.#all.version', $version)
+      ->save();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp() {
+    parent::setUp();
+
+    $this->config('update_test.settings')
+      ->set('xml_map', [
+        'drupal' => '0.0',
+      ])
+      ->save();
+    $this->config('update.settings')
+      ->set('fetch.url', $this->baseUrl . '/automatic-update-test')
+      ->save();
+  }
+
+  /**
+   * 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.
+   */
+  public function testFormNotDisplayedIfAlreadyCurrent(): void {
+    $this->setCoreVersion('9.8.1');
+
+    $this->drupalLogin($this->rootUser);
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/automatic-update');
+
+    $assert_session = $this->assertSession();
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains('No update available');
+    $assert_session->buttonNotExists('Download these updates');
+  }
+
+  /**
+   * Tests that available updates are rendered correctly in a table.
+   */
+  public function testTableLooksCorrect(): void {
+    $this->setCoreVersion('9.8.0');
+
+    $this->drupalLogin($this->rootUser);
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/automatic-update');
+
+    $assert_session = $this->assertSession();
+    $cells = $assert_session->elementExists('css', '#edit-projects .update-recommended')
+      ->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'));
+  }
+
+  /**
+   * Tests that the form runs update validators before starting the batch job.
+   */
+  public function testValidation(): void {
+    $this->setCoreVersion('9.8.0');
+
+    // Ensure that one of the update validators will produce an error when we
+    // try to run updates.
+    $this->createTestValidationResults();
+    $expected_results = $this->testResults['checker_1']['1 error'];
+    TestChecker1::setTestResult($expected_results, AutomaticUpdatesEvents::PRE_START);
+
+    $this->drupalLogin($this->rootUser);
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/automatic-update');
+    $this->getSession()->getPage()->pressButton('Download these updates');
+
+    $assert_session = $this->assertSession();
+    // We should still be on the same page, having not passed validation.
+    $assert_session->addressEquals('/admin/automatic-update');
+    foreach ($expected_results[0]->getMessages() as $message) {
+      $assert_session->pageTextContains($message);
+    }
+    // Since there is only one error message, we shouldn't see the summary.
+    $assert_session->pageTextNotContains($expected_results[0]->getSummary());
+
+    // Ensure the update-ready form runs pre-commit checks immediately, even
+    // before it's submitted.
+    $expected_results = $this->testResults['checker_1']['1 error 1 warning'];
+    TestChecker1::setTestResult($expected_results, AutomaticUpdatesEvents::PRE_COMMIT);
+    $this->drupalGet('/admin/automatic-update-ready');
+    $assert_session->pageTextContains($expected_results['1:error']->getMessages()[0]);
+    // Only show errors, not warnings.
+    $assert_session->pageTextNotContains($expected_results['1:warning']->getMessages()[0]);
+    // Since there is only one error message, we shouldn't see the summary. And
+    // we shouldn't see the warning's summary in any case.
+    $assert_session->pageTextNotContains($expected_results['1:error']->getSummary());
+    $assert_session->pageTextNotContains($expected_results['1:warning']->getSummary());
+  }
+
+  /**
+   * Tests that errors during the update process are displayed as messages.
+   */
+  public function testBatchErrorsAreForwardedToMessenger(): void {
+    $this->setCoreVersion('9.8.0');
+
+    $error = ValidationResult::createError([
+      t('💥'),
+    ], t('The update exploded.'));
+    TestUpdater::setBeginErrors([$error]);
+
+    $this->drupalLogin($this->rootUser);
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/automatic-update');
+    $this->submitForm([], 'Download these updates');
+    $assert_session = $this->assertSession();
+    $assert_session->pageTextContains('An error has occurred.');
+    $this->getSession()->getPage()->clickLink('the error page');
+    $assert_session->pageTextContains('💥');
+    $assert_session->pageTextContains('The update exploded.');
+  }
+
+  /**
+   * Checks for available updates.
+   *
+   * Assumes that a user with appropriate permissions is logged in.
+   */
+  private function checkForUpdates(): void {
+    $this->drupalGet('/admin/reports/updates');
+    $this->getSession()->getPage()->clickLink('Check manually');
+    $this->checkForMetaRefresh();
+  }
+
+}
diff --git a/tests/src/Kernel/ComposerStagerServicesTest.php b/tests/src/Kernel/ComposerStagerServicesTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..c02717ec1b4333b50a12510e78949ec97a427cc3
--- /dev/null
+++ b/tests/src/Kernel/ComposerStagerServicesTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel;
+
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests that the Composer Stager library's services are available.
+ *
+ * @todo Remove this test when Composer Stager's API is stable and tagged.
+ *
+ * @group automatic_updates
+ */
+class ComposerStagerServicesTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['automatic_updates', 'update'];
+
+  /**
+   * Tests that the Composer Stager services are available in the container.
+   */
+  public function testServices(): void {
+    $services = [
+      'automatic_updates.beginner',
+      'automatic_updates.stager',
+      'automatic_updates.cleaner',
+      'automatic_updates.committer',
+      'automatic_updates.composer_runner',
+      'automatic_updates.file_copier',
+      'automatic_updates.file_system',
+      'automatic_updates.symfony_file_system',
+      'automatic_updates.symfony_exec_finder',
+      'automatic_updates.rsync',
+      'automatic_updates.exec_finder',
+      'automatic_updates.process_factory',
+    ];
+    foreach ($services as $service_id) {
+      $service = $this->container->get($service_id);
+      $this->assertIsObject($service);
+      $this->assertSame($service_id, $service->_serviceId);
+    }
+  }
+
+}
diff --git a/tests/src/Kernel/ProjectInfoTraitTest.php b/tests/src/Kernel/ProjectInfoTraitTest.php
deleted file mode 100644
index d2f6d9c01cb6561f1ec22a844de0ab479b0deba1..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ProjectInfoTraitTest.php
+++ /dev/null
@@ -1,135 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel;
-
-use Drupal\automatic_updates\ProjectInfoTrait;
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * @coversDefaultClass \Drupal\automatic_updates\ProjectInfoTrait
- * @group automatic_updates
- */
-class ProjectInfoTraitTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * @covers ::getExtensionVersion
-   * @covers ::getProjectName
-   * @dataProvider providerInfos
-   */
-  public function testTrait($expected, $info, $extension_name) {
-    $class = new ProjectInfoTestClass();
-    $project_name = $class->getProjectName($extension_name, $info);
-    $this->assertSame($expected['project'], $project_name);
-    $this->assertSame($expected['version'], $class->getExtensionVersion($info + ['project' => $project_name]));
-  }
-
-  /**
-   * Data provider for testTrait.
-   */
-  public function providerInfos() {
-    $infos['node']['expected'] = [
-      'version' => NULL,
-      'project' => 'drupal',
-    ];
-    $infos['node']['info'] = [
-      'name' => 'Node',
-      'type' => 'module',
-      'description' => 'Allows content to be submitted to the site and displayed on pages.',
-      'package' => 'Core',
-      'version' => '8.8.x-dev',
-      'project' => 'drupal',
-      'core' => '8.x',
-      'configure' => 'entity.node_type.collection',
-      'dependencies' => ['drupal:text'],
-      'install path' => '',
-    ];
-    $infos['node']['extension_name'] = 'node';
-
-    $infos['update']['expected'] = [
-      'version' => NULL,
-      'project' => 'drupal/update',
-    ];
-    $infos['update']['info'] = [
-      'name' => 'Update manager',
-      'type' => 'module',
-      'description' => 'Checks for available updates, and can securely install or update modules and themes via a web interface.',
-      'package' => 'Core',
-      'core' => '8.x',
-      'configure' => 'update.settings',
-      'dependencies' => ['file'],
-      'install path' => '',
-    ];
-    $infos['update']['extension_name'] = 'drupal/update';
-
-    $infos['system']['expected'] = [
-      'version' => '8.8.0',
-      'project' => 'drupal',
-    ];
-    $infos['system']['info'] = [
-      'name' => 'System',
-      'type' => 'module',
-      'description' => 'Handles general site configuration for administrators.',
-      'package' => 'Core',
-      'version' => '8.8.0',
-      'project' => 'drupal',
-      'core' => '8.x',
-      'required' => 'true',
-      'configure' => 'system.admin_config_system',
-      'dependencies' => [],
-      'install path' => '',
-    ];
-    $infos['system']['extension_name'] = 'system';
-
-    $infos['automatic_updates']['expected'] = [
-      'version' => NULL,
-      'project' => 'automatic_updates',
-    ];
-    $infos['automatic_updates']['info'] = [
-      'name' => 'Automatic Updates',
-      'type' => 'module',
-      'description' => 'Display public service announcements and verify readiness for applying automatic updates to the site.',
-      'package' => 'Core',
-      'core' => '8.x',
-      'configure' => 'automatic_updates.settings',
-      'dependencies' => ['system', 'update'],
-      'install path' => '',
-    ];
-    $infos['automatic_updates']['extension_name'] = 'automatic_updates';
-
-    return $infos;
-  }
-
-}
-
-/**
- * Class ProjectInfoTestClass.
- */
-class ProjectInfoTestClass {
-
-  use ProjectInfoTrait {
-    getExtensionVersion as getVersion;
-    getProjectName as getProject;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getExtensionVersion(array $info) {
-    return $this->getVersion($info);
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getProjectName($extension_name, array $info) {
-    return $this->getProject($extension_name, $info);
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/CronFrequencyTest.php b/tests/src/Kernel/ReadinessChecker/CronFrequencyTest.php
deleted file mode 100644
index 6bef7cc2640262e842da08b9cdaf87fe87db1b3a..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/CronFrequencyTest.php
+++ /dev/null
@@ -1,45 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * Tests what happens when cron has frequency of greater than 3 hours.
- *
- * @group automatic_updates
- */
-class CronFrequencyTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automated_cron',
-    'automatic_updates',
-    'system',
-  ];
-
-  /**
-   * Tests the functionality of supported PHP version readiness checks.
-   */
-  public function testSupportedPhpVersion() {
-    // Module automated_cron is disabled.
-    $messages = $this->container->get('automatic_updates.cron_frequency')->run();
-    $this->assertEmpty($messages);
-
-    // Module automated_cron has default configuration.
-    $this->enableModules(['automated_cron']);
-    $messages = $this->container->get('automatic_updates.cron_frequency')->run();
-    $this->assertEmpty($messages);
-
-    // Module automated_cron has 6 hour configuration.
-    $this->container->get('config.factory')
-      ->getEditable('automated_cron.settings')
-      ->set('interval', 21600)
-      ->save();
-    $messages = $this->container->get('automatic_updates.cron_frequency')->run();
-    self::assertEquals('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.', $messages[0]);
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/DiskSpaceTest.php b/tests/src/Kernel/ReadinessChecker/DiskSpaceTest.php
deleted file mode 100644
index a3b499947ff629729c2ce4f3239116ac21d096a3..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/DiskSpaceTest.php
+++ /dev/null
@@ -1,70 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\automatic_updates\ReadinessChecker\DiskSpace;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * Tests disk space readiness checking.
- *
- * @group automatic_updates
- */
-class DiskSpaceTest extends KernelTestBase {
-  use StringTranslationTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * Tests the functionality of disk space readiness checks.
-   */
-  public function testDiskSpace() {
-    // No disk space issues.
-    $disk_space = new DiskSpace($this->container->get('app.root'));
-    $messages = $disk_space->run();
-    $this->assertEmpty($messages);
-
-    // Out of space.
-    $disk_space = new TestDiskSpace($this->container->get('app.root'));
-    $messages = $disk_space->run();
-    $this->assertCount(2, $messages);
-
-    // Out of space not the same logical disk.
-    $disk_space = new TestDiskSpaceNonSameDisk($this->container->get('app.root'));
-    $messages = $disk_space->run();
-    $this->assertCount(3, $messages);
-  }
-
-}
-
-/**
- * Class TestDiskSpace.
- */
-class TestDiskSpace extends DiskSpace {
-
-  /**
-   * Override the default free disk space minimum to an insanely high number.
-   */
-  const MINIMUM_DISK_SPACE = 99999999999999999999999999999999999999999999999999;
-
-}
-
-/**
- * Class TestDiskSpaceNonSameDisk.
- */
-class TestDiskSpaceNonSameDisk extends TestDiskSpace {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function areSameLogicalDisk($root, $vendor) {
-    return FALSE;
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/FileOwnershipTest.php b/tests/src/Kernel/ReadinessChecker/FileOwnershipTest.php
deleted file mode 100644
index 3e4039c35868bd0f3280388448da77a34b8a7d70..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/FileOwnershipTest.php
+++ /dev/null
@@ -1,57 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\automatic_updates\ReadinessChecker\FileOwnership;
-use Drupal\KernelTests\KernelTestBase;
-use org\bovigo\vfs\vfsStream;
-
-/**
- * Tests modified code readiness checking.
- *
- * @group automatic_updates
- */
-class FileOwnershipTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * Tests the functionality of modified code readiness checks.
-   */
-  public function testFileOwnership() {
-    // No ownership problems.
-    $file_ownership = new FileOwnership($this->container->get('app.root'));
-    $messages = $file_ownership->run();
-    $this->assertEmpty($messages);
-
-    // Ownership problems.
-    $file_ownership = new TestFileOwnership($this->container->get('app.root'));
-    $messages = $file_ownership->run();
-    $this->assertCount(1, $messages);
-    $this->assertStringStartsWith('Files are owned by uid "23"', (string) $messages[0]);
-    $this->assertStringEndsWith('The file owner and PHP user should be the same during an update.', (string) $messages[0]);
-  }
-
-}
-
-/**
- * Class TestFileOwnership.
- */
-class TestFileOwnership extends FileOwnership {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function doCheck() {
-    $file_stream = vfsStream::setup('core', '755', ['core.api.php' => 'contents']);
-    $file = $file_stream->getChild('core.api.php');
-    $file->chown(23)->chgrp(23);
-    return $this->ownerIsScriptUser($file->url());
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/MissingProjectInfoTest.php b/tests/src/Kernel/ReadinessChecker/MissingProjectInfoTest.php
deleted file mode 100644
index 14a728dcb430123d03ca8138ce922caa6842a66e..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/MissingProjectInfoTest.php
+++ /dev/null
@@ -1,80 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\automatic_updates\ReadinessChecker\MissingProjectInfo;
-use Drupal\Core\Extension\ExtensionList;
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * Tests missing project info readiness checking.
- *
- * @group automatic_updates
- */
-class MissingProjectInfoTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * {@inheritdoc}
-   */
-  public function setUp() {
-    parent::setUp();
-    $this->installConfig(['automatic_updates']);
-  }
-
-  /**
-   * Tests pending db updates readiness checks.
-   */
-  public function testMissingProjectInfo() {
-    // The checker should always have messages on the testbot, since project
-    // info is added by the packager.
-    $ignore_paths = "modules/custom/*\nthemes/custom/*\nprofiles/custom/*";
-    $this->config('automatic_updates.settings')->set('ignored_paths', $ignore_paths)
-      ->save();
-    $messages = $this->container->get('automatic_updates.missing_project_info')->run();
-    $this->assertNotEmpty($messages);
-
-    // Now test with a some dummy info data that won't cause any issues.
-    $extension_list = $this->createMock(ExtensionList::class);
-    $messages = (new TestMissingProjectInfo($extension_list, $extension_list, $extension_list))->run();
-    $this->assertEmpty($messages);
-  }
-
-}
-
-/**
- * Class TestMissingProjectInfo.
- */
-class TestMissingProjectInfo extends MissingProjectInfo {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function getInfos($extension_type) {
-    $infos = [];
-    if ($extension_type === 'modules') {
-      $infos['system'] = [
-        'name' => 'System',
-        'type' => 'module',
-        'description' => 'Handles general site configuration for administrators.',
-        'package' => 'Core',
-        'version' => 'VERSION',
-        'packaged' => FALSE,
-        'project' => $this->getProjectName('system', ['install path' => 'core']),
-        'install path' => drupal_get_path('module', 'system'),
-        'core' => '8.x',
-        'required' => 'true',
-        'configure' => 'system.admin_config_system',
-        'dependencies' => [],
-      ];
-    }
-    return $infos;
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/ModifiedFilesTest.php b/tests/src/Kernel/ReadinessChecker/ModifiedFilesTest.php
deleted file mode 100644
index 79e72b9ee4339e938b59a3225a1c42f4e0d19390..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/ModifiedFilesTest.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\automatic_updates\ReadinessChecker\ModifiedFiles;
-use Drupal\automatic_updates\Services\ModifiedFilesInterface;
-use Drupal\KernelTests\KernelTestBase;
-use Prophecy\Argument;
-
-/**
- * Tests of automatic updates.
- *
- * @group automatic_updates
- */
-class ModifiedFilesTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = [
-    'automatic_updates',
-    'test_automatic_updates',
-  ];
-
-  /**
-   * Tests modified files service.
-   */
-  public function testModifiedFiles() {
-    /** @var \Prophecy\Prophecy\ObjectProphecy|\Drupal\automatic_updates\Services\ModifiedFilesInterface $service */
-    $service = $this->prophesize(ModifiedFilesInterface::class);
-    $service->getModifiedFiles(Argument::type('array'))->willReturn(new \ArrayIterator());
-    $modules = $this->container->get('extension.list.module');
-    $profiles = $this->container->get('extension.list.profile');
-    $themes = $this->container->get('extension.list.theme');
-
-    // No modified code.
-    $modified_files = new ModifiedFiles(
-      $service->reveal(),
-      $modules,
-      $profiles,
-      $themes
-    );
-    $messages = $modified_files->run();
-    $this->assertEmpty($messages);
-
-    // Hash doesn't match i.e. modified code.
-    $service->getModifiedFiles(Argument::type('array'))->willReturn(new \ArrayIterator(['core/LICENSE.txt']));
-    $messages = $modified_files->run();
-    $this->assertCount(1, $messages);
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/OpcodeCacheTest.php b/tests/src/Kernel/ReadinessChecker/OpcodeCacheTest.php
deleted file mode 100644
index 385ee77ec09c75dfb73e0591e427e3f7ddcb0dee..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/OpcodeCacheTest.php
+++ /dev/null
@@ -1,81 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * Tests opcode caching and execution via CLI.
- *
- * @group automatic_updates
- */
-class OpcodeCacheTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * Tests the functionality of supported PHP version readiness checks.
-   *
-   * @dataProvider opcodeCacheProvider
-   */
-  public function testOpcodeCache($ini, $ini_value, $failure) {
-    ini_set($ini, $ini_value);
-    $messages = $this->container->get('automatic_updates.opcode_cache')->run();
-    if ($failure) {
-      $this->assertNotEmpty($messages);
-      self::assertEquals((string) $messages[0], 'Automatic updates cannot run via CLI  when opcode file cache is enabled.');
-    }
-    else {
-      $this->assertEmpty($messages);
-    }
-  }
-
-  /**
-   * Data provider for opcode cache testing.
-   */
-  public function opcodeCacheProvider() {
-    $datum[] = [
-      'ini' => 'opcache.validate_timestamps',
-      'ini_value' => 0,
-      'failure' => TRUE,
-    ];
-    $datum[] = [
-      'ini' => 'opcache.validate_timestamps',
-      'ini_value' => 1,
-      'failure' => FALSE,
-    ];
-    $datum[] = [
-      'ini' => 'opcache.validate_timestamps',
-      'ini_value' => FALSE,
-      'failure' => TRUE,
-    ];
-    $datum[] = [
-      'ini' => 'opcache.validate_timestamps',
-      'ini_value' => TRUE,
-      'failure' => FALSE,
-    ];
-    $datum[] = [
-      'ini' => 'opcache.validate_timestamps',
-      'ini_value' => 2,
-      'failure' => FALSE,
-    ];
-    $datum[] = [
-      'ini' => 'opcache.revalidate_freq',
-      'ini_value' => 3,
-      'failure' => TRUE,
-    ];
-    $datum[] = [
-      'ini' => 'opcache.revalidate_freq',
-      'ini_value' => 2,
-      'failure' => FALSE,
-    ];
-
-    return $datum;
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/PendingDbUpdatesTest.php b/tests/src/Kernel/ReadinessChecker/PendingDbUpdatesTest.php
deleted file mode 100644
index 6a693790a6aadda762ad16d8de4516b25a86856e..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/PendingDbUpdatesTest.php
+++ /dev/null
@@ -1,52 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\automatic_updates\ReadinessChecker\PendingDbUpdates;
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * Tests pending db updates readiness checking.
- *
- * @group automatic_updates
- */
-class PendingDbUpdatesTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * Tests pending db updates readiness checks.
-   */
-  public function testPendingDbUpdates() {
-    $messages = $this->container->get('automatic_updates.pending_db_updates')->run();
-    $this->assertEmpty($messages);
-
-    $messages = (new TestPendingDbUpdates())->run();
-    self::assertEquals('There are pending database updates. Please run update.php.', $messages[0]);
-  }
-
-}
-
-/**
- * Class TestPendingDbUpdates.
- */
-class TestPendingDbUpdates extends PendingDbUpdates {
-
-  /**
-   * {@inheritdoc}
-   */
-  public function __construct() {}
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function areDbUpdatesPending() {
-    return TRUE;
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/PhpSapiTest.php b/tests/src/Kernel/ReadinessChecker/PhpSapiTest.php
deleted file mode 100644
index 32052ac30b0ca3f8f195f773301a4bb79ebbdbe4..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/PhpSapiTest.php
+++ /dev/null
@@ -1,35 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * Tests what happens when PHP SAPI changes from one value to another.
- *
- * @group automatic_updates
- */
-class PhpSapiTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * Tests PHP SAPI changes.
-   */
-  public function testPhpSapiChanges() {
-    $messages = $this->container->get('automatic_updates.php_sapi')->run();
-    $this->assertEmpty($messages);
-    $messages = $this->container->get('automatic_updates.php_sapi')->run();
-    $this->assertEmpty($messages);
-
-    $this->container->get('state')->set('automatic_updates.php_sapi', 'foo');
-    $messages = $this->container->get('automatic_updates.php_sapi')->run();
-    self::assertEquals('PHP changed from running as "foo" to "cli". This can lead to inconsistent and misleading results.', $messages[0]);
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/ReadOnlyFilesystemTest.php b/tests/src/Kernel/ReadinessChecker/ReadOnlyFilesystemTest.php
deleted file mode 100644
index acff7f82a2c7f712cec0a956f3c95143e68b41bb..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/ReadOnlyFilesystemTest.php
+++ /dev/null
@@ -1,216 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\automatic_updates\ReadinessChecker\ReadOnlyFilesystem;
-use Drupal\Core\File\Exception\FileException;
-use Drupal\Core\File\FileSystemInterface;
-use Drupal\KernelTests\KernelTestBase;
-use org\bovigo\vfs\vfsStream;
-use Psr\Log\LoggerInterface;
-
-/**
- * @coversDefaultClass \Drupal\automatic_updates\ReadinessChecker\ReadOnlyFilesystem
- *
- * @group automatic_updates
- */
-class ReadOnlyFilesystemTest extends KernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-    'system',
-  ];
-
-  /**
-   * Tests the readiness check where the root directory does not exist.
-   *
-   * @covers ::run
-   *
-   * @dataProvider providerNoWebRoot
-   */
-  public function testNoWebRoot($files) {
-    vfsStream::setup('root');
-    vfsStream::create($files);
-    $readOnly = new ReadOnlyFilesystem(
-      vfsStream::url('root'),
-      $this->prophesize(LoggerInterface::class)->reveal(),
-      $this->prophesize(FileSystemInterface::class)->reveal()
-    );
-    $this->assertEquals(['The web root could not be located.'], $readOnly->run());
-  }
-
-  /**
-   * Data provider for testNoWebRoot().
-   */
-  public function providerNoWebRoot() {
-    return [
-      'no core.api.php' => [
-        [
-          'core' => [
-            'core.txt' => 'test',
-          ],
-        ],
-      ],
-      'core.api.php in wrong location' => [
-        [
-          'core.api.php' => 'test',
-        ],
-      ],
-
-    ];
-  }
-
-  /**
-   * Tests the readiness check on writable file system on same logic disk.
-   *
-   * @covers ::run
-   */
-  public function testSameLogicDiskWritable() {
-    $readOnly = new ReadOnlyFilesystem(
-      self::getVfsRoot(),
-      $this->container->get('logger.channel.automatic_updates'),
-      $this->container->get('file_system')
-    );
-    $this->assertEquals([], $readOnly->run());
-  }
-
-  /**
-   * Tests root and vendor directories are writable on different logical disks.
-   *
-   * @covers ::run
-   */
-  public function testDifferentLogicDiskWritable() {
-    $readOnly = new TestReadOnlyFilesystem(
-      self::getVfsRoot(),
-      $this->container->get('logger.channel.automatic_updates'),
-      $this->container->get('file_system')
-    );
-    $this->assertEquals([], $readOnly->run());
-  }
-
-  /**
-   * Tests non-writable core and vendor directories on same logic disk.
-   *
-   * @covers ::run
-   */
-  public function testSameLogicDiskNotWritable() {
-    $file_system = $this->createMock(FileSystemInterface::class);
-    $file_system->expects($this->once())
-      ->method('copy')
-      ->willThrowException(new FileException());
-
-    $root = self::getVfsRoot();
-    $readOnly = new ReadOnlyFilesystem(
-      $root,
-      $this->container->get('logger.channel.automatic_updates'),
-      $file_system
-    );
-    $this->assertEquals(["Logical disk at \"$root\" is read only. Updates to Drupal cannot be applied against a read only file system."], $readOnly->run());
-  }
-
-  /**
-   * Tests the readiness check on read-only file system.
-   *
-   * @covers ::run
-   */
-  public function testDifferentLogicDiskNotWritable() {
-    $root = self::getVfsRoot();
-
-    // Assert messages if both core and vendor directory are not writable.
-    $file_system = $this->createMock(FileSystemInterface::class);
-    $file_system->expects($this->any())
-      ->method('copy')
-      ->willThrowException(new FileException());
-    $readOnly = new TestReadOnlyFileSystem(
-      $root,
-      $this->container->get('logger.channel.automatic_updates'),
-      $file_system
-    );
-    $this->assertEquals(
-      [
-        "Drupal core filesystem at \"$root/core\" is read only. Updates to Drupal core cannot be applied against a read only file system.",
-        "Vendor filesystem at \"$root/vendor\" is read only. Updates to the site's code base cannot be applied against a read only file system.",
-      ],
-      $readOnly->run()
-    );
-
-    // Assert messages if core directory is not writable.
-    $file_system = $this->createMock(FileSystemInterface::class);
-    $file_system
-      ->method('copy')
-      ->withConsecutive(
-        ['vfs://root/core/core.api.php', 'vfs://root/core/core.api.php.automatic_updates'],
-        ['vfs://root/vendor/composer/LICENSE', 'vfs://root/vendor/composer/LICENSE.automatic_updates']
-      )
-      ->willReturnOnConsecutiveCalls(FALSE, TRUE);
-    $readOnly = new TestReadOnlyFileSystem(
-      $root,
-      $this->container->get('logger.channel.automatic_updates'),
-      $file_system
-    );
-    $this->assertEquals(
-      ["Drupal core filesystem at \"$root/core\" is read only. Updates to Drupal core cannot be applied against a read only file system."],
-      $readOnly->run()
-    );
-
-    // Assert messages if vendor directory is not writable.
-    $file_system = $this->createMock(FileSystemInterface::class);
-    $file_system
-      ->method('copy')
-      ->withConsecutive(
-        ['vfs://root/core/core.api.php', 'vfs://root/core/core.api.php.automatic_updates'],
-        ['vfs://root/vendor/composer/LICENSE', 'vfs://root/vendor/composer/LICENSE.automatic_updates']
-      )
-      ->willReturnOnConsecutiveCalls(TRUE, FALSE);
-    $readOnly = new TestReadOnlyFileSystem(
-      $root,
-      $this->container->get('logger.channel.automatic_updates'),
-      $file_system
-    );
-    $this->assertEquals(
-      ["Vendor filesystem at \"$root/vendor\" is read only. Updates to the site's code base cannot be applied against a read only file system."],
-      $readOnly->run()
-    );
-  }
-
-  /**
-   * Gets root of virtual Drupal directory.
-   *
-   * @return string
-   *   The root.
-   */
-  protected static function getVfsRoot() {
-    vfsStream::setup('root');
-    vfsStream::create([
-      'core' => [
-        'core.api.php' => 'test',
-      ],
-      'vendor' => [
-        'composer' => [
-          'LICENSE' => 'test',
-        ],
-      ],
-    ]);
-    return vfsStream::url('root');
-  }
-
-}
-
-/**
- * Test class to root and vendor directories in different logic disks.
- *
- * Calls to stat() does not work on \org\bovigo\vfs\vfsStream.
- */
-class TestReadOnlyFileSystem extends ReadOnlyFilesystem {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function areSameLogicalDisk($root, $vendor) {
-    return FALSE;
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php b/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php
deleted file mode 100644
index 0d50ef0050c7cc6006a447d61aedf4d5bb36e65e..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/ReadinessCheckerTest.php
+++ /dev/null
@@ -1,34 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\KernelTests\KernelTestBase;
-
-/**
- * Tests automatic updates readiness checking.
- *
- * @group automatic_updates
- */
-class ReadinessCheckerTest extends KernelTestBase {
-  use StringTranslationTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * Tests the functionality of readiness checks.
-   */
-  public function testReadinessChecker() {
-    /** @var \Drupal\automatic_updates\ReadinessChecker\ReadinessCheckerManagerInterface $checker */
-    $checker = $this->container->get('automatic_updates.readiness_checker');
-    foreach ($checker->getCategories() as $category) {
-      $this->assertEmpty($checker->run($category));
-    }
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessChecker/VendorTest.php b/tests/src/Kernel/ReadinessChecker/VendorTest.php
deleted file mode 100644
index d1a00bbb7715cd1022db40936eced0cfc350fb6c..0000000000000000000000000000000000000000
--- a/tests/src/Kernel/ReadinessChecker/VendorTest.php
+++ /dev/null
@@ -1,44 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Kernel\ReadinessChecker;
-
-use Drupal\automatic_updates\ReadinessChecker\Vendor;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\KernelTests\KernelTestBase;
-use org\bovigo\vfs\vfsStream;
-
-/**
- * Tests locating vendor folder.
- *
- * @group automatic_updates
- */
-class VendorTest extends KernelTestBase {
-  use StringTranslationTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  public static $modules = [
-    'automatic_updates',
-  ];
-
-  /**
-   * Tests vendor folder existing.
-   */
-  public function testVendor() {
-    $vendor = $this->container->get('automatic_updates.vendor');
-    $this->assertEmpty($vendor->run());
-
-    vfsStream::setup('root');
-    vfsStream::create([
-      'core' => [
-        'core.api.php' => 'test',
-      ],
-    ]);
-    $missing_vendor = new Vendor(vfsStream::url('root'));
-    $this->assertEquals([], $vendor->run());
-    $expected_messages = [$this->t('The vendor folder could not be located.')];
-    self::assertEquals($expected_messages, $missing_vendor->run());
-  }
-
-}
diff --git a/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..cebf09f39a2cc9b1d1d5bb35a32adb0c332d1241
--- /dev/null
+++ b/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
@@ -0,0 +1,249 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel\Validation;
+
+use Drupal\automatic_updates_test\ReadinessChecker\TestChecker1;
+use Drupal\automatic_updates_test2\ReadinessChecker\TestChecker2;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\system\SystemManager;
+use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates\Validation\ReadinessValidationManager
+ *
+ * @group automatic_updates
+ */
+class ReadinessValidationManagerTest extends KernelTestBase {
+
+  use ValidationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['automatic_updates_test', '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(['update', 'automatic_updates', 'automatic_updates_test2']);
+    $this->installConfig(['update', 'automatic_updates']);
+    $this->assertSame([], $this->getResultsFromManager(TRUE));
+    $expected_results = [
+      array_pop($this->testResults['checker_1']),
+      array_pop($this->testResults['checker_2']),
+    ];
+    TestChecker1::setTestResult($expected_results[0]);
+    TestChecker2::setTestResult($expected_results[1]);
+    $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.
+    // @see \Drupal\automatic_updates_test\AutoUpdatesTestServiceProvider::alter().
+    define('AUTOMATIC_UPDATES_TEST_SET_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]);
+    TestChecker2::setTestResult($expected_results[1]);
+    $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]);
+    // Confirm that messages from an existing module are displayed when
+    // 'automatic_updates' is installed.
+    $this->container->get('module_installer')->install(['automatic_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]);
+    TestChecker2::setTestResult($expected_results[1]);
+    $this->container->get('module_installer')->install(['automatic_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]);
+    TestChecker2::setTestResult($unexpected_results[1]);
+    $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]);
+    TestChecker2::setTestResult($expected_results[1]);
+    // Confirm that messages from existing modules are displayed when
+    // 'automatic_updates' is installed.
+    $this->container->get('module_installer')->install(['automatic_updates', 'automatic_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]);
+    TestChecker2::setTestResult(array_pop($this->testResults['checker_2']));
+    $this->container->get('module_installer')->uninstall(['automatic_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]);
+    $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);
+    $this->container->get('module_installer')->install(['automatic_updates', 'automatic_updates_test2']);
+    $this->assertCheckerResultsFromManager($expected_results);
+
+    $unexpected_results = array_pop($this->testResults['checker_1']);
+    TestChecker1::setTestResult($unexpected_results);
+    $manager = $this->container->get('automatic_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('automatic_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);
+    /** @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
+    // 'automatic_updates_test.checker' to change from 2 to 4 which will be now
+    // higher than 'automatic_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.
+    // @see \Drupal\automatic_updates_test\AutoUpdatesTestServiceProvider::alter().
+    define('AUTOMATIC_UPDATES_TEST_SET_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\automatic_updates\Validation\ReadinessValidationManager $manager */
+    $manager = $this->container->get('automatic_updates.readiness_validation_manager');
+    $manager->runIfNoStoredResults();
+    $this->assertCheckerResultsFromManager($expected_results);
+  }
+
+  /**
+   * Asserts expected validation results from the manager.
+   *
+   * @param \Drupal\automatic_updates\Validation\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.
+   */
+  private 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);
+  }
+
+  /**
+   * 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\automatic_updates\Validation\ValidationResult[]|null
+   *   The messages of the type.
+   */
+  protected function getResultsFromManager(bool $call_run = FALSE, ?int $severity = NULL): ?array {
+    $manager = $this->container->get('automatic_updates.readiness_validation_manager');
+    if ($call_run) {
+      $manager->run();
+    }
+    return $manager->getResults($severity);
+  }
+
+}
diff --git a/tests/src/Kernel/UpdaterTest.php b/tests/src/Kernel/UpdaterTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..e08a7993729146d66281e3abbd72a65708ffdd55
--- /dev/null
+++ b/tests/src/Kernel/UpdaterTest.php
@@ -0,0 +1,113 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Kernel;
+
+use Composer\Autoload\ClassLoader;
+use Drupal\automatic_updates\AutomaticUpdatesEvents;
+use Drupal\automatic_updates\Exception\UpdateException;
+use Drupal\automatic_updates_test\ReadinessChecker\TestChecker1;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\KernelTests\KernelTestBase;
+use Drupal\Tests\automatic_updates\Traits\ValidationTestTrait;
+use PhpTuf\ComposerStager\Domain\BeginnerInterface;
+use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates\Updater
+ */
+class UpdaterTest extends KernelTestBase {
+
+  use ValidationTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'update',
+    'automatic_updates',
+    'automatic_updates_test',
+  ];
+
+  /**
+   * The directory that should be used for staging an update.
+   *
+   * @var string
+   */
+  protected $stageDirectory;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $class_loader_reflection = new \ReflectionClass(ClassLoader::class);
+    $vendor_directory = dirname($class_loader_reflection->getFileName(), 2);
+    $this->stageDirectory = realpath($vendor_directory . '/..') . '/.automatic_updates_stage';
+    $this->assertDirectoryDoesNotExist($this->stageDirectory);
+    $this->createTestValidationResults();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown(): void {
+    // Clean up the staging area and ensure it's gone. This tests that the
+    // cleaner service works as expected, AND keeps the file system in a
+    // consistent state if a test fails.
+    $this->container->get('automatic_updates.updater')->clean();
+    $this->assertDirectoryDoesNotExist($this->stageDirectory);
+    parent::tearDown();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('automatic_updates.beginner')
+      ->setClass(TestBeginner::class);
+  }
+
+  /**
+   * Tests that validation errors will stop an update attempt.
+   */
+  public function testCheckerErrors(): void {
+    $expected_results = $this->testResults['checker_1']['1 error'];
+    TestChecker1::setTestResult($expected_results, AutomaticUpdatesEvents::PRE_START);
+    try {
+      $this->container->get('automatic_updates.updater')->begin();
+      $this->fail('Updater should fail.');
+    }
+    catch (UpdateException $exception) {
+      $actual_results = $exception->getValidationResults();
+      $this->assertValidationResultsEqual($expected_results, $actual_results);
+    }
+  }
+
+  /**
+   * Tests that validation warnings do not stop an update attempt.
+   */
+  public function testCheckerWarnings() {
+    $expected_results = $this->testResults['checker_1']['1 warning'];
+    TestChecker1::setTestResult($expected_results, AutomaticUpdatesEvents::PRE_START);
+    $updater = $this->container->get('automatic_updates.updater');
+    $updater->begin();
+    $this->assertDirectoryExists($this->stageDirectory);
+  }
+
+}
+
+/**
+ * A beginner that creates the staging directly but doesn't copy any files.
+ */
+class TestBeginner implements BeginnerInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function begin(string $activeDir, string $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
+    mkdir($stagingDir);
+  }
+
+}
diff --git a/tests/src/Traits/InstallTestTrait.php b/tests/src/Traits/InstallTestTrait.php
deleted file mode 100644
index 27b0bddf70f703566a6b9e6e1baf6f58b87a2655..0000000000000000000000000000000000000000
--- a/tests/src/Traits/InstallTestTrait.php
+++ /dev/null
@@ -1,41 +0,0 @@
-<?php
-
-namespace Drupal\Tests\automatic_updates\Traits;
-
-use Drupal\Component\Utility\Html;
-
-/**
- * Provides common functionality for automatic update test classes.
- */
-trait InstallTestTrait {
-
-  /**
-   * Helper method that uses Drupal's module page to install a module.
-   */
-  protected function moduleInstall($module_name) {
-    $assert = $this->visit('/admin/modules')
-      ->assertSession();
-    $field = Html::getClass("edit-modules $module_name enable");
-    // No need to install a module if it is already install.
-    if ($this->getMink()->getSession()->getPage()->findField($field)->isChecked()) {
-      return;
-    }
-    $assert->fieldExists($field)->check();
-    $session = $this->getMink()->getSession();
-    $session->getPage()->findButton('Install')->submit();
-    $assert->fieldExists($field)->isChecked();
-    $assert->statusCodeEquals(200);
-  }
-
-  /**
-   * Helper method that uses Drupal's theme page to install a theme.
-   */
-  protected function themeInstall($theme_name) {
-    $this->moduleInstall('test_automatic_updates');
-    $assert = $this->visit("/admin/appearance/default?theme=$theme_name")
-      ->assertSession();
-    $assert->pageTextNotContains('theme was not found');
-    $assert->statusCodeEquals(200);
-  }
-
-}
diff --git a/tests/src/Traits/JsonTrait.php b/tests/src/Traits/JsonTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..3299ea439ff8d5b67c093329229e5d5e17b1d5e5
--- /dev/null
+++ b/tests/src/Traits/JsonTrait.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\automatic_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 array
+   *   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/tests/src/Traits/LocalPackagesTrait.php b/tests/src/Traits/LocalPackagesTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..938ca0a268edf44f10d0864dd0d5fa7e0aeb2ed2
--- /dev/null
+++ b/tests/src/Traits/LocalPackagesTrait.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Traits;
+
+use PHPUnit\Framework\Assert;
+
+/**
+ * Provides methods for interacting with installed Composer packages.
+ */
+trait LocalPackagesTrait {
+
+  use JsonTrait;
+
+  /**
+   * 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'];
+  }
+
+  /**
+   * Generates local path repositories for a set of installed packages.
+   *
+   * @param string $dir
+   *   The directory which contains composer.lock.
+   *
+   * @return array[]
+   *   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] = [
+        'type' => 'path',
+        'url' => $path,
+        'options' => [
+          'symlink' => FALSE,
+        ],
+      ];
+    }
+    return $repositories;
+  }
+
+  /**
+   * Reads all package information from a composer.lock file.
+   *
+   * @param string $dir
+   *   The directory which contains the lock file.
+   *
+   * @return array[]
+   *   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/tests/src/Traits/SettingsTrait.php b/tests/src/Traits/SettingsTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..b9ae2948990b758c4fda838b72f7f3af4ff3b215
--- /dev/null
+++ b/tests/src/Traits/SettingsTrait.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\Tests\automatic_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/tests/src/Traits/ValidationTestTrait.php b/tests/src/Traits/ValidationTestTrait.php
new file mode 100644
index 0000000000000000000000000000000000000000..382f6352d77ea75623de8b3c4087379121a60997
--- /dev/null
+++ b/tests/src/Traits/ValidationTestTrait.php
@@ -0,0 +1,99 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Traits;
+
+use Drupal\automatic_updates\Validation\ValidationResult;
+
+/**
+ * Common methods for testing validation.
+ */
+trait ValidationTestTrait {
+
+  /**
+   * Test validation results.
+   *
+   * @var \Drupal\automatic_updates\Validation\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."),
+        ),
+      ];
+    }
+  }
+
+  /**
+   * Asserts two validation result sets are equal.
+   *
+   * @param \Drupal\automatic_updates\Validation\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param \Drupal\automatic_updates\Validation\ValidationResult[]|null $actual_results
+   *   The actual validation results or NULL if known are available.
+   */
+  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/tests/src/Unit/StagedProjectsValidationTest.php b/tests/src/Unit/StagedProjectsValidationTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..02358a32900bff7b0a07c8a5de11445b53ff9833
--- /dev/null
+++ b/tests/src/Unit/StagedProjectsValidationTest.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit;
+
+use Drupal\automatic_updates\Event\UpdateEvent;
+use Drupal\automatic_updates\Updater;
+use Drupal\automatic_updates\Validation\StagedProjectsValidation;
+use Drupal\Component\FileSystem\FileSystem;
+use Drupal\Core\StringTranslation\PluralTranslatableMarkup;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates\Validation\StagedProjectsValidation
+ */
+class StagedProjectsValidationTest extends UnitTestCase {
+
+  /**
+   * Tests that if an exception is thrown, the update event will absorb it.
+   */
+  public function testUpdateEventConsumesExceptionResults(): void {
+    $prefix = FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR;
+    $active_dir = uniqid($prefix);
+    $stage_dir = uniqid($prefix);
+
+    $updater = $this->prophesize(Updater::class);
+    $updater->getActiveDirectory()->willReturn($active_dir);
+    $updater->getStageDirectory()->willReturn($stage_dir);
+    $validator = new StagedProjectsValidation(new TestTranslationManager(), $updater->reveal());
+
+    $event = new UpdateEvent();
+    $validator->validateStagedProjects($event);
+    $results = $event->getResults();
+    $this->assertCount(1, $results);
+    $messages = reset($results)->getMessages();
+    $this->assertCount(1, $messages);
+    $this->assertSame("composer.lock file '$active_dir/composer.lock' not found.", (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
+   *
+   * @covers ::validateStagedProjects
+   */
+  public function testErrors(string $fixtures_dir, string $expected_summary, array $expected_messages): void {
+    $updater = $this->prophesize(Updater::class);
+    $this->assertNotEmpty($fixtures_dir);
+    $this->assertDirectoryExists($fixtures_dir);
+
+    $updater->getActiveDirectory()->willReturn("$fixtures_dir/active");
+    $updater->getStageDirectory()->willReturn("$fixtures_dir/staged");
+    $validator = new StagedProjectsValidation(new TestTranslationManager(), $updater->reveal());
+    $event = new UpdateEvent();
+    $validator->validateStagedProjects($event);
+    $results = $event->getResults();
+    $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/testmodule2' installed.",
+          "custom module 'drupal/dev-testmodule2' 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/testmodule' from 1.3.0 to  1.3.1.",
+          "module 'drupal/dev-testmodule' from 1.3.0 to  1.3.1.",
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests validation when there are no errors.
+   *
+   * @covers ::validateStagedProjects
+   */
+  public function testNoErrors() {
+    $fixtures_dir = realpath(__DIR__ . '/../../fixtures/project_staged_validation/no_errors');
+    $updater = $this->prophesize(Updater::class);
+    $updater->getActiveDirectory()->willReturn("$fixtures_dir/active");
+    $updater->getStageDirectory()->willReturn("$fixtures_dir/staged");
+    $validator = new StagedProjectsValidation(new TestTranslationManager(), $updater->reveal());
+    $event = new UpdateEvent();
+    $validator->validateStagedProjects($event);
+    $results = $event->getResults();
+    $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');
+    $updater = $this->prophesize(Updater::class);
+    $updater->getActiveDirectory()->willReturn("$fixtures_dir/active");
+    $updater->getStageDirectory()->willReturn("$fixtures_dir");
+    $validator = new StagedProjectsValidation(new TestTranslationManager(), $updater->reveal());
+    $event = new UpdateEvent();
+    $validator->validateStagedProjects($event);
+    $results = $event->getResults();
+    $this->assertCount(1, $results);
+    $result = array_pop($results);
+    $this->assertMatchesRegularExpression(
+      "/.*automatic_updates\/tests\/fixtures\/project_staged_validation\/no_errors\/composer.lock' not found/",
+        (string) $result->getMessages()[0]
+      );
+    $this->assertSame('', (string) $result->getSummary());
+  }
+
+}
+
+/**
+ * Implements a translation manager in tests.
+ *
+ * @todo Copied from core/modules/user/tests/src/Unit/PermissionHandlerTest.php
+ *   when moving to core open an issue consolidate this test class.
+ */
+class TestTranslationManager implements TranslationInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function translate($string, array $args = [], array $options = []) {
+    return new TranslatableMarkup($string, $args, $options, $this);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function translateString(TranslatableMarkup $translated_string) {
+    return $translated_string->getUntranslatedString();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function formatPlural($count, $singular, $plural, array $args = [], array $options = []) {
+    return new PluralTranslatableMarkup($count, $singular, $plural, $args, $options, $this);
+  }
+
+}
diff --git a/tests/src/Unit/ValidationResultTest.php b/tests/src/Unit/ValidationResultTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..1662c64c65f6b23ee9b71ad83ee53b7570f972e1
--- /dev/null
+++ b/tests/src/Unit/ValidationResultTest.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Unit;
+
+use Drupal\automatic_updates\Validation\ValidationResult;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\system\SystemManager;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates\Validation\ValidationResult
+ *
+ * @group automatic_updates
+ */
+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\automatic_updates\Validation\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());
+  }
+
+}