From ba38ee11d4d9214bb10db0ffc1ff339512d4483b Mon Sep 17 00:00:00 2001
From: Ted Bowman <ted+git@tedbow.com>
Date: Fri, 30 Sep 2022 18:11:31 +0200
Subject: [PATCH] Contrib: Issue #3310901 by rahul_, phenaproxima,
 TravisCarden: Stage::require() should validate the incoming package names -
 3efd96d822c35bb53ccf4de9a7ae3bc07883256e

---
 core/misc/cspell/dictionary.txt               |   2 +-
 .../auto_updates/auto_updates.libraries.yml   |   4 +
 core/modules/auto_updates/auto_updates.module | 132 +++--
 .../auto_updates/auto_updates.routing.yml     |   9 +
 .../auto_updates/auto_updates.services.yml    |  85 ++--
 .../config/schema/auto_updates.schema.yml     |   3 +
 .../auto_updates/css/update-status.css        |   8 +
 .../auto_updates/src/BatchProcessor.php       |  44 +-
 .../Controller/ReadinessCheckerController.php |   2 +-
 .../src/Controller/UpdateController.php       |  14 +-
 core/modules/auto_updates/src/CronUpdater.php | 294 ++++++++---
 .../src/Event/ReadinessCheckEvent.php         |   6 +
 .../src/EventSubscriber/ConfigSubscriber.php  |  44 ++
 .../auto_updates/src/Form/UpdateReady.php     | 118 +++--
 .../auto_updates/src/Form/UpdaterForm.php     | 385 +++++++++++----
 core/modules/auto_updates/src/ProjectInfo.php | 106 ----
 .../auto_updates/src/ReleaseChooser.php       |  70 +--
 .../src/Routing/RouteSubscriber.php           |   6 +-
 core/modules/auto_updates/src/Updater.php     |  33 +-
 .../src/Validation/AdminReadinessMessages.php |  33 +-
 .../src/Validation/ReadinessRequirements.php  |   4 +-
 .../src/Validation/ReadinessTrait.php         |  45 +-
 .../Validation/ReadinessValidationManager.php |  21 +-
 .../src/Validator/CronFrequencyValidator.php  |  26 +-
 .../Validator/CronUpdateVersionValidator.php  | 100 ----
 .../PackageManagerReadinessCheck.php          |  12 +-
 .../ScaffoldFilePermissionsValidator.php      | 137 ++++++
 .../StagedDatabaseUpdateValidator.php         | 120 +----
 .../src/Validator/StagedProjectsValidator.php |   5 +
 .../src/Validator/UpdateVersionValidator.php  | 206 --------
 .../VersionPolicy/ForbidDevSnapshot.php       |  43 ++
 .../VersionPolicy/ForbidDowngrade.php         |  43 ++
 .../VersionPolicy/ForbidMinorUpdates.php      |  47 ++
 .../VersionPolicy/MajorVersionMatch.php       |  48 ++
 .../VersionPolicy/MinorUpdatesEnabled.php     |  86 ++++
 .../VersionPolicy/StableReleaseInstalled.php  |  43 ++
 .../SupportedBranchInstalled.php              | 110 +++++
 .../VersionPolicy/TargetSecurityRelease.php   |  44 ++
 .../TargetVersionInstallable.php              |  45 ++
 .../VersionPolicy/TargetVersionStable.php     |  45 ++
 .../src/Validator/VersionPolicyValidator.php  | 264 ++++++++++
 .../src/Validator/XdebugValidator.php         |  67 +++
 .../auto_updates/src/VersionParsingTrait.php  |  15 +
 .../StagedProjectsValidatorTest/README.md     |  30 ++
 .../dev-test_module.info.yml.hide             |   1 +
 .../test_module/test_module.info.yml.hide     |   1 +
 .../active/vendor/composer/installed.json     |  12 +-
 .../active/vendor/composer/installed.php      |  19 +
 .../dev-test_module.info.yml.hide             |   1 +
 .../dev-test_module2.info.yml.hide            |   1 +
 .../test_module/test_module.info.yml.hide     |   1 +
 .../test_module2/test_module2.info.yml.hide   |   1 +
 .../staged/vendor/composer/installed.json     |  12 +-
 .../staged/vendor/composer/installed.php      |  35 ++
 .../active/vendor/composer/installed.json     |  12 +-
 .../staged/vendor/composer/installed.json     |  12 +-
 .../staged/vendor/composer/installed.php      |  20 +
 .../active/vendor/composer/installed.json     |  12 +-
 .../staged/vendor/composer/installed.json     |  33 ++
 .../active/vendor/composer/installed.json     |  12 +-
 .../staged/vendor/composer/installed.json     |  12 +-
 .../composer.json                             |   0
 .../composer.lock}                            |   0
 .../vendor/composer/installed.json            |  14 +
 .../vendor/composer/installed.php             |  10 +
 .../tests/fixtures/fake-site/composer.json    |  15 -
 .../fake-site/files/private/ignore.txt        |   1 -
 .../fake-site/files/public/ignore.txt         |   1 -
 .../fake-site/sites/default/services.yml      |   2 -
 .../sites/default/settings.local.php          |   6 -
 .../fake-site/sites/default/settings.php      |   6 -
 .../fake-site/sites/default/staged.txt        |   1 -
 .../fake-site/sites/simpletest/ignore.txt     |   1 -
 .../no_core_requirements/composer.json        |  15 -
 .../vendor/composer/installed.json            |  12 -
 .../no_errors/staged/composer.json            |   1 -
 .../project_removed/active/composer.json      |   1 -
 .../project_removed/staged/composer.json      |   1 -
 .../staged/vendor/composer/installed.json     |  23 -
 .../version_changed/active/composer.json      |   1 -
 .../version_changed/staged/composer.json      |   1 -
 .../release-history/drupal.9.8.1-security.xml |  40 --
 .../drupal.9.8.2-older-sec-release.xml        | 102 ----
 .../fixtures/release-history/drupal.9.8.2.xml |  98 ----
 .../9.8.1/vendor/composer/installed.json      |   9 -
 .../aaa_auto_updates_test.info.yml            |   4 +
 .../auto_updates_test.module                  |  29 ++
 .../auto_updates_test.routing.yml             |   9 -
 .../auto_updates_test.services.yml            |   5 +
 .../EventSubscriber/RequestTimeRecorder.php   |  65 +++
 .../auto_updates_test/src/TestController.php  |  36 +-
 .../auto_updates_test_cron.info.yml           |   4 +
 .../auto_updates_test_cron.module             |  60 +++
 .../auto_updates_test_cron.services.yml       |   5 +
 .../auto_updates_test_cron/src/Enabler.php    |  37 ++
 ...esTestDisableValidatorsServiceProvider.php |   2 +-
 .../tests/src/Build/CoreUpdateTest.php        |  42 +-
 .../tests/src/Build/UpdateTestBase.php        |  51 +-
 .../AutoUpdatesFunctionalTestBase.php         | 105 +++-
 .../Functional/AvailableUpdatesReportTest.php |  98 ++++
 .../Functional/ReadinessValidationTest.php    | 133 ++---
 .../tests/src/Functional/UpdateLockTest.php   |  11 +-
 ...terFormNoRecommendedReleaseMessageTest.php | 101 ++++
 .../tests/src/Functional/UpdaterFormTest.php  | 464 +++++++++++++++---
 .../src/Kernel/AutoUpdatesKernelTestBase.php  |  99 +---
 .../tests/src/Kernel/CronUpdaterTest.php      | 262 +++++++---
 .../{Unit => Kernel}/ReadinessTraitTest.php   |  74 +--
 .../CronFrequencyValidatorTest.php            |  29 +-
 .../PackageManagerReadinessChecksTest.php     |   7 +-
 .../ReadinessValidationManagerTest.php        | 160 +++---
 .../ScaffoldFilePermissionsValidatorTest.php  | 341 +++++++++++++
 .../SettingsValidatorTest.php                 |  64 ---
 .../StagedDatabaseUpdateValidatorTest.php     | 203 ++++----
 .../StagedProjectsValidatorTest.php           | 163 +++---
 .../UpdateVersionValidatorTest.php            | 375 --------------
 .../SupportedBranchInstalledTest.php          |  93 ++++
 .../VersionPolicyValidatorTest.php            | 415 ++++++++++++++++
 .../XdebugValidatorTest.php                   |  76 +++
 .../tests/src/Kernel/ReleaseChooserTest.php   |  48 +-
 .../tests/src/Kernel/UpdaterTest.php          |  82 +++-
 .../tests/src/Traits/ValidationTestTrait.php  |  83 +---
 .../src/Traits/VersionPolicyTestTrait.php     |  32 ++
 .../src/Unit/LegacyVersionUtilityTest.php     |  75 +++
 .../tests/src/Unit/ProjectInfoTest.php        | 194 --------
 .../VersionPolicy/ForbidDevSnapshotTest.php   |  64 +++
 .../VersionPolicy/ForbidDowngradeTest.php     |  66 +++
 .../VersionPolicy/ForbidMinorUpdatesTest.php  |  92 ++++
 .../VersionPolicy/MajorVersionMatchTest.php   |  81 +++
 .../VersionPolicy/MinorUpdatesEnabledTest.php | 137 ++++++
 .../StableReleaseInstalledTest.php            |  60 +++
 .../TargetSecurityReleaseTest.php             |  68 +++
 .../TargetVersionInstallableTest.php          |  69 +++
 .../VersionPolicy/TargetVersionStableTest.php |  64 +++
 .../auto_updates_theme.info.yml               |   5 +
 .../auto_updates_theme_with_updates.info.yml  |   5 +
 .../auto_updates_theme_with_updates.install   |   8 +
 ...updates_theme_with_updates.post_update.php |   8 +
 .../package_manager/core_packages.json        |   9 -
 .../modules/package_manager/core_packages.yml |  15 +
 .../package_manager/package_manager.info.yml  |   3 +
 .../package_manager/package_manager.module    |  64 ++-
 .../package_manager.services.yml              | 191 ++++---
 .../package_manager/src/ComposerUtility.php   | 108 +++-
 .../src/Event/ExcludedPathsTrait.php          |   2 +-
 .../src/Event/PostRequireEvent.php            |   3 +
 .../src/Event/PreOperationStageEvent.php      |   6 +
 .../src/Event/PreRequireEvent.php             |   3 +
 .../src/Event/RequireEventTrait.php           |  97 ++++
 .../src/Event/StatusCheckEvent.php            |  29 ++
 .../ExcludedPathsSubscriber.php               | 250 ----------
 .../EventSubscriber/UpdateDataSubscriber.php  |   7 +-
 .../src/Exception/ApplyFailedException.php    |  16 +
 .../Exception/StageValidationException.php    |  29 +-
 .../package_manager/src/ExecutableFinder.php  |  13 +-
 .../package_manager/src/FailureMarker.php     |  92 ++++
 .../package_manager/src/FileSyncerFactory.php |  26 +-
 .../src/LegacyVersionUtility.php              |  84 ++++
 .../src/PackageManagerServiceProvider.php     |  29 +-
 .../src/PackageManagerUninstallValidator.php  |  12 +-
 .../src/PackageManagerUpdateProcessor.php     | 103 ++++
 .../src/PathExcluder/GitExcluder.php          |  68 +++
 .../src/PathExcluder/PathExclusionsTrait.php  |  64 +++
 .../SiteConfigurationExcluder.php             |  79 +++
 .../src/PathExcluder/SiteFilesExcluder.php    |  92 ++++
 .../PathExcluder/SqliteDatabaseExcluder.php   |  73 +++
 .../src/PathExcluder/TestSiteExcluder.php     |  55 +++
 .../PathExcluder/VendorHardeningExcluder.php  |  60 +++
 .../package_manager/src/PathLocator.php       |  73 ++-
 .../package_manager/src/ProcessFactory.php    |  34 +-
 .../package_manager/src/ProjectInfo.php       | 188 +++++++
 core/modules/package_manager/src/Stage.php    | 275 +++++++++--
 .../package_manager/src/ValidationResult.php  |   5 +-
 .../Validator/ComposerExecutableValidator.php |  75 ++-
 .../Validator/ComposerPatchesValidator.php    |  50 ++
 .../Validator/ComposerSettingsValidator.php   |   9 +-
 .../src/Validator/DiskSpaceValidator.php      |   9 +-
 .../Validator/DuplicateInfoFileValidator.php  | 138 ++++++
 .../src/Validator/LockFileValidator.php       |   9 +-
 .../src/Validator/MultisiteValidator.php      |   9 +-
 .../OverwriteExistingPackagesValidator.php    |  83 ++++
 .../src/Validator/PendingUpdatesValidator.php |   9 +-
 .../src/Validator/SettingsValidator.php       |  33 +-
 .../src/Validator/StagedDBUpdateValidator.php | 185 +++++++
 .../Validator/SupportedReleaseValidator.php   | 126 +++++
 .../src/Validator/SymlinkValidator.php        | 132 +++++
 .../Validator/WritableFileSystemValidator.php |  47 +-
 .../fixtures/alpha/1.0.0/alpha.info.yml.hide  |   4 +
 .../fixtures/alpha/1.1.0/alpha.info.yml.hide  |   4 +
 .../tests/fixtures/distro_core/composer.json  |  12 -
 .../tests/fixtures/distro_core/composer.lock  |  16 -
 .../vendor/composer/installed.json            |  15 -
 .../distro_core_recommended/composer.json     |  12 -
 .../tests/fixtures/fake_site/_git/ignore.txt  |   4 +
 .../tests/fixtures/fake_site/composer.json    |   8 +
 .../tests/fixtures/fake_site/composer.lock}   |   0
 .../fake_site/modules/example/_git/ignore.txt |   4 +
 .../modules/example/example.info.yml          |   3 +
 .../fixtures/fake_site/private/ignore.txt     |   1 +
 .../sites/default/default.services.yml        |   2 +
 .../sites/default/default.settings.php        |   6 +
 .../fake_site/sites/default/services.yml      |   2 +
 .../sites/default/settings.local.php          |   6 +
 .../fake_site/sites/default/settings.php      |   6 +
 .../fake_site/sites/default/stage.txt}        |   0
 .../fake_site/sites/example.com/db.sqlite     |   1 +
 .../fake_site/sites/example.com/db.sqlite-shm |   1 +
 .../fake_site/sites/example.com/db.sqlite-wal |   1 +
 .../sites/example.com/files/ignore.txt        |   1 +
 .../fake_site/sites/example.com/services.yml  |   2 +
 .../sites/example.com/settings.local.php      |   6 +
 .../fake_site/sites/example.com/settings.php  |   6 +
 .../fake_site/sites/simpletest/ignore.txt     |   1 +
 .../tests/fixtures/fake_site/vendor/.htaccess |   1 +
 .../fake_site/vendor/composer/installed.json  |  24 +
 .../fake_site/vendor/composer/installed.php   |  10 +
 .../fixtures/fake_site/vendor/web.config      |   1 +
 .../modules/module_1/module_1.info.yml.hide   |   1 +
 .../modules/module_2/module_2.info.yml.hide   |   1 +
 .../modules/module_5/module_5.info.yml.hide   |   1 +
 .../active/vendor/composer/installed.json     |   6 +
 .../active/vendor/composer/installed.php      |  12 +
 .../modules/module_1/module_1.info.yml.hide   |   1 +
 .../modules/module_2/module_2.info.yml.hide   |   1 +
 .../modules/module_3/module_3.info.yml.hide   |   1 +
 .../module_5_different_path.info.yml.hide     |   1 +
 .../staged/vendor/composer/installed.json     |  32 ++
 .../staged/vendor/composer/installed.php      |  35 ++
 .../packages_comparison/active/composer.json  |   1 -
 .../active/vendor/composer/installed.json     |  16 -
 .../packages_comparison/stage/composer.json   |   1 -
 .../stage/vendor/composer/installed.json      |  16 -
 .../project_package_conversion}/composer.json |   0
 .../project_package_conversion/composer.lock} |   0
 .../vendor/composer/installed.json            |  34 ++
 .../vendor/composer/installed.php             |  35 ++
 .../any_sub_folder/any_yml_file.info.yml.hide |   3 +
 .../custom_module/custom_module.info.yml.hide |   1 +
 .../not_match_path_project.info.yml.hide      |   1 +
 .../not_match_project.info.yml.hide           |   1 +
 .../other_project/other_project.info.yml.hide |   1 +
 .../packge_project_match.info.yml.hide        |   1 +
 .../aaa_auto_updates_test.9.8.2.xml           | 122 +++++
 .../release-history/aaa_update_test.1.1.xml   | 184 +++++++
 .../fixtures/release-history/alpha.1.1.0.xml  |  40 ++
 .../release-history/drupal.9.8.1-security.xml | 108 ++++
 .../drupal.9.8.2-older-sec-release.xml        | 211 ++++++++
 .../drupal.9.8.2-unsupported_unpublished.xml  | 233 +++++++++
 .../fixtures/release-history/drupal.9.8.2.xml | 229 +++++++++
 .../drupal.9.8.2_unknown_status.xml           | 231 +++++++++
 .../release-history/semver_test.1.1.xml       |   1 -
 .../release-history/updated_module.1.1.0.xml  |  40 ++
 .../aaa_auto_updates_test.info.yml.hide       |   1 +
 .../vendor/composer/installed.json}           |  21 +-
 .../vendor/composer/installed.php             |  16 +
 .../aaa_auto_updates_test.info.yml.hide       |   1 +
 .../vendor/composer/installed.json            |  21 +-
 .../vendor/composer/installed.php             |  16 +
 .../aaa_update_test.info.yml.hide             |   1 +
 .../semver_test/semver_test.info.yml.hide     |   1 +
 .../active/vendor/composer/installed.json     |  30 ++
 .../active/vendor/composer/installed.php      |  20 +
 .../aaa_update_test.info.yml.hide             |   1 +
 .../vendor/composer/installed.json            |  25 +
 .../vendor/composer/installed.php             |  16 +
 .../aaa_update_test.info.yml.hide             |   1 +
 .../vendor/composer/installed.json            |  25 +
 .../vendor/composer/installed.php             |  16 +
 .../semver_test/semver_test.info.yml.hide     |   1 +
 .../vendor/composer/installed.json            |  17 +-
 .../vendor/composer/installed.php             |  16 +
 .../semver_test/semver_test.info.yml.hide     |   1 +
 .../vendor/composer/installed.json}           |  20 +-
 .../vendor/composer/installed.php             |  16 +
 ....info.yml => updated_module.info.yml.hide} |   1 +
 .../1.0.0/updated_module.module               |  11 +-
 .../1.0.0/updated_module.permissions.yml      |   4 -
 .../1.0.0/updated_module.routing.yml          |  12 -
 ....info.yml => updated_module.info.yml.hide} |   1 +
 .../1.1.0/updated_module.module               |  17 +-
 .../1.1.0/updated_module.permissions.yml      |   4 -
 .../1.1.0/updated_module.routing.yml          |  12 -
 .../package_manager_bypass/src/Beginner.php   |  14 +-
 .../src/BypassedStagerServiceBase.php         | 100 ++++
 .../package_manager_bypass/src/Committer.php  |  45 +-
 .../src/InvocationRecorderBase.php            |  36 --
 .../PackageManagerBypassServiceProvider.php   |  34 +-
 .../src/PathLocator.php                       |  82 ++++
 .../package_manager_bypass/src/Stager.php     |  36 +-
 .../package_manager_test_api.routing.yml      |   6 +
 .../package_manager_test_api.services.yml     |  10 -
 .../src/ApiController.php                     |  47 +-
 .../src/SystemChangeRecorder.php              | 129 -----
 .../package_manager_test_fixture.info.yml     |   6 -
 .../package_manager_test_fixture.services.yml |   8 -
 .../src/EventSubscriber/FixtureStager.php     |  88 ----
 ...kage_manager_test_release_history.info.yml |   7 +
 ...e_manager_test_release_history.routing.yml |   9 +
 .../src/TestController.php                    |  44 ++
 .../src/EventSubscriber/TestSubscriber.php    |  42 +-
 ...geManagerTestValidationServiceProvider.php |  30 ++
 .../src/StagedDatabaseUpdateValidator.php     |  54 ++
 .../tests/src/Build/PackageInstallTest.php    |  45 ++
 .../tests/src/Build/PackageUpdateTest.php     |  69 +--
 .../src/Build/TemplateProjectTestBase.php     | 137 +++++-
 .../FailureMarkerRequirementTest.php          |  53 ++
 .../ComposerExecutableValidatorTest.php       | 106 +++-
 .../Kernel/ComposerPatchesValidatorTest.php   |  42 ++
 .../Kernel/ComposerSettingsValidatorTest.php  |  19 +-
 .../tests/src/Kernel/ComposerUtilityTest.php  | 138 ++++--
 .../src/Kernel/CorePackageManifestTest.php    |   5 +-
 .../src/Kernel/DiskSpaceValidatorTest.php     |   9 +-
 .../Kernel/DuplicateInfoFileValidatorTest.php | 240 +++++++++
 .../tests/src/Kernel/ExcludedPathsTest.php    | 243 ---------
 .../tests/src/Kernel/ExecutableFinderTest.php |  20 +-
 .../tests/src/Kernel/FailureMarkerTest.php    |  41 ++
 .../src/Kernel/FileSyncerFactoryTest.php      |  19 +-
 .../src/Kernel/LockFileValidatorTest.php      |  19 +-
 .../src/Kernel/MultisiteValidatorTest.php     |   9 +-
 ...OverwriteExistingPackagesValidatorTest.php |  79 +++
 .../Kernel/PackageManagerKernelTestBase.php   | 342 ++++++++-----
 .../Kernel/PathExcluder/GitExcluderTest.php   |  60 +++
 .../SiteConfigurationExcluderTest.php         | 102 ++++
 .../PathExcluder/SiteFilesExcluderTest.php    |  66 +++
 .../SqliteDatabaseExcluderTest.php            | 184 +++++++
 .../PathExcluder/TestSiteExcluderTest.php     |  57 +++
 .../VendorHardeningExcluderTest.php           |  58 +++
 .../Kernel/PendingUpdatesValidatorTest.php    |   9 +-
 .../tests/src/Kernel/ProjectInfoTest.php      | 179 +++++++
 .../StagedDBUpdateValidatorTest.php           | 166 +++++++
 .../tests/src/Kernel/ServicesTest.php         |  16 +-
 .../src/Kernel/SettingsValidatorTest.php      |  46 ++
 .../tests/src/Kernel/StageEventsTest.php      |  46 +-
 .../tests/src/Kernel/StageOwnershipTest.php   |  46 +-
 .../tests/src/Kernel/StageTest.php            | 344 +++++++++++--
 .../Kernel/StageValidationExceptionTest.php   |  81 +++
 .../Kernel/SupportedReleaseValidatorTest.php  |  98 ++++
 .../tests/src/Kernel/SymlinkValidatorTest.php | 107 ++++
 .../WritableFileSystemValidatorTest.php       | 110 +++--
 .../tests/src/Traits/FixtureUtilityTrait.php  |  79 +++
 .../src/Traits/InfoYmlConverterTrait.php      |  48 ++
 .../Traits/PackageManagerBypassTestTrait.php  |  16 +-
 .../tests/src/Unit/ComposerUtilityTest.php    | 111 +++++
 .../src/Unit/InstalledPackagesDataTest.php    |  42 ++
 .../tests/src/Unit/PathLocatorTest.php        | 140 ++++++
 .../tests/src/Unit/ProcessFactoryTest.php     |  37 ++
 .../tests/src/Unit/RequireEventTraitTest.php  |  67 +++
 .../tests/src/Unit/ValidationResultTest.php   |  47 +-
 347 files changed, 14107 insertions(+), 4420 deletions(-)
 create mode 100644 core/modules/auto_updates/auto_updates.libraries.yml
 create mode 100644 core/modules/auto_updates/css/update-status.css
 create mode 100644 core/modules/auto_updates/src/EventSubscriber/ConfigSubscriber.php
 delete mode 100644 core/modules/auto_updates/src/ProjectInfo.php
 delete mode 100644 core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php
 create mode 100644 core/modules/auto_updates/src/Validator/ScaffoldFilePermissionsValidator.php
 delete mode 100644 core/modules/auto_updates/src/Validator/UpdateVersionValidator.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDevSnapshot.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDowngrade.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/ForbidMinorUpdates.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/MajorVersionMatch.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/MinorUpdatesEnabled.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/StableReleaseInstalled.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/SupportedBranchInstalled.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/TargetSecurityRelease.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionInstallable.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionStable.php
 create mode 100644 core/modules/auto_updates/src/Validator/VersionPolicyValidator.php
 create mode 100644 core/modules/auto_updates/src/Validator/XdebugValidator.php
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/README.md
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/modules/dev-test_module/dev-test_module.info.yml.hide
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/modules/test_module/test_module.info.yml.hide
 rename core/modules/auto_updates/tests/fixtures/{project_staged_validation => StagedProjectsValidatorTest}/new_project_added/active/vendor/composer/installed.json (66%)
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.php
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/dev-test_module/dev-test_module.info.yml.hide
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/dev-test_module2/dev-test_module2.info.yml.hide
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/test_module/test_module.info.yml.hide
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/test_module2/test_module2.info.yml.hide
 rename core/modules/auto_updates/tests/fixtures/{project_staged_validation => StagedProjectsValidatorTest}/new_project_added/staged/vendor/composer/installed.json (73%)
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.php
 rename core/modules/auto_updates/tests/fixtures/{project_staged_validation => StagedProjectsValidatorTest}/no_errors/active/vendor/composer/installed.json (75%)
 rename core/modules/auto_updates/tests/fixtures/{project_staged_validation => StagedProjectsValidatorTest}/no_errors/staged/vendor/composer/installed.json (76%)
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.php
 rename core/modules/auto_updates/tests/fixtures/{project_staged_validation => StagedProjectsValidatorTest}/project_removed/active/vendor/composer/installed.json (72%)
 create mode 100644 core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/project_removed/staged/vendor/composer/installed.json
 rename core/modules/auto_updates/tests/fixtures/{project_staged_validation => StagedProjectsValidatorTest}/version_changed/active/vendor/composer/installed.json (66%)
 rename core/modules/auto_updates/tests/fixtures/{project_staged_validation => StagedProjectsValidatorTest}/version_changed/staged/vendor/composer/installed.json (67%)
 rename core/modules/auto_updates/tests/fixtures/{staged/9.8.1 => drupal-9.8.1-installed}/composer.json (100%)
 rename core/modules/auto_updates/tests/fixtures/{project_staged_validation/new_project_added/active/composer.json => drupal-9.8.1-installed/composer.lock} (100%)
 create mode 100644 core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.json
 create mode 100644 core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.php
 delete mode 100644 core/modules/auto_updates/tests/fixtures/fake-site/composer.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt
 delete mode 100644 core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt
 delete mode 100644 core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml
 delete mode 100644 core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php
 delete mode 100644 core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php
 delete mode 100644 core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt
 delete mode 100644 core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt
 delete mode 100644 core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/vendor/composer/installed.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/vendor/composer/installed.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json
 delete mode 100644 core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml
 delete mode 100644 core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
 delete mode 100644 core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml
 delete mode 100644 core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json
 create mode 100644 core/modules/auto_updates/tests/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml
 create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.module
 create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/RequestTimeRecorder.php
 create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.info.yml
 create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.module
 create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.services.yml
 create mode 100644 core/modules/auto_updates/tests/modules/auto_updates_test_cron/src/Enabler.php
 create mode 100644 core/modules/auto_updates/tests/src/Functional/AvailableUpdatesReportTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Functional/UpdaterFormNoRecommendedReleaseMessageTest.php
 rename core/modules/auto_updates/tests/src/{Unit => Kernel}/ReadinessTraitTest.php (52%)
 create mode 100644 core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php
 delete mode 100644 core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/SettingsValidatorTest.php
 delete mode 100644 core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/VersionPolicy/SupportedBranchInstalledTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/VersionPolicyValidatorTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/XdebugValidatorTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Traits/VersionPolicyTestTrait.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/LegacyVersionUtilityTest.php
 delete mode 100644 core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/MinorUpdatesEnabledTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php
 create mode 100644 core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php
 create mode 100644 core/modules/auto_updates/tests/themes/auto_updates_theme/auto_updates_theme.info.yml
 create mode 100644 core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.info.yml
 create mode 100644 core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.install
 create mode 100644 core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.post_update.php
 delete mode 100644 core/modules/package_manager/core_packages.json
 create mode 100644 core/modules/package_manager/core_packages.yml
 create mode 100644 core/modules/package_manager/src/Event/RequireEventTrait.php
 create mode 100644 core/modules/package_manager/src/Event/StatusCheckEvent.php
 delete mode 100644 core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
 create mode 100644 core/modules/package_manager/src/Exception/ApplyFailedException.php
 create mode 100644 core/modules/package_manager/src/FailureMarker.php
 create mode 100644 core/modules/package_manager/src/LegacyVersionUtility.php
 create mode 100644 core/modules/package_manager/src/PackageManagerUpdateProcessor.php
 create mode 100644 core/modules/package_manager/src/PathExcluder/GitExcluder.php
 create mode 100644 core/modules/package_manager/src/PathExcluder/PathExclusionsTrait.php
 create mode 100644 core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php
 create mode 100644 core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php
 create mode 100644 core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
 create mode 100644 core/modules/package_manager/src/PathExcluder/TestSiteExcluder.php
 create mode 100644 core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php
 create mode 100644 core/modules/package_manager/src/ProjectInfo.php
 create mode 100644 core/modules/package_manager/src/Validator/ComposerPatchesValidator.php
 create mode 100644 core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php
 create mode 100644 core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php
 rename core/modules/{auto_updates => package_manager}/src/Validator/SettingsValidator.php (52%)
 create mode 100644 core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php
 create mode 100644 core/modules/package_manager/src/Validator/SupportedReleaseValidator.php
 create mode 100644 core/modules/package_manager/src/Validator/SymlinkValidator.php
 create mode 100644 core/modules/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml.hide
 delete mode 100644 core/modules/package_manager/tests/fixtures/distro_core/composer.json
 delete mode 100644 core/modules/package_manager/tests/fixtures/distro_core/composer.lock
 delete mode 100644 core/modules/package_manager/tests/fixtures/distro_core/vendor/composer/installed.json
 delete mode 100644 core/modules/package_manager/tests/fixtures/distro_core_recommended/composer.json
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/composer.json
 rename core/modules/{auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json => package_manager/tests/fixtures/fake_site/composer.lock} (100%)
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/private/ignore.txt
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php
 rename core/modules/{auto_updates/tests/fixtures/fake-site/files/staged.txt => package_manager/tests/fixtures/fake_site/sites/default/stage.txt} (100%)
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_1/module_1.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_2/module_2.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_5/module_5.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/vendor/composer/installed.json
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_1/module_1.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_2/module_2.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_3/module_3.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_5_different_path/module_5_different_path.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/vendor/composer/installed.json
 create mode 100644 core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/vendor/composer/installed.php
 delete mode 100644 core/modules/package_manager/tests/fixtures/packages_comparison/active/composer.json
 delete mode 100644 core/modules/package_manager/tests/fixtures/packages_comparison/active/vendor/composer/installed.json
 delete mode 100644 core/modules/package_manager/tests/fixtures/packages_comparison/stage/composer.json
 delete mode 100644 core/modules/package_manager/tests/fixtures/packages_comparison/stage/vendor/composer/installed.json
 rename core/modules/{auto_updates/tests/fixtures/project_staged_validation/no_errors/active => package_manager/tests/fixtures/project_package_conversion}/composer.json (100%)
 rename core/modules/{auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json => package_manager/tests/fixtures/project_package_conversion/composer.lock} (100%)
 create mode 100644 core/modules/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json
 create mode 100644 core/modules/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_path_project/not_match_path_project.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/aaa_auto_updates_test.9.8.2.xml
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml
 rename core/modules/{auto_updates => package_manager}/tests/fixtures/release-history/semver_test.1.1.xml (98%)
 create mode 100644 core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml.hide
 rename core/modules/{auto_updates/tests/fixtures/fake-site/composer.lock => package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/vendor/composer/installed.json} (54%)
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml.hide
 rename core/modules/{auto_updates/tests/fixtures/fake-site => package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage}/vendor/composer/installed.json (51%)
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/active/modules/aaa_update_test/aaa_update_test.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/active/modules/semver_test/semver_test.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/active/vendor/composer/installed.json
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/active/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/modules/aaa_update_test/aaa_update_test.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/vendor/composer/installed.json
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/modules/aaa_update_test/aaa_update_test.info.yml.hide
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/vendor/composer/installed.json
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/modules/semver_test/semver_test.info.yml.hide
 rename core/modules/package_manager/tests/fixtures/{distro_core_recommended => supported_release_validator/semver_supported_update_stage}/vendor/composer/installed.json (57%)
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/vendor/composer/installed.php
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/modules/semver_test/semver_test.info.yml.hide
 rename core/modules/package_manager/tests/fixtures/{distro_core_recommended/composer.lock => supported_release_validator/semver_unsupported_update_stage/vendor/composer/installed.json} (56%)
 create mode 100644 core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/vendor/composer/installed.php
 rename core/modules/package_manager/tests/fixtures/updated_module/1.0.0/{updated_module.info.yml => updated_module.info.yml.hide} (86%)
 delete mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml
 delete mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml
 rename core/modules/package_manager/tests/fixtures/updated_module/1.1.0/{updated_module.info.yml => updated_module.info.yml.hide} (86%)
 delete mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml
 delete mode 100644 core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/BypassedStagerServiceBase.php
 delete mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_bypass/src/PathLocator.php
 delete mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml
 delete mode 100644 core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php
 delete mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml
 delete mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml
 delete mode 100644 core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php
 create mode 100644 core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php
 create mode 100644 core/modules/package_manager/tests/src/Build/PackageInstallTest.php
 create mode 100644 core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php
 delete mode 100644 core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/ReadinessValidation/StagedDBUpdateValidatorTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/StageValidationExceptionTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php
 create mode 100644 core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php
 create mode 100644 core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php
 create mode 100644 core/modules/package_manager/tests/src/Traits/InfoYmlConverterTrait.php
 create mode 100644 core/modules/package_manager/tests/src/Unit/ComposerUtilityTest.php
 create mode 100644 core/modules/package_manager/tests/src/Unit/InstalledPackagesDataTest.php
 create mode 100644 core/modules/package_manager/tests/src/Unit/PathLocatorTest.php
 create mode 100644 core/modules/package_manager/tests/src/Unit/ProcessFactoryTest.php
 create mode 100644 core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php

diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt
index 50a37621c564..28548f2f2cb7 100644
--- a/core/misc/cspell/dictionary.txt
+++ b/core/misc/cspell/dictionary.txt
@@ -1636,4 +1636,4 @@ zzgroup
 åwesome
 èxample
 über
-ȅchȏ
+ȅchȏ
\ No newline at end of file
diff --git a/core/modules/auto_updates/auto_updates.libraries.yml b/core/modules/auto_updates/auto_updates.libraries.yml
new file mode 100644
index 000000000000..6d61739cee21
--- /dev/null
+++ b/core/modules/auto_updates/auto_updates.libraries.yml
@@ -0,0 +1,4 @@
+update_status:
+  css:
+    theme:
+      css/update-status.css: {}
diff --git a/core/modules/auto_updates/auto_updates.module b/core/modules/auto_updates/auto_updates.module
index b7580c9a3bf7..464dbbf7c3c0 100644
--- a/core/modules/auto_updates/auto_updates.module
+++ b/core/modules/auto_updates/auto_updates.module
@@ -6,15 +6,11 @@
  */
 
 use Drupal\auto_updates\BatchProcessor;
-use Drupal\auto_updates\ProjectInfo;
 use Drupal\Core\Routing\RouteMatchInterface;
-use Drupal\auto_updates\CronUpdater;
 use Drupal\auto_updates\Validation\AdminReadinessMessages;
-use Drupal\Core\Extension\ExtensionVersion;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Url;
 use Drupal\system\Controller\DbUpdateController;
-use Drupal\update\ProjectSecurityData;
 
 /**
  * Implements hook_help().
@@ -38,6 +34,42 @@ function auto_updates_help($route_name, RouteMatchInterface $route_match) {
   }
 }
 
+/**
+ * Implements hook_mail().
+ */
+function auto_updates_mail(string $key, array &$message, array $params): void {
+  // Explicitly pass the language code to all translated strings.
+  $options = [
+    'langcode' => $message['langcode'],
+  ];
+  switch ($key) {
+    case 'cron_successful':
+      $message['subject'] = t("Drupal core was successfully updated", [], $options);
+      $message['body'][] = t('Congratulations!', [], $options);
+      $message['body'][] = t('Drupal core was automatically updated from @previous_version to @updated_version.', [
+        '@previous_version' => $params['previous_version'],
+        '@updated_version' => $params['updated_version'],
+      ], $options);
+      break;
+
+    case 'cron_failed':
+      $message['subject'] = t("Drupal core update failed", [], $options);
+      $message['body'][] = t('Drupal core failed to update automatically from @previous_version to @target_version. The following error was logged:', [
+        '@previous_version' => $params['previous_version'],
+        '@target_version' => $params['target_version'],
+      ], $options);
+      $message['body'][] = $params['error_message'];
+      break;
+  }
+
+  // If this email was related to an unattended update, explicitly state that
+  // this isn't supported yet.
+  if (str_starts_with($key, 'cron_')) {
+    $message['body'][] = t('This e-mail was sent by the Automatic Updates module. Unattended updates are not yet fully supported.', [], $options);
+    $message['body'][] = t('If you are using this feature in production, it is strongly recommended for you to visit your site and ensure that everything still looks good.', [], $options);
+  }
+}
+
 /**
  * Implements hook_page_top().
  */
@@ -76,6 +108,12 @@ function auto_updates_module_implements_alter(&$implementations, $hook) {
     // own routes to avoid these messages while an update is in progress.
     unset($implementations['update']);
   }
+  if ($hook === 'cron') {
+    // Whatever mofo.
+    $hook = $implementations['auto_updates'];
+    unset($implementations['auto_updates']);
+    $implementations['auto_updates'] = $hook;
+  }
 }
 
 /**
@@ -144,52 +182,6 @@ function auto_updates_form_update_manager_update_form_alter(&$form, FormStateInt
   }
 }
 
-/**
- * Implements hook_form_FORM_ID_alter() for 'update_settings' form.
- */
-function auto_updates_form_update_settings_alter(array &$form, FormStateInterface $form_state, string $form_id) {
-  $project_info = new ProjectInfo();
-  $version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion());
-  $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion();
-  // @todo In https://www.drupal.org/node/2998285 use the update XML to
-  //   determine when the installed of core will become unsupported.
-  $supported_until_version = $version->getMajorVersion() . '.'
-      . ((int) $version->getMinorVersion() + ProjectSecurityData::CORE_MINORS_WITH_SECURITY_COVERAGE)
-      . '.0';
-
-  $form['auto_updates_cron'] = [
-    '#type' => 'radios',
-    '#title' => t('Automatically update Drupal core'),
-    '#options' => [
-      CronUpdater::DISABLED => t('Disabled'),
-      CronUpdater::ALL => t('All supported updates'),
-      CronUpdater::SECURITY => t('Security updates only'),
-    ],
-    '#default_value' => \Drupal::config('auto_updates.settings')->get('cron'),
-    '#description' => t(
-      'If enabled, Drupal core will be automatically updated when an update is available. Automatic updates are only supported for @current_minor.x versions of Drupal core. Drupal @current_minor will receive security updates until @supported_until_version is released.',
-      [
-        '@current_minor' => $current_minor,
-        '@supported_until_version' => $supported_until_version,
-      ]
-    ),
-  ];
-  $form += [
-    '#submit' => ['::submitForm'],
-  ];
-  $form['#submit'][] = '_auto_updates_update_settings_form_submit';
-}
-
-/**
- * Submit function for the 'update_settings' form.
- */
-function _auto_updates_update_settings_form_submit(array &$form, FormStateInterface $form_state) {
-  \Drupal::configFactory()
-    ->getEditable('auto_updates.settings')
-    ->set('cron', $form_state->getValue('auto_updates_cron'))
-    ->save();
-}
-
 /**
  * Implements hook_local_tasks_alter().
  */
@@ -221,3 +213,43 @@ function auto_updates_batch_alter(array &$batch): void {
     }
   }
 }
+
+/**
+ * Implements hook_preprocess_update_project_status().
+ */
+function auto_updates_preprocess_update_project_status(array &$variables) {
+  $project = &$variables['project'];
+  if ($project['name'] !== 'drupal') {
+    return;
+  }
+  $updater = \Drupal::service('auto_updates.updater');
+  $supported_target_versions = [];
+  /** @var \Drupal\auto_updates\ReleaseChooser $recommender */
+  $recommender = \Drupal::service('auto_updates.release_chooser');
+  try {
+    if ($installed_minor_release = $recommender->getLatestInInstalledMinor($updater)) {
+      $supported_target_versions[] = $installed_minor_release->getVersion();
+    }
+    if ($next_minor_release = $recommender->getLatestInNextMinor($updater)) {
+      $supported_target_versions[] = $next_minor_release->getVersion();
+    }
+  }
+  catch (RuntimeException $exception) {
+    // If for some reason we are not able to get the update recommendations
+    // do not alter the report.
+    watchdog_exception('auto_updates', $exception);
+    return;
+  }
+  $variables['#attached']['library'][] = 'auto_updates/update_status';
+
+  $status = &$variables['status'];
+  if ($supported_target_versions && $status['label']) {
+    $status['label'] = [
+      '#markup' => t(
+        '@label <a href=":update-form">Update now</a>', [
+          '@label' => $status['label'],
+          ':update-form' => Url::fromRoute('auto_updates.report_update')->toString(),
+        ]),
+    ];
+  }
+}
diff --git a/core/modules/auto_updates/auto_updates.routing.yml b/core/modules/auto_updates/auto_updates.routing.yml
index d456921b07cc..89560ca1e360 100644
--- a/core/modules/auto_updates/auto_updates.routing.yml
+++ b/core/modules/auto_updates/auto_updates.routing.yml
@@ -7,6 +7,7 @@ auto_updates.update_readiness:
     _permission: 'administer software updates'
   options:
     _maintenance_access: TRUE
+    _auto_updates_readiness_messages: skip
 auto_updates.confirmation_page:
   path: '/admin/automatic-update-ready/{stage_id}'
   defaults:
@@ -16,6 +17,7 @@ auto_updates.confirmation_page:
     _permission: 'administer software updates'
   options:
     _maintenance_access: TRUE
+    _auto_updates_readiness_messages: skip
 auto_updates.finish:
   path: '/automatic-update/finish'
   defaults:
@@ -24,6 +26,13 @@ auto_updates.finish:
     _permission: 'administer software updates'
   options:
     _maintenance_access: TRUE
+    _auto_updates_readiness_messages: skip
+auto_updates.cron.post_apply:
+  path: '/automatic-update/cron/post-apply/{stage_id}/{installed_version}/{target_version}/{key}'
+  defaults:
+    _controller: 'auto_updates.cron_updater:handlePostApply'
+  requirements:
+    _access_system_cron: 'TRUE'
 # Links to our updater form appear in three different sets of local tasks. To ensure the breadcrumbs and paths are
 # consistent with the other local tasks in each set, we need two separate routes to the same form.
 auto_updates.report_update:
diff --git a/core/modules/auto_updates/auto_updates.services.yml b/core/modules/auto_updates/auto_updates.services.yml
index 579fbbbaac2f..bb54fd6d009c 100644
--- a/core/modules/auto_updates/auto_updates.services.yml
+++ b/core/modules/auto_updates/auto_updates.services.yml
@@ -11,7 +11,6 @@ services:
       - '@event_dispatcher'
       - '@auto_updates.updater'
       - '@auto_updates.cron_updater'
-      - '@config.factory'
       - 24
     tags:
       - { name: event_subscriber }
@@ -27,11 +26,18 @@ services:
       - '@event_dispatcher'
       - '@tempstore.shared'
       - '@datetime.time'
+      - '@PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface'
+      - '@package_manager.failure_marker'
+    calls:
+      - ['setLogger', ['@logger.channel.auto_updates']]
   auto_updates.cron_updater:
     class: Drupal\auto_updates\CronUpdater
     arguments:
-      - '@auto_updates.cron_release_chooser'
+      - '@auto_updates.release_chooser'
       - '@logger.factory'
+      - '@plugin.manager.mail'
+      - '@language_manager'
+      - '@state'
       - '@config.factory'
       - '@package_manager.path_locator'
       - '@package_manager.beginner'
@@ -41,46 +47,32 @@ services:
       - '@event_dispatcher'
       - '@tempstore.shared'
       - '@datetime.time'
+      - '@PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface'
+      - '@package_manager.failure_marker'
+    calls:
+      - ['setLogger', ['@logger.channel.auto_updates']]
   auto_updates.staged_projects_validator:
     class: Drupal\auto_updates\Validator\StagedProjectsValidator
     arguments:
       - '@string_translation'
     tags:
       - { name: event_subscriber }
-  auto_updates.validator.settings:
-    class: Drupal\auto_updates\Validator\SettingsValidator
-    arguments:
-      - '@string_translation'
-    tags:
-      - { name: event_subscriber }
-  auto_updates.update_version_validator:
-    class: Drupal\auto_updates\Validator\UpdateVersionValidator
-    arguments:
-      - '@string_translation'
-      - '@config.factory'
-    tags:
-      - { name: event_subscriber }
-  auto_updates.cron_update_version_validator:
-    class: Drupal\auto_updates\Validator\CronUpdateVersionValidator
-    arguments:
-      - '@string_translation'
-      - '@config.factory'
-    tags:
-      - { name: event_subscriber }
   auto_updates.release_chooser:
     class: Drupal\auto_updates\ReleaseChooser
     arguments:
-      - '@auto_updates.update_version_validator'
-  auto_updates.cron_release_chooser:
-    class: Drupal\auto_updates\ReleaseChooser
-    arguments:
-      - '@auto_updates.cron_update_version_validator'
+      - '@auto_updates.validator.version_policy'
   auto_updates.composer_executable_validator:
     class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
     arguments:
       - '@package_manager.validator.composer_executable'
     tags:
       - { name: event_subscriber }
+  auto_updates.settings_validator:
+    class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+    arguments:
+      - '@package_manager.validator.settings'
+    tags:
+      - { name: event_subscriber }
   auto_updates.validator.composer_settings:
     class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
     arguments:
@@ -111,6 +103,18 @@ services:
       - '@package_manager.validator.multisite'
     tags:
       - { name: event_subscriber }
+  auto_updates.validator.symlink:
+    class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+    arguments:
+      - '@package_manager.validator.symlink'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.validator.patches:
+    class: Drupal\auto_updates\Validator\PackageManagerReadinessCheck
+    arguments:
+      - '@package_manager.validator.patches'
+    tags:
+      - { name: event_subscriber }
   auto_updates.cron_frequency_validator:
     class: Drupal\auto_updates\Validator\CronFrequencyValidator
     arguments:
@@ -119,13 +123,36 @@ services:
       - '@state'
       - '@datetime.time'
       - '@string_translation'
+      - '@auto_updates.cron_updater'
     tags:
       - { name: event_subscriber }
   auto_updates.validator.staged_database_updates:
     class: Drupal\auto_updates\Validator\StagedDatabaseUpdateValidator
     arguments:
-      - '@package_manager.path_locator'
-      - '@extension.list.module'
+      - '@package_manager.validator.staged_database_updates'
       - '@string_translation'
     tags:
       - { name: event_subscriber }
+  auto_updates.validator.xdebug:
+    class: Drupal\auto_updates\Validator\XdebugValidator
+    tags:
+      - { name: event_subscriber }
+  auto_updates.validator.version_policy:
+    class: Drupal\auto_updates\Validator\VersionPolicyValidator
+    arguments:
+      - '@class_resolver'
+    tags:
+      - { name: event_subscriber }
+  auto_updates.config_subscriber:
+    class: Drupal\auto_updates\EventSubscriber\ConfigSubscriber
+    tags:
+      - { name: event_subscriber }
+  auto_updates.validator.scaffold_file_permissions:
+    class: Drupal\auto_updates\Validator\ScaffoldFilePermissionsValidator
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  logger.channel.auto_updates:
+    parent: logger.channel_base
+    arguments: ['auto_updates']
diff --git a/core/modules/auto_updates/config/schema/auto_updates.schema.yml b/core/modules/auto_updates/config/schema/auto_updates.schema.yml
index ee8bb2ea654f..e82825b0c60f 100644
--- a/core/modules/auto_updates/config/schema/auto_updates.schema.yml
+++ b/core/modules/auto_updates/config/schema/auto_updates.schema.yml
@@ -5,6 +5,9 @@ auto_updates.settings:
     cron:
       type: string
       label: 'Enable automatic updates during cron'
+    cron_port:
+      type: integer
+      label: 'Port to use for finalization sub-request'
     allow_core_minor_updates:
       type: boolean
       label: 'Allow minor level Drupal core updates'
diff --git a/core/modules/auto_updates/css/update-status.css b/core/modules/auto_updates/css/update-status.css
new file mode 100644
index 000000000000..62adba0eb4b1
--- /dev/null
+++ b/core/modules/auto_updates/css/update-status.css
@@ -0,0 +1,8 @@
+/**
+ * @file
+ * Styles used by the Automatic Updates module.
+ */
+
+.automatic-updates-unsupported-version .project-update__download-link {
+  display: none;
+}
diff --git a/core/modules/auto_updates/src/BatchProcessor.php b/core/modules/auto_updates/src/BatchProcessor.php
index ffacbfd6ae94..054174d8e9db 100644
--- a/core/modules/auto_updates/src/BatchProcessor.php
+++ b/core/modules/auto_updates/src/BatchProcessor.php
@@ -9,8 +9,13 @@
 
 /**
  * A batch processor for updates.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class BatchProcessor {
+final class BatchProcessor {
 
   /**
    * The session key under which the stage ID is stored.
@@ -49,9 +54,7 @@ protected static function getUpdater(): Updater {
    *   have been recorded.
    */
   protected static function handleException(\Throwable $error, array &$context): void {
-    $error_messages = [
-      $error->getMessage(),
-    ];
+    $error_messages = [];
 
     if ($error instanceof StageValidationException) {
       foreach ($error->getResults() as $result) {
@@ -62,6 +65,9 @@ protected static function handleException(\Throwable $error, array &$context): v
         $error_messages = array_merge($error_messages, $messages);
       }
     }
+    else {
+      $error_messages[] = $error->getMessage();
+    }
 
     foreach ($error_messages as $error_message) {
       $context['results']['errors'][] = $error_message;
@@ -121,6 +127,34 @@ public static function stage(array &$context): void {
   public static function commit(string $stage_id, array &$context): void {
     try {
       static::getUpdater()->claim($stage_id)->apply();
+      // The batch system does not allow any single request to run for longer
+      // than a second, so this will force the next operation to be done in a
+      // new request. This helps keep the running code in as consistent a state
+      // as possible.
+      // @see \Drupal\package_manager\Stage::apply()
+      // @see \Drupal\package_manager\Stage::postApply()
+      // @todo See if there's a better way to ensure the post-apply tasks run
+      //   in a new request in https://www.drupal.org/i/3293150.
+      sleep(1);
+    }
+    catch (\Throwable $e) {
+      static::handleException($e, $context);
+    }
+  }
+
+  /**
+   * Calls the updater's postApply() method.
+   *
+   * @param string $stage_id
+   *   The stage ID.
+   * @param array $context
+   *   The current context of the batch job.
+   *
+   * @see \Drupal\auto_updates\Updater::postApply()
+   */
+  public static function postApply(string $stage_id, array &$context): void {
+    try {
+      static::getUpdater()->claim($stage_id)->postApply();
     }
     catch (\Throwable $e) {
       static::handleException($e, $context);
@@ -230,7 +264,7 @@ protected static function handleBatchError(array $results): void {
    * @see \Drupal\update\Form\UpdateReady::submitForm()
    * @see auto_updates_batch_alter()
    */
-  public static function dbUpdateBatchFinished(bool $success, array $results, array $operations) {
+  public static function dbUpdateBatchFinished(bool $success, array $results, array $operations): void {
     DbUpdateController::batchFinished($success, $results, $operations);
     // Now that the update is done, we can put the site back online if it was
     // previously not in maintenance mode.
diff --git a/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php b/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php
index adb05762b945..6491bafc3b64 100644
--- a/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php
+++ b/core/modules/auto_updates/src/Controller/ReadinessCheckerController.php
@@ -16,7 +16,7 @@
  * @internal
  *   Controller classes are internal.
  */
-class ReadinessCheckerController extends ControllerBase {
+final class ReadinessCheckerController extends ControllerBase {
 
   use ReadinessTrait;
 
diff --git a/core/modules/auto_updates/src/Controller/UpdateController.php b/core/modules/auto_updates/src/Controller/UpdateController.php
index 42a2a536abe9..f2c57cc9c703 100644
--- a/core/modules/auto_updates/src/Controller/UpdateController.php
+++ b/core/modules/auto_updates/src/Controller/UpdateController.php
@@ -4,6 +4,7 @@
 
 use Drupal\auto_updates\BatchProcessor;
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\State\StateInterface;
 use Drupal\Core\Url;
 use Drupal\package_manager\Validator\PendingUpdatesValidator;
@@ -17,7 +18,7 @@
  * @internal
  *   Controller classes are internal.
  */
-class UpdateController extends ControllerBase {
+final class UpdateController extends ControllerBase {
 
   /**
    * The pending updates validator.
@@ -74,6 +75,17 @@ public function onFinish(Request $request): RedirectResponse {
       // previously not in maintenance mode.
       if (!$request->getSession()->remove(BatchProcessor::MAINTENANCE_MODE_SESSION_KEY)) {
         $this->state()->set('system.maintenance_mode', FALSE);
+        // @todo Remove once the core bug that shows the maintenance mode
+        //   message after the site is out of maintenance mode is fixed in
+        //   https://www.drupal.org/i/3279246.
+        $status_messages = $this->messenger()->messagesByType(MessengerInterface::TYPE_STATUS);
+        $status_messages = array_filter($status_messages, function (string $message) {
+          return !str_starts_with($message, (string) $this->t('Operating in maintenance mode.'));
+        });
+        $this->messenger()->deleteByType(MessengerInterface::TYPE_STATUS);
+        foreach ($status_messages as $status_message) {
+          $this->messenger()->addStatus($status_message);
+        }
       }
     }
     $this->messenger()->addStatus($message);
diff --git a/core/modules/auto_updates/src/CronUpdater.php b/core/modules/auto_updates/src/CronUpdater.php
index 0b400a1e43ab..b82fd4765252 100644
--- a/core/modules/auto_updates/src/CronUpdater.php
+++ b/core/modules/auto_updates/src/CronUpdater.php
@@ -2,8 +2,16 @@
 
 namespace Drupal\auto_updates;
 
+use Drupal\Core\Language\LanguageManagerInterface;
 use Drupal\Core\Logger\LoggerChannelFactoryInterface;
+use Drupal\Core\Mail\MailManagerInterface;
+use Drupal\Core\State\StateInterface;
+use Drupal\Core\Url;
 use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ProjectInfo;
+use Drupal\update\ProjectRelease;
+use GuzzleHttp\Psr7\Uri as GuzzleUri;
+use Symfony\Component\HttpFoundation\Response;
 
 /**
  * Defines a service that updates via cron.
@@ -14,6 +22,15 @@
  */
 class CronUpdater extends Updater {
 
+  /**
+   * Whether or not cron updates are hard-disabled.
+   *
+   * @var bool
+   *
+   * @todo Remove this when TUF integration is stable.
+   */
+  private static $disabled = TRUE;
+
   /**
    * All automatic updates are disabled.
    *
@@ -49,6 +66,27 @@ class CronUpdater extends Updater {
    */
   protected $releaseChooser;
 
+  /**
+   * The mail manager service.
+   *
+   * @var \Drupal\Core\Mail\MailManagerInterface
+   */
+  protected $mailManager;
+
+  /**
+   * The language manager service.
+   *
+   * @var \Drupal\Core\Language\LanguageManagerInterface
+   */
+  protected $languageManager;
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
   /**
    * Constructs a CronUpdater object.
    *
@@ -56,37 +94,79 @@ class CronUpdater extends Updater {
    *   The cron release chooser service.
    * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory
    *   The logger channel factory.
+   * @param \Drupal\Core\Mail\MailManagerInterface $mail_manager
+   *   The mail manager service.
+   * @param \Drupal\Core\Language\LanguageManagerInterface $language_manager
+   *   The language manager service.
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
    * @param mixed ...$arguments
    *   Additional arguments to pass to the parent constructor.
    */
-  public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, ...$arguments) {
+  public function __construct(ReleaseChooser $release_chooser, LoggerChannelFactoryInterface $logger_factory, MailManagerInterface $mail_manager, LanguageManagerInterface $language_manager, StateInterface $state, ...$arguments) {
     parent::__construct(...$arguments);
     $this->releaseChooser = $release_chooser;
     $this->logger = $logger_factory->get('auto_updates');
+    $this->mailManager = $mail_manager;
+    $this->languageManager = $language_manager;
+    $this->state = $state;
   }
 
   /**
    * Handles updates during cron.
+   *
+   * @param int|null $timeout
+   *   (optional) How long to allow the file copying operation to run before
+   *   timing out, in seconds, or NULL to never time out. Defaults to 300
+   *   seconds.
    */
-  public function handleCron(): void {
-    if ($this->isDisabled()) {
+  public function handleCron(?int $timeout = 300): void {
+    if ($this->getMode() === static::DISABLED) {
       return;
     }
 
-    $next_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor();
+    $next_release = $this->getTargetRelease();
     if ($next_release) {
-      $this->performUpdate($next_release->getVersion());
+      $this->performUpdate($next_release->getVersion(), $timeout);
     }
   }
 
+  /**
+   * Returns the release of Drupal core to update to, if any.
+   *
+   * @return \Drupal\update\ProjectRelease|null
+   *   The release of Drupal core to which we will update, or NULL if there is
+   *   nothing to update to.
+   */
+  public function getTargetRelease(): ?ProjectRelease {
+    return $this->releaseChooser->getLatestInInstalledMinor($this);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  final public function begin(array $project_versions, ?int $timeout = 300): string {
+    // Unattended updates should never be started using this method. They should
+    // only be done by ::handleCron(), which has a strong opinion about which
+    // release to update to. Throwing an exception here is just to enforce this
+    // boundary. To update to a specific version of core, use
+    // \Drupal\auto_updates\Updater::begin() (which is called in
+    // ::performUpdate() to start the update to the target version of core
+    // chosen by ::handleCron()).
+    throw new \BadMethodCallException(__METHOD__ . '() cannot be called directly.');
+  }
+
   /**
    * Performs the update.
    *
-   * @param string $update_version
-   *   The version to which to update.
+   * @param string $target_version
+   *   The target version.
+   * @param int|null $timeout
+   *   How long to allow the operation to run before timing out, in seconds, or
+   *   NULL to never time out.
    */
-  private function performUpdate(string $update_version): void {
-    $installed_version = (new ProjectInfo())->getInstalledVersion();
+  private function performUpdate(string $target_version, ?int $timeout): void {
+    $installed_version = (new ProjectInfo('drupal'))->getInstalledVersion();
     if (empty($installed_version)) {
       $this->logger->error('Unable to determine the current version of Drupal core.');
       return;
@@ -96,29 +176,121 @@ private function performUpdate(string $update_version): void {
     // handle any exceptions or validation errors consistently, and destroy the
     // stage regardless of whether the update succeeds.
     try {
-      $this->begin([
-        'drupal' => $update_version,
-      ]);
+      // @see ::begin()
+      $stage_id = parent::begin(['drupal' => $target_version], $timeout);
       $this->stage();
       $this->apply();
+    }
+    catch (\Throwable $e) {
+      // Send notifications about the failed update.
+      $mail_params = [
+        'previous_version' => $installed_version,
+        'target_version' => $target_version,
+        'error_message' => $e->getMessage(),
+      ];
+      foreach ($this->getEmailRecipients() as $email => $langcode) {
+        $this->mailManager->mail('auto_updates', 'cron_failed', $email, $langcode, $mail_params);
+      }
+      $this->logger->error($e->getMessage());
+
+      // If an error occurred during the pre-create event, the stage will be
+      // marked as available and we shouldn't try to destroy it, since the stage
+      // must be claimed in order to be destroyed.
+      if (!$this->isAvailable()) {
+        $this->destroy();
+      }
+      return;
+    }
+
+    // Perform a subrequest to run ::postApply(), which needs to be done in a
+    // separate request.
+    // @see parent::apply()
+    $url = Url::fromRoute('auto_updates.cron.post_apply', [
+      'stage_id' => $stage_id,
+      'installed_version' => $installed_version,
+      'target_version' => $target_version,
+      'key' => $this->state->get('system.cron_key'),
+    ]);
+    $this->triggerPostApply($url);
+  }
+
+  /**
+   * Executes a subrequest to run post-apply tasks.
+   *
+   * @param \Drupal\Core\Url $url
+   *   The URL of the post-apply handler.
+   */
+  protected function triggerPostApply(Url $url): void {
+    $url = $url->setAbsolute()->toString();
+
+    // If we're using a single-threaded web server (e.g., the built-in PHP web
+    // server used in build tests), allow the post-apply request to be sent to
+    // an alternate port.
+    // @todo If using the built-in PHP web server, validate that this port is
+    //   set in https://www.drupal.org/i/3293146.
+    $port = $this->configFactory->get('auto_updates.settings')
+      ->get('cron_port');
+    if ($port) {
+      $url = (string) (new GuzzleUri($url))->withPort($port);
+    }
+
+    // Use the bare cURL API to make the request, so that we're not relying on
+    // any third-party classes or other code which may have changed during the
+    // update.
+    $curl = curl_init($url);
+    curl_setopt($curl, CURLOPT_RETURNTRANSFER, TRUE);
+    $response = curl_exec($curl);
+    $status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
+    if ($status !== 200) {
+      $this->logger->error('Post-apply tasks failed with output: %status %response', [
+        '%status' => $status,
+        '%response' => $response,
+      ]);
+    }
+    curl_close($curl);
+  }
+
+  /**
+   * Runs post-apply tasks.
+   *
+   * @param string $stage_id
+   *   The stage ID.
+   * @param string $installed_version
+   *   The version of Drupal core that started the update.
+   * @param string $target_version
+   *   The version of Drupal core to which we updated.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   *   An empty 200 response if the post-apply tasks succeeded.
+   */
+  public function handlePostApply(string $stage_id, string $installed_version, string $target_version): Response {
+    $this->claim($stage_id);
+
+    // Run post-apply tasks in their own try-catch block so that, if anything
+    // raises an exception, we'll log it and proceed to destroy the stage as
+    // soon as possible (which is also what we do in ::performUpdate()).
+    try {
+      $this->postApply();
 
       $this->logger->info(
-        'Drupal core has been updated from %previous_version to %update_version',
+        'Drupal core has been updated from %previous_version to %target_version',
         [
           '%previous_version' => $installed_version,
-          '%update_version' => $update_version,
+          '%target_version' => $target_version,
         ]
       );
+
+      // Send notifications about the successful update.
+      $mail_params = [
+        'previous_version' => $installed_version,
+        'updated_version' => $target_version,
+      ];
+      foreach ($this->getEmailRecipients() as $recipient => $langcode) {
+        $this->mailManager->mail('auto_updates', 'cron_successful', $recipient, $langcode, $mail_params);
+      }
     }
     catch (\Throwable $e) {
-      $this->handleException($e);
-    }
-
-    // If an error occurred during the pre-create event, the stage will be
-    // marked as available and we shouldn't try to destroy it, since the stage
-    // must be claimed in order to be destroyed.
-    if ($this->isAvailable()) {
-      return;
+      $this->logger->error($e->getMessage());
     }
 
     // If any pre-destroy event subscribers raise validation errors, ensure they
@@ -129,58 +301,64 @@ private function performUpdate(string $update_version): void {
       $this->destroy();
     }
     catch (StageValidationException $e) {
-      $this->handleException($e);
+      $this->logger->error($e->getMessage());
     }
+
+    return new Response();
   }
 
   /**
-   * Determines if cron updates are disabled.
+   * Retrieves preferred language to send email.
    *
-   * @return bool
-   *   TRUE if cron updates are disabled, otherwise FALSE.
+   * @param string $recipient
+   *   The email address of the recipient.
+   *
+   * @return string
+   *   The preferred language of the recipient.
    */
-  private function isDisabled(): bool {
-    return $this->configFactory->get('auto_updates.settings')->get('cron') === static::DISABLED;
+  protected function getEmailLangcode(string $recipient): string {
+    $user = user_load_by_mail($recipient);
+    if ($user) {
+      return $user->getPreferredLangcode();
+    }
+    return $this->languageManager->getDefaultLanguage()->getId();
   }
 
   /**
-   * Generates a log message from a stage validation exception.
-   *
-   * @param \Drupal\package_manager\Exception\StageValidationException $exception
-   *   The validation exception.
+   * Returns an array of people to email with success or failure notifications.
    *
-   * @return string
-   *   The formatted log message, including all the validation results.
-   */
-  protected static function formatValidationException(StageValidationException $exception): string {
-    $log_message = '';
-    foreach ($exception->getResults() as $result) {
-      $summary = $result->getSummary();
-      if ($summary) {
-        $log_message .= "<h3>$summary</h3><ul>";
-        foreach ($result->getMessages() as $message) {
-          $log_message .= "<li>$message</li>";
-        }
-        $log_message .= "</ul>";
-      }
-      else {
-        $log_message .= ($log_message ? ' ' : '') . $result->getMessages()[0];
-      }
+   * @return string[]
+   *   An array whose keys are the email addresses to send notifications to, and
+   *   values are the langcodes that they should be emailed in.
+   */
+  protected function getEmailRecipients(): array {
+    $recipients = $this->configFactory->get('update.settings')
+      ->get('notification.emails');
+    $emails = [];
+    foreach ($recipients as $recipient) {
+      $emails[$recipient] = $this->getEmailLangcode($recipient);
     }
-    return "<h2>{$exception->getMessage()}</h2>$log_message";
+    return $emails;
   }
 
   /**
-   * Handles an exception that is caught during an update.
+   * Gets the cron update mode.
    *
-   * @param \Throwable $e
-   *   The caught exception.
+   * @return string
+   *   The cron update mode. Will be one of the following constants:
+   *   - \Drupal\auto_updates\CronUpdater::DISABLED if updates during cron
+   *     are entirely disabled.
+   *   - \Drupal\auto_updates\CronUpdater::SECURITY only security updates
+   *     can be done during cron.
+   *   - \Drupal\auto_updates\CronUpdater::ALL if all updates are allowed
+   *     during cron.
    */
-  protected function handleException(\Throwable $e): void {
-    $message = $e instanceof StageValidationException
-      ? static::formatValidationException($e)
-      : $e->getMessage();
-    $this->logger->error($message);
+  final public function getMode(): string {
+    if (self::$disabled) {
+      return static::DISABLED;
+    }
+    $mode = $this->configFactory->get('auto_updates.settings')->get('cron');
+    return $mode ?: CronUpdater::SECURITY;
   }
 
 }
diff --git a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
index 53467824924c..b0fe0b6a9e5e 100644
--- a/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
+++ b/core/modules/auto_updates/src/Event/ReadinessCheckEvent.php
@@ -65,6 +65,12 @@ public function getPackageVersions(): array {
 
   /**
    * Adds warning information to the event.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
+   *   The warning messages.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
+   *   (optional) The summary of warning messages. Required if there is more
+   *   than one message.
    */
   public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL): void {
     $this->results[] = ValidationResult::createWarning($messages, $summary);
diff --git a/core/modules/auto_updates/src/EventSubscriber/ConfigSubscriber.php b/core/modules/auto_updates/src/EventSubscriber/ConfigSubscriber.php
new file mode 100644
index 000000000000..d41d152304a1
--- /dev/null
+++ b/core/modules/auto_updates/src/EventSubscriber/ConfigSubscriber.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\auto_updates\EventSubscriber;
+
+use Drupal\Core\Config\ConfigCrudEvent;
+use Drupal\Core\Config\ConfigEvents;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Clears stored validation results after certain config changes.
+ *
+ * @todo Move this functionality into ReadinessValidationManager when
+ *   https://www.drupal.org/i/3275317#comment-14482995 is resolved.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class ConfigSubscriber implements EventSubscriberInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ConfigEvents::SAVE => 'onConfigSave',
+    ];
+  }
+
+  /**
+   * Reacts when config is saved.
+   *
+   * @param \Drupal\Core\Config\ConfigCrudEvent $event
+   *   The event object.
+   */
+  public function onConfigSave(ConfigCrudEvent $event): void {
+    if ($event->getConfig()->getName() === 'package_manager.settings' && $event->isChanged('executables.composer')) {
+      \Drupal::service('auto_updates.readiness_validation_manager')
+        ->clearStoredResults();
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Form/UpdateReady.php b/core/modules/auto_updates/src/Form/UpdateReady.php
index 7765a16f82b5..5e2b3834fa4c 100644
--- a/core/modules/auto_updates/src/Form/UpdateReady.php
+++ b/core/modules/auto_updates/src/Form/UpdateReady.php
@@ -4,24 +4,32 @@
 
 use Drupal\auto_updates\BatchProcessor;
 use Drupal\auto_updates\Updater;
-use Drupal\auto_updates\Validator\StagedDatabaseUpdateValidator;
+use Drupal\auto_updates\Validation\ReadinessTrait;
+use Drupal\package_manager\Validator\StagedDBUpdateValidator;
 use Drupal\Core\Batch\BatchBuilder;
 use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\Core\Form\FormBase;
 use Drupal\Core\Form\FormStateInterface;
 use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Exception\ApplyFailedException;
+use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\Exception\StageOwnershipException;
+use Drupal\system\SystemManager;
 use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 
 /**
  * Defines a form to commit staged updates.
  *
  * @internal
- *   Form classes are internal.
+ *   Form classes are internal and the form structure may change at any time.
  */
-class UpdateReady extends FormBase {
+final class UpdateReady extends FormBase {
+
+  use ReadinessTrait;
 
   /**
    * The updater service.
@@ -47,10 +55,24 @@ class UpdateReady extends FormBase {
   /**
    * The staged database update validator service.
    *
-   * @var \Drupal\auto_updates\Validator\StagedDatabaseUpdateValidator
+   * @var \Drupal\package_manager\Validator\StagedDBUpdateValidator
    */
   protected $stagedDatabaseUpdateValidator;
 
+  /**
+   * The renderer service.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * The event dispatcher.
+   *
+   * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface
+   */
+  protected $eventDispatcher;
+
   /**
    * Constructs a new UpdateReady object.
    *
@@ -62,15 +84,21 @@ class UpdateReady extends FormBase {
    *   The state service.
    * @param \Drupal\Core\Extension\ModuleExtensionList $module_list
    *   The module list service.
-   * @param \Drupal\auto_updates\Validator\StagedDatabaseUpdateValidator $staged_database_update_validator
+   * @param \Drupal\package_manager\Validator\StagedDBUpdateValidator $staged_database_update_validator
    *   The staged database update validator service.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer service.
+   * @param \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher
+   *   Event dispatcher service.
    */
-  public function __construct(Updater $updater, MessengerInterface $messenger, StateInterface $state, ModuleExtensionList $module_list, StagedDatabaseUpdateValidator $staged_database_update_validator) {
+  public function __construct(Updater $updater, MessengerInterface $messenger, StateInterface $state, ModuleExtensionList $module_list, StagedDBUpdateValidator $staged_database_update_validator, RendererInterface $renderer, EventDispatcherInterface $event_dispatcher) {
     $this->updater = $updater;
     $this->setMessenger($messenger);
     $this->state = $state;
     $this->moduleList = $module_list;
     $this->stagedDatabaseUpdateValidator = $staged_database_update_validator;
+    $this->renderer = $renderer;
+    $this->eventDispatcher = $event_dispatcher;
   }
 
   /**
@@ -89,7 +117,9 @@ public static function create(ContainerInterface $container) {
       $container->get('messenger'),
       $container->get('state'),
       $container->get('extension.list.module'),
-      $container->get('auto_updates.validator.staged_database_updates')
+      $container->get('package_manager.validator.staged_database_updates'),
+      $container->get('renderer'),
+      $container->get('event_dispatcher')
     );
   }
 
@@ -104,18 +134,28 @@ public function buildForm(array $form, FormStateInterface $form_state, string $s
       $this->messenger()->addError($this->t('Cannot continue the update because another Composer operation is currently in progress.'));
       return $form;
     }
+    catch (ApplyFailedException $e) {
+      $this->messenger()->addError($e->getMessage());
+      return $form;
+    }
 
     $messages = [];
 
-    // If there are any installed modules with database updates in the staging
-    // area, warn the user that they might be sent to update.php once the
-    // staged changes have been applied.
-    $pending_updates = $this->getModulesWithStagedDatabaseUpdates();
+    // If there are any installed extensions with database updates in the
+    // staging area, warn the user that they might be sent to update.php once
+    // the staged changes have been applied.
+    $pending_updates = $this->stagedDatabaseUpdateValidator->getExtensionsWithDatabaseUpdates($this->updater);
     if ($pending_updates) {
-      $messages[MessengerInterface::TYPE_WARNING][] = $this->t('Possible database updates were detected in the following modules; you may be redirected to the database update page in order to complete the update process.');
-      foreach ($pending_updates as $info) {
-        $messages[MessengerInterface::TYPE_WARNING][] = $info['name'];
-      }
+      natcasesort($pending_updates);
+      $message_item_list = [
+        '#theme' => 'item_list',
+        '#prefix' => '<p>' . $this->t('Possible database updates were detected in the following extensions; you may be redirected to the database update page in order to complete the update process.') . '</p>',
+        '#items' => $pending_updates,
+        '#context' => [
+          'list_style' => 'automatic-updates__pending-database-updates',
+        ],
+      ];
+      $messages[MessengerInterface::TYPE_WARNING][] = $this->renderer->renderRoot($message_item_list);
     }
 
     try {
@@ -153,7 +193,7 @@ public function buildForm(array $form, FormStateInterface $form_state, string $s
       return $form;
     }
 
-    $form['update_version'] = [
+    $form['target_version'] = [
       '#type' => 'html_tag',
       '#tag' => 'p',
       '#value' => $this->t('Drupal core will be updated to %version', [
@@ -162,36 +202,38 @@ public function buildForm(array $form, FormStateInterface $form_state, string $s
     ];
     $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']),
+      '#markup' => $this->t('This cannot be undone, so it is strongly recommended to <a href=":url">back up your database and site</a> before continuing, if you haven\'t already.', [':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,
-    ];
+    if (!$this->state->get('system.maintenance_mode')) {
+      $form['maintenance_mode'] = [
+        '#title' => $this->t('Perform updates with site in maintenance mode (strongly recommended)'),
+        '#type' => 'checkbox',
+        '#default_value' => TRUE,
+      ];
+    }
+
+    // Don't run the status checks once the form has been submitted.
+    if (!$form_state->getUserInput()) {
+      $event = new StatusCheckEvent($this->updater);
+      $this->eventDispatcher->dispatch($event);
+      /** @var \Drupal\package_manager\ValidationResult[] $results */
+      $results = $event->getResults();
+      // This will have no effect if $results is empty.
+      $this->displayResults($results, $this->messenger(), $this->renderer);
+      // If any errors occurred, return the form early so the user cannot
+      // continue.
+      if ($this->getOverallSeverity($results) === SystemManager::REQUIREMENT_ERROR) {
+        return $form;
+      }
+    }
     $form['actions']['submit'] = [
       '#type' => 'submit',
       '#value' => $this->t('Continue'),
     ];
-
     return $form;
   }
 
-  /**
-   * Returns info for all installed modules that have staged database updates.
-   *
-   * @return array[]
-   *   The info arrays for the modules which have staged database updates, keyed
-   *   by module machine name.
-   */
-  protected function getModulesWithStagedDatabaseUpdates(): array {
-    $filter = function (string $name): bool {
-      return $this->stagedDatabaseUpdateValidator->hasStagedUpdates($this->updater, $this->moduleList->get($name));
-    };
-    return array_filter($this->moduleList->getAllInstalledInfo(), $filter, ARRAY_FILTER_USE_KEY);
-  }
-
   /**
    * {@inheritdoc}
    */
@@ -204,11 +246,13 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
     if ($form_state->getValue('maintenance_mode')) {
       $this->state->set('system.maintenance_mode', TRUE);
     }
+
     $stage_id = $form_state->getValue('stage_id');
     $batch = (new BatchBuilder())
       ->setTitle($this->t('Apply updates'))
       ->setInitMessage($this->t('Preparing to apply updates'))
       ->addOperation([BatchProcessor::class, 'commit'], [$stage_id])
+      ->addOperation([BatchProcessor::class, 'postApply'], [$stage_id])
       ->addOperation([BatchProcessor::class, 'clean'], [$stage_id])
       ->setFinishCallback([BatchProcessor::class, 'finishCommit'])
       ->toArray();
diff --git a/core/modules/auto_updates/src/Form/UpdaterForm.php b/core/modules/auto_updates/src/Form/UpdaterForm.php
index 90074c6c5de3..9638ced6404c 100644
--- a/core/modules/auto_updates/src/Form/UpdaterForm.php
+++ b/core/modules/auto_updates/src/Form/UpdaterForm.php
@@ -4,18 +4,25 @@
 
 use Drupal\auto_updates\BatchProcessor;
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
-use Drupal\auto_updates\ProjectInfo;
+use Drupal\package_manager\FailureMarker;
+use Drupal\package_manager\ProjectInfo;
 use Drupal\auto_updates\ReleaseChooser;
 use Drupal\auto_updates\Updater;
 use Drupal\auto_updates\Validation\ReadinessTrait;
+use Drupal\package_manager\Exception\ApplyFailedException;
+use Drupal\update\ProjectRelease;
 use Drupal\Core\Batch\BatchBuilder;
+use Drupal\Core\Extension\ExtensionVersion;
 use Drupal\Core\Form\FormBase;
 use Drupal\Core\Form\FormStateInterface;
-use Drupal\Core\Link;
+use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\State\StateInterface;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\Url;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\Exception\StageOwnershipException;
+use Drupal\package_manager\ValidationResult;
 use Drupal\system\SystemManager;
 use Drupal\update\UpdateManagerInterface;
 use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -25,11 +32,13 @@
  * Defines a form to update Drupal core.
  *
  * @internal
- *   Form classes are internal.
+ *   Form classes are internal and the form structure may change at any time.
  */
-class UpdaterForm extends FormBase {
+final class UpdaterForm extends FormBase {
 
-  use ReadinessTrait;
+  use ReadinessTrait {
+    formatResult as traitFormatResult;
+  }
 
   /**
    * The updater service.
@@ -59,6 +68,20 @@ class UpdaterForm extends FormBase {
    */
   protected $releaseChooser;
 
+  /**
+   * The renderer service.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
+
+  /**
+   * Failure marker service.
+   *
+   * @var \Drupal\package_manager\FailureMarker
+   */
+  protected $failureMarker;
+
   /**
    * Constructs a new UpdaterForm object.
    *
@@ -70,12 +93,18 @@ class UpdaterForm extends FormBase {
    *   The event dispatcher service.
    * @param \Drupal\auto_updates\ReleaseChooser $release_chooser
    *   The release chooser service.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer service.
+   * @param \Drupal\package_manager\FailureMarker $failure_marker
+   *   The failure marker service.
    */
-  public function __construct(StateInterface $state, Updater $updater, EventDispatcherInterface $event_dispatcher, ReleaseChooser $release_chooser) {
+  public function __construct(StateInterface $state, Updater $updater, EventDispatcherInterface $event_dispatcher, ReleaseChooser $release_chooser, RendererInterface $renderer, FailureMarker $failure_marker) {
     $this->updater = $updater;
     $this->state = $state;
     $this->eventDispatcher = $event_dispatcher;
     $this->releaseChooser = $release_chooser;
+    $this->renderer = $renderer;
+    $this->failureMarker = $failure_marker;
   }
 
   /**
@@ -93,7 +122,9 @@ public static function create(ContainerInterface $container) {
       $container->get('state'),
       $container->get('auto_updates.updater'),
       $container->get('event_dispatcher'),
-      $container->get('auto_updates.release_chooser')
+      $container->get('auto_updates.release_chooser'),
+      $container->get('renderer'),
+      $container->get('package_manager.failure_marker')
     );
   }
 
@@ -101,8 +132,13 @@ public static function create(ContainerInterface $container) {
    * {@inheritdoc}
    */
   public function buildForm(array $form, FormStateInterface $form_state) {
-    $this->messenger()->addWarning($this->t('This is an experimental Automatic Updater using Composer. Use at your own risk.'));
-
+    try {
+      $this->failureMarker->assertNotExists();
+    }
+    catch (ApplyFailedException $e) {
+      $this->messenger()->addError($e->getMessage());
+      return $form;
+    }
     if ($this->updater->isAvailable()) {
       $stage_exists = FALSE;
     }
@@ -131,24 +167,15 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       '#theme' => 'update_last_check',
       '#last' => $this->state->get('update.last_check', 0),
     ];
-    $project_info = new ProjectInfo();
+    $project_info = new ProjectInfo('drupal');
 
     try {
       // @todo Until https://www.drupal.org/i/3264849 is fixed, we can only show
       //   one release on the form. First, try to show the latest release in the
       //   currently installed minor. Failing that, try to show the latest
-      //   release in the next minor. If neither of those are available, just
-      //   show the first available release.
-      $recommended_release = $this->releaseChooser->refresh()->getLatestInInstalledMinor();
-      if (!$recommended_release) {
-        $recommended_release = $this->releaseChooser->getLatestInNextMinor();
-        if (!$recommended_release) {
-          // @todo Do not list an update that can't be validated in
-          //   https://www.drupal.org/i/3271235.
-          $updates = $project_info->getInstallableReleases();
-          $recommended_release = array_pop($updates);
-        }
-      }
+      //   release in the next minor.
+      $installed_minor_release = $this->releaseChooser->getLatestInInstalledMinor($this->updater);
+      $next_minor_release = $this->releaseChooser->getLatestInNextMinor($this->updater);
     }
     catch (\RuntimeException $e) {
       $form['message'] = [
@@ -157,102 +184,124 @@ public function buildForm(array $form, FormStateInterface $form_state) {
       return $form;
     }
 
-    // @todo Should we be using the Update module's library here, or our own?
-    $form['#attached']['library'][] = 'update/drupal.update.admin';
-
-    // If we're already up-to-date, there's nothing else we need to do.
-    if ($recommended_release === NULL) {
-      // @todo Link to the Available Updates report if there are other updates
-      //   that are not supported by this module in
-      //   https://www.drupal.org/i/3271235.
-      $this->messenger()->addMessage('No update available');
+    if ($form_state->getUserInput()) {
+      $results = [];
+    }
+    else {
+      $event = new ReadinessCheckEvent($this->updater);
+      $this->eventDispatcher->dispatch($event);
+      $results = $event->getResults();
+    }
+    $this->displayResults($results, $this->messenger(), $this->renderer);
+    $project = $project_info->getProjectInfo();
+    if ($installed_minor_release === NULL && $next_minor_release === NULL) {
+      if ($project['status'] === UpdateManagerInterface::CURRENT) {
+        $this->messenger()->addMessage($this->t('No update available'));
+      }
+      else {
+        $message = $this->t('Updates were found, but they must be performed manually. See <a href=":url">the list of available updates</a> for more information.', [
+          ':url' => Url::fromRoute('update.status')->toString(),
+        ]);
+        // If the current release is old, but otherwise secure and supported,
+        // this should be a regular status message. In any other case, urgent
+        // action is needed so flag it as an error.
+        $this->messenger()->addMessage($message, $project['status'] === UpdateManagerInterface::NOT_CURRENT ? MessengerInterface::TYPE_STATUS : MessengerInterface::TYPE_ERROR);
+      }
       return $form;
     }
 
-    $form['update_version'] = [
-      '#type' => 'value',
-      '#value' => [
-        'drupal' => $recommended_release->getVersion(),
-      ],
-    ];
-
-    $project = $project_info->getProjectInfo();
     if (empty($project['title']) || empty($project['link'])) {
       throw new \UnexpectedValueException('Expected project data to have a title and link.');
     }
-    $title = Link::fromTextAndUrl($project['title'], Url::fromUri($project['link']))->toRenderable();
+
+    $form['title'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'h2',
+      '#value' => $this->t(
+        'Update <a href=":url">Drupal core</a>',
+        [':url' => $project['link']],
+      ),
+    ];
+    $form['current'] = [
+      '#type' => 'html_tag',
+      '#tag' => 'p',
+      '#value' => $this->t(
+        'Currently installed: @version (@status)',
+        [
+          '@version' => $project_info->getInstalledVersion(),
+          '@status' => $this->getUpdateStatus($project['status']),
+        ]
+      ),
+    ];
 
     switch ($project['status']) {
       case UpdateManagerInterface::NOT_SECURE:
       case UpdateManagerInterface::REVOKED:
-        $title['#suffix'] = ' ' . $this->t('(Security update)');
+        $release_status = $this->t('Security update');
         $type = 'update-security';
         break;
 
-      case UpdateManagerInterface::NOT_SUPPORTED:
-        $title['#suffix'] = ' ' . $this->t('(Unsupported)');
-        $type = 'unsupported';
-        break;
-
       default:
-        $type = 'recommended';
-        break;
+        $release_status = $this->t('Available update');
+        $type = 'update-recommended';
+    }
+    $create_update_buttons = !$stage_exists && $this->getOverallSeverity($results) !== SystemManager::REQUIREMENT_ERROR;
+    if ($installed_minor_release) {
+      $installed_version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion());
+      $form['installed_minor'] = $this->createReleaseTable(
+        $installed_minor_release,
+        $release_status,
+        $this->t('Latest version of Drupal @major.@minor (currently installed):', [
+          '@major' => $installed_version->getMajorVersion(),
+          '@minor' => $installed_version->getMinorVersion(),
+        ]),
+        $type,
+        $create_update_buttons,
+        // Any update in the current minor should be the primary update.
+        TRUE,
+      );
     }
+    if ($next_minor_release) {
+      // If there is no update in the current minor make the button for the next
+      // minor primary unless the project status is 'CURRENT' or 'NOT_CURRENT'.
+      // 'NOT_CURRENT' does not denote that installed version is not a valid
+      // only that there is newer version available.
+      $is_primary = !$installed_minor_release && !($project['status'] === UpdateManagerInterface::CURRENT || $project['status'] === UpdateManagerInterface::NOT_CURRENT);
+      $next_minor_version = ExtensionVersion::createFromVersionString($next_minor_release->getVersion());
+
+      // Since updating to another minor version of Drupal is more disruptive
+      // than updating within the currently installed minor version, ensure we
+      // display a link to the release notes for the first (x.y.0) release of
+      // the next minor version, which will inform site owners of any potential
+      // pitfalls or major changes. We should always be able to get release info
+      // for it; if we can't, that's an error condition.
+      $first_release_version = $next_minor_version->getMajorVersion() . '.' . $next_minor_version->getMinorVersion() . '.0';
+      $available_updates = update_get_available(TRUE);
+      if (isset($available_updates['drupal']['releases'][$first_release_version])) {
+        $next_minor_first_release = ProjectRelease::createFromArray($available_updates['drupal']['releases'][$first_release_version]);
+      }
+      else {
+        throw new \LogicException("Release information for Drupal $first_release_version is not available.");
+      }
 
-    // Create an entry for this project.
-    $entry = [
-      'title' => [
-        'data' => $title,
-      ],
-      'installed_version' => $project_info->getInstalledVersion(),
-      '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['next_minor'] = $this->createReleaseTable(
+        $next_minor_release,
+        $installed_minor_release ? $this->t('Minor update') : $release_status,
+        $this->t('Latest version of Drupal @major.@minor (next minor) (<a href=":url">Release notes</a>):', [
+          '@major' => $next_minor_version->getMajorVersion(),
+          '@minor' => $next_minor_version->getMinorVersion(),
+          ':url' => $next_minor_first_release->getReleaseUrl(),
+        ]),
+        $installed_minor_release ? 'update-optional' : $type,
+        $create_update_buttons,
+        $is_primary
+      );
+    }
 
-    $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['backup'] = [
+      '#markup' => $this->t('It\'s a good idea to <a href=":url">back up your database and site code</a> before you begin.', [':url' => 'https://www.drupal.org/node/22281']),
     ];
 
-    if ($form_state->getUserInput()) {
-      $results = [];
-    }
-    else {
-      $event = new ReadinessCheckEvent($this->updater, [
-        'drupal' => $recommended_release->getVersion(),
-      ]);
-      $this->eventDispatcher->dispatch($event);
-      $results = $event->getResults();
-    }
-    $this->displayResults($results, $this->messenger());
-
     if ($stage_exists) {
       // If the form has been submitted, do not display this error message
       // because ::deleteExistingUpdate() may run on submit. The message will
@@ -266,13 +315,6 @@ public function buildForm(array $form, FormStateInterface $form_state) {
         '#submit' => ['::deleteExistingUpdate'],
       ];
     }
-    // If there were no errors, allow the user to proceed with the update.
-    elseif ($this->getOverallSeverity($results) !== SystemManager::REQUIREMENT_ERROR) {
-      $form['actions']['submit'] = [
-        '#type' => 'submit',
-        '#value' => $this->t('Update'),
-      ];
-    }
     $form['actions']['#type'] = 'actions';
 
     return $form;
@@ -295,12 +337,13 @@ public function deleteExistingUpdate(): void {
    * {@inheritdoc}
    */
   public function submitForm(array &$form, FormStateInterface $form_state) {
+    $button = $form_state->getTriggeringElement();
     $batch = (new BatchBuilder())
       ->setTitle($this->t('Downloading updates'))
       ->setInitMessage($this->t('Preparing to download updates'))
       ->addOperation(
         [BatchProcessor::class, 'begin'],
-        [$form_state->getValue('update_version')]
+        [['drupal' => $button['#target_version']]]
       )
       ->addOperation([BatchProcessor::class, 'stage'])
       ->setFinishCallback([BatchProcessor::class, 'finishStage'])
@@ -309,4 +352,132 @@ public function submitForm(array &$form, FormStateInterface $form_state) {
     batch_set($batch);
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function formatResult(ValidationResult $result) {
+    $messages = $result->getMessages();
+
+    if (count($messages) > 1) {
+      return [
+        '#theme' => 'item_list__auto_updates_validation_results',
+        '#prefix' => $result->getSummary(),
+        '#items' => $messages,
+      ];
+    }
+    return $this->traitFormatResult($result);
+  }
+
+  /**
+   * Gets the update table for a specific release.
+   *
+   * @param \Drupal\update\ProjectRelease $release
+   *   The project release.
+   * @param string $release_description
+   *   The release description.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $caption
+   *   The table caption, if any.
+   * @param string $update_type
+   *   The update type.
+   * @param bool $create_update_button
+   *   Whether the update button should be created.
+   * @param bool $is_primary
+   *   Whether update button should be a primary button.
+   *
+   * @return string[][]
+   *   The table render array.
+   */
+  private function createReleaseTable(ProjectRelease $release, string $release_description, ?TranslatableMarkup $caption, string $update_type, bool $create_update_button, bool $is_primary): array {
+    $release_section = ['#type' => 'container'];
+    $release_section['table'] = [
+      '#type' => 'table',
+      '#description' => $this->t('more'),
+      '#header' => [
+        'title' => [
+          'data' => $this->t('Update type'),
+          'class' => ['update-project-name'],
+        ],
+        'target_version' => [
+          'data' => $this->t('Version'),
+        ],
+      ],
+    ];
+    if ($caption) {
+      $release_section['table']['#caption'] = $caption;
+    }
+    $release_section['table'][$release->getVersion()] = [
+      'title' => [
+        '#type' => 'html_tag',
+        '#tag' => 'p',
+        '#value' => $release_description,
+      ],
+      'target_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' => $release->getVersion(),
+            'release_link' => $release->getReleaseUrl(),
+            'project_title' => $this->t(
+              'Release notes for @project_title @version',
+              [
+                '@project_title' => 'Drupal core',
+                '@version' => $release->getVersion(),
+              ]
+            ),
+            'release_notes' => $this->t('Release notes'),
+          ],
+        ],
+      ],
+      '#attributes' => ['class' => ['update-' . $update_type]],
+    ];
+    if ($create_update_button) {
+      $release_section['submit'] = [
+        '#type' => 'submit',
+        '#value' => $this->t('Update to @version', ['@version' => $release->getVersion()]),
+        '#target_version' => $release->getVersion(),
+      ];
+      if ($is_primary) {
+        $release_section['submit']['#button_type'] = 'primary';
+      }
+    }
+    $release_section['#suffix'] = '<br />';
+    return $release_section;
+
+  }
+
+  /**
+   * Gets the human-readable project status.
+   *
+   * @param int $status
+   *   The project status, one of \Drupal\update\UpdateManagerInterface
+   *   constants.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+   *   The human-readable status.
+   */
+  private function getUpdateStatus(int $status): TranslatableMarkup {
+    switch ($status) {
+      case UpdateManagerInterface::NOT_SECURE:
+        return $this->t('Security update required!');
+
+      case UpdateManagerInterface::REVOKED:
+        return $this->t('Revoked!');
+
+      case UpdateManagerInterface::NOT_SUPPORTED:
+        return $this->t('Not supported!');
+
+      case UpdateManagerInterface::NOT_CURRENT:
+        return $this->t('Update available');
+
+      case UpdateManagerInterface::CURRENT:
+        return $this->t('Up to date');
+
+      default:
+        return $this->t('Unknown status');
+    }
+  }
+
 }
diff --git a/core/modules/auto_updates/src/ProjectInfo.php b/core/modules/auto_updates/src/ProjectInfo.php
deleted file mode 100644
index df802a80d906..000000000000
--- a/core/modules/auto_updates/src/ProjectInfo.php
+++ /dev/null
@@ -1,106 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates;
-
-use Composer\Semver\Comparator;
-use Composer\Semver\Semver;
-use Drupal\update\ProjectRelease;
-use Drupal\update\UpdateManagerInterface;
-
-/**
- * Defines a class for retrieving project information from Update module.
- *
- * @todo Allow passing a project name to handle more than Drupal core in
- *    https://www.drupal.org/i/3271240.
- */
-class ProjectInfo {
-
-  /**
-   * Returns up-to-date project information for Drupal core.
-   *
-   * @param bool $refresh
-   *   (optional) Whether to fetch the latest information about available
-   *   updates from drupal.org. This can be an expensive operation, so defaults
-   *   to FALSE.
-   *
-   * @return array
-   *   The retrieved project information for Drupal core.
-   *
-   * @throws \RuntimeException
-   *   If data about available updates cannot be retrieved.
-   */
-  public function getProjectInfo(bool $refresh = FALSE): array {
-    $available_updates = update_get_available($refresh);
-    $project_data = update_calculate_project_data($available_updates);
-    return $project_data['drupal'];
-  }
-
-  /**
-   * Gets all releases of Drupal core to which the site can update.
-   *
-   * @param bool $refresh
-   *   (optional) Whether to fetch the latest information about available
-   *   updates from drupal.org. This can be an expensive operation, so defaults
-   *   to FALSE.
-   *
-   * @return \Drupal\update\ProjectRelease[]
-   *   An array of possible update releases with release versions as keys. The
-   *   releases are in descending order by version number (i.e., higher versions
-   *   are listed first).
-   *
-   * @throws \RuntimeException
-   *   Thrown if $refresh is TRUE and there are no available releases.
-   *
-   * @todo Remove or simplify this function in https://www.drupal.org/i/3252190.
-   */
-  public function getInstallableReleases(bool $refresh = FALSE): array {
-    $project = $this->getProjectInfo($refresh);
-    $installed_version = $this->getInstalledVersion();
-    // If we refreshed and we were able to get available releases we should
-    // always have at least have the current release stored.
-    if ($refresh && empty($project['releases'])) {
-      throw new \RuntimeException('There was a problem getting update information. Try again later.');
-    }
-    // If we're already up-to-date, there's nothing else we need to do.
-    if ($project['status'] === UpdateManagerInterface::CURRENT) {
-      return [];
-    }
-    elseif (empty($project['recommended'])) {
-      // If we don't know what to recommend they update to, time to freak out.
-      throw new \LogicException('Drupal core is out of date, but the recommended version could not be determined.');
-    }
-    $installable_releases = [];
-    if (Comparator::greaterThan($project['recommended'], $installed_version)) {
-      $release = ProjectRelease::createFromArray($project['releases'][$project['recommended']]);
-      $installable_releases[$release->getVersion()] = $release;
-    }
-    if (!empty($project['security updates'])) {
-      foreach ($project['security updates'] as $security_update) {
-        $release = ProjectRelease::createFromArray($security_update);
-        $version = $release->getVersion();
-        if (Comparator::greaterThan($version, $installed_version)) {
-          $installable_releases[$version] = $release;
-        }
-      }
-    }
-    $sorted_versions = Semver::rsort(array_keys($installable_releases));
-    return array_replace(array_flip($sorted_versions), $installable_releases);
-  }
-
-  /**
-   * Returns the installed project version, according to the Update module.
-   *
-   * @param bool $refresh
-   *   (optional) Whether to fetch the latest information about available
-   *   updates from drupal.org. This can be an expensive operation, so defaults
-   *   to FALSE.
-   *
-   * @return string
-   *   The installed project version as known to the Update module.
-   */
-  public function getInstalledVersion(bool $refresh = FALSE): string {
-    $project_data = $this->getProjectInfo($refresh);
-    return $project_data['existing_version'];
-  }
-
-}
diff --git a/core/modules/auto_updates/src/ReleaseChooser.php b/core/modules/auto_updates/src/ReleaseChooser.php
index c72ec3a572e9..7fc2b66b7f68 100644
--- a/core/modules/auto_updates/src/ReleaseChooser.php
+++ b/core/modules/auto_updates/src/ReleaseChooser.php
@@ -3,64 +3,60 @@
 namespace Drupal\auto_updates;
 
 use Composer\Semver\Semver;
-use Drupal\auto_updates\Validator\UpdateVersionValidator;
+use Drupal\auto_updates\Validator\VersionPolicyValidator;
+use Drupal\package_manager\ProjectInfo;
 use Drupal\update\ProjectRelease;
 use Drupal\Core\Extension\ExtensionVersion;
 
 /**
  * Defines a class to choose a release of Drupal core to update to.
  */
-class ReleaseChooser {
+final class ReleaseChooser {
 
   use VersionParsingTrait;
 
   /**
-   * The version validator service.
+   * The version policy validator service.
    *
-   * @var \Drupal\auto_updates\Validator\UpdateVersionValidator
+   * @var \Drupal\auto_updates\Validator\VersionPolicyValidator
    */
-  protected $versionValidator;
+  protected $versionPolicyValidator;
 
   /**
    * The project information fetcher.
    *
-   * @var \Drupal\auto_updates\ProjectInfo
+   * @var \Drupal\package_manager\ProjectInfo
    */
   protected $projectInfo;
 
   /**
    * Constructs an ReleaseChooser object.
    *
-   * @param \Drupal\auto_updates\Validator\UpdateVersionValidator $version_validator
+   * @param \Drupal\auto_updates\Validator\VersionPolicyValidator $version_policy_validator
    *   The version validator.
    */
-  public function __construct(UpdateVersionValidator $version_validator) {
-    $this->versionValidator = $version_validator;
-    $this->projectInfo = new ProjectInfo();
-  }
-
-  /**
-   * Refreshes the project information through the Update module.
-   *
-   * @return $this
-   *   The called object.
-   */
-  public function refresh(): self {
-    $this->projectInfo->getProjectInfo(TRUE);
-    return $this;
+  public function __construct(VersionPolicyValidator $version_policy_validator) {
+    $this->versionPolicyValidator = $version_policy_validator;
+    $this->projectInfo = new ProjectInfo('drupal');
   }
 
   /**
    * Returns the releases that are installable.
    *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater that will be used to install the releases.
+   *
    * @return \Drupal\update\ProjectRelease[]
-   *   The releases that are installable according to the version validator
-   *   service.
+   *   The releases that are installable by the given updtaer, according to the
+   *   version validator service.
    */
-  protected function getInstallableReleases(): array {
+  protected function getInstallableReleases(Updater $updater): array {
+    $filter = function (string $version) use ($updater): bool {
+      return empty($this->versionPolicyValidator->validateVersion($updater, $version));
+    };
     return array_filter(
       $this->projectInfo->getInstallableReleases(),
-      [$this->versionValidator, 'isValidVersion'],
+      $filter,
       ARRAY_FILTER_USE_KEY
     );
   }
@@ -68,6 +64,8 @@ protected function getInstallableReleases(): array {
   /**
    * Gets the most recent release in the same minor as a specified version.
    *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater that will be used to install the release.
    * @param string $version
    *   The full semantic version number, which must include a patch version.
    *
@@ -77,12 +75,16 @@ protected function getInstallableReleases(): array {
    * @throws \InvalidArgumentException
    *   If the given semantic version number does not contain a patch version.
    */
-  protected function getMostRecentReleaseInMinor(string $version): ?ProjectRelease {
+  protected function getMostRecentReleaseInMinor(Updater $updater, string $version): ?ProjectRelease {
     if (static::getPatchVersion($version) === NULL) {
       throw new \InvalidArgumentException("The version number $version does not contain a patch version");
     }
-    $releases = $this->getInstallableReleases();
+    $releases = $this->getInstallableReleases($updater);
     foreach ($releases as $release) {
+      // Checks if the release is in the same minor as the currently installed
+      // version. For example, if the current version is 9.8.0 then the
+      // constraint ~9.8.0 (equivalent to >=9.8.0 && <9.9.0) will be used to
+      // check if the release is in the same minor.
       if (Semver::satisfies($release->getVersion(), "~$version")) {
         return $release;
       }
@@ -106,12 +108,15 @@ protected function getInstalledVersion(): string {
    * This will only return a release if it passes the ::isValidVersion() method
    * of the version validator service injected into this class.
    *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater which will install the release.
+   *
    * @return \Drupal\update\ProjectRelease|null
    *   The latest release in the currently installed minor, if any, otherwise
    *   NULL.
    */
-  public function getLatestInInstalledMinor(): ?ProjectRelease {
-    return $this->getMostRecentReleaseInMinor($this->getInstalledVersion());
+  public function getLatestInInstalledMinor(Updater $updater): ?ProjectRelease {
+    return $this->getMostRecentReleaseInMinor($updater, $this->getInstalledVersion());
   }
 
   /**
@@ -120,13 +125,16 @@ public function getLatestInInstalledMinor(): ?ProjectRelease {
    * This will only return a release if it passes the ::isValidVersion() method
    * of the version validator service injected into this class.
    *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater which will install the release.
+   *
    * @return \Drupal\update\ProjectRelease|null
    *   The latest release in the next minor, if any, otherwise NULL.
    */
-  public function getLatestInNextMinor(): ?ProjectRelease {
+  public function getLatestInNextMinor(Updater $updater): ?ProjectRelease {
     $installed_version = ExtensionVersion::createFromVersionString($this->getInstalledVersion());
     $next_minor = $installed_version->getMajorVersion() . '.' . (((int) $installed_version->getMinorVersion()) + 1) . '.0';
-    return $this->getMostRecentReleaseInMinor($next_minor);
+    return $this->getMostRecentReleaseInMinor($updater, $next_minor);
   }
 
 }
diff --git a/core/modules/auto_updates/src/Routing/RouteSubscriber.php b/core/modules/auto_updates/src/Routing/RouteSubscriber.php
index bbcad03e9443..98e121e6ab2a 100644
--- a/core/modules/auto_updates/src/Routing/RouteSubscriber.php
+++ b/core/modules/auto_updates/src/Routing/RouteSubscriber.php
@@ -9,8 +9,11 @@
  * Modifies route definitions.
  *
  * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class RouteSubscriber extends RouteSubscriberBase {
+final class RouteSubscriber extends RouteSubscriberBase {
 
   /**
    * {@inheritdoc}
@@ -27,6 +30,7 @@ protected function alterRoutes(RouteCollection $collection) {
       'update.settings',
       'system.status',
       'update.confirmation_page',
+      'system.batch_page.html',
     ];
     foreach ($disabled_routes as $route) {
       $route = $collection->get($route);
diff --git a/core/modules/auto_updates/src/Updater.php b/core/modules/auto_updates/src/Updater.php
index 68b3aec8564e..e964ecf490a3 100644
--- a/core/modules/auto_updates/src/Updater.php
+++ b/core/modules/auto_updates/src/Updater.php
@@ -3,7 +3,9 @@
 namespace Drupal\auto_updates;
 
 use Drupal\auto_updates\Exception\UpdateException;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\ApplyFailedException;
 use Drupal\package_manager\Exception\StageValidationException;
 use Drupal\package_manager\Stage;
 
@@ -22,6 +24,10 @@ class Updater extends Stage {
    *
    * @param string[] $project_versions
    *   The versions of the packages to update to, keyed by package name.
+   * @param int|null $timeout
+   *   (optional) How long to allow the file copying operation to run before
+   *   timing out, in seconds, or NULL to never time out. Defaults to 300
+   *   seconds.
    *
    * @return string
    *   The unique ID of the stage.
@@ -29,7 +35,7 @@ class Updater extends Stage {
    * @throws \InvalidArgumentException
    *   Thrown if no project version for Drupal core is provided.
    */
-  public function begin(array $project_versions): string {
+  public function begin(array $project_versions, ?int $timeout = 300): string {
     if (count($project_versions) !== 1 || !array_key_exists('drupal', $project_versions)) {
       throw new \InvalidArgumentException("Currently only updates to Drupal core are supported.");
     }
@@ -54,7 +60,7 @@ public function begin(array $project_versions): string {
     $this->tempStore->set(static::TEMPSTORE_METADATA_KEY, [
       'packages' => $package_versions,
     ]);
-    return $this->create();
+    return $this->create($timeout);
   }
 
   /**
@@ -72,7 +78,7 @@ public function getPackageVersions(): array {
   /**
    * Stages the update.
    */
-  public function stage(): void {
+  public function stage(?int $timeout = 300): void {
     $this->checkOwnership();
 
     // Convert an associative array of package versions, keyed by name, to
@@ -85,7 +91,7 @@ public function stage(): void {
       return $requirements;
     };
     $versions = array_map($map, $this->getPackageVersions());
-    $this->require($versions['production'], $versions['dev']);
+    $this->require($versions['production'], $versions['dev'], $timeout);
   }
 
   /**
@@ -100,4 +106,23 @@ protected function dispatch(StageEvent $event, callable $on_error = NULL): void
     }
   }
 
+  /**
+   * {@inheritdoc}
+   */
+  public function apply(?int $timeout = 600): void {
+    try {
+      parent::apply($timeout);
+    }
+    catch (ApplyFailedException $exception) {
+      throw new UpdateException([], 'The update operation failed to apply. The update may have been partially applied. It is recommended that the site be restored from a code backup.', $exception->getCode(), $exception);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function getFailureMarkerMessage(): TranslatableMarkup {
+    return $this->t('Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.');
+  }
+
 }
diff --git a/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php b/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php
index b8d4cb767145..38954fee8532 100644
--- a/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php
+++ b/core/modules/auto_updates/src/Validation/AdminReadinessMessages.php
@@ -3,10 +3,10 @@
 namespace Drupal\auto_updates\Validation;
 
 use Drupal\auto_updates\CronUpdater;
-use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
 use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\Messenger\MessengerTrait;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\Routing\AdminContext;
 use Drupal\Core\Routing\CurrentRouteMatch;
 use Drupal\Core\Routing\RedirectDestinationTrait;
@@ -60,11 +60,18 @@ final class AdminReadinessMessages implements ContainerInjectionInterface {
   protected $currentRouteMatch;
 
   /**
-   * The config factory service.
+   * The cron updater service.
    *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   * @var \Drupal\auto_updates\CronUpdater
    */
-  protected $config;
+  protected $cronUpdater;
+
+  /**
+   * The renderer service.
+   *
+   * @var \Drupal\Core\Render\RendererInterface
+   */
+  protected $renderer;
 
   /**
    * Constructs a ReadinessRequirement object.
@@ -81,17 +88,20 @@ final class AdminReadinessMessages implements ContainerInjectionInterface {
    *   The translation service.
    * @param \Drupal\Core\Routing\CurrentRouteMatch $current_route_match
    *   The current route match.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config
-   *   The config factory service.
+   * @param \Drupal\auto_updates\CronUpdater $cron_updater
+   *   The cron updater service.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer service.
    */
-  public function __construct(ReadinessValidationManager $readiness_checker_manager, MessengerInterface $messenger, AdminContext $admin_context, AccountProxyInterface $current_user, TranslationInterface $translation, CurrentRouteMatch $current_route_match, ConfigFactoryInterface $config) {
+  public function __construct(ReadinessValidationManager $readiness_checker_manager, MessengerInterface $messenger, AdminContext $admin_context, AccountProxyInterface $current_user, TranslationInterface $translation, CurrentRouteMatch $current_route_match, CronUpdater $cron_updater, RendererInterface $renderer) {
     $this->readinessCheckerManager = $readiness_checker_manager;
     $this->setMessenger($messenger);
     $this->adminContext = $admin_context;
     $this->currentUser = $current_user;
     $this->setStringTranslation($translation);
     $this->currentRouteMatch = $current_route_match;
-    $this->config = $config;
+    $this->cronUpdater = $cron_updater;
+    $this->renderer = $renderer;
   }
 
   /**
@@ -105,7 +115,8 @@ public static function create(ContainerInterface $container): self {
       $container->get('current_user'),
       $container->get('string_translation'),
       $container->get('current_route_match'),
-      $container->get('config.factory')
+      $container->get('auto_updates.cron_updater'),
+      $container->get('renderer')
     );
   }
 
@@ -142,7 +153,7 @@ public function displayAdminPageMessages(): void {
   protected function displayResultsOnCurrentPage(): bool {
     // If updates will not run during cron then we don't need to show the
     // readiness checks on admin pages.
-    if ($this->config->get('auto_updates.settings')->get('cron') === CronUpdater::DISABLED) {
+    if ($this->cronUpdater->getMode() === CronUpdater::DISABLED) {
       return FALSE;
     }
 
@@ -168,7 +179,7 @@ protected function displayResultsForSeverity(int $severity): bool {
     if (empty($results)) {
       return FALSE;
     }
-    $this->displayResults($results, $this->messenger());
+    $this->displayResults($results, $this->messenger(), $this->renderer);
     return TRUE;
   }
 
diff --git a/core/modules/auto_updates/src/Validation/ReadinessRequirements.php b/core/modules/auto_updates/src/Validation/ReadinessRequirements.php
index 9a1e706264be..411ebe998671 100644
--- a/core/modules/auto_updates/src/Validation/ReadinessRequirements.php
+++ b/core/modules/auto_updates/src/Validation/ReadinessRequirements.php
@@ -69,11 +69,11 @@ public static function create(ContainerInterface $container): self {
   /**
    * Gets requirements arrays as specified in hook_requirements().
    *
-   * @return array[]
+   * @return mixed[]
    *   Requirements arrays as specified by hook_requirements().
    */
   public function getRequirements(): array {
-    $results = $this->readinessCheckerManager->runIfNoStoredResults()->getResults();
+    $results = $this->readinessCheckerManager->run()->getResults();
     $requirements = [];
     if (empty($results)) {
       $requirements['auto_updates_readiness'] = [
diff --git a/core/modules/auto_updates/src/Validation/ReadinessTrait.php b/core/modules/auto_updates/src/Validation/ReadinessTrait.php
index a4f707393a50..49c41db98e50 100644
--- a/core/modules/auto_updates/src/Validation/ReadinessTrait.php
+++ b/core/modules/auto_updates/src/Validation/ReadinessTrait.php
@@ -3,11 +3,18 @@
 namespace Drupal\auto_updates\Validation;
 
 use Drupal\Core\Messenger\MessengerInterface;
+use Drupal\Core\Render\RendererInterface;
 use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\package_manager\ValidationResult;
 use Drupal\system\SystemManager;
 
 /**
  * Common methods for working with readiness checkers.
+ *
+ * @internal
+ *   This trait implements logic to output the messages from readiness checkers
+ *   on admin pages. It may be changed or removed at any time without warning
+ *   and should not be used by external code.
  */
 trait ReadinessTrait {
 
@@ -59,33 +66,45 @@ protected function getOverallSeverity(array $results): int {
    *   The validation results.
    * @param \Drupal\Core\Messenger\MessengerInterface $messenger
    *   The messenger service.
+   * @param \Drupal\Core\Render\RendererInterface $renderer
+   *   The renderer service.
    */
-  protected function displayResults(array $results, MessengerInterface $messenger): void {
+  protected function displayResults(array $results, MessengerInterface $messenger, RendererInterface $renderer): void {
     $severity = $this->getOverallSeverity($results);
 
     if ($severity === SystemManager::REQUIREMENT_OK) {
       return;
     }
 
-    $message = $this->getFailureMessageForSeverity($severity);
+    // Format the results as a single item list prefixed by a preamble message.
+    $build = [
+      '#theme' => 'item_list__auto_updates_validation_results',
+      '#prefix' => $this->getFailureMessageForSeverity($severity),
+      '#items' => array_map([$this, 'formatResult'], $results),
+    ];
+    $message = $renderer->renderRoot($build);
+
     if ($severity === SystemManager::REQUIREMENT_ERROR) {
       $messenger->addError($message);
     }
     else {
       $messenger->addWarning($message);
     }
+  }
 
-    foreach ($results as $result) {
-      $messages = $result->getMessages();
-      $message = count($messages) === 1 ? $messages[0] : $result->getSummary();
-
-      if ($result->getSeverity() === SystemManager::REQUIREMENT_ERROR) {
-        $messenger->addError($message);
-      }
-      else {
-        $messenger->addWarning($message);
-      }
-    }
+  /**
+   * Formats a single validation result as an item in an item list.
+   *
+   * @param \Drupal\package_manager\ValidationResult $result
+   *   A validation result.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup|array
+   *   The validation result, formatted for inclusion in a themed item list as
+   *   either a translated string, or a renderable array.
+   */
+  protected function formatResult(ValidationResult $result) {
+    $messages = $result->getMessages();
+    return count($messages) === 1 ? reset($messages) : $result->getSummary();
   }
 
 }
diff --git a/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php
index c513af498f8f..a41ab89d8f80 100644
--- a/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php
+++ b/core/modules/auto_updates/src/Validation/ReadinessValidationManager.php
@@ -4,10 +4,8 @@
 
 use Drupal\auto_updates\CronUpdater;
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
-use Drupal\auto_updates\ProjectInfo;
 use Drupal\auto_updates\Updater;
 use Drupal\Component\Datetime\TimeInterface;
-use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
 use Drupal\package_manager\Event\PostApplyEvent;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -16,7 +14,7 @@
 /**
  * Defines a manager to run readiness validation.
  */
-class ReadinessValidationManager implements EventSubscriberInterface {
+final class ReadinessValidationManager implements EventSubscriberInterface {
 
   /**
    * The key/value expirable storage.
@@ -46,7 +44,6 @@ class ReadinessValidationManager implements EventSubscriberInterface {
    */
   protected $resultsTimeToLive;
 
-
   /**
    * The updater service.
    *
@@ -61,13 +58,6 @@ class ReadinessValidationManager implements EventSubscriberInterface {
    */
   protected $cronUpdater;
 
-  /**
-   * The config factory service.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $config;
-
   /**
    * Constructs a ReadinessValidationManager.
    *
@@ -81,18 +71,15 @@ class ReadinessValidationManager implements EventSubscriberInterface {
    *   The updater service.
    * @param \Drupal\auto_updates\CronUpdater $cron_updater
    *   The cron updater service.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config
-   *   The config factory service.
    * @param int $results_time_to_live
    *   The number of hours to store results.
    */
-  public function __construct(KeyValueExpirableFactoryInterface $key_value_expirable_factory, TimeInterface $time, EventDispatcherInterface $dispatcher, Updater $updater, CronUpdater $cron_updater, ConfigFactoryInterface $config, int $results_time_to_live) {
+  public function __construct(KeyValueExpirableFactoryInterface $key_value_expirable_factory, TimeInterface $time, EventDispatcherInterface $dispatcher, Updater $updater, CronUpdater $cron_updater, int $results_time_to_live) {
     $this->keyValueExpirable = $key_value_expirable_factory->get('auto_updates');
     $this->time = $time;
     $this->eventDispatcher = $dispatcher;
     $this->updater = $updater;
     $this->cronUpdater = $cron_updater;
-    $this->config = $config;
     $this->resultsTimeToLive = $results_time_to_live;
   }
 
@@ -105,15 +92,13 @@ public function run(): self {
     // If updates will run during cron, use the cron updater service provided by
     // this module. This will allow subscribers to ReadinessCheckEvent to run
     // specific validation for conditions that only affect cron updates.
-    if ($this->config->get('auto_updates.settings')->get('cron') === CronUpdater::DISABLED) {
+    if ($this->cronUpdater->getMode() === CronUpdater::DISABLED) {
       $stage = $this->updater;
     }
     else {
       $stage = $this->cronUpdater;
     }
     $event = new ReadinessCheckEvent($stage);
-    // Version validators will need up-to-date project info.
-    (new ProjectInfo())->getProjectInfo(TRUE);
     $this->eventDispatcher->dispatch($event);
     $results = $event->getResults();
     $this->keyValueExpirable->setWithExpire(
diff --git a/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php b/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php
index 1f86460908ff..af2dc2d262f5 100644
--- a/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php
+++ b/core/modules/auto_updates/src/Validator/CronFrequencyValidator.php
@@ -15,6 +15,11 @@
 
 /**
  * Validates that cron runs frequently enough to perform automatic updates.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
 class CronFrequencyValidator implements EventSubscriberInterface {
 
@@ -75,6 +80,13 @@ class CronFrequencyValidator implements EventSubscriberInterface {
    */
   protected $time;
 
+  /**
+   * The cron updater service.
+   *
+   * @var \Drupal\auto_updates\CronUpdater
+   */
+  protected $cronUpdater;
+
   /**
    * CronFrequencyValidator constructor.
    *
@@ -88,13 +100,16 @@ class CronFrequencyValidator implements EventSubscriberInterface {
    *   The time service.
    * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
    *   The translation service.
+   * @param \Drupal\auto_updates\CronUpdater $cron_updater
+   *   The cron updater service.
    */
-  public function __construct(ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler, StateInterface $state, TimeInterface $time, TranslationInterface $translation) {
+  public function __construct(ConfigFactoryInterface $config_factory, ModuleHandlerInterface $module_handler, StateInterface $state, TimeInterface $time, TranslationInterface $translation, CronUpdater $cron_updater) {
     $this->configFactory = $config_factory;
     $this->moduleHandler = $module_handler;
     $this->state = $state;
     $this->time = $time;
     $this->setStringTranslation($translation);
+    $this->cronUpdater = $cron_updater;
   }
 
   /**
@@ -104,12 +119,13 @@ public function __construct(ConfigFactoryInterface $config_factory, ModuleHandle
    *   The event object.
    */
   public function checkCronFrequency(ReadinessCheckEvent $event): void {
-    $cron_enabled = $this->configFactory->get('auto_updates.settings')
-      ->get('cron');
-
+    // We only want to do this check if the stage belongs to Automatic Updates.
+    if (!$event->getStage() instanceof CronUpdater) {
+      return;
+    }
     // If automatic updates are disabled during cron, there's nothing we need
     // to validate.
-    if ($cron_enabled === CronUpdater::DISABLED) {
+    if ($this->cronUpdater->getMode() === CronUpdater::DISABLED) {
       return;
     }
     elseif ($this->moduleHandler->moduleExists('automated_cron')) {
diff --git a/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php b/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php
deleted file mode 100644
index de52a4ad0fa2..000000000000
--- a/core/modules/auto_updates/src/Validator/CronUpdateVersionValidator.php
+++ /dev/null
@@ -1,100 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates\Validator;
-
-use Drupal\auto_updates\CronUpdater;
-use Drupal\auto_updates\ProjectInfo;
-use Drupal\auto_updates\VersionParsingTrait;
-use Drupal\Core\Extension\ExtensionVersion;
-use Drupal\package_manager\Stage;
-use Drupal\package_manager\ValidationResult;
-
-/**
- * Validates the target version of Drupal core before a cron update.
- *
- * @internal
- *   This class is an internal part of the module's cron update handling and
- *   should not be used by external code.
- */
-final class CronUpdateVersionValidator extends UpdateVersionValidator {
-
-  use VersionParsingTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static function isStageSupported(Stage $stage): bool {
-    return $stage instanceof CronUpdater;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function getValidationResult(string $to_version_string): ?ValidationResult {
-    if ($result = parent::getValidationResult($to_version_string)) {
-      return $result;
-    }
-    $from_version_string = $this->getCoreVersion();
-    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
-    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
-    $variables = [
-      '@to_version' => $to_version_string,
-      '@from_version' => $from_version_string,
-    ];
-    // @todo Return multiple validation messages and summary in
-    //   https://www.drupal.org/project/auto_updates/issues/3272068.
-    // Validate that both the from and to versions are stable releases.
-    if ($from_version->getVersionExtra()) {
-      return ValidationResult::createError([
-        $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, because Automatic Updates only supports updating from stable versions during cron.', $variables),
-      ]);
-    }
-    if ($to_version->getVersionExtra()) {
-      // Because we do not support updating to a new minor version during
-      // cron it is probably impossible to update from a stable version to
-      // a unstable/pre-release version, but we should check this condition
-      // just in case.
-      return ValidationResult::createError([
-        $this->t('Drupal cannot be automatically updated during cron to the recommended version, @to_version, because Automatic Updates only supports updating to stable versions during cron.', $variables),
-      ]);
-    }
-
-    if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
-      return ValidationResult::createError([
-        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported during cron.', $variables),
-      ]);
-    }
-
-    // Only updating to the next patch release is supported during cron.
-    $supported_patch_version = $from_version->getMajorVersion() . '.' . $from_version->getMinorVersion() . '.' . (((int) static::getPatchVersion($from_version_string)) + 1);
-    if ($to_version_string !== $supported_patch_version) {
-      return ValidationResult::createError([
-        $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because Automatic Updates only supports 1 patch version update during cron.', $variables),
-      ]);
-    }
-
-    // If both the from and to version numbers are valid check if the current
-    // settings only allow security updates during cron and if so ensure the
-    // update release is a security release.
-    $level = $this->configFactory->get('auto_updates.settings')->get('cron');
-    if ($level === CronUpdater::SECURITY) {
-      $releases = (new ProjectInfo())->getInstallableReleases();
-      // @todo Remove this check and add validation to
-      //   \Drupal\auto_updates\Validator\UpdateVersionValidator::getValidationResult()
-      //   to ensure the update release is always secure and supported in
-      //   https://www.drupal.org/i/3271468.
-      if (!isset($releases[$to_version_string])) {
-        return ValidationResult::createError([
-          $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a valid release.', $variables),
-        ]);
-      }
-      if (!$releases[$to_version_string]->isSecurityRelease()) {
-        return ValidationResult::createError([
-          $this->t('Drupal cannot be automatically updated during cron from its current version, @from_version, to the recommended version, @to_version, because @to_version is not a security release.', $variables),
-        ]);
-      }
-    }
-    return NULL;
-  }
-
-}
diff --git a/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php b/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php
index 0d4838f266a5..4372532ec7de 100644
--- a/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php
+++ b/core/modules/auto_updates/src/Validator/PackageManagerReadinessCheck.php
@@ -3,6 +3,7 @@
 namespace Drupal\auto_updates\Validator;
 
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\Updater;
 use Drupal\package_manager\Validator\PreOperationStageValidatorInterface;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
@@ -12,8 +13,13 @@
  * This class exists to facilitate re-use of Package Manager's stage validators
  * during update readiness checks, in addition to whatever events they normally
  * subscribe to.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class PackageManagerReadinessCheck implements EventSubscriberInterface {
+final class PackageManagerReadinessCheck implements EventSubscriberInterface {
 
   /**
    * The validator to run.
@@ -39,6 +45,10 @@ public function __construct(PreOperationStageValidatorInterface $validator) {
    *   The event object.
    */
   public function validate(ReadinessCheckEvent $event): void {
+    // We only want to do this check if the stage belongs to Automatic Updates.
+    if (!$event->getStage() instanceof Updater) {
+      return;
+    }
     $this->validator->validateStagePreOperation($event);
   }
 
diff --git a/core/modules/auto_updates/src/Validator/ScaffoldFilePermissionsValidator.php b/core/modules/auto_updates/src/Validator/ScaffoldFilePermissionsValidator.php
new file mode 100644
index 000000000000..b18fb14e3c3b
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/ScaffoldFilePermissionsValidator.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\Updater;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\ComposerUtility;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\Validator\PreOperationStageValidatorInterface;
+
+/**
+ * Validates that scaffold files have appropriate permissions.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class ScaffoldFilePermissionsValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a SiteDirectoryPermissionsValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    // We only want to do this check if the stage belongs to Automatic Updates.
+    if (!$event->getStage() instanceof Updater) {
+      return;
+    }
+    $paths = [];
+
+    // Figure out the absolute path of `sites/default`.
+    $site_dir = $this->pathLocator->getProjectRoot();
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $site_dir .= '/' . $web_root;
+    }
+    $site_dir .= '/sites/default';
+
+    $stage = $event->getStage();
+    $active_scaffold_files = $this->getDefaultSiteFilesFromScaffold($stage->getActiveComposer());
+
+    // If the active directory and staging area have different files scaffolded
+    // into `sites/default` (i.e., files were added, renamed, or deleted), the
+    // site directory itself must be writable for the changes to be applied.
+    if ($event instanceof PreApplyEvent) {
+      $staged_scaffold_files = $this->getDefaultSiteFilesFromScaffold($stage->getStageComposer());
+
+      if ($active_scaffold_files !== $staged_scaffold_files) {
+        $paths[] = $site_dir;
+      }
+    }
+    // The scaffolded files themselves must be writable, so that any changes to
+    // them in the staging area can be synced back to the active directory.
+    foreach ($active_scaffold_files as $scaffold_file) {
+      $paths[] = $site_dir . '/' . $scaffold_file;
+    }
+
+    // Flag messages about anything in $paths which exists, but isn't writable.
+    $non_writable_files = array_filter($paths, function (string $path): bool {
+      return file_exists($path) && !is_writable($path);
+    });
+    if ($non_writable_files) {
+      // Re-key the messages in order to prevent false negative comparisons in
+      // tests.
+      $non_writable_files = array_values($non_writable_files);
+      $event->addError($non_writable_files, $this->t('The following paths must be writable in order to update default site configuration files.'));
+    }
+  }
+
+  /**
+   * Returns the list of file names scaffolded into `sites/default`.
+   *
+   * @param \Drupal\package_manager\ComposerUtility $composer
+   *   A Composer utility helper for a directory.
+   *
+   * @return string[]
+   *   The names of files that are scaffolded into `sites/default`, stripped
+   *   of the preceding path. For example,
+   *   `[web-root]/sites/default/default.settings.php` will be
+   *   `default.settings.php`. Will be sorted alphabetically. If the target
+   *   directory doesn't have the `drupal/core` package installed, the returned
+   *   array will be empty.
+   */
+  protected function getDefaultSiteFilesFromScaffold(ComposerUtility $composer): array {
+    $installed = $composer->getInstalledPackages();
+
+    if (array_key_exists('drupal/core', $installed)) {
+      $extra = $installed['drupal/core']->getExtra();
+      // We expect Drupal core to provide a list of scaffold files.
+      $files = $extra['drupal-scaffold']['file-mapping'];
+    }
+    else {
+      $files = [];
+    }
+    $files = array_keys($files);
+    $files = preg_grep('/sites\/default\//', $files);
+    $files = array_map('basename', $files);
+    sort($files);
+
+    return $files;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => 'validateStagePreOperation',
+      PreCreateEvent::class => 'validateStagePreOperation',
+      PreApplyEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/StagedDatabaseUpdateValidator.php b/core/modules/auto_updates/src/Validator/StagedDatabaseUpdateValidator.php
index 021db4b521b7..5b0421313517 100644
--- a/core/modules/auto_updates/src/Validator/StagedDatabaseUpdateValidator.php
+++ b/core/modules/auto_updates/src/Validator/StagedDatabaseUpdateValidator.php
@@ -3,49 +3,41 @@
 namespace Drupal\auto_updates\Validator;
 
 use Drupal\auto_updates\CronUpdater;
-use Drupal\auto_updates\Updater;
-use Drupal\Core\Extension\Extension;
-use Drupal\Core\Extension\ModuleExtensionList;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
 use Drupal\package_manager\Event\PreApplyEvent;
-use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\Validator\StagedDBUpdateValidator;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
  * Validates that there are no database updates in a staged update.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
 class StagedDatabaseUpdateValidator implements EventSubscriberInterface {
 
   use StringTranslationTrait;
 
   /**
-   * The path locator service.
+   * The Staged DB Update Validator service.
    *
-   * @var \Drupal\package_manager\PathLocator
+   * @var \Drupal\package_manager\Validator\StagedDBUpdateValidator
    */
-  protected $pathLocator;
-
-  /**
-   * The module list service.
-   *
-   * @var \Drupal\Core\Extension\ModuleExtensionList
-   */
-  protected $moduleList;
+  protected $stagedDBUpdateValidator;
 
   /**
    * Constructs a StagedDatabaseUpdateValidator object.
    *
-   * @param \Drupal\package_manager\PathLocator $path_locator
-   *   The path locator service.
-   * @param \Drupal\Core\Extension\ModuleExtensionList $module_list
-   *   The module list service.
+   * @param \Drupal\package_manager\Validator\StagedDBUpdateValidator $staged_db_update_update_validator
+   *   The Staged DB Update Validator service.
    * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
    *   The string translation service.
    */
-  public function __construct(PathLocator $path_locator, ModuleExtensionList $module_list, TranslationInterface $translation) {
-    $this->pathLocator = $path_locator;
-    $this->moduleList = $module_list;
+  public function __construct(StagedDBUpdateValidator $staged_db_update_update_validator, TranslationInterface $translation) {
+    $this->stagedDBUpdateValidator = $staged_db_update_update_validator;
     $this->setStringTranslation($translation);
   }
 
@@ -61,90 +53,10 @@ public function checkUpdateHooks(PreApplyEvent $event): void {
       return;
     }
 
-    $invalid_modules = [];
-    // Although \Drupal\auto_updates\Validator\StagedProjectsValidator
-    // should prevent non-core modules from being added, updated, or removed in
-    // the staging area, we check all installed modules so as not to rely on the
-    // presence of StagedProjectsValidator.
-    foreach ($this->moduleList->getAllInstalledInfo() as $name => $info) {
-      if ($this->hasStagedUpdates($stage, $this->moduleList->get($name))) {
-        $invalid_modules[] = $info['name'];
-      }
-    }
-
-    if ($invalid_modules) {
-      $event->addError($invalid_modules, $this->t('The update cannot proceed because possible database updates have been detected in the following modules.'));
-    }
-  }
-
-  /**
-   * Determines if a staged extension has changed update functions.
-   *
-   * @param \Drupal\auto_updates\Updater $updater
-   *   The updater which is controlling the update process.
-   * @param \Drupal\Core\Extension\Extension $extension
-   *   The extension to check.
-   *
-   * @return bool
-   *   TRUE if the staged copy of the extension has changed update functions
-   *   compared to the active copy, FALSE otherwise.
-   *
-   * @todo Use a more sophisticated method to detect changes in the staged
-   *   extension. Right now, we just compare hashes of the .install and
-   *   .post_update.php files in both copies of the given extension, but this
-   *   will cause false positives for changes to comments, whitespace, or
-   *   runtime code like requirements checks. It would be preferable to use a
-   *   static analyzer to detect new or changed functions that are actually
-   *   executed during an update. No matter what, this method must NEVER cause
-   *   false negatives, since that could result in code which is incompatible
-   *   with the current database schema being copied to the active directory.
-   *
-   * @see https://www.drupal.org/project/auto_updates/issues/3253828
-   */
-  public function hasStagedUpdates(Updater $updater, Extension $extension): bool {
-    $active_dir = $this->pathLocator->getProjectRoot();
-    $stage_dir = $updater->getStageDirectory();
-
-    $web_root = $this->pathLocator->getWebRoot();
-    if ($web_root) {
-      $active_dir .= DIRECTORY_SEPARATOR . $web_root;
-      $stage_dir .= DIRECTORY_SEPARATOR . $web_root;
-    }
-
-    $active_hashes = $this->getHashes($active_dir, $extension);
-    $staged_hashes = $this->getHashes($stage_dir, $extension);
-
-    return $active_hashes !== $staged_hashes;
-  }
-
-  /**
-   * Returns hashes of the .install and .post-update.php files for a module.
-   *
-   * @param string $root_dir
-   *   The root directory of the Drupal code base.
-   * @param \Drupal\Core\Extension\Extension $module
-   *   The module to check.
-   *
-   * @return string[]
-   *   The hashes of the module's .install and .post_update.php files, in that
-   *   order, if they exist. The array will be keyed by file extension.
-   */
-  protected function getHashes(string $root_dir, Extension $module): array {
-    $path = implode(DIRECTORY_SEPARATOR, [
-      $root_dir,
-      $module->getPath(),
-      $module->getName(),
-    ]);
-    $hashes = [];
-
-    foreach (['.install', '.post_update.php'] as $suffix) {
-      $file = $path . $suffix;
-
-      if (file_exists($file)) {
-        $hashes[$suffix] = hash_file('sha256', $file);
-      }
+    $invalid_extensions = $this->stagedDBUpdateValidator->getExtensionsWithDatabaseUpdates($stage);
+    if ($invalid_extensions) {
+      $event->addError($invalid_extensions, $this->t('The update cannot proceed because possible database updates have been detected in the following extensions.'));
     }
-    return $hashes;
   }
 
   /**
diff --git a/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php b/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php
index 68352fe0ae64..cc3064464ce4 100644
--- a/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php
+++ b/core/modules/auto_updates/src/Validator/StagedProjectsValidator.php
@@ -11,6 +11,11 @@
 
 /**
  * Validates the staged Drupal projects.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
 final class StagedProjectsValidator implements EventSubscriberInterface {
 
diff --git a/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php b/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php
deleted file mode 100644
index 9ff9ea6e7f79..000000000000
--- a/core/modules/auto_updates/src/Validator/UpdateVersionValidator.php
+++ /dev/null
@@ -1,206 +0,0 @@
-<?php
-
-namespace Drupal\auto_updates\Validator;
-
-use Composer\Semver\Comparator;
-use Drupal\auto_updates\CronUpdater;
-use Drupal\auto_updates\Event\ReadinessCheckEvent;
-use Drupal\auto_updates\ProjectInfo;
-use Drupal\auto_updates\Updater;
-use Drupal\Core\Config\ConfigFactoryInterface;
-use Drupal\Core\Extension\ExtensionVersion;
-use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\StringTranslation\TranslationInterface;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\PreOperationStageEvent;
-use Drupal\package_manager\Event\StageEvent;
-use Drupal\package_manager\Stage;
-use Drupal\package_manager\ValidationResult;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-
-/**
- * Validates that core updates are within a supported version range.
- *
- * @internal
- *   This class is an internal part of the module's update handling and
- *   should not be used by external code.
- */
-class UpdateVersionValidator implements EventSubscriberInterface {
-
-  use StringTranslationTrait;
-
-  /**
-   * The config factory service.
-   *
-   * @var \Drupal\Core\Config\ConfigFactoryInterface
-   */
-  protected $configFactory;
-
-  /**
-   * Constructs a UpdateVersionValidation object.
-   *
-   * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
-   *   The translation service.
-   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
-   *   The config factory service.
-   */
-  public function __construct(TranslationInterface $translation, ConfigFactoryInterface $config_factory) {
-    $this->setStringTranslation($translation);
-    $this->configFactory = $config_factory;
-  }
-
-  /**
-   * Returns the running core version, according to the Update module.
-   *
-   * @return string
-   *   The running core version as known to the Update module.
-   */
-  protected function getCoreVersion(): string {
-    return (new ProjectInfo())->getInstalledVersion();
-  }
-
-  /**
-   * Validates that core is being updated within an allowed version range.
-   *
-   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
-   *   The event object.
-   */
-  public function checkUpdateVersion(PreOperationStageEvent $event): void {
-    if (!static::isStageSupported($event->getStage())) {
-      return;
-    }
-    if ($to_version = $this->getUpdateVersion($event)) {
-      if ($result = $this->getValidationResult($to_version)) {
-        $event->addError($result->getMessages(), $result->getSummary());
-      }
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PreCreateEvent::class => 'checkUpdateVersion',
-      ReadinessCheckEvent::class => 'checkUpdateVersion',
-    ];
-  }
-
-  /**
-   * Gets the update version.
-   *
-   * @param \Drupal\package_manager\Event\StageEvent $event
-   *   The event.
-   *
-   * @return string|null
-   *   The version that the site will update to if any, otherwise NULL.
-   */
-  protected function getUpdateVersion(StageEvent $event): ?string {
-    /** @var \Drupal\auto_updates\Updater $updater */
-    $updater = $event->getStage();
-    if ($event instanceof ReadinessCheckEvent) {
-      $package_versions = $event->getPackageVersions();
-    }
-    else {
-      // If the stage has begun its life cycle, we expect it knows the desired
-      // package versions.
-      $package_versions = $updater->getPackageVersions()['production'];
-    }
-    if ($package_versions) {
-      // All the core packages will be updated to the same version, so it
-      // doesn't matter which specific package we're looking at.
-      $core_package_name = key($updater->getActiveComposer()->getCorePackages());
-      return $package_versions[$core_package_name];
-    }
-    else {
-      // During readiness checks we might not have a version to update to. Check
-      // if there are any possible updates and add a message about why we cannot
-      // update to that version.
-      // @todo Remove this code in https://www.drupal.org/i/3272326 when we add
-      //   add a validator that will warn if cron updates will no longer work
-      //   because the site is more than 1 patch release behind.
-      $project_info = new ProjectInfo();
-      if ($possible_releases = $project_info->getInstallableReleases()) {
-        $possible_release = array_pop($possible_releases);
-        return $possible_release->getVersion();
-      }
-    }
-    return NULL;
-  }
-
-  /**
-   * Determines if a version is valid.
-   *
-   * @param string $version
-   *   The version string.
-   *
-   * @return bool
-   *   TRUE if the version is valid (i.e., the site can update to it), otherwise
-   *   FALSE.
-   */
-  public function isValidVersion(string $version): bool {
-    return empty($this->getValidationResult($version));
-  }
-
-  /**
-   * Validates if an update to a specific version is allowed.
-   *
-   * @param string $to_version_string
-   *   The version to update to.
-   *
-   * @return \Drupal\package_manager\ValidationResult|null
-   *   NULL if the update is allowed, otherwise returns a validation result with
-   *   the reason why the update is not allowed.
-   */
-  protected function getValidationResult(string $to_version_string): ?ValidationResult {
-    $from_version_string = $this->getCoreVersion();
-    $variables = [
-      '@to_version' => $to_version_string,
-      '@from_version' => $from_version_string,
-    ];
-    $from_version = ExtensionVersion::createFromVersionString($from_version_string);
-    // @todo Return multiple validation messages and summary in
-    //   https://www.drupal.org/project/auto_updates/issues/3272068.
-    if ($from_version->getVersionExtra() === 'dev') {
-      return ValidationResult::createError([
-        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from a dev version to any other version are not supported.', $variables),
-      ]);
-    }
-    if (Comparator::lessThan($to_version_string, $from_version_string)) {
-      return ValidationResult::createError([
-        $this->t('Update version @to_version is lower than @from_version, downgrading is not supported.', $variables),
-      ]);
-    }
-    $to_version = ExtensionVersion::createFromVersionString($to_version_string);
-    if ($from_version->getMajorVersion() !== $to_version->getMajorVersion()) {
-      return ValidationResult::createError([
-        $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one major version to another are not supported.', $variables),
-      ]);
-    }
-    if ($from_version->getMinorVersion() !== $to_version->getMinorVersion()) {
-      if (!$this->configFactory->get('auto_updates.settings')->get('allow_core_minor_updates')) {
-        return ValidationResult::createError([
-          $this->t('Drupal cannot be automatically updated from its current version, @from_version, to the recommended version, @to_version, because automatic updates from one minor version to another are not supported.', $variables),
-        ]);
-      }
-    }
-    return NULL;
-  }
-
-  /**
-   * Determines if a stage is supported by this validator.
-   *
-   * @param \Drupal\package_manager\Stage $stage
-   *   The stage to check.
-   *
-   * @return bool
-   *   TRUE if the stage is supported by this validator, otherwise FALSE.
-   */
-  protected static function isStageSupported(Stage $stage): bool {
-    // We only want to do this check if the stage belongs to Automatic Updates,
-    // and it is not a cron update.
-    // @see \Drupal\auto_updates\Validator\CronUpdateVersionValidator
-    return $stage instanceof Updater && !$stage instanceof CronUpdater;
-  }
-
-}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDevSnapshot.php b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDevSnapshot.php
new file mode 100644
index 000000000000..7cd25db3af8a
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDevSnapshot.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule that forbids updating from a dev snapshot.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class ForbidDevSnapshot {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks if the installed version of Drupal is a dev snapshot.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version): array {
+    $extra = ExtensionVersion::createFromVersionString($installed_version)
+      ->getVersionExtra();
+
+    if ($extra === 'dev') {
+      return [
+        $this->t('Drupal cannot be automatically updated from the installed version, @installed_version, because automatic updates from a dev version to any other version are not supported.', [
+          '@installed_version' => $installed_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDowngrade.php b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDowngrade.php
new file mode 100644
index 000000000000..a660e2da13a0
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidDowngrade.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Composer\Semver\Comparator;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule that forbids downgrading.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class ForbidDowngrade {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks if the target version of Drupal is older than the installed version.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    if (Comparator::lessThan($target_version, $installed_version)) {
+      return [
+        $this->t('Update version @target_version is lower than @installed_version, downgrading is not supported.', [
+          '@target_version' => $target_version,
+          '@installed_version' => $installed_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidMinorUpdates.php b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidMinorUpdates.php
new file mode 100644
index 000000000000..0983b544ac5e
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/ForbidMinorUpdates.php
@@ -0,0 +1,47 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\auto_updates\VersionParsingTrait;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule forbidding minor updates during cron.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class ForbidMinorUpdates {
+
+  use StringTranslationTrait;
+  use VersionParsingTrait;
+
+  /**
+   * Checks if the target minor version of Drupal is different than installed.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    $installed_minor = static::getMajorAndMinorVersion($installed_version);
+    $target_minor = static::getMajorAndMinorVersion($target_version);
+
+    if ($installed_minor !== $target_minor) {
+      return [
+        $this->t('Drupal cannot be automatically updated from @installed_version to @target_version because automatic updates from one minor version to another are not supported during cron.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/MajorVersionMatch.php b/core/modules/auto_updates/src/Validator/VersionPolicy/MajorVersionMatch.php
new file mode 100644
index 000000000000..fd36cf6dd291
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/MajorVersionMatch.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule that requires updating within the same major version.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class MajorVersionMatch {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the target version of Drupal is in the same minor as installed.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    $installed_major = ExtensionVersion::createFromVersionString($installed_version)
+      ->getMajorVersion();
+    $target_major = ExtensionVersion::createFromVersionString($target_version)
+      ->getMajorVersion();
+
+    if ($installed_major !== $target_major) {
+      return [
+        $this->t('Drupal cannot be automatically updated from @installed_version to @target_version because automatic updates from one major version to another are not supported.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/MinorUpdatesEnabled.php b/core/modules/auto_updates/src/Validator/VersionPolicy/MinorUpdatesEnabled.php
new file mode 100644
index 000000000000..f51eb46ff2c3
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/MinorUpdatesEnabled.php
@@ -0,0 +1,86 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\auto_updates\VersionParsingTrait;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * A policy rule that allows minor updates if enabled in configuration.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class MinorUpdatesEnabled implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+  use VersionParsingTrait;
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  private $configFactory;
+
+  /**
+   * Constructs a MinorUpdatesEnabled object.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory) {
+    $this->configFactory = $config_factory;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('config.factory')
+    );
+  }
+
+  /**
+   * Checks that the target minor version of Drupal can be updated to.
+   *
+   * The update will only be allowed if the allow_core_minor_updates flag is
+   * set to TRUE in config.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    $installed_minor = static::getMajorAndMinorVersion($installed_version);
+    $target_minor = static::getMajorAndMinorVersion($target_version);
+
+    if ($installed_minor === $target_minor) {
+      return [];
+    }
+
+    $minor_updates_allowed = $this->configFactory->get('auto_updates.settings')
+      ->get('allow_core_minor_updates');
+
+    if (!$minor_updates_allowed) {
+      return [
+        $this->t('Drupal cannot be automatically updated from @installed_version to @target_version because automatic updates from one minor version to another are not supported.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/StableReleaseInstalled.php b/core/modules/auto_updates/src/Validator/VersionPolicy/StableReleaseInstalled.php
new file mode 100644
index 000000000000..cafc0a31ed9b
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/StableReleaseInstalled.php
@@ -0,0 +1,43 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule that requiring the installed version to be stable.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class StableReleaseInstalled {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks if the installed version of Drupal is a stable release.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version): array {
+    $extra = ExtensionVersion::createFromVersionString($installed_version)
+      ->getVersionExtra();
+
+    if ($extra) {
+      return [
+        $this->t('Drupal cannot be automatically updated during cron from its current version, @installed_version, because it is not a stable version.', [
+          '@installed_version' => $installed_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/SupportedBranchInstalled.php b/core/modules/auto_updates/src/Validator/VersionPolicy/SupportedBranchInstalled.php
new file mode 100644
index 000000000000..d6b7edc67566
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/SupportedBranchInstalled.php
@@ -0,0 +1,110 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\Url;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * A policy rule that requires updating from a supported branch.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class SupportedBranchInstalled implements ContainerInjectionInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  private $configFactory;
+
+  /**
+   * Constructs a SupportedBranchInstalled object.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory) {
+    $this->configFactory = $config_factory;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('config.factory')
+    );
+  }
+
+  /**
+   * Checks if the installed version of Drupal is in a supported branch.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version): array {
+    $available_updates = update_get_available(TRUE);
+
+    $installed = ExtensionVersion::createFromVersionString($installed_version);
+    $installed_major = $installed->getMajorVersion();
+    $installed_minor = $installed->getMinorVersion();
+    $in_supported_major = FALSE;
+
+    $supported_branches = explode(',', $available_updates['drupal']['supported_branches']);
+    foreach ($supported_branches as $supported_branch) {
+      $supported_branch = ExtensionVersion::createFromSupportBranch($supported_branch);
+
+      // Check if this supported branch is in the same major version as what's
+      // installed, since that will influence our messaging.
+      if ($installed_major === $supported_branch->getMajorVersion()) {
+        $in_supported_major = TRUE;
+
+        // If the supported branch's major and minor versions are the same as
+        // the installed ones, this rule is fulfilled.
+        if ($installed_minor === $supported_branch->getMinorVersion()) {
+          return [];
+        }
+      }
+    }
+
+    // By this point, we know the installed version of Drupal is not in a
+    // supported branch, so we'll always show this message.
+    $messages = [
+      $this->t('The currently installed version of Drupal core, @installed_version, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.', [
+        '@installed_version' => $installed_version,
+      ]),
+    ];
+
+    // If the installed version of Drupal is in a supported major branch, an
+    // attended update may be possible, depending on configuration.
+    $allow_minor_updates = $this->configFactory->get('auto_updates.settings')
+      ->get('allow_core_minor_updates');
+
+    if ($in_supported_major && $allow_minor_updates) {
+      $messages[] = $this->t('Use the <a href=":url">update form</a> to update to a supported version.', [
+        ':url' => Url::fromRoute('auto_updates.module_update')->toString(),
+      ]);
+    }
+    else {
+      $messages[] = $this->t('See the <a href=":url">available updates page</a> for available updates.', [
+        ':url' => Url::fromRoute('update.status')->toString(),
+      ]);
+    }
+    return $messages;
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/TargetSecurityRelease.php b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetSecurityRelease.php
new file mode 100644
index 000000000000..cc979eac391c
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetSecurityRelease.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule requiring the target version to be a security release.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class TargetSecurityRelease {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the target version of Drupal is a security release.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   * @param \Drupal\update\ProjectRelease[] $available_releases
+   *   The available releases of Drupal core.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version, array $available_releases): array {
+    if (!$available_releases[$target_version]->isSecurityRelease()) {
+      return [
+        $this->t('Drupal cannot be automatically updated during cron from @installed_version to @target_version because @target_version is not a security release.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionInstallable.php b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionInstallable.php
new file mode 100644
index 000000000000..180b921ac392
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionInstallable.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule requiring the target version to be an installable release.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class TargetVersionInstallable {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the target version of Drupal is a known installable release.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   * @param \Drupal\update\ProjectRelease[] $available_releases
+   *   The available releases of Drupal core.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version, array $available_releases): array {
+    // If the target version isn't in the list of installable releases, we
+    // should flag an error.
+    if (empty($available_releases) || !array_key_exists($target_version, $available_releases)) {
+      return [
+        $this->t('Cannot update Drupal core to @target_version because it is not in the list of installable releases.', [
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionStable.php b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionStable.php
new file mode 100644
index 000000000000..23fde9a6978c
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicy/TargetVersionStable.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\auto_updates\Validator\VersionPolicy;
+
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+
+/**
+ * A policy rule requiring the target version to be a stable release.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates' version policy for
+ *   Drupal core. It may be changed or removed at any time without warning.
+ *   External code should not interact with this class.
+ */
+final class TargetVersionStable {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks that the target version of Drupal is a stable release.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages, if any.
+   */
+  public function validate(string $installed_version, ?string $target_version): array {
+    $extra = ExtensionVersion::createFromVersionString($target_version)
+      ->getVersionExtra();
+
+    if ($extra) {
+      return [
+        $this->t('Drupal cannot be automatically updated during cron to the recommended version, @target_version, because it is not a stable version.', [
+          '@target_version' => $target_version,
+        ]),
+      ];
+    }
+    return [];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/VersionPolicyValidator.php b/core/modules/auto_updates/src/Validator/VersionPolicyValidator.php
new file mode 100644
index 000000000000..f21872eaf515
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/VersionPolicyValidator.php
@@ -0,0 +1,264 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Drupal\auto_updates\CronUpdater;
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\package_manager\ProjectInfo;
+use Drupal\auto_updates\Updater;
+use Drupal\auto_updates\Validator\VersionPolicy\ForbidDowngrade;
+use Drupal\auto_updates\Validator\VersionPolicy\ForbidMinorUpdates;
+use Drupal\auto_updates\Validator\VersionPolicy\MajorVersionMatch;
+use Drupal\auto_updates\Validator\VersionPolicy\MinorUpdatesEnabled;
+use Drupal\auto_updates\Validator\VersionPolicy\StableReleaseInstalled;
+use Drupal\auto_updates\Validator\VersionPolicy\ForbidDevSnapshot;
+use Drupal\auto_updates\Validator\VersionPolicy\SupportedBranchInstalled;
+use Drupal\auto_updates\Validator\VersionPolicy\TargetSecurityRelease;
+use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionInstallable;
+use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionStable;
+use Drupal\Core\DependencyInjection\ClassResolverInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates the installed and target versions of Drupal before an update.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class VersionPolicyValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The class resolver service.
+   *
+   * @var \Drupal\Core\DependencyInjection\ClassResolverInterface
+   */
+  private $classResolver;
+
+  /**
+   * Constructs a VersionPolicyValidator object.
+   *
+   * @param \Drupal\Core\DependencyInjection\ClassResolverInterface $class_resolver
+   *   The class resolver service.
+   */
+  public function __construct(ClassResolverInterface $class_resolver) {
+    $this->classResolver = $class_resolver;
+  }
+
+  /**
+   * Validates a target version of Drupal core.
+   *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater which will perform the update.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if it is not known.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup[]
+   *   The error messages returned from the first policy rule which rejected
+   *   the given target version.
+   *
+   * @see \Drupal\auto_updates\Validator\VersionPolicy\RuleBase::validate()
+   */
+  public function validateVersion(Updater $updater, ?string $target_version): array {
+    // Check that the installed version of Drupal isn't a dev snapshot.
+    $rules = [
+      ForbidDevSnapshot::class,
+    ];
+
+    // If the target version is known, it must conform to a few basic rules.
+    if ($target_version) {
+      // The target version must be newer than the installed version...
+      $rules[] = ForbidDowngrade::class;
+      // ...and in the same major version as the installed version...
+      $rules[] = MajorVersionMatch::class;
+      // ...and it must be a known, secure, installable release.
+      $rules[] = TargetVersionInstallable::class;
+    }
+
+    // If this is a cron update, we may need to do additional checks.
+    if ($updater instanceof CronUpdater) {
+      $mode = $updater->getMode();
+
+      if ($mode !== CronUpdater::DISABLED) {
+        // If cron updates are enabled, the installed version must be stable;
+        // no alphas, betas, or RCs.
+        $rules[] = StableReleaseInstalled::class;
+        // It must also be in a supported branch.
+        $rules[] = SupportedBranchInstalled::class;
+
+        // If the target version is known, more rules apply.
+        if ($target_version) {
+          // The target version must be stable too...
+          $rules[] = TargetVersionStable::class;
+          // ...and it must be in the same minor as the installed version.
+          $rules[] = ForbidMinorUpdates::class;
+
+          // If only security updates are allowed during cron, the target
+          // version must be a security release.
+          if ($mode === CronUpdater::SECURITY) {
+            $rules[] = TargetSecurityRelease::class;
+          }
+        }
+      }
+    }
+    // If this is not a cron update, and we know the target version, minor
+    // version updates are allowed if configuration says so.
+    elseif ($target_version) {
+      $rules[] = MinorUpdatesEnabled::class;
+    }
+
+    $installed_version = $this->getInstalledVersion();
+    $available_releases = $this->getAvailableReleases($updater);
+
+    // Invoke each rule in the order that they were added to $rules, stopping
+    // when one returns error messages.
+    // @todo Return all the error messages in https://www.drupal.org/i/3281379.
+    foreach ($rules as $rule) {
+      $messages = $this->classResolver
+        ->getInstanceFromDefinition($rule)
+        ->validate($installed_version, $target_version, $available_releases);
+
+      if ($messages) {
+        return $messages;
+      }
+    }
+    return [];
+  }
+
+  /**
+   * Checks that the target version of Drupal is valid.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function checkVersion(StageEvent $event): void {
+    $stage = $event->getStage();
+
+    // Only do these checks for automatic updates.
+    if (!$stage instanceof Updater) {
+      return;
+    }
+    $target_version = $this->getTargetVersion($event);
+
+    $messages = $this->validateVersion($stage, $target_version);
+    if ($messages) {
+      $installed_version = $this->getInstalledVersion();
+
+      if ($target_version) {
+        $summary = $this->t('Updating from Drupal @installed_version to @target_version is not allowed.', [
+          '@installed_version' => $installed_version,
+          '@target_version' => $target_version,
+        ]);
+      }
+      else {
+        $summary = $this->t('Updating from Drupal @installed_version is not allowed.', [
+          '@installed_version' => $installed_version,
+        ]);
+      }
+      $event->addError($messages, $summary);
+    }
+  }
+
+  /**
+   * Returns the target version of Drupal core.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   *
+   * @return string|null
+   *   The target version of Drupal core, or NULL if it could not be determined
+   *   during a readiness check.
+   *
+   * @throws \LogicException
+   *   Thrown if the target version cannot be determined due to unexpected
+   *   conditions. This can happen if, during a stage life cycle event (i.e.,
+   *   NOT a readiness check), the event or updater does not have a list of
+   *   desired package versions, or the list of package versions does not
+   *   include any Drupal core packages.
+   */
+  private function getTargetVersion(StageEvent $event): ?string {
+    $updater = $event->getStage();
+
+    if ($event instanceof ReadinessCheckEvent) {
+      $package_versions = $event->getPackageVersions();
+    }
+    else {
+      $package_versions = $updater->getPackageVersions()['production'];
+    }
+
+    $unknown_target = new \LogicException('The target version of Drupal core could not be determined.');
+
+    if ($package_versions) {
+      $core_package_name = key($updater->getActiveComposer()->getCorePackages());
+
+      if ($core_package_name && array_key_exists($core_package_name, $package_versions)) {
+        return $package_versions[$core_package_name];
+      }
+      else {
+        throw $unknown_target;
+      }
+    }
+    elseif ($event instanceof ReadinessCheckEvent) {
+      if ($updater instanceof CronUpdater) {
+        $target_release = $updater->getTargetRelease();
+        if ($target_release) {
+          return $target_release->getVersion();
+        }
+      }
+      return NULL;
+    }
+    // If we got here, something has gone very wrong.
+    throw $unknown_target;
+  }
+
+  /**
+   * Returns the available releases of Drupal core for a given updater.
+   *
+   * @param \Drupal\auto_updates\Updater $updater
+   *   The updater which will perform the update.
+   *
+   * @return \Drupal\update\ProjectRelease[]
+   *   The available releases of Drupal core, keyed by version number and in
+   *   descending order (i.e., newest first). Will be in ascending order (i.e.,
+   *   oldest first) if $updater is the cron updater.
+   *
+   * @see \Drupal\package_manager\ProjectInfo::getInstallableReleases()
+   */
+  private function getAvailableReleases(Updater $updater): array {
+    $project_info = new ProjectInfo('drupal');
+    $available_releases = $project_info->getInstallableReleases() ?? [];
+
+    if ($updater instanceof CronUpdater) {
+      $available_releases = array_reverse($available_releases);
+    }
+    return $available_releases;
+  }
+
+  /**
+   * Returns the currently installed version of Drupal core.
+   *
+   * @return string|null
+   *   The currently installed version of Drupal core, or NULL if it could not
+   *   be determined.
+   */
+  private function getInstalledVersion(): ?string {
+    return (new ProjectInfo('drupal'))->getInstalledVersion();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => 'checkVersion',
+      PreCreateEvent::class => 'checkVersion',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/Validator/XdebugValidator.php b/core/modules/auto_updates/src/Validator/XdebugValidator.php
new file mode 100644
index 000000000000..819f300cde9c
--- /dev/null
+++ b/core/modules/auto_updates/src/Validator/XdebugValidator.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\auto_updates\Validator;
+
+use Drupal\auto_updates\CronUpdater;
+use Drupal\auto_updates\Event\ReadinessCheckEvent;
+use Drupal\auto_updates\Updater;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Performs validation if Xdebug is enabled.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class XdebugValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Performs validation if Xdebug is enabled.
+   *
+   * If Xdebug is enabled, cron updates are prevented. For other updates, only
+   * a warning is flagged during readiness checks.
+   *
+   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
+   *   The event object.
+   */
+  public function checkForXdebug(PreOperationStageEvent $event): void {
+    // We only want to do this check if the stage belongs to Automatic Updates.
+    if (!($event->getStage() instanceof Updater)) {
+      return;
+    }
+
+    if (function_exists('xdebug_break')) {
+      $messages = [
+        $this->t('Xdebug is enabled, which may cause timeout errors.'),
+      ];
+
+      if ($event->getStage() instanceof CronUpdater) {
+        // Cron updates are not allowed if Xdebug is enabled.
+        $event->addError($messages);
+      }
+      elseif ($event instanceof ReadinessCheckEvent) {
+        // For non-cron updates provide a warning but do not stop updates from
+        // executing.
+        $event->addWarning($messages);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      ReadinessCheckEvent::class => 'checkForXdebug',
+      PreCreateEvent::class => 'checkForXdebug',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/src/VersionParsingTrait.php b/core/modules/auto_updates/src/VersionParsingTrait.php
index c94eb113043e..162459d5415e 100644
--- a/core/modules/auto_updates/src/VersionParsingTrait.php
+++ b/core/modules/auto_updates/src/VersionParsingTrait.php
@@ -34,4 +34,19 @@ protected static function getPatchVersion(string $version_string): ?string {
     return count($version_parts) === 3 ? $version_parts[2] : NULL;
   }
 
+  /**
+   * Returns the semantic major.minor numbers of a version string.
+   *
+   * @param string $version
+   *   The version string.
+   *
+   * @return string
+   *   The major.minor numbers of the version string. For example, if $version
+   *   is 8.9.1, '8.9' will be returned.
+   */
+  protected static function getMajorAndMinorVersion(string $version): string {
+    $version = ExtensionVersion::createFromVersionString($version);
+    return $version->getMajorVersion() . '.' . $version->getMinorVersion();
+  }
+
 }
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/README.md b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/README.md
new file mode 100644
index 000000000000..b29def63dd82
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/README.md
@@ -0,0 +1,30 @@
+# `StagedProjectsValidatorTest` Fixtures
+
+### new_project_added
+Simulates a scenario where, while updating Drupal core in a site with no non-core dependencies, a new contrib module and
+a new custom module are unexpectedly installed (as runtime and dev dependencies, respectively). Additionally, two new
+non-Drupal packages are installed (again, one as a runtime dependency, the other dev).
+
+**Expectation**: The validator should complain about the new modules; the new non-Drupal packages are ignored.
+
+### no_errors
+Simulates a scenario where, while updating Drupal core in a site with two unpinned contrib dependencies (one runtime and
+one dev), no Drupal packages are updated, but two non-Drupal libraries are removed (again, one a runtime dependency, the
+other dev), two are updated (same arrangement), and two are added (ditto).
+
+**Expectation**: The validator to raise no errors; changes to non-Drupal packages are ignored.
+
+### project_removed
+Simulates a scenario where, while updating Drupal core in a site with no non-core dependencies, an installed contrib
+theme and an installed custom theme are unexpectedly removed (from runtime and dev dependencies, respectively).
+Additionally, two installed non-Drupal packages are removed (again, one from a runtime dependency, the other dev). The 
+existing contrib dependencies' installed versions are unchanged.
+
+**Expectation**: The validator should complain about the removed themes; the removed non-Drupal packages are ignored.
+
+### version_changed
+Simulates a scenario where, while updating Drupal core in a site with two unpinned contrib dependencies (one runtime and
+one dev), the contrib modules are unexpectedly updated, as are two installed non-Drupal packages (again, one a runtime
+dependency, the other dev).
+
+**Expectation**: The validator should complain about the updated modules; the updated non-Drupal packages are ignored.
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/modules/dev-test_module/dev-test_module.info.yml.hide b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/modules/dev-test_module/dev-test_module.info.yml.hide
new file mode 100644
index 000000000000..9701769c81a6
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/modules/dev-test_module/dev-test_module.info.yml.hide
@@ -0,0 +1 @@
+project: dev-test_module
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/modules/test_module/test_module.info.yml.hide b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/modules/test_module/test_module.info.yml.hide
new file mode 100644
index 000000000000..b64a0a22aa70
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/modules/test_module/test_module.info.yml.hide
@@ -0,0 +1 @@
+project: test_module
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.json
similarity index 66%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/vendor/composer/installed.json
rename to core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.json
index df420decb6b9..bc87d26d79de 100644
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/vendor/composer/installed.json
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.json
@@ -1,9 +1,19 @@
 {
+  "_readme": [
+    "This file simuluates a list of packages installed in the active directory of a virtual project.",
+    "It will be compared against staged.installed.json.",
+    "See \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\StagedProjectsValidatorTest::testErrors()"
+  ],
   "packages": [
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.php b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.php
new file mode 100644
index 000000000000..249cef05ef6c
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/active/vendor/composer/installed.php
@@ -0,0 +1,19 @@
+<?php
+
+/**
+ * @file
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/test_module' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/test_module',
+    ],
+    'drupal/dev-test_module' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/dev-test_module',
+    ],
+  ],
+];
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/dev-test_module/dev-test_module.info.yml.hide b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/dev-test_module/dev-test_module.info.yml.hide
new file mode 100644
index 000000000000..9701769c81a6
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/dev-test_module/dev-test_module.info.yml.hide
@@ -0,0 +1 @@
+project: dev-test_module
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/dev-test_module2/dev-test_module2.info.yml.hide b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/dev-test_module2/dev-test_module2.info.yml.hide
new file mode 100644
index 000000000000..2e702849a3c9
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/dev-test_module2/dev-test_module2.info.yml.hide
@@ -0,0 +1 @@
+project: dev-test_module2
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/test_module/test_module.info.yml.hide b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/test_module/test_module.info.yml.hide
new file mode 100644
index 000000000000..b64a0a22aa70
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/test_module/test_module.info.yml.hide
@@ -0,0 +1 @@
+project: test_module
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/test_module2/test_module2.info.yml.hide b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/test_module2/test_module2.info.yml.hide
new file mode 100644
index 000000000000..8630db660d7f
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/modules/test_module2/test_module2.info.yml.hide
@@ -0,0 +1 @@
+project: test_module2
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.json
similarity index 73%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/vendor/composer/installed.json
rename to core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.json
index db60c8425c0a..a485678d2278 100644
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/vendor/composer/installed.json
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.json
@@ -1,9 +1,19 @@
 {
+  "_readme": [
+    "This file simuluates a list of packages installed in a virtual staging area.",
+    "It will be compared against active.installed.json.",
+    "See \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\StagedProjectsValidatorTest::testErrors()"
+  ],
   "packages": [
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.php b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.php
new file mode 100644
index 000000000000..8871c50abbdf
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/new_project_added/staged/vendor/composer/installed.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @file
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/test_module' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/test_module',
+    ],
+    'drupal/dev-test_module' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/dev-test_module',
+    ],
+    'drupal/test_module2' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/test_module2',
+    ],
+    'drupal/dev-test_module2' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/dev-test_module2',
+    ],
+    'other/new_project' => [
+      'type' => 'library',
+      'install_path' => __DIR__ . '/../../new_project',
+    ],
+    'other/dev-new_project' => [
+      'type' => 'library',
+      'install_path' => __DIR__ . '/../../dev-new_project',
+    ],
+  ],
+];
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/active/vendor/composer/installed.json
similarity index 75%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/vendor/composer/installed.json
rename to core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/active/vendor/composer/installed.json
index cc5be9cb71d6..4005167f65a3 100644
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/vendor/composer/installed.json
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/active/vendor/composer/installed.json
@@ -1,9 +1,19 @@
 {
+  "_readme": [
+    "This file simuluates a list of packages installed in the active directory of a virtual project.",
+    "It will be compared against staged.installed.json.",
+    "See \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\StagedProjectsValidatorTest::testErrors()"
+  ],
   "packages": [
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.json
similarity index 76%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/vendor/composer/installed.json
rename to core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.json
index e7061278bd8b..317eff012aea 100644
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/vendor/composer/installed.json
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.json
@@ -1,9 +1,19 @@
 {
+  "_readme": [
+    "This file simuluates a list of packages installed in a virtual staging area.",
+    "It will be compared against active.installed.json.",
+    "See \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\StagedProjectsValidatorTest::testErrors()"
+  ],
   "packages": [
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.php b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.php
new file mode 100644
index 000000000000..706c6fd4632f
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/no_errors/staged/vendor/composer/installed.php
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * @file
+ * Simulates that 2 packages are installed in virtual staging area.
+ */
+
+$projects_dir = __DIR__ . '/../../';
+return [
+  'versions' => [
+    'other/new_project' => [
+      'type' => 'library',
+      'install_path' => $projects_dir . '/other/new_project',
+    ],
+    'other/dev-new_project' => [
+      'type' => 'library',
+      'install_path' => $projects_dir . '/other/dev-new_project',
+    ],
+  ],
+];
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/project_removed/active/vendor/composer/installed.json
similarity index 72%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/vendor/composer/installed.json
rename to core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/project_removed/active/vendor/composer/installed.json
index ecede582dfe9..08242008b82c 100644
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/vendor/composer/installed.json
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/project_removed/active/vendor/composer/installed.json
@@ -1,9 +1,19 @@
 {
+  "_readme": [
+    "This file simuluates a list of packages installed in the active directory of a virtual project.",
+    "It will be compared against staged.installed.json.",
+    "See \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\StagedProjectsValidatorTest::testErrors()"
+  ],
   "packages": [
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_theme",
diff --git a/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/project_removed/staged/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/project_removed/staged/vendor/composer/installed.json
new file mode 100644
index 000000000000..9e91821930a9
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/project_removed/staged/vendor/composer/installed.json
@@ -0,0 +1,33 @@
+{
+  "_readme": [
+    "This file simuluates a list of packages installed in a virtual staging area.",
+    "It will be compared against active.installed.json.",
+    "See \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\StagedProjectsValidatorTest::testErrors()"
+  ],
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.1",
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
+    },
+    {
+      "name": "drupal/test_module2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/dev-test_module2",
+      "version": "1.3.1",
+      "type": "drupal-module"
+    }
+  ],
+  "dev": true,
+  "dev-package-names": [
+    "drupal/dev-test_module2"
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/version_changed/active/vendor/composer/installed.json
similarity index 66%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/vendor/composer/installed.json
rename to core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/version_changed/active/vendor/composer/installed.json
index da5feae3bc3e..b4f51c18de2b 100644
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/vendor/composer/installed.json
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/version_changed/active/vendor/composer/installed.json
@@ -1,9 +1,19 @@
 {
+  "_readme": [
+    "This file simuluates a list of packages installed in the active directory of a virtual project.",
+    "It will be compared against staged.installed.json.",
+    "See \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\StagedProjectsValidatorTest::testErrors()"
+  ],
   "packages": [
     {
       "name": "drupal/core",
       "version": "9.8.0",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/version_changed/staged/vendor/composer/installed.json
similarity index 67%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/vendor/composer/installed.json
rename to core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/version_changed/staged/vendor/composer/installed.json
index d35464c3cd1b..9f6db978918b 100644
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/vendor/composer/installed.json
+++ b/core/modules/auto_updates/tests/fixtures/StagedProjectsValidatorTest/version_changed/staged/vendor/composer/installed.json
@@ -1,9 +1,19 @@
 {
+  "_readme": [
+    "This file simuluates a list of packages installed in a virtual staging area.",
+    "It will be compared against active.installed.json.",
+    "See \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\StagedProjectsValidatorTest::testErrors()"
+  ],
   "packages": [
     {
       "name": "drupal/core",
       "version": "9.8.1",
-      "type": "drupal-core"
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
     },
     {
       "name": "drupal/test_module",
diff --git a/core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json b/core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/composer.json
similarity index 100%
rename from core/modules/auto_updates/tests/fixtures/staged/9.8.1/composer.json
rename to core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/composer.json
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.json b/core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/composer.lock
similarity index 100%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/active/composer.json
rename to core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/composer.lock
diff --git a/core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.json
new file mode 100644
index 000000000000..d4f0f343d726
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.json
@@ -0,0 +1,14 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.1",
+      "type": "drupal-core",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {}
+        }
+      }
+    }
+  ]
+}
diff --git a/core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.php b/core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.php
new file mode 100644
index 000000000000..52ff3f53b1b0
--- /dev/null
+++ b/core/modules/auto_updates/tests/fixtures/drupal-9.8.1-installed/vendor/composer/installed.php
@@ -0,0 +1,10 @@
+<?php
+
+/**
+ * @file
+ * Simulates that no packages are installed.
+ */
+
+return [
+  'versions' => [],
+];
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/composer.json b/core/modules/auto_updates/tests/fixtures/fake-site/composer.json
deleted file mode 100644
index ed5b67b7c8e9..000000000000
--- a/core/modules/auto_updates/tests/fixtures/fake-site/composer.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-    "require": {
-        "drupal/test-distribution": "*"
-    },
-    "require-dev": {
-        "drupal/core-dev": "^9"
-    },
-    "extra": {
-        "_comment": [
-            "This is a fake composer.json simulating a site which requires a distribution.",
-            "The required core packages are determined by scanning the lock file.",
-            "The required dev packages are determined by looking at the require-dev section of this file."
-        ]
-    }
-}
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt
deleted file mode 100644
index 4f9da38be562..000000000000
--- a/core/modules/auto_updates/tests/fixtures/fake-site/files/private/ignore.txt
+++ /dev/null
@@ -1 +0,0 @@
-This private file should never be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt
deleted file mode 100644
index ab6a7649fa22..000000000000
--- a/core/modules/auto_updates/tests/fixtures/fake-site/files/public/ignore.txt
+++ /dev/null
@@ -1 +0,0 @@
-This public file should never be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml
deleted file mode 100644
index ea9529af0109..000000000000
--- a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/services.yml
+++ /dev/null
@@ -1,2 +0,0 @@
-# A fake services file that should never be staged.
-services: {}
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php
deleted file mode 100644
index e54016a9d2c0..000000000000
--- a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.local.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-
-/**
- * @file
- * A fake local settings file that should never be staged.
- */
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php
deleted file mode 100644
index 9b995731d127..000000000000
--- a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/settings.php
+++ /dev/null
@@ -1,6 +0,0 @@
-<?php
-
-/**
- * @file
- * A fake settings file that should never be staged.
- */
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt b/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt
deleted file mode 100644
index 0087269e33e5..000000000000
--- a/core/modules/auto_updates/tests/fixtures/fake-site/sites/default/staged.txt
+++ /dev/null
@@ -1 +0,0 @@
-This file should be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt b/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt
deleted file mode 100644
index e4525907f263..000000000000
--- a/core/modules/auto_updates/tests/fixtures/fake-site/sites/simpletest/ignore.txt
+++ /dev/null
@@ -1 +0,0 @@
-This file should not be staged.
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json
deleted file mode 100644
index c8a8d4a5d203..000000000000
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/composer.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-    "require": {
-        "drupal/core-composer-scaffold": "*",
-        "drupal/core-vendor-hardening": "*",
-        "drupal/core-project-message": "*",
-        "drupal/core-dev": "*",
-        "drupal/core-dev-pinned": "*"
-    },
-    "extra": {
-        "_comment": [
-            "This is an example composer.json that does not require Drupal core.",
-            "@see \\Drupal\\Tests\\auto_updates\\Kernel\\ReadinessValidation\\CoreComposerValidatorTest"
-        ]
-    }
-}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/vendor/composer/installed.json
deleted file mode 100644
index 8848abc1a7ff..000000000000
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_core_requirements/vendor/composer/installed.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-    "packages": [
-        {
-            "name": "drupal/core-recommended",
-            "version": "9.8.0"
-        },
-        {
-            "name": "drupal/core",
-            "version": "9.8.0"
-        }
-    ]
-}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json
deleted file mode 100644
index 0967ef424bce..000000000000
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/staged/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json
deleted file mode 100644
index 0967ef424bce..000000000000
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/active/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json
deleted file mode 100644
index 0967ef424bce..000000000000
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/vendor/composer/installed.json
deleted file mode 100644
index d9247b6a62bd..000000000000
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/project_removed/staged/vendor/composer/installed.json
+++ /dev/null
@@ -1,23 +0,0 @@
-{
-  "packages": [
-    {
-      "name": "drupal/core",
-      "version": "9.8.1",
-      "type": "drupal-core"
-    },
-    {
-      "name": "drupal/test_module2",
-      "version": "1.3.1",
-      "type": "drupal-module"
-    },
-    {
-      "name": "drupal/dev-test_module2",
-      "version": "1.3.1",
-      "type": "drupal-module"
-    }
-  ],
-  "dev": true,
-  "dev-package-names": [
-    "drupal/dev-test_module2"
-  ]
-}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json
deleted file mode 100644
index 0967ef424bce..000000000000
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/active/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json b/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json
deleted file mode 100644
index 0967ef424bce..000000000000
--- a/core/modules/auto_updates/tests/fixtures/project_staged_validation/version_changed/staged/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml
deleted file mode 100644
index dbcb825a86d3..000000000000
--- a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.1-security.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<project xmlns:dc="http://purl.org/dc/elements/1.1/">
-<title>Drupal</title>
-<short_name>drupal</short_name>
-<dc:creator>Drupal</dc:creator>
-<supported_branches>9.8.</supported_branches>
-<project_status>published</project_status>
-<link>http://example.com/project/drupal</link>
-  <terms>
-   <term><name>Projects</name><value>Drupal project</value></term>
-  </terms>
-<releases>
- <release>
-  <name>Drupal 9.8.1</name>
-  <version>9.8.1</version>
-  <status>published</status>
-  <release_link>http://example.com/drupal-9-8-1-release</release_link>
-  <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
-  <date>1250425521</date>
-  <terms>
-    <term><name>Release type</name><value>New features</value></term>
-    <term><name>Release type</name><value>Bug fixes</value></term>
-    <term><name>Release type</name><value>Security update</value></term>
-  </terms>
- </release>
- <release>
-   <name>Drupal 9.8.0</name>
-   <version>9.8.0</version>
-   <status>published</status>
-   <release_link>http://example.com/drupal-9-8-0-release</release_link>
-   <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link>
-   <date>1250424521</date>
-   <terms>
-     <term><name>Release type</name><value>New features</value></term>
-     <term><name>Release type</name><value>Bug fixes</value></term>
-     <term><name>Release type</name><value>Insecure</value></term>
-   </terms>
- </release>
-</releases>
-</project>
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
deleted file mode 100644
index cc65b78a690d..000000000000
--- a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
+++ /dev/null
@@ -1,102 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<project xmlns:dc="http://purl.org/dc/elements/1.1/">
-<title>Drupal</title>
-<short_name>drupal</short_name>
-<dc:creator>Drupal</dc:creator>
-<supported_branches>9.7.,9.8.</supported_branches>
-<project_status>published</project_status>
-<link>http://example.com/project/drupal</link>
-  <terms>
-   <term><name>Projects</name><value>Drupal project</value></term>
-  </terms>
-<releases>
-  <release>
-    <name>Drupal 9.8.2</name>
-    <version>9.8.2</version>
-    <status>published</status>
-    <release_link>http://example.com/drupal-9-8-2-release</release_link>
-    <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link>
-    <date>1250425521</date>
-    <terms>
-      <term><name>Release type</name><value>New features</value></term>
-      <term><name>Release type</name><value>Bug fixes</value></term>
-    </terms>
-  </release>
- <release>
-  <name>Drupal 9.8.1</name>
-  <version>9.8.1</version>
-  <status>published</status>
-  <release_link>http://example.com/drupal-9-8-1-release</release_link>
-  <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
-  <date>1250425521</date>
-  <terms>
-    <term><name>Release type</name><value>New features</value></term>
-    <term><name>Release type</name><value>Bug fixes</value></term>
-    <term><name>Release type</name><value>Security update</value></term>
-  </terms>
- </release>
- <release>
-   <name>Drupal 9.8.0</name>
-   <version>9.8.0</version>
-   <status>published</status>
-   <release_link>http://example.com/drupal-9-8-0-release</release_link>
-   <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link>
-   <date>1250424521</date>
-   <terms>
-     <term><name>Release type</name><value>New features</value></term>
-     <term><name>Release type</name><value>Bug fixes</value></term>
-     <term><name>Release type</name><value>Insecure</value></term>
-   </terms>
- </release>
-  <release>
-    <name>Drupal 9.8.0-alpha1</name>
-    <version>9.8.0-alpha1</version>
-    <status>published</status>
-    <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
-    <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link>
-    <date>1250424521</date>
-    <terms>
-      <term><name>Release type</name><value>New features</value></term>
-      <term><name>Release type</name><value>Bug fixes</value></term>
-    </terms>
-  </release>
-    <release>
-        <name>Drupal 9.7.1</name>
-        <version>9.7.1</version>
-        <status>published</status>
-        <release_link>http://example.com/drupal-9-7-1-release</release_link>
-        <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
-        <date>1250425521</date>
-        <terms>
-            <term><name>Release type</name><value>New features</value></term>
-            <term><name>Release type</name><value>Bug fixes</value></term>
-            <term><name>Release type</name><value>Security update</value></term>
-        </terms>
-    </release>
-    <release>
-        <name>Drupal 9.7.0</name>
-        <version>9.7.0</version>
-        <status>published</status>
-        <release_link>http://example.com/drupal-9-7-0-release</release_link>
-        <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
-        <date>1250424521</date>
-        <terms>
-            <term><name>Release type</name><value>New features</value></term>
-            <term><name>Release type</name><value>Bug fixes</value></term>
-            <term><name>Release type</name><value>Insecure</value></term>
-        </terms>
-    </release>
-    <release>
-        <name>Drupal 9.7.0-alpha1</name>
-        <version>9.7.0-alpha1</version>
-        <status>published</status>
-        <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
-        <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
-        <date>1250424521</date>
-        <terms>
-            <term><name>Release type</name><value>New features</value></term>
-            <term><name>Release type</name><value>Bug fixes</value></term>
-        </terms>
-    </release>
-</releases>
-</project>
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml b/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml
deleted file mode 100644
index 2de1077e5e2d..000000000000
--- a/core/modules/auto_updates/tests/fixtures/release-history/drupal.9.8.2.xml
+++ /dev/null
@@ -1,98 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<project xmlns:dc="http://purl.org/dc/elements/1.1/">
-<title>Drupal</title>
-<short_name>drupal</short_name>
-<dc:creator>Drupal</dc:creator>
-<supported_branches>9.7.,9.8.</supported_branches>
-<project_status>published</project_status>
-<link>http://example.com/project/drupal</link>
-  <terms>
-   <term><name>Projects</name><value>Drupal project</value></term>
-  </terms>
-<releases>
-  <release>
-    <name>Drupal 9.8.2</name>
-    <version>9.8.2</version>
-    <status>published</status>
-    <release_link>http://example.com/drupal-9-8-2-release</release_link>
-    <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link>
-    <date>1250425521</date>
-    <terms>
-      <term><name>Release type</name><value>New features</value></term>
-      <term><name>Release type</name><value>Bug fixes</value></term>
-    </terms>
-  </release>
- <release>
-  <name>Drupal 9.8.1</name>
-  <version>9.8.1</version>
-  <status>published</status>
-  <release_link>http://example.com/drupal-9-8-1-release</release_link>
-  <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
-  <date>1250425521</date>
-  <terms>
-    <term><name>Release type</name><value>New features</value></term>
-    <term><name>Release type</name><value>Bug fixes</value></term>
-  </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>
-  <release>
-    <name>Drupal 9.8.0-alpha1</name>
-    <version>9.8.0-alpha1</version>
-    <status>published</status>
-    <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
-    <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link>
-    <date>1250424521</date>
-    <terms>
-      <term><name>Release type</name><value>New features</value></term>
-      <term><name>Release type</name><value>Bug fixes</value></term>
-    </terms>
-  </release>
-    <release>
-        <name>Drupal 9.7.1</name>
-        <version>9.7.1</version>
-        <status>published</status>
-        <release_link>http://example.com/drupal-9-7-1-release</release_link>
-        <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
-        <date>1250425521</date>
-        <terms>
-            <term><name>Release type</name><value>New features</value></term>
-            <term><name>Release type</name><value>Bug fixes</value></term>
-        </terms>
-    </release>
-    <release>
-        <name>Drupal 9.7.0</name>
-        <version>9.7.0</version>
-        <status>published</status>
-        <release_link>http://example.com/drupal-9-7-0-release</release_link>
-        <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
-        <date>1250424521</date>
-        <terms>
-            <term><name>Release type</name><value>New features</value></term>
-            <term><name>Release type</name><value>Bug fixes</value></term>
-        </terms>
-    </release>
-    <release>
-        <name>Drupal 9.7.0-alpha1</name>
-        <version>9.7.0-alpha1</version>
-        <status>published</status>
-        <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
-        <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
-        <date>1250424521</date>
-        <terms>
-            <term><name>Release type</name><value>New features</value></term>
-            <term><name>Release type</name><value>Bug fixes</value></term>
-        </terms>
-    </release>
-</releases>
-</project>
diff --git a/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json b/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json
deleted file mode 100644
index 87b5a18421ce..000000000000
--- a/core/modules/auto_updates/tests/fixtures/staged/9.8.1/vendor/composer/installed.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "packages": [
-    {
-      "name": "drupal/core",
-      "version": "9.8.1",
-      "type": "drupal-core"
-    }
-  ]
-}
diff --git a/core/modules/auto_updates/tests/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml b/core/modules/auto_updates/tests/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml
new file mode 100644
index 000000000000..79010053c982
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml
@@ -0,0 +1,4 @@
+name: AAA test module
+description: A module to test updates
+type: module
+package: Testing
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.module b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.module
new file mode 100644
index 000000000000..719ffea93fa7
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.module
@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * @file
+ * Contains hook implementations for testing Automatic Updates.
+ */
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+
+/**
+ * Implements hook_mail_alter().
+ */
+function auto_updates_test_mail_alter(array &$message): void {
+  if (str_starts_with($message['id'], 'auto_updates_')) {
+    $line_langcodes = [];
+
+    // Get the langcode of every translated line in the mssage, including the
+    // subject line.
+    $lines = array_merge($message['body'], [
+      $message['subject'],
+    ]);
+    foreach ($lines as $line) {
+      if ($line instanceof TranslatableMarkup) {
+        $line_langcodes[] = $line->getOption('langcode');
+      }
+    }
+    $message['line_langcodes'] = array_unique($line_langcodes);
+  }
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml
index f22ab077fb3b..dc74095f0315 100644
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.routing.yml
@@ -1,12 +1,3 @@
-auto_updates_test.metadata:
-  path: '/automatic-update-test/{project_name}/{version}'
-  defaults:
-    _title: 'Update test'
-    _controller: '\Drupal\auto_updates_test\TestController::metadata'
-  requirements:
-    _access: 'TRUE'
-  options:
-    _maintenance_access: TRUE
 auto_updates_test.update:
   path: '/automatic-update-test/update/{to_version}'
   defaults:
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml
index c3c46dc8d051..3e46bff0d0df 100644
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/auto_updates_test.services.yml
@@ -4,6 +4,11 @@ services:
     tags:
       - { name: event_subscriber }
     arguments: ['@state']
+  auto_updates_test.request.time:
+    class: Drupal\auto_updates_test\EventSubscriber\RequestTimeRecorder
+    tags:
+      - { name: event_subscriber }
+    arguments: ['@state', '@datetime.time']
   auto_updates_test.time:
     class: Drupal\auto_updates_test\Datetime\TestTime
     decorates: datetime.time
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/RequestTimeRecorder.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/RequestTimeRecorder.php
new file mode 100644
index 000000000000..56420b07db10
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/EventSubscriber/RequestTimeRecorder.php
@@ -0,0 +1,65 @@
+<?php
+
+namespace Drupal\auto_updates_test\EventSubscriber;
+
+use Drupal\Component\Datetime\TimeInterface;
+use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Event\PostApplyEvent;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Records the request time during various events.
+ */
+class RequestTimeRecorder implements EventSubscriberInterface {
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The time service.
+   *
+   * @var \Drupal\Component\Datetime\TimeInterface
+   */
+  protected $time;
+
+  /**
+   * Constructs a new instance.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Drupal\Component\Datetime\TimeInterface $time
+   *   The time service.
+   */
+  public function __construct(StateInterface $state, TimeInterface $time) {
+    $this->state = $state;
+    $this->time = $time;
+  }
+
+  /**
+   * Records the request time.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function updateState(StageEvent $event) {
+    $key = get_class($event) . ' time';
+    $this->state->set($key, $this->time->getRequestMicroTime());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreApplyEvent::class => 'updateState',
+      PostApplyEvent::class => 'updateState',
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php b/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php
index 7342479e95bd..a8bc4bea8fe4 100644
--- a/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test/src/TestController.php
@@ -6,7 +6,6 @@
 use Drupal\Component\Utility\Environment;
 use Drupal\Core\Controller\ControllerBase;
 use Drupal\Core\Render\HtmlResponse;
-use Symfony\Component\HttpFoundation\BinaryFileResponse;
 use Symfony\Component\HttpFoundation\Response;
 
 class TestController extends ControllerBase {
@@ -33,6 +32,7 @@ public function update(string $to_version): Response {
       $updater->begin(['drupal' => $to_version]);
       $updater->stage();
       $updater->apply();
+      $updater->postApply();
       $updater->destroy();
 
       // The code base has been updated, but as far as the PHP runtime is
@@ -62,38 +62,4 @@ public function update(string $to_version): Response {
     return new HtmlResponse($content, $status);
   }
 
-  /**
-   * Page callback: Prints mock XML for the Update Manager module.
-   *
-   * This is a wholesale copy of
-   * \Drupal\update_test\Controller\UpdateTestController::updateTest() for
-   * testing automatic updates. This was done in order to use a different
-   * directory of mock XML files.
-   */
-  public function metadata($project_name = 'drupal', $version = NULL): Response {
-    $xml_map = $this->config('update_test.settings')->get('xml_map');
-    if (isset($xml_map[$project_name])) {
-      $availability_scenario = $xml_map[$project_name];
-    }
-    elseif (isset($xml_map['#all'])) {
-      $availability_scenario = $xml_map['#all'];
-    }
-    else {
-      // The test didn't specify (for example, the webroot has other modules and
-      // themes installed but they're disabled by the version of the site
-      // running the test. So, we default to a file we know won't exist, so at
-      // least we'll get an empty xml response instead of a bunch of Drupal page
-      // output.
-      $availability_scenario = '#broken#';
-    }
-
-    $file = __DIR__ . "/../../../fixtures/release-history/$project_name.$availability_scenario.xml";
-    $headers = ['Content-Type' => 'text/xml; charset=utf-8'];
-    if (!is_file($file)) {
-      // Return an empty response.
-      return new Response('', 200, $headers);
-    }
-    return new BinaryFileResponse($file, 200, $headers);
-  }
-
 }
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.info.yml b/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.info.yml
new file mode 100644
index 000000000000..9d5d25b7936a
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.info.yml
@@ -0,0 +1,4 @@
+name: 'Automatic Updates Test: Cron'
+type: module
+description: 'Enables cron updates for testing purposes.'
+package: Testing
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.module b/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.module
new file mode 100644
index 000000000000..22936ff08366
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.module
@@ -0,0 +1,60 @@
+<?php
+
+/**
+ * @file
+ * Contains hook implementations to enable automatic updates during cron.
+ *
+ * @todo Move into auto_updates when TUF integration is stable.
+ */
+
+use Drupal\package_manager\ProjectInfo;
+use Drupal\auto_updates\CronUpdater;
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\Core\Form\FormStateInterface;
+use Drupal\update\ProjectSecurityData;
+
+/**
+ * Implements hook_form_FORM_ID_alter() for 'update_settings' form.
+ */
+function auto_updates_test_cron_form_update_settings_alter(array &$form, FormStateInterface $form_state, string $form_id) {
+  $project_info = new ProjectInfo('drupal');
+  $version = ExtensionVersion::createFromVersionString($project_info->getInstalledVersion());
+  $current_minor = $version->getMajorVersion() . '.' . $version->getMinorVersion();
+  // @todo In https://www.drupal.org/node/2998285 use the update XML to
+  //   determine when the installed of core will become unsupported.
+  $supported_until_version = $version->getMajorVersion() . '.'
+    . ((int) $version->getMinorVersion() + ProjectSecurityData::CORE_MINORS_WITH_SECURITY_COVERAGE)
+    . '.0';
+
+  $form['auto_updates_cron'] = [
+    '#type' => 'radios',
+    '#title' => t('Automatically update Drupal core'),
+    '#options' => [
+      CronUpdater::DISABLED => t('Disabled'),
+      CronUpdater::ALL => t('All supported updates'),
+      CronUpdater::SECURITY => t('Security updates only'),
+    ],
+    '#default_value' => \Drupal::config('auto_updates.settings')->get('cron'),
+    '#description' => t(
+      'If enabled, Drupal core will be automatically updated when an update is available. Automatic updates are only supported for @current_minor.x versions of Drupal core. Drupal @current_minor will receive security updates until @supported_until_version is released.',
+      [
+        '@current_minor' => $current_minor,
+        '@supported_until_version' => $supported_until_version,
+      ]
+    ),
+  ];
+  $form += [
+    '#submit' => ['::submitForm'],
+  ];
+  $form['#submit'][] = '_auto_updates_test_cron_update_settings_form_submit';
+}
+
+/**
+ * Submit function for the 'update_settings' form.
+ */
+function _auto_updates_test_cron_update_settings_form_submit(array &$form, FormStateInterface $form_state) {
+  \Drupal::configFactory()
+    ->getEditable('auto_updates.settings')
+    ->set('cron', $form_state->getValue('auto_updates_cron'))
+    ->save();
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.services.yml b/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.services.yml
new file mode 100644
index 000000000000..c7127c858f6c
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test_cron/auto_updates_test_cron.services.yml
@@ -0,0 +1,5 @@
+services:
+  auto_updates_test_cron.enabler:
+    class: Drupal\auto_updates_test_cron\Enabler
+    tags:
+      - { name: event_subscriber }
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_cron/src/Enabler.php b/core/modules/auto_updates/tests/modules/auto_updates_test_cron/src/Enabler.php
new file mode 100644
index 000000000000..d03e83253f16
--- /dev/null
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test_cron/src/Enabler.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\auto_updates_test_cron;
+
+use Drupal\auto_updates\CronUpdater;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\HttpKernel\KernelEvents;
+
+/**
+ * Enables automatic updates during cron.
+ *
+ * @todo Remove this when TUF integration is stable.
+ */
+class Enabler implements EventSubscriberInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      KernelEvents::REQUEST => 'enableCron',
+    ];
+  }
+
+  /**
+   * Enables automatic updates during cron.
+   */
+  public function enableCron(): void {
+    if (class_exists(CronUpdater::class)) {
+      $reflector = new \ReflectionClass(CronUpdater::class);
+      $reflector = $reflector->getProperty('disabled');
+      $reflector->setAccessible(TRUE);
+      $reflector->setValue(NULL, FALSE);
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php
index cc33e3f6ca4c..ed1866016b24 100644
--- a/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php
+++ b/core/modules/auto_updates/tests/modules/auto_updates_test_disable_validators/src/AutoUpdatesTestDisableValidatorsServiceProvider.php
@@ -14,7 +14,7 @@ class AutoUpdatesTestDisableValidatorsServiceProvider extends ServiceProviderBas
   /**
    * {@inheritdoc}
    */
-  public function alter(ContainerBuilder $container) {
+  public function alter(ContainerBuilder $container): void {
     parent::alter($container);
 
     $validators = Settings::get('auto_updates_test_disable_validators', []);
diff --git a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
index 06f149e3b437..9454f0436e62 100644
--- a/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
+++ b/core/modules/auto_updates/tests/src/Build/CoreUpdateTest.php
@@ -14,7 +14,7 @@ class CoreUpdateTest extends UpdateTestBase {
   /**
    * {@inheritdoc}
    */
-  public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL) {
+  public function copyCodebase(\Iterator $iterator = NULL, $working_dir = NULL): void {
     parent::copyCodebase($iterator, $working_dir);
 
     // Ensure that we will install Drupal 9.8.0 (a fake version that should
@@ -43,7 +43,9 @@ protected function createTestProject(string $template): void {
     // referenced in our fake release metadata (see
     // fixtures/release-history/drupal.0.0.xml).
     $this->setUpstreamCoreVersion('9.8.1');
-    $this->setReleaseMetadata(['drupal' => '9.8.1-security']);
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml',
+    ]);
 
     // Ensure that Drupal thinks we are running 9.8.0, then refresh information
     // about available updates and ensure that an update to 9.8.1 is available.
@@ -51,6 +53,9 @@ protected function createTestProject(string $template): void {
     $this->checkForUpdates();
     $this->visit('/admin/modules/automatic-update');
     $this->getMink()->assertSession()->pageTextContains('9.8.1');
+
+    // Ensure that Drupal has write-protected the site directory.
+    $this->assertDirectoryIsNotWritable($this->getWebRoot() . '/sites/default');
   }
 
   /**
@@ -106,7 +111,7 @@ public function testUi(string $template): void {
     $session->reload();
 
     $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
-    $page->pressButton('Update');
+    $page->pressButton('Update to 9.8.1');
     $this->waitForBatchJob();
     $assert_session->pageTextContains('Ready to update');
     $page->pressButton('Continue');
@@ -170,7 +175,7 @@ private function setUpstreamCoreVersion(string $version): void {
     $workspace_dir = $this->getWorkspaceDirectory();
 
     // Loop through core's metapackages and plugins, and alter them as needed.
-    $packages = str_replace("$workspace_dir/", NULL, $this->getCorePackages());
+    $packages = str_replace("$workspace_dir/", '', $this->getCorePackages());
     foreach ($packages as $path) {
       // Assign the new upstream version.
       $this->runComposer("composer config version $version", $path);
@@ -184,10 +189,20 @@ private function setUpstreamCoreVersion(string $version): void {
     }
 
     // Change the \Drupal::VERSION constant and put placeholder text in the
-    // README so we can ensure that we really updated to the correct version.
+    // README so we can ensure that we really updated to the correct version. We
+    // also change the default site configuration files so we can ensure that
+    // these are updated as well, despite `sites/default` being write-protected.
     // @see ::assertUpdateSuccessful()
+    // @see ::createTestProject()
     Composer::setDrupalVersion($workspace_dir, $version);
     file_put_contents("$workspace_dir/core/README.txt", "Placeholder for Drupal core $version.");
+
+    foreach (['default.settings.php', 'default.services.yml'] as $file) {
+      $file = fopen("$workspace_dir/core/assets/scaffold/files/$file", 'a');
+      $this->assertIsResource($file);
+      fwrite($file, "# This is part of Drupal $version.\n");
+      fclose($file);
+    }
   }
 
   /**
@@ -225,12 +240,23 @@ private function assertUpdateSuccessful(string $expected_version): void {
     $this->getMink()->assertSession()->pageTextContains('No update available');
 
     // The status page should report that we're running the expected version and
-    // the README should contain the placeholder text written by
-    // ::setUpstreamCoreVersion().
+    // the README and default site configuration files should contain the
+    // placeholder text written by ::setUpstreamCoreVersion(), even though
+    // `sites/default` is write-protected.
+    // @see ::createTestProject()
+    // @see ::setUpstreamCoreVersion()
     $this->assertCoreVersion($expected_version);
-    $placeholder = file_get_contents($this->getWebRoot() . '/core/README.txt');
+    $web_root = $this->getWebRoot();
+    $placeholder = file_get_contents("$web_root/core/README.txt");
     $this->assertSame("Placeholder for Drupal core $expected_version.", $placeholder);
 
+    foreach (['default.settings.php', 'default.services.yml'] as $file) {
+      $file = $web_root . '/sites/default/' . $file;
+      $this->assertFileIsReadable($file);
+      $this->assertStringContainsString("# This is part of Drupal $expected_version.", file_get_contents($file));
+    }
+    $this->assertDirectoryIsNotWritable("$web_root/sites/default");
+
     $info = $this->runComposer('composer info --self --format json', 'project', TRUE);
 
     // The production dependencies should have been updated.
diff --git a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
index 3009388294ee..fac5b18b3c3e 100644
--- a/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
+++ b/core/modules/auto_updates/tests/src/Build/UpdateTestBase.php
@@ -10,67 +10,20 @@
  */
 abstract class UpdateTestBase extends TemplateProjectTestBase {
 
-  /**
-   * 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 createTestProject(string $template): void {
     parent::createTestProject($template);
 
-    // Install Drupal, Automatic Updates, and other modules needed for testing.
-    $this->installQuickStart('minimal');
-    $this->formLogin($this->adminUsername, $this->adminPassword);
+    // Install Automatic Updates, and other modules needed for testing.
     $this->installModules([
       'auto_updates',
       'auto_updates_test',
-      'update_test',
+      'auto_updates_test_cron',
     ]);
   }
 
-  /**
-   * Prepares the test site to serve an XML feed of available release metadata.
-   *
-   * @param array $xml_map
-   *   The update XML map, as used by update_test.settings.
-   *
-   * @see \Drupal\auto_updates_test\TestController::metadata()
-   */
-  protected function setReleaseMetadata(array $xml_map): void {
-    $xml_map = var_export($xml_map, TRUE);
-    $code = <<<END
-\$config['update_test.settings']['xml_map'] = $xml_map;
-END;
-
-    // When checking for updates, we need to be able to make sub-requests, but
-    // the built-in PHP server is single-threaded. Therefore, if needed, open a
-    // second server instance on another port, which will serve the metadata
-    // about available updates.
-    if (empty($this->metadataServer)) {
-      $port = $this->findAvailablePort();
-      $this->metadataServer = $this->instantiateServer($port);
-      $code .= <<<END
-\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/automatic-update-test';
-END;
-    }
-    $this->writeSettings($code);
-  }
-
   /**
    * Checks for available updates.
    *
diff --git a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php
index 8f1a0e316faf..df6381ea95eb 100644
--- a/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php
+++ b/core/modules/auto_updates/tests/src/Functional/AutoUpdatesFunctionalTestBase.php
@@ -3,21 +3,25 @@
 namespace Drupal\Tests\auto_updates\Functional;
 
 use Drupal\Core\Site\Settings;
+use Drupal\package_manager_bypass\Beginner;
+use Drupal\package_manager_bypass\Stager;
 use Drupal\Tests\BrowserTestBase;
+use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
+use Symfony\Component\DependencyInjection\ContainerInterface;
 
 /**
  * Base class for functional tests of the Automatic Updates module.
  */
 abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase {
 
+  use FixtureUtilityTrait;
   /**
    * {@inheritdoc}
    */
   protected static $modules = [
+    'auto_updates_test_cron',
     'auto_updates_test_disable_validators',
     'package_manager_bypass',
-    'update',
-    'update_test',
   ];
 
   /**
@@ -26,13 +30,6 @@ abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase {
    * @var string[]
    */
   protected $disableValidators = [
-    // Disable the filesystem permissions validators, since we cannot guarantee
-    // that the current code base will be writable in all testing situations. We
-    // test these validators in our build tests, since those do give us control
-    // over the filesystem permissions.
-    // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
-    'auto_updates.validator.file_system_permissions',
-    'package_manager.validator.file_system',
     // Disable the Composer executable validator, since it may cause the tests
     // to fail if a supported version of Composer is unavailable to the web
     // server. This should be okay in most situations because, apart from the
@@ -40,18 +37,33 @@ abstract class AutoUpdatesFunctionalTestBase extends BrowserTestBase {
     // package_manager_bypass is disabling those operations.
     'auto_updates.composer_executable_validator',
     'package_manager.validator.composer_executable',
-    // Disable the lock file validator, because it may cause the tests to fail
-    // if either the active and stage directories don't have a composer.lock
-    // file, which is the case with some of our fixtures.
-    'package_manager.validator.lock_file',
+    // Always allow tests to run with Xdebug on.
+    'auto_updates.validator.xdebug',
   ];
 
   /**
    * {@inheritdoc}
    */
-  protected function setUp() {
+  protected function setUp(): void {
     parent::setUp();
     $this->disableValidators($this->disableValidators);
+    $this->useFixtureDirectoryAsActive(__DIR__ . '/../../../package_manager/tests/fixtures/fake_site');
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function installModulesFromClassProperty(ContainerInterface $container): void {
+    $container->get('module_installer')->install([
+      'package_manager_test_release_history',
+    ]);
+    $this->container = $container->get('kernel')->getContainer();
+
+    // To prevent tests from making real requests to the Internet, use fake
+    // release metadata that exposes a pretend Drupal 9.8.2 release.
+    $this->setReleaseMetadata(__DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml');
+
+    parent::installModulesFromClassProperty($container);
   }
 
   /**
@@ -116,15 +128,17 @@ protected function setCoreVersion(string $version): void {
    *   The path of the XML metadata file to use.
    */
   protected function setReleaseMetadata(string $file): void {
+    $this->assertFileIsReadable($file);
+
     $this->config('update.settings')
-      ->set('fetch.url', $this->baseUrl . '/automatic-update-test')
+      ->set('fetch.url', $this->baseUrl . '/test-release-history')
       ->save();
 
-    [$project, $fixture] = explode('.', basename($file, '.xml'), 2);
+    [$project] = explode('.', basename($file, '.xml'), 2);
+    $xml_map = $this->config('update_test.settings')->get('xml_map') ?? [];
+    $xml_map[$project] = $file;
     $this->config('update_test.settings')
-      ->set('xml_map', [
-        $project => $fixture,
-      ])
+      ->set('xml_map', $xml_map)
       ->save();
   }
 
@@ -142,13 +156,58 @@ protected function checkForUpdates(): void {
   /**
    * Asserts that we are on the "update ready" form.
    *
-   * @param string $update_version
-   *   The version of Drupal core that we are updating to.
+   * @param string $target_version
+   *   The target version of Drupal core.
    */
-  protected function assertUpdateReady(string $update_version): void {
+  protected function assertUpdateReady(string $target_version): void {
     $assert_session = $this->assertSession();
     $assert_session->addressMatches('/\/admin\/automatic-update-ready\/[a-zA-Z0-9_\-]+$/');
-    $assert_session->pageTextContainsOnce('Drupal core will be updated to ' . $update_version);
+    $assert_session->pageTextContainsOnce('Drupal core will be updated to ' . $target_version);
+  }
+
+  /**
+   * Copies a fixture directory to a temporary directory.
+   *
+   * @param string $fixture_directory
+   *   The fixture directory.
+   *
+   * @return string
+   *   The temporary directory.
+   */
+  protected function copyFixtureToTempDirectory(string $fixture_directory): string {
+    $temp_directory = $this->root . DIRECTORY_SEPARATOR . $this->siteDirectory . DIRECTORY_SEPARATOR . $this->randomMachineName(20);
+    static::copyFixtureFilesTo($fixture_directory, $temp_directory);
+    return $temp_directory;
+  }
+
+  /**
+   * Sets a fixture directory to use as the active directory.
+   *
+   * @param string $fixture_directory
+   *   The fixture directory.
+   */
+  protected function useFixtureDirectoryAsActive(string $fixture_directory): void {
+    // Create a temporary directory from our fixture directory that will be
+    // unique for each test run. This will enable changing files in the
+    // directory and not affect other tests.
+    $active_dir = $this->copyFixtureToTempDirectory($fixture_directory);
+    Beginner::setFixturePath($active_dir);
+    $this->container->get('package_manager.path_locator')
+      ->setPaths($active_dir, $active_dir . '/vendor', '', NULL);
+  }
+
+  /**
+   * Sets a fixture directory to use as the staged directory.
+   *
+   * @param string $fixture_directory
+   *   The fixture directory.
+   */
+  protected function useFixtureDirectoryAsStaged(string $fixture_directory): void {
+    // Create a temporary directory from our fixture directory that will be
+    // unique for each test run. This will enable changing files in the
+    // directory and not affect other tests.
+    $staged_dir = $this->copyFixtureToTempDirectory($fixture_directory);
+    Stager::setFixturePath($staged_dir);
   }
 
 }
diff --git a/core/modules/auto_updates/tests/src/Functional/AvailableUpdatesReportTest.php b/core/modules/auto_updates/tests/src/Functional/AvailableUpdatesReportTest.php
new file mode 100644
index 000000000000..053f5408ab38
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Functional/AvailableUpdatesReportTest.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Functional;
+
+use Drupal\Core\Url;
+
+/**
+ * Tests changes to the Available Updates report provided by the Update module.
+ *
+ * @group auto_updates
+ */
+class AvailableUpdatesReportTest extends AutoUpdatesFunctionalTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'block',
+    'auto_updates',
+    'auto_updates_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    $user = $this->createUser([
+      'administer site configuration',
+      'administer software updates',
+      'access administration pages',
+      'access site reports',
+    ]);
+    $this->drupalLogin($user);
+  }
+
+  /**
+   * Tests the Available Updates report links are correct.
+   */
+  public function testReportLinks(): void {
+    $assert = $this->assertSession();
+    $form_url = Url::fromRoute('auto_updates.report_update')->toString();
+
+    $this->config('auto_updates.settings')->set('allow_core_minor_updates', TRUE)->save();
+    $fixture_directory = __DIR__ . '/../../../package_manager/tests/fixtures/release-history';
+    $this->setReleaseMetadata("$fixture_directory/drupal.9.8.1-security.xml");
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $assert->pageTextContains('Security update required! Update now');
+    $assert->elementAttributeContains('named', ['link', 'Update now'], 'href', $form_url);
+    $this->assertVersionLink('9.8.1', $form_url);
+
+    $this->setReleaseMetadata("$fixture_directory/drupal.9.8.2-older-sec-release.xml");
+    $this->setCoreVersion('9.7.0');
+    $this->checkForUpdates();
+    $assert->pageTextContains('Security update required! Update now');
+
+    $assert->elementAttributeContains('named', ['link', 'Update now'], 'href', $form_url);
+    // Releases that will available on the form should link to the form.
+    $this->assertVersionLink('9.8.2', $form_url);
+    $this->assertVersionLink('9.7.1', $form_url);
+    // Releases that will not be available in the form should link to the
+    // project release page.
+    $this->assertVersionLink('9.8.1', 'http://example.com/drupal-9-8-1-release');
+
+    $this->setReleaseMetadata("$fixture_directory/drupal.9.8.2.xml");
+    $this->checkForUpdates();
+    $assert->pageTextContains('Update available Update now');
+    $assert->elementAttributeContains('named', ['link', 'Update now'], 'href', $form_url);
+    $this->assertVersionLink('9.8.2', $form_url);
+  }
+
+  /**
+   * Asserts the version download link is correct.
+   *
+   * @param string $version
+   *   The version.
+   * @param string $url
+   *   The expected URL.
+   */
+  private function assertVersionLink(string $version, string $url): void {
+    $assert = $this->assertSession();
+    $row = $assert->elementExists('css', "table.update .project-update__version:contains(\"$version\")");
+    // In Drupal 9.5 and later, the "Download" link does not exist. We can drop
+    // this assertion (and likely this entire method) when Drupal 9.5 is the
+    // minimum supported version of core.
+    $link = $row->findLink('Download');
+    if ($link) {
+      $this->assertStringEndsWith($url, $link->getAttribute('href'));
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php
index 67e4c83ecb0c..7707013e000f 100644
--- a/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php
+++ b/core/modules/auto_updates/tests/src/Functional/ReadinessValidationTest.php
@@ -8,7 +8,6 @@
 use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1;
 use Drupal\auto_updates_test2\EventSubscriber\TestSubscriber2;
 use Drupal\Core\Url;
-use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager;
 use Drupal\system\SystemManager;
 use Drupal\Tests\auto_updates\Traits\ValidationTestTrait;
 use Drupal\Tests\Traits\Core\CronRunTrait;
@@ -54,7 +53,7 @@ class ReadinessValidationTest extends AutoUpdatesFunctionalTestBase {
    */
   protected function setUp(): void {
     parent::setUp();
-    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml');
+    $this->setReleaseMetadata(__DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml');
     $this->setCoreVersion('9.8.1');
 
     $this->reportViewerUser = $this->createUser([
@@ -67,7 +66,6 @@ protected function setUp(): void {
       'access administration pages',
       'access site in maintenance mode',
     ]);
-    $this->createTestValidationResults();
     $this->drupalLogin($this->reportViewerUser);
   }
 
@@ -77,9 +75,9 @@ protected function setUp(): void {
   public function testReadinessChecksStatusReport(): void {
     $assert = $this->assertSession();
 
-    // Ensure automated_cron is disabled before installing auto_updates. This
-    // ensures we are testing that auto_updates runs the checkers when the
-    // module itself is installed and they weren't run on cron.
+    // Ensure automated_cron is disabled before installing auto_updates.
+    // This ensures we are testing that auto_updates runs the checkers when
+    // the module itself is installed and they weren't run on cron.
     $this->assertFalse($this->container->get('module_handler')->moduleExists('automated_cron'));
     $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test']);
 
@@ -116,7 +114,7 @@ public function testReadinessChecksStatusReport(): void {
     $this->drupalGet('admin/reports/status');
     $this->assertNoErrors(TRUE);
     /** @var \Drupal\package_manager\ValidationResult[] $expected_results */
-    $expected_results = $this->testResults['checker_1']['1 error'];
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
 
     // Run the readiness checks.
@@ -137,40 +135,42 @@ public function testReadinessChecksStatusReport(): void {
     $this->drupalGet('admin/reports/status');
     $this->assertErrors($expected_results);
 
-    $expected_results = $this->testResults['checker_1']['1 error 1 warning'];
+    $expected_results = [
+      'error' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR),
+      'warning' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
+    ];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
-    $key_value->delete('readiness_validation_last_run');
-    // Confirm a new message is displayed if the stored messages are deleted.
-    $this->drupalGet('admin/reports/status');
+    // Confirm a new message is displayed if the page is reloaded.
+    $this->getSession()->reload();
     // Confirm that on the status page if there is only 1 warning or error the
-    // the summaries will not be displayed.
-    $this->assertErrors([$expected_results['1:error']]);
-    $this->assertWarnings([$expected_results['1:warning']]);
-    $assert->pageTextNotContains($expected_results['1:error']->getSummary());
-    $assert->pageTextNotContains($expected_results['1:warning']->getSummary());
-
-    $key_value->delete('readiness_validation_last_run');
-    $expected_results = $this->testResults['checker_1']['2 errors 2 warnings'];
+    // summaries will not be displayed.
+    $this->assertErrors([$expected_results['error']]);
+    $this->assertWarnings([$expected_results['warning']]);
+    $assert->pageTextNotContains($expected_results['error']->getSummary());
+    $assert->pageTextNotContains($expected_results['warning']->getSummary());
+
+    $expected_results = [
+      'error' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2),
+      'warning' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2),
+    ];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
-    $this->drupalGet('admin/reports/status');
+    $this->getSession()->reload();
     // Confirm that both messages and summaries will be displayed on status
     // report when there multiple messages.
-    $this->assertErrors([$expected_results['1:errors']]);
-    $this->assertWarnings([$expected_results['1:warnings']]);
+    $this->assertErrors([$expected_results['error']]);
+    $this->assertWarnings([$expected_results['warning']]);
 
-    $key_value->delete('readiness_validation_last_run');
-    $expected_results = $this->testResults['checker_1']['2 warnings'];
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
-    $this->drupalGet('admin/reports/status');
+    $this->getSession()->reload();
     $assert->pageTextContainsOnce('Update readiness checks');
     // Confirm that warnings will display on the status report if there are no
     // errors.
     $this->assertWarnings($expected_results);
 
-    $key_value->delete('readiness_validation_last_run');
-    $expected_results = $this->testResults['checker_1']['1 warning'];
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
-    $this->drupalGet('admin/reports/status');
+    $this->getSession()->reload();
     $assert->pageTextContainsOnce('Update readiness checks');
     $this->assertWarnings($expected_results);
   }
@@ -197,7 +197,7 @@ public function testReadinessChecksAdminPages(): void {
 
     // 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'];
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
     // @todo Change this to use ::delayRequestTime() to simulate running cron
     //   after a 24 wait instead of directly deleting 'readiness_validation_last_run'
@@ -220,7 +220,10 @@ public function testReadinessChecksAdminPages(): void {
     $assert->addressEquals('admin/structure');
     $assert->pageTextContainsOnce($expected_results[0]->getMessages()[0]);
 
-    $expected_results = $this->testResults['checker_1']['1 error 1 warning'];
+    $expected_results = [
+      '1 error' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR),
+      '1 warning' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
+    ];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
     // Confirm a new message is displayed if the cron is run after an hour.
     $this->delayRequestTime();
@@ -229,25 +232,28 @@ public function testReadinessChecksAdminPages(): void {
     $assert->pageTextContainsOnce(static::$errorsExplanation);
     // Confirm on admin pages that a single error will be displayed instead of a
     // summary.
-    $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['1:error']->getSeverity());
-    $assert->pageTextContainsOnce($expected_results['1:error']->getMessages()[0]);
-    $assert->pageTextNotContains($expected_results['1:error']->getSummary());
+    $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());
+    $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'];
+    $unexpected_results = [
+      '2 errors' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2),
+      '2 warnings' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2),
+    ];
     TestSubscriber1::setTestResult($unexpected_results, ReadinessCheckEvent::class);
     $this->delayRequestTime(30);
     $this->cronRun();
     $this->drupalGet('admin/structure');
-    $assert->pageTextNotContains($unexpected_results['1:errors']->getSummary());
-    $assert->pageTextContainsOnce($expected_results['1:error']->getMessages()[0]);
-    $assert->pageTextNotContains($unexpected_results['1:warnings']->getSummary());
-    $assert->pageTextNotContains($expected_results['1:warning']->getMessages()[0]);
+    $assert->pageTextNotContains($unexpected_results['2 errors']->getSummary());
+    $assert->pageTextContainsOnce($expected_results['1 error']->getMessages()[0]);
+    $assert->pageTextNotContains($unexpected_results['2 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.
@@ -257,18 +263,18 @@ public function testReadinessChecksAdminPages(): void {
     $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());
+    $this->assertSame(SystemManager::REQUIREMENT_ERROR, $expected_results['2 errors']->getSeverity());
+    $assert->pageTextNotContains($expected_results['2 errors']->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results['2 errors']->getMessages()[1]);
+    $assert->pageTextContainsOnce($expected_results['2 errors']->getSummary());
     $assert->pageTextContainsOnce(static::$errorsExplanation);
     // Warnings are not displayed on admin pages if there are any errors.
-    $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['1:warnings']->getSeverity());
-    $assert->pageTextNotContains($expected_results['1:warnings']->getMessages()[0]);
-    $assert->pageTextNotContains($expected_results['1:warnings']->getMessages()[1]);
-    $assert->pageTextNotContains($expected_results['1:warnings']->getSummary());
+    $this->assertSame(SystemManager::REQUIREMENT_WARNING, $expected_results['2 warnings']->getSeverity());
+    $assert->pageTextNotContains($expected_results['2 warnings']->getMessages()[0]);
+    $assert->pageTextNotContains($expected_results['2 warnings']->getMessages()[1]);
+    $assert->pageTextNotContains($expected_results['2 warnings']->getSummary());
 
-    $expected_results = $this->testResults['checker_1']['2 warnings'];
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
     $this->delayRequestTime();
     $this->cronRun();
@@ -282,7 +288,7 @@ public function testReadinessChecksAdminPages(): void {
     $assert->pageTextContainsOnce(static::$warningsExplanation);
     $assert->pageTextContainsOnce($expected_results[0]->getSummary());
 
-    $expected_results = $this->testResults['checker_1']['1 warning'];
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
     $this->delayRequestTime();
     $this->cronRun();
@@ -321,7 +327,7 @@ public function testReadinessCheckAfterInstall(): void {
     $this->drupalGet('admin/reports/status');
     $this->assertNoErrors(TRUE);
 
-    $expected_results = $this->testResults['checker_1']['1 error'];
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber2::setTestResult($expected_results, ReadinessCheckEvent::class);
     $this->container->get('module_installer')->install(['auto_updates_test2']);
     $this->drupalGet('admin/structure');
@@ -330,7 +336,10 @@ public function testReadinessCheckAfterInstall(): void {
     // Confirm that installing a module runs the checkers, even if the new
     // module does not provide any validators.
     $previous_results = $expected_results;
-    $expected_results = $this->testResults['checker_1']['2 errors 2 warnings'];
+    $expected_results = [
+      '2 errors' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2),
+      '2 warnings' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING, 2),
+    ];
     TestSubscriber2::setTestResult($expected_results, ReadinessCheckEvent::class);
     $this->container->get('module_installer')->install(['help']);
     // Check for messages on 'admin/structure' instead of the status report,
@@ -338,8 +347,8 @@ public function testReadinessCheckAfterInstall(): void {
     $this->drupalGet('admin/structure');
     // Confirm that new checker messages are displayed.
     $assert->pageTextNotContains($previous_results[0]->getMessages()[0]);
-    $assert->pageTextNotContains($expected_results['1:errors']->getMessages()[0]);
-    $assert->pageTextContainsOnce($expected_results['1:errors']->getSummary());
+    $assert->pageTextNotContains($expected_results['2 errors']->getMessages()[0]);
+    $assert->pageTextContainsOnce($expected_results['2 errors']->getSummary());
   }
 
   /**
@@ -349,9 +358,9 @@ public function testReadinessCheckerUninstall(): void {
     $assert = $this->assertSession();
     $this->drupalLogin($this->checkerRunnerUser);
 
-    $expected_results_1 = $this->testResults['checker_1']['1 error'];
+    $expected_results_1 = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber1::setTestResult($expected_results_1, ReadinessCheckEvent::class);
-    $expected_results_2 = $this->testResults['checker_2']['1 error'];
+    $expected_results_2 = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber2::setTestResult($expected_results_2, ReadinessCheckEvent::class);
     $this->container->get('module_installer')->install([
       'auto_updates',
@@ -392,20 +401,14 @@ public function testStoredResultsClearedAfterUpdate(): void {
     $this->setCoreVersion('9.8.0');
 
     // Flag a validation error, which will be displayed in the messages area.
-    $results = $this->testResults['checker_1']['1 error'];
+    $results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber1::setTestResult($results, ReadinessCheckEvent::class);
     $message = $results[0]->getMessages()[0];
 
     $this->container->get('module_installer')->install([
       'auto_updates',
       'auto_updates_test',
-      'package_manager_test_fixture',
     ]);
-    // Because all actual staging operations are bypassed by
-    // package_manager_bypass (enabled by the parent class), disable this
-    // validator because it will complain if there's no actual Composer data to
-    // inspect.
-    $this->disableValidators(['auto_updates.staged_projects_validator']);
 
     // The error should be persistently visible, even after the checker stops
     // flagging it.
@@ -420,7 +423,7 @@ public function testStoredResultsClearedAfterUpdate(): void {
     // readiness check (without storing the results), and the checker is no
     // longer raising an error.
     $this->drupalGet('/admin/modules/automatic-update');
-    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
+    $this->useFixtureDirectoryAsStaged(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
     $assert_session->buttonExists('Update');
     // Ensure that the previous results are still displayed on another admin
     // page, to confirm that the updater form is not discarding the previous
diff --git a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php
index f540f47f4493..6a250006d711 100644
--- a/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php
+++ b/core/modules/auto_updates/tests/src/Functional/UpdateLockTest.php
@@ -2,7 +2,7 @@
 
 namespace Drupal\Tests\auto_updates\Functional;
 
-use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager;
+use Drupal\package_manager_bypass\Stager;
 
 /**
  * Tests that only one Automatic Update operation can be performed at a time.
@@ -22,7 +22,6 @@ class UpdateLockTest extends AutoUpdatesFunctionalTestBase {
   protected static $modules = [
     'auto_updates',
     'auto_updates_test',
-    'package_manager_test_fixture',
   ];
 
   /**
@@ -31,8 +30,10 @@ class UpdateLockTest extends AutoUpdatesFunctionalTestBase {
   protected function setUp(): void {
     parent::setUp();
 
-    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml');
-    $this->drupalLogin($this->rootUser);
+    $user = $this->createUser([
+      'administer site configuration',
+    ]);
+    $this->drupalLogin($user);
     $this->checkForUpdates();
   }
 
@@ -51,7 +52,7 @@ public function testLock(): void {
     // We should be able to get partway through an update without issue.
     $this->drupalLogin($user_1);
     $this->drupalGet('/admin/modules/automatic-update');
-    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
+    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
     $page->pressButton('Update');
     $this->checkForMetaRefresh();
     $this->assertUpdateReady('9.8.1');
diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormNoRecommendedReleaseMessageTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormNoRecommendedReleaseMessageTest.php
new file mode 100644
index 000000000000..188f79c5862c
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormNoRecommendedReleaseMessageTest.php
@@ -0,0 +1,101 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Functional;
+
+/**
+ * Tests messages on the updater form when there is no recommended release.
+ *
+ * @group auto_updates
+ */
+class UpdaterFormNoRecommendedReleaseMessageTest extends AutoUpdatesFunctionalTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'auto_updates',
+    'auto_updates_test',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $account = $this->drupalCreateUser([
+      'administer software updates',
+      'administer site configuration',
+    ]);
+    $this->drupalLogin($account);
+  }
+
+  /**
+   * Data provider for testMessages().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerMessages(): array {
+    $dir = __DIR__ . '/../../../package_manager/tests/fixtures/release-history';
+
+    return [
+      'current' => [
+        $dir . '/drupal.9.8.1-security.xml',
+        '9.8.1',
+        FALSE,
+        'status',
+      ],
+      'not current' => [
+        $dir . '/drupal.9.8.2.xml',
+        '9.7.1',
+        TRUE,
+        'status',
+      ],
+      'insecure' => [
+        $dir . '/drupal.9.8.1-security.xml',
+        '9.7.1',
+        TRUE,
+        'error',
+      ],
+    ];
+  }
+
+  /**
+   * Tests messages when there is no recommended release.
+   *
+   * @param string $release_metadata
+   *   The path of the release metadata to use.
+   * @param string $installed_version
+   *   The currently installed version of Drupal core.
+   * @param bool $updates_available
+   *   Whether or not any available updates will be detected.
+   * @param string $expected_message_type
+   *   The expected type of message (status or error).
+   *
+   * @dataProvider providerMessages
+   */
+  public function testMessages(string $release_metadata, string $installed_version, bool $updates_available, string $expected_message_type): void {
+    $this->setReleaseMetadata($release_metadata);
+    $this->setCoreVersion($installed_version);
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/reports/updates/automatic-update');
+
+    $assert_session = $this->assertSession();
+    $message_selector = $expected_message_type === 'status' ? "//div[@role='contentinfo' and h2[text()='Status message']]" : "//div[@role='alert' and h2[text()='Error message']]";
+    if ($updates_available) {
+      $assert_session->elementTextContains('xpath', $message_selector, 'Updates were found, but they must be performed manually.');
+      $assert_session->linkExists('the list of available updates');
+    }
+    else {
+      $assert_session->elementTextContains('xpath', $message_selector, 'No update available');
+    }
+    $assert_session->buttonNotExists('Update');
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
index 599f0471ecbc..40e460bf9bca 100644
--- a/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
+++ b/core/modules/auto_updates/tests/src/Functional/UpdaterFormTest.php
@@ -4,13 +4,18 @@
 
 use Drupal\auto_updates\Event\ReadinessCheckEvent;
 use Drupal\auto_updates_test\Datetime\TestTime;
-use Drupal\Component\FileSystem\FileSystem;
+use Drupal\package_manager_test_validation\StagedDatabaseUpdateValidator;
 use Drupal\package_manager\Event\PostRequireEvent;
 use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PostApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\package_manager\ValidationResult;
 use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1;
-use Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager;
+use Drupal\package_manager_bypass\Committer;
+use Drupal\package_manager_bypass\Stager;
+use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber;
+use Drupal\system\SystemManager;
 use Drupal\Tests\auto_updates\Traits\ValidationTestTrait;
 use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
 
@@ -36,22 +41,30 @@ class UpdaterFormTest extends AutoUpdatesFunctionalTestBase {
     'block',
     'auto_updates',
     'auto_updates_test',
-    'package_manager_test_fixture',
   ];
 
   /**
    * {@inheritdoc}
    */
   protected function setUp(): void {
-    // In this test class, all actual staging operations are bypassed by
-    // package_manager_bypass, which means this validator will complain because
-    // there is no actual Composer data for it to inspect.
-    $this->disableValidators[] = 'auto_updates.staged_projects_validator';
-
     parent::setUp();
 
-    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml');
-    $this->drupalLogin($this->rootUser);
+    $this->setReleaseMetadata(__DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml');
+    $permissions = [
+      'administer site configuration',
+      'administer software updates',
+      'access administration pages',
+      'access site in maintenance mode',
+      'administer modules',
+      'access site reports',
+    ];
+    // Check for permission that was added in Drupal core 9.4.x.
+    $available_permissions = array_keys($this->container->get('user.permissions')->getPermissions());
+    if (in_array('view update notifications', $available_permissions, TRUE)) {
+      array_push($permissions, 'view update notifications');
+    }
+    $user = $this->createUser($permissions);
+    $this->drupalLogin($user);
     $this->checkForUpdates();
   }
 
@@ -59,7 +72,7 @@ protected function setUp(): void {
    * Data provider for URLs to the update form.
    *
    * @return string[][]
-   *   Test case parameters.
+   *   The test cases.
    */
   public function providerUpdateFormReferringUrl(): array {
     return [
@@ -72,7 +85,7 @@ public function providerUpdateFormReferringUrl(): array {
    * Data provider for testTableLooksCorrect().
    *
    * @return string[][]
-   *   Test case parameters.
+   *   The test cases.
    */
   public function providerTableLooksCorrect(): array {
     return [
@@ -99,9 +112,8 @@ public function testFormNotDisplayedIfAlreadyCurrent(string $update_form_url): v
     $this->drupalGet($update_form_url);
 
     $assert_session = $this->assertSession();
-    $assert_session->statusCodeEquals(200);
     $assert_session->pageTextContains('No update available');
-    $assert_session->buttonNotExists('Update');
+    $this->assertNoUpdateButtons();
   }
 
   /**
@@ -115,6 +127,7 @@ public function testFormNotDisplayedIfAlreadyCurrent(string $update_form_url): v
    * @dataProvider providerTableLooksCorrect
    */
   public function testTableLooksCorrect(string $access_page): void {
+    $page = $this->getSession()->getPage();
     $this->drupalPlaceBlock('local_tasks_block', ['primary' => TRUE]);
     $assert_session = $this->assertSession();
     $this->setCoreVersion('9.8.0');
@@ -132,17 +145,100 @@ public function testTableLooksCorrect(string $access_page): void {
       $this->clickLink('Available updates');
     }
     $this->clickLink('Update');
+
+    // Check the form when there is an updates in the next minor only.
+    $assert_session->pageTextContainsOnce('Currently installed: 9.8.0 (Security update required!)');
+    $this->checkReleaseTable('#edit-installed-minor', '.update-update-security', '9.8.1', TRUE, 'Latest version of Drupal 9.8 (currently installed):');
+    $assert_session->elementNotExists('css', '#edit-next-minor');
+
+    // Check the form when there is an updates in the next minor only.
+    $this->config('auto_updates.settings')->set('allow_core_minor_updates', TRUE)->save();
+    $this->setCoreVersion('9.7.0');
+    $page->clickLink('Check manually');
+    $this->checkForMetaRefresh();
+    $this->checkReleaseTable('#edit-next-minor', '.update-update-recommended', '9.8.1', TRUE, 'Latest version of Drupal 9.8 (next minor) (Release notes):');
+    $this->assertReleaseNotesLink(9, 8);
+    $assert_session->pageTextContainsOnce('Currently installed: 9.7.0 (Not supported!)');
+    $assert_session->elementNotExists('css', '#edit-installed-minor');
+
+    // Check the form when there are updates in the current and next minors but
+    // the site does not support minor updates.
+    $this->config('auto_updates.settings')->set('allow_core_minor_updates', FALSE)->save();
+    $this->setReleaseMetadata(__DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml');
+    $page->clickLink('Check manually');
+    $this->checkForMetaRefresh();
+    $assert_session->pageTextContainsOnce('Currently installed: 9.7.0 (Update available)');
+    $this->checkReleaseTable('#edit-installed-minor', '.update-update-recommended', '9.7.1', TRUE, 'Latest version of Drupal 9.7 (currently installed):');
+    $assert_session->elementNotExists('css', '#edit-next-minor');
+
+    // Check that if minor updates are enabled the update in the next minor will
+    // be visible.
+    $this->config('auto_updates.settings')->set('allow_core_minor_updates', TRUE)->save();
+    $this->getSession()->reload();
+    $this->checkReleaseTable('#edit-installed-minor', '.update-update-recommended', '9.7.1', TRUE, 'Latest version of Drupal 9.7 (currently installed):');
+    $this->checkReleaseTable('#edit-next-minor', '.update-update-optional', '9.8.2', FALSE, 'Latest version of Drupal 9.8 (next minor) (Release notes):');
+    $this->assertReleaseNotesLink(9, 8);
+
+    $this->setCoreVersion('9.7.1');
+    $page->clickLink('Check manually');
+    $this->checkForMetaRefresh();
+    $assert_session->pageTextContainsOnce('Currently installed: 9.7.1 (Update available)');
+    $assert_session->elementNotExists('css', '#edit-installed-minor');
+    $this->checkReleaseTable('#edit-next-minor', '.update-update-recommended', '9.8.2', FALSE, 'Latest version of Drupal 9.8 (next minor) (Release notes):');
+    $this->assertReleaseNotesLink(9, 8);
+
+    $this->assertUpdateStagedTimes(0);
+  }
+
+  /**
+   * Tests readiness checks are displayed when there is no update available.
+   */
+  public function testReadinessCheckFailureWhenNoUpdate() {
+    $assert_session = $this->assertSession();
+    $this->setCoreVersion('9.8.1');
+    $message = "You've not experienced Shakespeare until you have read him in the original Klingon.";
+    $result = ValidationResult::createError([$message]);
+    TestSubscriber1::setTestResult([$result], ReadinessCheckEvent::class);
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/reports/updates/automatic-update');
+    $assert_session->pageTextContains('No update available');
+    $assert_session->pageTextContains($message);
+  }
+
+  /**
+   * Checks the table for a release on the form.
+   *
+   * @param string $container_locator
+   *   The CSS locator for the element with contains the table.
+   * @param string $row_class
+   *   The row class for the update.
+   * @param string $version
+   *   The release version number.
+   * @param bool $is_primary
+   *   Whether update button should be a primary button.
+   * @param string|null $table_caption
+   *   The table caption or NULL if none expected.
+   */
+  private function checkReleaseTable(string $container_locator, string $row_class, string $version, bool $is_primary, ?string $table_caption = NULL): void {
+    $assert_session = $this->assertSession();
     $assert_session->pageTextNotContains('There is a security update available for your version of Drupal.');
-    $cells = $assert_session->elementExists('css', '#edit-projects .update-update-security')
+    $assert_session->linkExists('Drupal core');
+    $container = $assert_session->elementExists('css', $container_locator);
+    if ($table_caption) {
+      $this->assertSame($table_caption, $assert_session->elementExists('css', 'caption', $container)->getText());
+    }
+    else {
+      $assert_session->elementNotExists('css', 'caption', $container);
+    }
+
+    $cells = $assert_session->elementExists('css', $row_class, $container)
       ->findAll('css', 'td');
-    $this->assertCount(3, $cells);
-    $assert_session->elementExists('named', ['link', 'Drupal'], $cells[0]);
-    $this->assertSame('9.8.0', $cells[1]->getText());
-    $this->assertSame('9.8.1 (Release notes)', $cells[2]->getText());
-    $release_notes = $assert_session->elementExists('named', ['link', 'Release notes'], $cells[2]);
-    $this->assertSame('Release notes for Drupal', $release_notes->getAttribute('title'));
-    $assert_session->buttonExists('Update');
-    $this->assertUpdateStagedTimes(0);
+    $this->assertCount(2, $cells);
+    $this->assertSame("$version (Release notes)", $cells[1]->getText());
+    $release_notes = $assert_session->elementExists('named', ['link', 'Release notes'], $cells[1]);
+    $this->assertSame("Release notes for Drupal core $version", $release_notes->getAttribute('title'));
+    $button = $assert_session->buttonExists("Update to $version", $container);
+    $this->assertSame($is_primary, $button->hasClass('button--primary'));
   }
 
   /**
@@ -153,38 +249,33 @@ public function testUpdateErrors(): void {
     $assert_session = $this->assertSession();
     $page = $session->getPage();
 
-    // Store a fake readiness error, which will be cached.
-    $message = t("You've not experienced Shakespeare until you have read him in the original Klingon.");
-    $error = ValidationResult::createError([$message]);
-    TestSubscriber1::setTestResult([$error], ReadinessCheckEvent::class);
-
-    $this->drupalGet('/admin/reports/status');
-    $page->clickLink('Run readiness checks');
-    $assert_session->pageTextContainsOnce((string) $message);
+    $cached_message = $this->setAndAssertCachedMessage();
     // Ensure that the fake error is cached.
     $session->reload();
-    $assert_session->pageTextContainsOnce((string) $message);
+    $assert_session->pageTextContainsOnce($cached_message);
 
     $this->setCoreVersion('9.8.0');
     $this->checkForUpdates();
 
-    // Set up a new fake error.
-    $this->createTestValidationResults();
-    $expected_results = $this->testResults['checker_1']['1 error'];
+    // Set up a new fake error. Use an error with multiple messages so we can
+    // ensure that they're all displayed, along with their summary.
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR, 2)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
 
     // If a validator raises an error during readiness checking, the form should
     // not have a submit button.
     $this->drupalGet('/admin/modules/automatic-update');
-    $assert_session->buttonNotExists('Update');
+    $this->assertNoUpdateButtons();
     // Since this is an administrative page, the error message should be visible
     // thanks to auto_updates_page_top(). The readiness checks were re-run
     // during the form build, which means the new error should be cached and
     // displayed instead of the previously cached error.
     $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]);
+    $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[1]);
+    $assert_session->pageTextContainsOnce((string) $expected_results[0]->getSummary());
     $assert_session->pageTextContainsOnce(static::$errorsExplanation);
     $assert_session->pageTextNotContains(static::$warningsExplanation);
-    $assert_session->pageTextNotContains((string) $message);
+    $assert_session->pageTextNotContains($cached_message);
     TestSubscriber1::setTestResult(NULL, ReadinessCheckEvent::class);
 
     // Make the validator throw an exception during pre-create.
@@ -193,7 +284,8 @@ public function testUpdateErrors(): void {
     $session->reload();
     $assert_session->pageTextNotContains(static::$errorsExplanation);
     $assert_session->pageTextNotContains(static::$warningsExplanation);
-    $page->pressButton('Update');
+    $assert_session->pageTextNotContains($cached_message);
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(0);
     $assert_session->pageTextContainsOnce('An error has occurred.');
@@ -204,6 +296,7 @@ public function testUpdateErrors(): void {
     $assert_session->pageTextContainsOnce($error->getMessage());
     $assert_session->pageTextNotContains((string) $expected_results[0]->getMessages()[0]);
     $assert_session->pageTextNotContains($expected_results[0]->getSummary());
+    $assert_session->pageTextNotContains($cached_message);
     // Since the error occurred during pre-create, there should be no existing
     // update to delete.
     $assert_session->buttonNotExists('Delete existing update');
@@ -211,14 +304,52 @@ public function testUpdateErrors(): void {
     // If a validator flags an error, but doesn't throw, the update should still
     // be halted.
     TestSubscriber1::setTestResult($expected_results, PreCreateEvent::class);
-    $page->pressButton('Update');
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(0);
     $assert_session->pageTextContainsOnce('An error has occurred.');
     $page->clickLink('the error page');
-    // Since there's only one message, we shouldn't see the summary.
-    $assert_session->pageTextNotContains($expected_results[0]->getSummary());
+    $assert_session->pageTextContainsOnce($expected_results[0]->getSummary());
     $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[0]);
+    $assert_session->pageTextContainsOnce((string) $expected_results[0]->getMessages()[1]);
+    $assert_session->pageTextNotContains($cached_message);
+  }
+
+  /**
+   * Tests that an exception is thrown if a previous apply failed.
+   */
+  public function testMarkerFileFailure(): void {
+    $session = $this->getSession();
+    $assert_session = $this->assertSession();
+    $page = $session->getPage();
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+
+    $this->drupalGet('/admin/modules/automatic-update');
+    $assert_session->pageTextNotContains(static::$errorsExplanation);
+    $assert_session->pageTextNotContains(static::$warningsExplanation);
+    $page->pressButton('Update to 9.8.1');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateStagedTimes(1);
+
+    Committer::setException(new \Exception('failed at commiter'));
+    $page->pressButton('Continue');
+    $this->checkForMetaRefresh();
+    $assert_session->pageTextContainsOnce('An error has occurred.');
+    $assert_session->pageTextContains('The update operation failed to apply. The update may have been partially applied. It is recommended that the site be restored from a code backup.');
+    $page->clickLink('the error page');
+
+    $failure_message = 'Automatic updates failed to apply, and the site is in an indeterminate state. Consider restoring the code and database from a backup.';
+    // We should be on the form (i.e., 200 response code), but unable to
+    // continue the update.
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains($failure_message);
+    $assert_session->buttonNotExists('Continue');
+    // The same thing should be true if we try to start from the beginning.
+    $this->drupalGet('/admin/modules/automatic-update');
+    $assert_session->statusCodeEquals(200);
+    $assert_session->pageTextContains($failure_message);
+    $assert_session->buttonNotExists('Update');
   }
 
   /**
@@ -236,8 +367,10 @@ public function testMinorVersionUpdateNotSupported(string $update_form_url): voi
     $this->drupalGet($update_form_url);
 
     $assert_session = $this->assertSession();
-    $assert_session->pageTextContainsOnce('Drupal cannot be automatically updated from its current version, 9.7.1, to the recommended version, 9.8.1, because automatic updates from one minor version to another are not supported.');
-    $assert_session->buttonNotExists('Update');
+    $assert_session->pageTextContains('Updates were found, but they must be performed manually. See the list of available updates for more information.');
+    $this->clickLink('the list of available updates');
+    $assert_session->elementExists('css', 'table.update');
+    $this->assertNoUpdateButtons();
   }
 
   /**
@@ -253,8 +386,8 @@ public function testDeleteExistingUpdate(): void {
     $this->checkForUpdates();
 
     $this->drupalGet('/admin/modules/automatic-update');
-    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
-    $page->pressButton('Update');
+    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
 
@@ -273,7 +406,7 @@ public function testDeleteExistingUpdate(): void {
     $assert_session->pageTextContains($cancelled_message);
     $assert_session->pageTextNotContains($conflict_message);
     // Ensure we can start another update after deleting the existing one.
-    $page->pressButton('Update');
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
 
     // Confirm we are on the confirmation page.
@@ -287,12 +420,12 @@ public function testDeleteExistingUpdate(): void {
     $this->drupalLogin($account);
     $this->drupalGet('/admin/reports/updates/automatic-update');
     $assert_session->pageTextContains($conflict_message);
-    $assert_session->buttonNotExists('Update');
+    $this->assertNoUpdateButtons();
     // We should be able to delete the previous update, then start a new one.
     $page->pressButton('Delete existing update');
     $assert_session->pageTextContains('Staged update deleted');
     $assert_session->pageTextNotContains($conflict_message);
-    $page->pressButton('Update');
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateReady('9.8.1');
 
@@ -312,7 +445,11 @@ public function testDeleteExistingUpdate(): void {
 
     // We should get the same error if we log in as another user and try to
     // delete the staged update.
-    $this->drupalLogin($this->rootUser);
+    $user = $this->createUser([
+      'administer software updates',
+      'access site in maintenance mode',
+    ]);
+    $this->drupalLogin($user);
     $this->drupalGet('/admin/reports/updates/automatic-update');
     $assert_session->pageTextContains($conflict_message);
     $page->pressButton('Delete existing update');
@@ -331,10 +468,9 @@ public function testDeleteExistingUpdate(): void {
 
     // If a legitimate error is raised during pre-apply, we should be able to
     // delete the staged update right away.
-    $this->createTestValidationResults();
-    $results = $this->testResults['checker_1']['1 error'];
+    $results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber1::setTestResult($results, PreApplyEvent::class);
-    $page->pressButton('Update');
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateReady('9.8.1');
     $page->pressButton('Continue');
@@ -350,7 +486,7 @@ public function testDeleteExistingUpdate(): void {
    * @return bool[][]
    *   The test cases.
    */
-  public function providerStagedDatabaseUpdates() {
+  public function providerStagedDatabaseUpdates(): array {
     return [
       'maintenance mode on' => [TRUE],
       'maintenance mode off' => [FALSE],
@@ -369,24 +505,28 @@ public function providerStagedDatabaseUpdates() {
   public function testStagedDatabaseUpdates(bool $maintenance_mode_on): void {
     $this->setCoreVersion('9.8.0');
     $this->checkForUpdates();
+    $this->container->get('theme_installer')->install(['auto_updates_theme_with_updates']);
+    $cached_message = $this->setAndAssertCachedMessage();
 
     $state = $this->container->get('state');
     $state->set('system.maintenance_mode', $maintenance_mode_on);
 
     // Flag a warning, which will not block the update but should be displayed
     // on the updater form.
-    $this->createTestValidationResults();
-    $expected_results = $this->testResults['checker_1']['1 warning'];
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
     $messages = reset($expected_results)->getMessages();
 
+    StagedDatabaseUpdateValidator::setExtensionsWithUpdates(['system', 'auto_updates_theme_with_updates']);
+
     $page = $this->getSession()->getPage();
     $this->drupalGet('/admin/modules/automatic-update');
-    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
+    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
     // The warning should be visible.
     $assert_session = $this->assertSession();
     $assert_session->pageTextContains(reset($messages));
-    $page->pressButton('Update');
+    $assert_session->pageTextNotContains($cached_message);
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
     $this->assertUpdateReady('9.8.1');
@@ -399,10 +539,24 @@ public function testStagedDatabaseUpdates(bool $maintenance_mode_on): void {
     // changes have been applied, we should be redirected to update.php, where
     // neither warning should be visible.
     $assert_session->pageTextNotContains(reset($messages));
-    $possible_update_message = 'Possible database updates were detected in the following modules; you may be redirected to the database update page in order to complete the update process.';
-    $assert_session->pageTextContains($possible_update_message);
-    $assert_session->pageTextContains('System');
-    $assert_session->checkboxChecked('maintenance_mode');
+
+    // Ensure that a list of pending database updates is visible, along with a
+    // short explanation, in the warning messages.
+    $possible_update_message = 'Possible database updates were detected in the following extensions; you may be redirected to the database update page in order to complete the update process.';
+    $warning_messages = $assert_session->elementExists('xpath', '//div[@data-drupal-messages]//div[@aria-label="Warning message"]');
+    $this->assertStringContainsString($possible_update_message, $warning_messages->getText());
+    $pending_updates = $warning_messages->findAll('css', 'ul.item-list__automatic-updates__pending-database-updates li');
+    $this->assertCount(2, $pending_updates);
+    $this->assertSame('Automatic Updates Theme With Updates', $pending_updates[0]->getText());
+    $this->assertSame('System', $pending_updates[1]->getText());
+
+    if ($maintenance_mode_on === TRUE) {
+      $assert_session->fieldNotExists('maintenance_mode');
+    }
+    else {
+      $assert_session->checkboxChecked('maintenance_mode');
+    }
+    $assert_session->pageTextNotContains($cached_message);
     $page->pressButton('Continue');
     $this->checkForMetaRefresh();
     // Confirm that the site was in maintenance before the update was applied.
@@ -412,6 +566,7 @@ public function testStagedDatabaseUpdates(bool $maintenance_mode_on): void {
     // update.php.
     $this->assertTrue($state->get('system.maintenance_mode'));
     $assert_session->addressEquals('/update.php');
+    $assert_session->pageTextNotContains($cached_message);
     $assert_session->pageTextNotContains(reset($messages));
     $assert_session->pageTextNotContains($possible_update_message);
     $assert_session->pageTextContainsOnce('Please apply database updates to complete the update process.');
@@ -424,13 +579,14 @@ public function testStagedDatabaseUpdates(bool $maintenance_mode_on): void {
     $assert_session->pageTextContains('Updates were attempted.');
     // Confirm the site was returned to the original maintenance module state.
     $this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on);
+    $assert_session->pageTextNotContains($cached_message);
   }
 
   /**
    * Data provider for testSuccessfulUpdate().
    *
    * @return string[][]
-   *   Test case parameters.
+   *   The test cases.
    */
   public function providerSuccessfulUpdate(): array {
     return [
@@ -464,30 +620,83 @@ public function providerSuccessfulUpdate(): array {
    * @dataProvider providerSuccessfulUpdate
    */
   public function testSuccessfulUpdate(string $update_form_url, bool $maintenance_mode_on): void {
+    $assert_session = $this->assertSession();
     $this->setCoreVersion('9.8.0');
     $this->checkForUpdates();
     $state = $this->container->get('state');
     $state->set('system.maintenance_mode', $maintenance_mode_on);
-
     $page = $this->getSession()->getPage();
+    $cached_message = $this->setAndAssertCachedMessage();
+
     $this->drupalGet($update_form_url);
-    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
-    $page->pressButton('Update');
+    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $assert_session->pageTextNotContains($cached_message);
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
     $this->assertUpdateReady('9.8.1');
     // Confirm that the site was put into maintenance mode if needed.
-    $this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on);
+    $this->assertMaintenanceMode($maintenance_mode_on);
     $page->pressButton('Continue');
     $this->checkForMetaRefresh();
-    $assert_session = $this->assertSession();
     $assert_session->addressEquals('/admin/reports/updates');
+    $assert_session->pageTextNotContains($cached_message);
     // Confirm that the site was in maintenance before the update was applied.
     // @see \Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber::handleEvent()
     $this->assertTrue($state->get(PreApplyEvent::class . '.system.maintenance_mode'));
     $assert_session->pageTextContainsOnce('Update complete!');
     // Confirm the site was returned to the original maintenance mode state.
-    $this->assertSame($state->get('system.maintenance_mode'), $maintenance_mode_on);
+    $this->assertMaintenanceMode($maintenance_mode_on);
+    // Confirm that the apply and post-apply operations happened in
+    // separate requests.
+    // @see \Drupal\auto_updates_test\EventSubscriber\RequestTimeRecorder
+    $pre_apply_time = $state->get('Drupal\package_manager\Event\PreApplyEvent time');
+    $post_apply_time = $state->get('Drupal\package_manager\Event\PostApplyEvent time');
+    $this->assertNotEmpty($pre_apply_time);
+    $this->assertNotEmpty($post_apply_time);
+    $this->assertNotSame($pre_apply_time, $post_apply_time);
+  }
+
+  /**
+   * Data provider for testUpdateCompleteMessage().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerUpdateCompleteMessage(): array {
+    return [
+      'maintenance mode off' => [FALSE],
+      'maintenance mode on' => [TRUE],
+    ];
+  }
+
+  /**
+   * Tests the update complete message is displayed when another message exist.
+   *
+   * @param bool $maintenance_mode_on
+   *   Whether maintenance should be on at the beginning of the update.
+   *
+   * @dataProvider providerUpdateCompleteMessage
+   */
+  public function testUpdateCompleteMessage(bool $maintenance_mode_on): void {
+    $assert_session = $this->assertSession();
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $state = $this->container->get('state');
+    $state->set('system.maintenance_mode', $maintenance_mode_on);
+    $page = $this->getSession()->getPage();
+
+    $this->drupalGet('/admin/modules/automatic-update');
+    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $page->pressButton('Update to 9.8.1');
+    $this->checkForMetaRefresh();
+    // Confirm that the site was put into maintenance mode if needed.
+    $custom_message = 'custom status message.';
+    TestSubscriber1::setMessage($custom_message, PostApplyEvent::class);
+    $page->pressButton('Continue');
+    $this->checkForMetaRefresh();
+    $assert_session->pageTextContainsOnce($custom_message);
+    $assert_session->pageTextContainsOnce('Update complete!');
   }
 
   /**
@@ -499,17 +708,19 @@ public function testStagedUpdateDeletedImproperly(): void {
 
     $page = $this->getSession()->getPage();
     $this->drupalGet('/admin/modules/automatic-update');
-    FixtureStager::setFixturePath(__DIR__ . '/../../fixtures/staged/9.8.1');
-    $page->pressButton('Update');
+    Stager::setFixturePath(__DIR__ . '/../../fixtures/drupal-9.8.1-installed');
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
     $this->assertUpdateReady('9.8.1');
     // Confirm if the staged directory is deleted without using destroy(), then
     // an error message will be displayed on the page.
     // @see \Drupal\package_manager\Stage::getStagingRoot()
-    $dir = FileSystem::getOsTemporaryDirectory() . '/.package_manager' . $this->config('system.site')->get('uuid');
+    /** @var \Drupal\Core\File\FileSystemInterface $file_system */
+    $file_system = $this->container->get('file_system');
+    $dir = $file_system->getTempDirectory() . '/.package_manager' . $this->config('system.site')->get('uuid');
     $this->assertDirectoryExists($dir);
-    $this->container->get('file_system')->deleteRecursive($dir);
+    $file_system->deleteRecursive($dir);
     $this->getSession()->reload();
     $assert_session = $this->assertSession();
     $error_message = 'There was an error loading the pending update. Press the Cancel update button to start over.';
@@ -538,7 +749,7 @@ public function testStageDestroyedOnError(): void {
     TestSubscriber1::setException($error, PostRequireEvent::class);
     $assert_session->pageTextNotContains(static::$errorsExplanation);
     $assert_session->pageTextNotContains(static::$warningsExplanation);
-    $page->pressButton('Update');
+    $page->pressButton('Update to 9.8.1');
     $this->checkForMetaRefresh();
     $this->assertUpdateStagedTimes(1);
     $assert_session->pageTextContainsOnce('An error has occurred.');
@@ -550,4 +761,111 @@ public function testStageDestroyedOnError(): void {
     $assert_session->buttonExists('Update');
   }
 
+  /**
+   * Tests that update cannot be completed via the UI if a status check fails.
+   */
+  public function testNoContinueOnError(): void {
+    $session = $this->getSession();
+    $assert_session = $this->assertSession();
+    $page = $session->getPage();
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/modules/automatic-update');
+    $page->pressButton('Update to 9.8.1');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateStagedTimes(1);
+    $error = ValidationResult::createError(['Error occured.']);
+    TestSubscriber::setTestResult([$error], StatusCheckEvent::class);
+    $this->getSession()->reload();
+    $assert_session->buttonNotExists('Continue');
+    $assert_session->buttonExists('Cancel update');
+  }
+
+  /**
+   * Tests that update can be completed even if a status check throws a warning.
+   */
+  public function testContinueOnWarning(): void {
+    $session = $this->getSession();
+
+    $this->setCoreVersion('9.8.0');
+    $this->checkForUpdates();
+    $this->drupalGet('/admin/modules/automatic-update');
+    $session->getPage()->pressButton('Update to 9.8.1');
+    $this->checkForMetaRefresh();
+    $this->assertUpdateStagedTimes(1);
+
+    $warning = ValidationResult::createWarning(['Some warning.']);
+    TestSubscriber::setTestResult([$warning], StatusCheckEvent::class);
+    $session->reload();
+
+    $assert_session = $this->assertSession();
+    $assert_session->buttonExists('Continue');
+    $assert_session->pageTextContains('Some warning.');
+  }
+
+  /**
+   * Sets an error message, runs readiness checks, and asserts it is displayed.
+   *
+   * @return string
+   *   The cached error check message.
+   */
+  private function setAndAssertCachedMessage(): string {
+    // Store a readiness error, which will be cached.
+    $message = "You've not experienced Shakespeare until you have read him in the original Klingon.";
+    $result = ValidationResult::createError([$message]);
+    TestSubscriber1::setTestResult([$result], ReadinessCheckEvent::class);
+    // Run the readiness checks a visit an admin page the message will be
+    // displayed.
+    $this->drupalGet('/admin/reports/status');
+    $this->clickLink('Run readiness checks');
+    $this->drupalGet('/admin');
+    $this->assertSession()->pageTextContains($message);
+    // Clear the results so the only way the message could appear on the pages
+    // used for the update process is if they show the cached results.
+    TestSubscriber1::setTestResult(NULL, ReadinessCheckEvent::class);
+
+    return $message;
+  }
+
+  /**
+   * Asserts maintenance is the expected value and correct message appears.
+   *
+   * @param bool $expected_maintenance_mode
+   *   Whether maintenance mode is expected to be on or off.
+   */
+  private function assertMaintenanceMode(bool $expected_maintenance_mode): void {
+    $this->assertSame($this->container->get('state')
+      ->get('system.maintenance_mode'), $expected_maintenance_mode);
+    if ($expected_maintenance_mode) {
+      $this->assertSession()
+        ->pageTextContains('Operating in maintenance mode.');
+    }
+    else {
+      $this->assertSession()
+        ->pageTextNotContains('Operating in maintenance mode.');
+    }
+  }
+
+  /**
+   * Asserts that no update buttons exist.
+   */
+  private function assertNoUpdateButtons(): void {
+    $this->assertSession()->elementNotExists('css', "input[value*='Update']");
+  }
+
+  /**
+   * Asserts that the release notes link for a given minor version is correct.
+   *
+   * @param int $major
+   *   Major version of next minor release.
+   * @param int $minor
+   *   Minor version of next minor release.
+   */
+  private function assertReleaseNotesLink(int $major, int $minor): void {
+    $assert_session = $this->assertSession();
+    $row = $assert_session->elementExists('css', '#edit-next-minor');
+    $link_href = $assert_session->elementExists('named', ['link', 'Release notes'], $row)->getAttribute('href');
+    $this->assertSame('http://example.com/drupal-' . $major . '-' . $minor . '-0-release', $link_href);
+  }
+
 }
diff --git a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php
index f6c0efbaaf8c..0fc5390c7937 100644
--- a/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php
+++ b/core/modules/auto_updates/tests/src/Kernel/AutoUpdatesKernelTestBase.php
@@ -5,15 +5,10 @@
 use Drupal\auto_updates\CronUpdater;
 use Drupal\auto_updates\Updater;
 use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\Core\Url;
 use Drupal\Tests\auto_updates\Traits\ValidationTestTrait;
 use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
-use Drupal\Tests\package_manager\Kernel\TestStage;
-use GuzzleHttp\Client;
-use GuzzleHttp\Handler\MockHandler;
-use GuzzleHttp\HandlerStack;
-use GuzzleHttp\Psr7\Response;
-use GuzzleHttp\Psr7\Utils;
+use Drupal\Tests\package_manager\Kernel\TestStageTrait;
 
 /**
  * Base class for kernel tests of the Automatic Updates module.
@@ -25,19 +20,9 @@ abstract class AutoUpdatesKernelTestBase extends PackageManagerKernelTestBase {
   /**
    * {@inheritdoc}
    */
-  protected static $modules = ['system', 'update', 'update_test'];
-
-  /**
-   * The mocked HTTP client that returns metadata about available updates.
-   *
-   * We need to preserve this as a class property so that we can re-inject it
-   * into the container when a rebuild is triggered by module installation.
-   *
-   * @var \GuzzleHttp\Client
-   *
-   * @see ::register()
-   */
-  private $client;
+  protected static $modules = [
+    'auto_updates_test_cron',
+  ];
 
   /**
    * {@inheritdoc}
@@ -48,37 +33,27 @@ protected function setUp(): void {
     if (in_array('package_manager.validator.file_system', $this->disableValidators, TRUE)) {
       $this->disableValidators[] = 'auto_updates.validator.file_system_permissions';
     }
+    // If Package Manager's symlink validator is disabled, also disable the
+    // Automatic Updates validator which wraps it.
+    if (in_array('package_manager.validator.symlink', $this->disableValidators, TRUE)) {
+      $this->disableValidators[] = 'auto_updates.validator.symlink';
+    }
+    // Always disable the Xdebug validator to allow test to run with Xdebug on.
+    $this->disableValidators[] = 'auto_updates.validator.xdebug';
     parent::setUp();
 
-    // The Update module's default configuration must be installed for our
-    // fake release metadata to be fetched.
-    $this->installConfig('update');
-
-    // Make the update system think that all of System's post-update functions
-    // have run.
-    $this->registerPostUpdateFunctions();
-
     // By default, pretend we're running Drupal core 9.8.0 and a non-security
     // update to 9.8.1 is available.
-    $this->setCoreVersion('9.8.1');
-    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml');
+    $this->setCoreVersion('9.8.0');
+    $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml']);
 
     // Set a last cron run time so that the cron frequency validator will run
     // from a sane state.
     // @see \Drupal\auto_updates\Validator\CronFrequencyValidator
     $this->container->get('state')->set('system.cron_last', time());
-  }
 
-  /**
-   * Sets the current (running) version of core, as known to the Update module.
-   *
-   * @param string $version
-   *   The current version of core.
-   */
-  protected function setCoreVersion(string $version): void {
-    $this->config('update_test.settings')
-      ->set('system_info.#all.version', $version)
-      ->save();
+    // @todo Remove this when TUF integration is stable.
+    $this->container->get('auto_updates_test_cron.enabler')->enableCron();
   }
 
   /**
@@ -87,12 +62,6 @@ protected function setCoreVersion(string $version): void {
   public function register(ContainerBuilder $container) {
     parent::register($container);
 
-    // If we previously set up a mock HTTP client in ::setReleaseMetadata(),
-    // re-inject it into the container.
-    if ($this->client) {
-      $container->set('http_client', $this->client);
-    }
-
     // Use the test-only implementations of the regular and cron updaters.
     $overrides = [
       'auto_updates.updater' => TestUpdater::class,
@@ -105,22 +74,6 @@ public function register(ContainerBuilder $container) {
     }
   }
 
-  /**
-   * Sets the release metadata file to use when fetching available updates.
-   *
-   * @param string $file
-   *   The path of the XML metadata file to use.
-   */
-  protected function setReleaseMetadata(string $file): void {
-    $metadata = Utils::tryFopen($file, 'r');
-    $response = new Response(200, [], Utils::streamFor($metadata));
-    $handler = new MockHandler([$response]);
-    $this->client = new Client([
-      'handler' => HandlerStack::create($handler),
-    ]);
-    $this->container->set('http_client', $this->client);
-  }
-
 }
 
 /**
@@ -128,11 +81,13 @@ protected function setReleaseMetadata(string $file): void {
  */
 class TestUpdater extends Updater {
 
+  use TestStageTrait;
+
   /**
    * {@inheritdoc}
    */
-  public function getStagingRoot(): string {
-    return TestStage::$stagingRoot ?: parent::getStagingRoot();
+  public function setMetadata(string $key, $data): void {
+    parent::setMetadata($key, $data);
   }
 
 }
@@ -142,18 +97,16 @@ public function getStagingRoot(): string {
  */
 class TestCronUpdater extends CronUpdater {
 
-  /**
-   * {@inheritdoc}
-   */
-  public function getStagingRoot(): string {
-    return TestStage::$stagingRoot ?: parent::getStagingRoot();
-  }
+  use TestStageTrait;
 
   /**
    * {@inheritdoc}
    */
-  public static function formatValidationException(StageValidationException $exception): string {
-    return parent::formatValidationException($exception);
+  protected function triggerPostApply(Url $url): void {
+    // Subrequests don't work in kernel tests, so just call the post-apply
+    // handler directly.
+    $parameters = $url->getRouteParameters();
+    $this->handlePostApply($parameters['stage_id'], $parameters['installed_version'], $parameters['target_version']);
   }
 
 }
diff --git a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
index 97a3a2939fd4..633e036070ab 100644
--- a/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/CronUpdaterTest.php
@@ -7,6 +7,7 @@
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\Form\FormState;
 use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\Core\Test\AssertMailTrait;
 use Drupal\package_manager\Event\PostApplyEvent;
 use Drupal\package_manager\Event\PostCreateEvent;
 use Drupal\package_manager\Event\PostDestroyEvent;
@@ -18,6 +19,7 @@
 use Drupal\package_manager\Exception\StageValidationException;
 use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
+use Drupal\Tests\user\Traits\UserCreationTrait;
 use Drupal\update\UpdateSettingsForm;
 use Psr\Log\Test\TestLogger;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
@@ -30,7 +32,9 @@
  */
 class CronUpdaterTest extends AutoUpdatesKernelTestBase {
 
+  use AssertMailTrait;
   use PackageManagerBypassTestTrait;
+  use UserCreationTrait;
 
   /**
    * {@inheritdoc}
@@ -38,6 +42,7 @@ class CronUpdaterTest extends AutoUpdatesKernelTestBase {
   protected static $modules = [
     'auto_updates',
     'auto_updates_test',
+    'user',
   ];
 
   /**
@@ -47,21 +52,48 @@ class CronUpdaterTest extends AutoUpdatesKernelTestBase {
    */
   private $logger;
 
+  /**
+   * The people who should be emailed about successful or failed updates.
+   *
+   * The keys are the email addresses, and the values are the langcode they
+   * should be emailed in.
+   *
+   * @var string[]
+   */
+  private $emailRecipients = [];
+
   /**
    * {@inheritdoc}
    */
   protected function setUp(): void {
-    // Because package_manager_bypass is enabled, a staging directory will not
-    // actually exist. Therefore, we need to disable these validators because
-    // they attempt to compare the active and stage directories.
-    $this->disableValidators[] = 'auto_updates.validator.staged_database_updates';
-    $this->disableValidators[] = 'auto_updates.staged_projects_validator';
     parent::setUp();
 
     $this->logger = new TestLogger();
     $this->container->get('logger.factory')
       ->get('auto_updates')
       ->addLogger($this->logger);
+    $this->installEntitySchema('user');
+    $this->installSchema('user', ['users_data']);
+
+    // Prepare the recipient list to email when an update succeeds or fails.
+    // First, create a user whose preferred language is different from the
+    // default language, so we can be sure they're emailed in their preferred
+    // language; we also ensure that an email which doesn't correspond to a user
+    // account is emailed in the default language.
+    $default_language = $this->container->get('language_manager')
+      ->getDefaultLanguage()
+      ->getId();
+    $this->assertNotSame('fr', $default_language);
+
+    $account = $this->createUser([], NULL, FALSE, [
+      'preferred_langcode' => 'fr',
+    ]);
+    $this->emailRecipients['emissary@deep.space'] = $default_language;
+    $this->emailRecipients[$account->getEmail()] = $account->getPreferredLangcode();
+
+    $this->config('update.settings')
+      ->set('notification.emails', array_keys($this->emailRecipients))
+      ->save();
   }
 
   /**
@@ -78,43 +110,43 @@ public function register(ContainerBuilder $container) {
   }
 
   /**
-   * Data provider for ::testUpdaterCalled().
+   * Data provider for testUpdaterCalled().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerUpdaterCalled(): array {
-    $fixture_dir = __DIR__ . '/../../fixtures/release-history';
+    $fixture_dir = __DIR__ . '/../../../package_manager/tests/fixtures/release-history';
 
     return [
       'disabled, normal release' => [
         CronUpdater::DISABLED,
-        "$fixture_dir/drupal.9.8.2.xml",
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
         FALSE,
       ],
       'disabled, security release' => [
         CronUpdater::DISABLED,
-        "$fixture_dir/drupal.9.8.1-security.xml",
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
         FALSE,
       ],
       'security only, security release' => [
         CronUpdater::SECURITY,
-        "$fixture_dir/drupal.9.8.1-security.xml",
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
         TRUE,
       ],
       'security only, normal release' => [
         CronUpdater::SECURITY,
-        "$fixture_dir/drupal.9.8.2.xml",
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
         FALSE,
       ],
       'enabled, normal release' => [
         CronUpdater::ALL,
-        "$fixture_dir/drupal.9.8.2.xml",
-        FALSE,
+        ['drupal' => "$fixture_dir/drupal.9.8.2.xml"],
+        TRUE,
       ],
       'enabled, security release' => [
         CronUpdater::ALL,
-        "$fixture_dir/drupal.9.8.1-security.xml",
+        ['drupal' => "$fixture_dir/drupal.9.8.1-security.xml"],
         TRUE,
       ],
     ];
@@ -126,15 +158,16 @@ public function providerUpdaterCalled(): array {
    * @param string $setting
    *   Whether automatic updates should be enabled during cron. Possible values
    *   are 'disable', 'security', and 'patch'.
-   * @param string $release_data
+   * @param array $release_data
    *   If automatic updates are enabled, the path of the fake release metadata
-   *   that should be served when fetching information on available updates.
+   *   that should be served when fetching information on available updates,
+   *   keyed by project name.
    * @param bool $will_update
    *   Whether an update should be performed, given the previous two arguments.
    *
    * @dataProvider providerUpdaterCalled
    */
-  public function testUpdaterCalled(string $setting, string $release_data, bool $will_update): void {
+  public function testUpdaterCalled(string $setting, array $release_data, bool $will_update): void {
     // Our form alter does not refresh information on available updates, so
     // ensure that the appropriate update data is loaded beforehand.
     $this->setReleaseMetadata($release_data);
@@ -164,18 +197,20 @@ public function testUpdaterCalled(string $setting, string $release_data, bool $w
 
     $will_update = (int) $will_update;
     $this->assertCount($will_update, $this->container->get('package_manager.beginner')->getInvocationArguments());
-    // If updates happen, then there will be two calls to the stager: one to
-    // change the constraints in composer.json, and another to actually update
-    // the installed dependencies.
-    $this->assertCount($will_update * 2, $this->container->get('package_manager.stager')->getInvocationArguments());
+    // If updates happen, there will be at least two calls to the stager: one
+    // to change the runtime constraints in composer.json, and another to
+    // actually update the installed dependencies. If there are any core
+    // dev requirements (such as `drupal/core-dev`), the stager will also be
+    // called to update the dev constraints in composer.json.
+    $this->assertGreaterThanOrEqual($will_update * 2, $this->container->get('package_manager.stager')->getInvocationArguments());
     $this->assertCount($will_update, $this->container->get('package_manager.committer')->getInvocationArguments());
   }
 
   /**
-   * Data provider for ::testStageDestroyedOnError().
+   * Data provider for testStageDestroyedOnError().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return string[][]
+   *   The test cases.
    */
   public function providerStageDestroyedOnError(): array {
     return [
@@ -233,46 +268,6 @@ public function providerStageDestroyedOnError(): array {
     ];
   }
 
-  /**
-   * Data provider for testErrors().
-   *
-   * @return array[]
-   *   The test cases for testErrors().
-   */
-  public function providerErrors(): array {
-    $messages = [
-      'PreCreate Event Error',
-      'PreCreate Event Error 2',
-    ];
-    $summary = 'There were errors in updates';
-    $result_no_summary = ValidationResult::createError([$messages[0]]);
-    $result_with_summary = ValidationResult::createError($messages, t($summary));
-    $result_with_summary_message = "<h3>{$summary}</h3><ul><li>{$messages[0]}</li><li>{$messages[1]}</li></ul>";
-
-    return [
-      '1 result with summary' => [
-        [$result_with_summary],
-        $result_with_summary_message,
-      ],
-      '2 results with summary' => [
-        [$result_with_summary, $result_with_summary],
-        "$result_with_summary_message$result_with_summary_message",
-      ],
-      '1 result without summary' => [
-        [$result_no_summary],
-        $messages[0],
-      ],
-      '2 results without summary' => [
-        [$result_no_summary, $result_no_summary],
-        $messages[0] . ' ' . $messages[0],
-      ],
-      '1 result with summary, 1 result without summary' => [
-        [$result_with_summary, $result_no_summary],
-        $result_with_summary_message . ' ' . $messages[0],
-      ],
-    ];
-  }
-
   /**
    * Tests that the stage is destroyed if an error occurs during a cron update.
    *
@@ -287,7 +282,18 @@ public function testStageDestroyedOnError(string $event_class, string $exception
     $this->installConfig('auto_updates');
     $this->setCoreVersion('9.8.0');
     // Ensure that there is a security release to which we should update.
-    $this->setReleaseMetadata(__DIR__ . "/../../fixtures/release-history/drupal.9.8.1-security.xml");
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . "/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml",
+    ]);
+
+    // Disable the symlink validators so that this test isn't affected by
+    // symlinks that might be present in the running code base.
+    $validators = [
+      'auto_updates.validator.symlink',
+      'package_manager.validator.symlink',
+    ];
+    $validators = array_map([$this->container, 'get'], $validators);
+    array_walk($validators, [$this->container->get('event_dispatcher'), 'removeSubscriber']);
 
     // If the pre- or post-destroy events throw an exception, it will not be
     // caught by the cron updater, but it *will* be caught by the main cron
@@ -305,15 +311,15 @@ public function testStageDestroyedOnError(string $event_class, string $exception
         ValidationResult::createError(['Destroy the stage!']),
       ];
       TestSubscriber1::setTestResult($results, $event_class);
-      $exception = new StageValidationException($results, 'Unable to complete the update because of errors.');
-      $expected_log_message = TestCronUpdater::formatValidationException($exception);
+      $exception = new StageValidationException($results);
     }
     else {
       /** @var \Throwable $exception */
       $exception = new $exception_class('Destroy the stage!');
       TestSubscriber1::setException($exception, $event_class);
-      $expected_log_message = $exception->getMessage();
     }
+    $expected_log_message = $exception->getMessage();
+
     // Ensure that nothing has been logged yet.
     $this->assertEmpty($cron_logger->records);
     $this->assertEmpty($this->logger->records);
@@ -368,20 +374,116 @@ public function testStageDestroyedOnError(string $event_class, string $exception
   }
 
   /**
-   * Tests errors during a cron update attempt.
+   * Tests that CronUpdater::begin() unconditionally throws an exception.
+   */
+  public function testBeginThrowsException(): void {
+    $this->expectExceptionMessage(CronUpdater::class . '::begin() cannot be called directly.');
+    $this->container->get('auto_updates.cron_updater')
+      ->begin(['drupal' => '9.8.1']);
+  }
+
+  /**
+   * Tests that email is sent when an unattended update succeeds.
+   */
+  public function testEmailOnSuccess(): void {
+    $this->container->get('cron')->run();
+
+    // Ensure we sent a success message to all recipients.
+    $sent_messages = $this->getMails([
+      'subject' => "Drupal core was successfully updated",
+    ]);
+    $this->assertNotEmpty($sent_messages);
+    $this->assertSame(count($this->emailRecipients), count($sent_messages));
+
+    foreach ($sent_messages as $message) {
+      $email = $message['to'];
+      $this->assertSame($message['langcode'], $this->emailRecipients[$email]);
+      $this->assertCorrectMessageSent($email, $message, $message['langcode'], "Congratulations!\n\nDrupal core was automatically updated from 9.8.0 to 9.8.1.\n");
+    }
+  }
+
+  /**
+   * Data provider for ::testEmailOnFailure().
    *
-   * @param \Drupal\package_manager\ValidationResult[] $validation_results
-   *   The expected validation results which should be logged.
-   * @param string $expected_log_message
-   *   The error message should be logged.
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerEmailOnFailure(): array {
+    return [
+      'pre-create' => [
+        PreCreateEvent::class,
+      ],
+      'pre-require' => [
+        PreRequireEvent::class,
+      ],
+      'pre-apply' => [
+        PreApplyEvent::class,
+      ],
+    ];
+  }
+
+  /**
+   * Tests that email is sent when an unattended update fails.
    *
-   * @dataProvider providerErrors
+   * @param string $event_class
+   *   The event class that should trigger the failure.
+   *
+   * @dataProvider providerEmailOnFailure
    */
-  public function testErrors(array $validation_results, string $expected_log_message): void {
-    TestSubscriber1::setTestResult($validation_results, PreCreateEvent::class);
+  public function testEmailOnFailure(string $event_class): void {
+    $results = [
+      ValidationResult::createError(['Error while updating!']),
+    ];
+    TestSubscriber1::setTestResult($results, $event_class);
+    $exception = new StageValidationException($results);
+
     $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes(0);
-    $this->assertTrue($this->logger->hasRecord("<h2>Unable to complete the update because of errors.</h2>$expected_log_message", RfcLogLevel::ERROR));
+
+    // Ensure we sent a failure message to all recipients.
+    $sent_messages = $this->getMails([
+      'subject' => "Drupal core update failed",
+    ]);
+    $this->assertNotEmpty($sent_messages);
+    $this->assertSame(count($this->emailRecipients), count($sent_messages));
+
+    foreach ($sent_messages as $message) {
+      $email = $message['to'];
+      $this->assertSame($message['langcode'], $this->emailRecipients[$email]);
+      $this->assertCorrectMessageSent($email, $message, $message['langcode'], "Drupal core failed to update automatically from 9.8.0 to 9.8.1. The following\nerror was logged:\n\n" . $exception->getMessage());
+    }
+  }
+
+  /**
+   * Asserts correct message sent to correct recipient.
+   *
+   * @param string $expected_recipient
+   *   The email address that should have received the message.
+   * @param array $sent_message
+   *   The sent message, as processed by hook_mail().
+   * @param string $expected_language_code
+   *   The language code that the recipient should have been emailed in.
+   * @param string $expected_body_text
+   *   The expected message that the email body should contain.
+   */
+  private function assertCorrectMessageSent(string $expected_recipient, array $sent_message, string $expected_language_code, string $expected_body_text): void {
+    // Ensure the messages had the correct body text, and were sent to the right
+    // people.
+    $this->assertSame($sent_message['to'], $expected_recipient);
+    $this->assertStringStartsWith($expected_body_text, $sent_message['body']);
+    // The message, and every line in it, should have been sent in the
+    // expected language.
+    $this->assertSame($expected_language_code, $sent_message['langcode']);
+    // @see auto_updates_test_mail_alter()
+    $this->assertArrayHasKey('line_langcodes', $sent_message);
+    $this->assertSame([$expected_language_code], $sent_message['line_langcodes']);
+  }
+
+  /**
+   * Tests that setLogger is called on the cron updater service.
+   */
+  public function testLoggerIsSetByContainer(): void {
+    $updater_method_calls = $this->container->getDefinition('auto_updates.cron_updater')->getMethodCalls();
+    $this->assertSame('setLogger', $updater_method_calls[0][0]);
   }
 
 }
diff --git a/core/modules/auto_updates/tests/src/Unit/ReadinessTraitTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessTraitTest.php
similarity index 52%
rename from core/modules/auto_updates/tests/src/Unit/ReadinessTraitTest.php
rename to core/modules/auto_updates/tests/src/Kernel/ReadinessTraitTest.php
index d1a89932ebb1..43f0b88f516f 100644
--- a/core/modules/auto_updates/tests/src/Unit/ReadinessTraitTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessTraitTest.php
@@ -1,25 +1,19 @@
 <?php
 
-namespace Drupal\Tests\auto_updates\Unit;
+namespace Drupal\Tests\auto_updates\Kernel;
 
 use Drupal\auto_updates\Validation\ReadinessTrait;
-use Drupal\Core\Messenger\Messenger;
-use Drupal\Core\PageCache\ResponsePolicy\KillSwitch;
-use Drupal\Core\StringTranslation\PluralTranslatableMarkup;
+use Drupal\Core\Messenger\MessengerInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\StringTranslation\TranslatableMarkup;
-use Drupal\Core\StringTranslation\TranslationInterface;
 use Drupal\package_manager\ValidationResult;
 use Drupal\system\SystemManager;
-use Drupal\Tests\UnitTestCase;
-use Symfony\Component\HttpFoundation\Session\Flash\FlashBag;
 
 /**
  * @coversDefaultClass \Drupal\auto_updates\Validation\ReadinessTrait
  *
  * @group auto_updates
  */
-class ReadinessTraitTest extends UnitTestCase {
+class ReadinessTraitTest extends AutoUpdatesKernelTestBase {
 
   use ReadinessTrait;
   use StringTranslationTrait;
@@ -49,47 +43,43 @@ public function testOverallSeverity(): void {
    * @covers ::displayResults
    */
   public function testDisplayResults(): void {
-    $messenger = new Messenger(new FlashBag(), new KillSwitch());
-
-    $translation = new TestTranslationManager();
-    $this->setStringTranslation($translation);
+    $messenger = $this->container->get('messenger');
+    $renderer = $this->container->get('renderer');
 
     // An error and a warning should display the error preamble, and the result
     // messages as errors and warnings, respectively.
     $results = [
       ValidationResult::createError(['Boo!']),
-      ValidationResult::createError(['Wednesday', 'Thursday'], $this->t('The Addams Family')),
+      ValidationResult::createError(['Wednesday', 'Lurch'], $this->t('The Addams Family')),
       ValidationResult::createWarning(['Moo!']),
       ValidationResult::createWarning(['Shaggy', 'The dog'], $this->t('Mystery Mobile')),
     ];
-    $this->displayResults($results, $messenger);
+    $this->displayResults($results, $messenger, $renderer);
 
+    $failure_message = (string) $this->getFailureMessageForSeverity(SystemManager::REQUIREMENT_ERROR);
     $expected_errors = [
-      (string) $this->getFailureMessageForSeverity(SystemManager::REQUIREMENT_ERROR),
-      'Boo!',
-      'The Addams Family',
+      "$failure_message<ul><li>Boo!</li><li>The Addams Family</li><li>Moo!</li><li>Mystery Mobile</li></ul>",
     ];
-    $actual_errors = array_map('strval', $messenger->deleteByType(Messenger::TYPE_ERROR));
+    $actual_errors = array_map('strval', $messenger->deleteByType(MessengerInterface::TYPE_ERROR));
     $this->assertSame($expected_errors, $actual_errors);
 
-    // Even though there were warnings, we shouldn't see the warning preamble.
-    $expected_warnings = ['Moo!', 'Mystery Mobile'];
-    $actual_warnings = array_map('strval', $messenger->deleteByType(Messenger::TYPE_WARNING));
-    $this->assertSame($expected_warnings, $actual_warnings);
+    // Even though there were warnings, they should have been included with the
+    // errors.
+    $actual_warnings = array_map('strval', $messenger->deleteByType(MessengerInterface::TYPE_WARNING));
+    $this->assertEmpty($actual_warnings);
 
     // There shouldn't be any more messages.
     $this->assertEmpty($messenger->all());
 
     // If there are only warnings, we should see the warning preamble.
     $results = array_slice($results, -2);
-    $this->displayResults($results, $messenger);
+    $this->displayResults($results, $messenger, $renderer);
 
+    $failure_message = (string) $this->getFailureMessageForSeverity(SystemManager::REQUIREMENT_WARNING);
     $expected_warnings = [
-      (string) $this->getFailureMessageForSeverity(SystemManager::REQUIREMENT_WARNING),
-      'Moo!',
-      'Mystery Mobile',
+      "$failure_message<ul><li>Moo!</li><li>Mystery Mobile</li></ul>",
     ];
-    $actual_warnings = array_map('strval', $messenger->deleteByType(Messenger::TYPE_WARNING));
+    $actual_warnings = array_map('strval', $messenger->deleteByType(MessengerInterface::TYPE_WARNING));
     $this->assertSame($expected_warnings, $actual_warnings);
 
     // There shouldn't be any more messages.
@@ -97,31 +87,3 @@ public function testDisplayResults(): void {
   }
 
 }
-
-/**
- * Implements a translation manager in tests.
- */
-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/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php
index f6f8d9a420fc..83d7d91423a7 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/CronFrequencyValidatorTest.php
@@ -21,6 +21,20 @@ class CronFrequencyValidatorTest extends AutoUpdatesKernelTestBase {
    */
   protected static $modules = ['auto_updates'];
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+    // In this test, we do not want to do an update. We're just testing that
+    // cron is configured to run frequently enough to do automatic updates. So,
+    // pretend we're already on the latest secure version of core.
+    $this->setCoreVersion('9.8.1');
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml',
+    ]);
+  }
+
   /**
    * Tests that nothing is validated if updates are disabled during cron.
    */
@@ -34,7 +48,8 @@ public function testNoValidationIfCronDisabled(): void {
       $this->container->get('module_handler'),
       $this->container->get('state'),
       $this->container->get('datetime.time'),
-      $this->container->get('string_translation')
+      $this->container->get('string_translation'),
+      $this->container->get('auto_updates.cron_updater')
     ) extends CronFrequencyValidator {
 
       /**
@@ -57,10 +72,10 @@ protected function validateLastCronRun(ReadinessCheckEvent $event): void {
   }
 
   /**
-   * Data provider for ::testLastCronRunValidation().
+   * Data provider for testLastCronRunValidation().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerLastCronRunValidation(): array {
     $error = ValidationResult::createError([
@@ -103,10 +118,10 @@ public function testLastCronRunValidation(int $last_run, array $expected_results
   }
 
   /**
-   * Data provider for ::testAutomatedCronValidation().
+   * Data provider for testAutomatedCronValidation().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerAutomatedCronValidation(): array {
     return [
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
index a915c6b65d4c..d8df2fc380a3 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/PackageManagerReadinessChecksTest.php
@@ -38,10 +38,10 @@ class PackageManagerReadinessChecksTest extends AutoUpdatesKernelTestBase {
   ];
 
   /**
-   * Data provider for ::testValidatorInvoked().
+   * Data provider for testValidatorInvoked().
    *
    * @return string[][]
-   *   Sets of arguments to pass to the test method.
+   *   The test cases.
    */
   public function providerValidatorInvoked(): array {
     return [
@@ -51,6 +51,9 @@ public function providerValidatorInvoked(): array {
       'File system validator' => ['package_manager.validator.file_system'],
       'Composer settings validator' => ['package_manager.validator.composer_settings'],
       'Multisite validator' => ['package_manager.validator.multisite'],
+      'Symlink validator' => ['package_manager.validator.symlink'],
+      'Settings validator' => ['package_manager.validator.settings'],
+      'Patches validator' => ['package_manager.validator.patches'],
     ];
   }
 
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
index 8ff50db1a451..d52ea259191a 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ReadinessValidationManagerTest.php
@@ -33,7 +33,6 @@ protected function setUp(): void {
     $this->setCoreVersion('9.8.2');
     $this->installEntitySchema('user');
     $this->installSchema('user', ['users_data']);
-    $this->createTestValidationResults();
   }
 
   /**
@@ -42,14 +41,11 @@ protected function setUp(): void {
   public function testGetResults(): void {
     $this->enableModules(['auto_updates', 'auto_updates_test2']);
     $this->assertCheckerResultsFromManager([], TRUE);
-
-    $expected_results = [
-      array_pop($this->testResults['checker_1']),
-      array_pop($this->testResults['checker_2']),
-    ];
-    TestSubscriber1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
-    TestSubscriber2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
-    $expected_results_all = array_merge($expected_results[0], $expected_results[1]);
+    $checker_1_expected = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    $checker_2_expected = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    TestSubscriber1::setTestResult($checker_1_expected, ReadinessCheckEvent::class);
+    TestSubscriber2::setTestResult($checker_2_expected, ReadinessCheckEvent::class);
+    $expected_results_all = array_merge($checker_1_expected, $checker_2_expected);
     $this->assertCheckerResultsFromManager($expected_results_all, TRUE);
 
     // Define a constant flag that will cause the readiness checker
@@ -65,25 +61,29 @@ public function testGetResults(): void {
     $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'],
+    $checker_1_expected = [
+      'checker 1 errors' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR),
+      'checker 1 warnings' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
+    ];
+    $checker_2_expected = [
+      'checker 2 errors' => $this->createValidationResult(SystemManager::REQUIREMENT_ERROR),
+      'checker 2 warnings' => $this->createValidationResult(SystemManager::REQUIREMENT_WARNING),
     ];
-    TestSubscriber1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
-    TestSubscriber2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
-    $expected_results_all = array_merge($expected_results[1], $expected_results[0]);
+    TestSubscriber1::setTestResult($checker_1_expected, ReadinessCheckEvent::class);
+    TestSubscriber2::setTestResult($checker_2_expected, ReadinessCheckEvent::class);
+    $expected_results_all = array_merge($checker_2_expected, $checker_1_expected);
     $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'],
+      $checker_2_expected['checker 2 warnings'],
+      $checker_1_expected['checker 1 warnings'],
     ];
     $this->assertCheckerResultsFromManager($warnings_only_results, FALSE, SystemManager::REQUIREMENT_WARNING);
 
     $errors_only_results = [
-      $expected_results[1]['2:errors'],
-      $expected_results[0]['1:errors'],
+      $checker_2_expected['checker 2 errors'],
+      $checker_1_expected['checker 1 errors'],
     ];
     $this->assertCheckerResultsFromManager($errors_only_results, FALSE, SystemManager::REQUIREMENT_ERROR);
   }
@@ -92,34 +92,30 @@ public function testGetResults(): void {
    * Tests that the manager is run after modules are installed.
    */
   public function testRunOnInstall(): void {
-    $expected_results = [array_pop($this->testResults['checker_1'])];
-    TestSubscriber1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
+    $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    TestSubscriber1::setTestResult($checker_1_results, ReadinessCheckEvent::class);
     // Confirm that messages from an existing module are displayed when
     // 'auto_updates' is installed.
     $this->container->get('module_installer')->install(['auto_updates']);
-    $this->assertCheckerResultsFromManager($expected_results[0]);
+    $this->assertCheckerResultsFromManager($checker_1_results);
 
     // 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']),
-    ];
-    TestSubscriber1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
-    TestSubscriber2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
+    $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    $checker_2_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    TestSubscriber1::setTestResult($checker_1_results, ReadinessCheckEvent::class);
+    TestSubscriber2::setTestResult($checker_2_results, ReadinessCheckEvent::class);
     $this->container->get('module_installer')->install(['auto_updates_test2']);
-    $expected_results_all = array_merge($expected_results[0], $expected_results[1]);
+    $expected_results_all = array_merge($checker_1_results, $checker_2_results);
     $this->assertCheckerResultsFromManager($expected_results_all);
 
     // Confirm that the checkers are run when a module that does not provide a
     // readiness checker is installed.
-    $expected_results = [
-      array_pop($this->testResults['checker_1']),
-      array_pop($this->testResults['checker_2']),
-    ];
-    TestSubscriber1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
-    TestSubscriber2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
-    $expected_results_all = array_merge($expected_results[0], $expected_results[1]);
+    $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    $checker_2_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    TestSubscriber1::setTestResult($checker_1_results, ReadinessCheckEvent::class);
+    TestSubscriber2::setTestResult($checker_2_results, ReadinessCheckEvent::class);
+    $expected_results_all = array_merge($checker_1_results, $checker_2_results);
     $this->container->get('module_installer')->install(['help']);
     $this->assertCheckerResultsFromManager($expected_results_all);
   }
@@ -128,36 +124,31 @@ public function testRunOnInstall(): void {
    * 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']),
-    ];
-    TestSubscriber1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
-    TestSubscriber2::setTestResult($expected_results[1], ReadinessCheckEvent::class);
+    $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    $checker_2_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    TestSubscriber1::setTestResult($checker_1_results, ReadinessCheckEvent::class);
+    TestSubscriber2::setTestResult($checker_2_results, ReadinessCheckEvent::class);
     // Confirm that messages from existing modules are displayed when
     // 'auto_updates' is installed.
     $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test2', 'help']);
-    $expected_results_all = array_merge($expected_results[0], $expected_results[1]);
+    $expected_results_all = array_merge($checker_1_results, $checker_2_results);
     $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']),
-    ];
-    TestSubscriber1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
-    TestSubscriber2::setTestResult(array_pop($this->testResults['checker_2']), ReadinessCheckEvent::class);
+    $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    $checker_2_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    TestSubscriber1::setTestResult($checker_1_results, ReadinessCheckEvent::class);
+    TestSubscriber2::setTestResult($checker_2_results, ReadinessCheckEvent::class);
     $this->container->get('module_installer')->uninstall(['auto_updates_test2']);
-    $this->assertCheckerResultsFromManager($expected_results[0]);
+    $this->assertCheckerResultsFromManager($checker_1_results);
 
     // Confirm that the checkers are run when a module that does not provide a
     // readiness checker is uninstalled.
-    $expected_results = [
-      array_pop($this->testResults['checker_1']),
-    ];
-    TestSubscriber1::setTestResult($expected_results[0], ReadinessCheckEvent::class);
+    $checker_1_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    TestSubscriber1::setTestResult($checker_1_results, ReadinessCheckEvent::class);
     $this->container->get('module_installer')->uninstall(['help']);
-    $this->assertCheckerResultsFromManager($expected_results[0]);
+    $this->assertCheckerResultsFromManager($checker_1_results);
   }
 
   /**
@@ -165,12 +156,12 @@ public function testRunOnUninstall(): void {
    * @covers ::clearStoredResults
    */
   public function testRunIfNeeded(): void {
-    $expected_results = array_pop($this->testResults['checker_1']);
+    $expected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber1::setTestResult($expected_results, ReadinessCheckEvent::class);
     $this->container->get('module_installer')->install(['auto_updates', 'auto_updates_test2']);
     $this->assertCheckerResultsFromManager($expected_results);
 
-    $unexpected_results = array_pop($this->testResults['checker_1']);
+    $unexpected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber1::setTestResult($unexpected_results, ReadinessCheckEvent::class);
     $manager = $this->container->get('auto_updates.readiness_validation_manager');
     // Confirm that the new results will not be returned because the checkers
@@ -186,7 +177,7 @@ public function testRunIfNeeded(): void {
     $this->assertCheckerResultsFromManager($expected_results);
 
     // Confirm that the results are the same after rebuilding the container.
-    $unexpected_results = array_pop($this->testResults['checker_1']);
+    $unexpected_results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
     TestSubscriber1::setTestResult($unexpected_results, ReadinessCheckEvent::class);
     /** @var \Drupal\Core\DrupalKernel $kernel */
     $kernel = $this->container->get('kernel');
@@ -219,42 +210,71 @@ public function testCronSetting(): void {
    * Tests that stored validation results are deleted after an update.
    */
   public function testStoredResultsDeletedPostApply(): void {
-    $this->container->get('module_installer')
-      ->install(['auto_updates']);
-
-    // Ensure there's a simulated core release to update to.
-    $this->setCoreVersion('9.8.1');
-    $this->setReleaseMetadata(__DIR__ . '/../../../fixtures/release-history/drupal.9.8.2.xml');
+    $this->enableModules(['auto_updates']);
+    $this->setCoreVersion('9.8.0');
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml',
+    ]);
 
     // The readiness checker should raise a warning, so that the update is not
     // blocked or aborted.
-    $results = $this->testResults['checker_1']['1 warning'];
+    $results = [$this->createValidationResult(SystemManager::REQUIREMENT_WARNING)];
     TestSubscriber1::setTestResult($results, ReadinessCheckEvent::class);
 
     // Ensure that the validation manager collects the warning.
     /** @var \Drupal\auto_updates\Validation\ReadinessValidationManager $manager */
     $manager = $this->container->get('auto_updates.readiness_validation_manager')
       ->run();
+    $this->assertValidationResultsEqual($results, $manager->getResults());
     TestSubscriber1::setTestResult(NULL, ReadinessCheckEvent::class);
     // Even though the checker no longer returns any results, the previous
     // results should be stored.
     $this->assertValidationResultsEqual($results, $manager->getResults());
 
-    // Don't validate staged projects because actual staging operations are
-    // bypassed by package_manager_bypass, which will make this validator
-    // complain that there is no actual Composer data for it to inspect.
-    $validator = $this->container->get('auto_updates.staged_projects_validator');
-    $this->container->get('event_dispatcher')->removeSubscriber($validator);
+    // Don't validate staged projects or scaffold file permissions because
+    // actual staging operations are bypassed by package_manager_bypass, which
+    // will make these validators complain that there is no actual Composer data
+    // for them to inspect.
+    $validators = array_map([$this->container, 'get'], [
+      'auto_updates.staged_projects_validator',
+      'auto_updates.validator.scaffold_file_permissions',
+    ]);
+    $event_dispatcher = $this->container->get('event_dispatcher');
+    array_walk($validators, [$event_dispatcher, 'removeSubscriber']);
 
     /** @var \Drupal\auto_updates\Updater $updater */
     $updater = $this->container->get('auto_updates.updater');
-    $updater->begin(['drupal' => '9.8.2']);
+    $updater->begin(['drupal' => '9.8.1']);
     $updater->stage();
     $updater->apply();
+    $updater->postApply();
     $updater->destroy();
 
     // The readiness validation manager shouldn't have any stored results.
     $this->assertEmpty($manager->getResults());
   }
 
+  /**
+   * Tests that certain config changes clear stored results.
+   */
+  public function testStoredResultsClearedOnConfigChanges(): void {
+    $this->enableModules(['auto_updates']);
+
+    $results = [$this->createValidationResult(SystemManager::REQUIREMENT_ERROR)];
+    TestSubscriber1::setTestResult($results, ReadinessCheckEvent::class);
+    $this->assertCheckerResultsFromManager($results, TRUE);
+    // The results should be stored.
+    $this->assertCheckerResultsFromManager($results, FALSE);
+    // Changing the configured path to rsync should not clear the results.
+    $this->config('package_manager.settings')
+      ->set('executables.rsync', '/path/to/rsync')
+      ->save();
+    $this->assertCheckerResultsFromManager($results, FALSE);
+    // Changing the configured path to Composer should clear the results.
+    $this->config('package_manager.settings')
+      ->set('executables.composer', '/path/to/composer')
+      ->save();
+    $this->assertNull($this->getResultsFromManager(FALSE));
+  }
+
 }
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php
new file mode 100644
index 000000000000..38ee187c8a81
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/ScaffoldFilePermissionsValidatorTest.php
@@ -0,0 +1,341 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\ScaffoldFilePermissionsValidator
+ *
+ * @group auto_updates
+ */
+class ScaffoldFilePermissionsValidatorTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['auto_updates'];
+
+  /**
+   * The active directory of the virtual project.
+   *
+   * @var string
+   */
+  private $activeDir;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    parent::setUp();
+
+    $this->activeDir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function assertValidationResultsEqual(array $expected_results, array $actual_results): void {
+    $map = function (string $path): string {
+      return $this->activeDir . '/' . $path;
+    };
+    foreach ($expected_results as $i => $result) {
+      // Prepend the active directory to every path listed in the error result,
+      // and add the expected summary.
+      $messages = array_map($map, $result->getMessages());
+      $expected_results[$i] = ValidationResult::createError($messages, t('The following paths must be writable in order to update default site configuration files.'));
+    }
+    parent::assertValidationResultsEqual($expected_results, $actual_results);
+  }
+
+  /**
+   * Write-protects a set of paths in the active directory.
+   *
+   * @param string[] $paths
+   *   The paths to write-protect, relative to the active directory.
+   */
+  private function writeProtect(array $paths): void {
+    foreach ($paths as $path) {
+      $path = $this->activeDir . '/' . $path;
+      chmod($path, 0400);
+      $this->assertFileIsNotWritable($path, "Failed to write-protect $path.");
+    }
+  }
+
+  /**
+   * Data provider for testPermissionsBeforeStart().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerPermissionsBeforeStart(): array {
+    return [
+      'write-protected scaffold file, writable site directory' => [
+        ['sites/default/default.settings.php'],
+        [
+          ValidationResult::createError(['sites/default/default.settings.php']),
+        ],
+      ],
+      // Whether the site directory is write-protected only matters during
+      // pre-apply, because it only presents a problem if scaffold files have
+      // been added or removed in the staging area. Which is a condition we can
+      // only detect during pre-apply.
+      'write-protected scaffold file and site directory' => [
+        [
+          'sites/default/default.settings.php',
+          'sites/default',
+        ],
+        [
+          ValidationResult::createError(['sites/default/default.settings.php']),
+        ],
+      ],
+      'write-protected site directory' => [
+        ['sites/default'],
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that scaffold file permissions are checked before an update begins.
+   *
+   * @param string[] $write_protected_paths
+   *   A list of paths, relative to the project root, which should be write
+   *   protected before staged changes are applied.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results, if any.
+   *
+   * @dataProvider providerPermissionsBeforeStart
+   */
+  public function testPermissionsBeforeStart(array $write_protected_paths, array $expected_results): void {
+    $this->writeProtect($write_protected_paths);
+    $this->assertCheckerResultsFromManager($expected_results, TRUE);
+
+    try {
+      $this->container->get('auto_updates.updater')
+        ->begin(['drupal' => '9.8.1']);
+
+      // If no exception was thrown, ensure that we weren't expecting an error.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+  /**
+   * Data provider for testScaffoldFilesChanged().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerScaffoldFilesChanged(): array {
+    // The summary is always replaced by ::assertValidationResultsEqual(), so
+    // if there's more than one message in a result, just give it a mocked
+    // summary object to prevent an exception.
+    $summary = $this->prophesize('\Drupal\Core\StringTranslation\TranslatableMarkup')
+      ->reveal();
+
+    return [
+      // If no scaffold files are changed, it doesn't matter if the site
+      // directory is writable.
+      'no scaffold changes, site directory not writable' => [
+        ['sites/default'],
+        [],
+        [],
+        [],
+      ],
+      'no scaffold changes, site directory writable' => [
+        [],
+        [],
+        [],
+        [],
+      ],
+      // If scaffold files are added or deleted in the site directory, the site
+      // directory must be writable.
+      'new scaffold file added to non-writable site directory' => [
+        ['sites/default'],
+        [],
+        [
+          '[web-root]/sites/default/new.txt' => '',
+        ],
+        [
+          ValidationResult::createError(['sites/default']),
+        ],
+      ],
+      'new scaffold file added to writable site directory' => [
+        [],
+        [],
+        [
+          '[web-root]/sites/default/new.txt' => '',
+        ],
+        [],
+      ],
+      'writable scaffold file removed from non-writable site directory' => [
+        ['sites/default'],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default']),
+        ],
+      ],
+      'writable scaffold file removed from writable site directory' => [
+        [],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed from non-writable site directory' => [
+        [
+          // The file must be made write-protected before the site directory is,
+          // or the permissions change will fail.
+          'sites/default/deleted.txt',
+          'sites/default',
+        ],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default', 'sites/default/deleted.txt'], $summary),
+        ],
+      ],
+      'non-writable scaffold file removed from writable site directory' => [
+        ['sites/default/deleted.txt'],
+        [
+          '[web-root]/sites/default/deleted.txt' => '',
+        ],
+        [],
+        [
+          ValidationResult::createError(['sites/default/deleted.txt']),
+        ],
+      ],
+      // If only scaffold files outside the site directory changed, the
+      // validator doesn't care if the site directory is writable.
+      'new scaffold file added outside non-writable site directory' => [
+        ['sites/default'],
+        [],
+        [
+          '[web-root]/foo.html' => '',
+        ],
+        [],
+      ],
+      'new scaffold file added outside writable site directory' => [
+        [],
+        [],
+        [
+          '[web-root]/foo.html' => '',
+        ],
+        [],
+      ],
+      'writable scaffold file removed outside non-writable site directory' => [
+        ['sites/default'],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'writable scaffold file removed outside writable site directory' => [
+        [],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed outside non-writable site directory' => [
+        [
+          'sites/default',
+          'foo.txt',
+        ],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+      'non-writable scaffold file removed outside writable site directory' => [
+        ['foo.txt'],
+        [
+          '[web-root]/foo.txt' => '',
+        ],
+        [],
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests site directory permissions are checked before changes are applied.
+   *
+   * @param string[] $write_protected_paths
+   *   A list of paths, relative to the project root, which should be write
+   *   protected before staged changes are applied.
+   * @param string[] $active_scaffold_files
+   *   An array simulating the extra.drupal-scaffold.file-mapping section of the
+   *   active drupal/core package.
+   * @param string[] $staged_scaffold_files
+   *   An array simulating the extra.drupal-scaffold.file-mapping section of the
+   *   staged drupal/core package.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results, if any.
+   *
+   * @dataProvider providerScaffoldFilesChanged
+   */
+  public function testScaffoldFilesChanged(array $write_protected_paths, array $active_scaffold_files, array $staged_scaffold_files, array $expected_results): void {
+    // Create fake scaffold files so we can test scenarios in which a scaffold
+    // file that exists in the active directory is deleted in the staging area.
+    touch($this->activeDir . '/sites/default/deleted.txt');
+    touch($this->activeDir . '/foo.txt');
+
+    $updater = $this->container->get('auto_updates.updater');
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
+
+    // Rewrite the active and staged installed.json files, inserting the given
+    // lists of scaffold files.
+    $installed = [
+      'packages' => [
+        [
+          'name' => 'drupal/core',
+          'version' => \Drupal::VERSION,
+          'extra' => [
+            'drupal-scaffold' => [
+              'file_mapping' => [],
+            ],
+          ],
+        ],
+      ],
+    ];
+    // Since the list of scaffold files is in a deeply nested array, reference
+    // it for readability.
+    $scaffold_files = &$installed['packages'][0]['extra']['drupal-scaffold']['file-mapping'];
+
+    // Change the list of scaffold files in the active and stage directories.
+    $scaffold_files = $active_scaffold_files;
+    file_put_contents($this->activeDir . '/vendor/composer/installed.json', json_encode($installed));
+    $scaffold_files = $staged_scaffold_files;
+    file_put_contents($updater->getStageDirectory() . '/vendor/composer/installed.json', json_encode($installed));
+
+    $this->writeProtect($write_protected_paths);
+
+    try {
+      $updater->apply();
+
+      // If no exception was thrown, ensure that we weren't expecting an error.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/SettingsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/SettingsValidatorTest.php
deleted file mode 100644
index f399916bfdd9..000000000000
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/SettingsValidatorTest.php
+++ /dev/null
@@ -1,64 +0,0 @@
-<?php
-
-namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
-
-use Drupal\package_manager\Exception\StageValidationException;
-use Drupal\package_manager\ValidationResult;
-use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
-
-/**
- * @covers \Drupal\Tests\auto_updates\Kernel\ReadinessValidation\SettingsValidatorTest
- *
- * @group auto_updates
- */
-class SettingsValidatorTest extends AutoUpdatesKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['auto_updates'];
-
-  /**
-   * Data provider for ::testSettingsValidation().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerSettingsValidation(): array {
-    $result = ValidationResult::createError([
-      'The <code>update_fetch_with_http_fallback</code> setting must be disabled for automatic updates.',
-    ]);
-
-    return [
-      'HTTP fallback enabled' => [TRUE, [$result]],
-      'HTTP fallback disabled' => [FALSE, []],
-    ];
-  }
-
-  /**
-   * Tests settings validation before starting an update.
-   *
-   * @param bool $setting
-   *   The value of the update_fetch_with_http_fallback setting.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results.
-   *
-   * @dataProvider providerSettingsValidation
-   */
-  public function testSettingsValidation(bool $setting, array $expected_results): void {
-    $this->setSetting('update_fetch_with_http_fallback', $setting);
-
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-    try {
-      $this->container->get('auto_updates.updater')->begin([
-        'drupal' => '9.8.1',
-      ]);
-      // If there was no exception, ensure we're not expecting any errors.
-      $this->assertSame([], $expected_results);
-    }
-    catch (StageValidationException $e) {
-      $this->assertValidationResultsEqual($expected_results, $e->getResults());
-    }
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php
index 0118612e65c4..f2d2930c0ce7 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedDatabaseUpdateValidatorTest.php
@@ -2,10 +2,10 @@
 
 namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
 
-use Drupal\package_manager\Exception\StageValidationException;
-use Drupal\package_manager\ValidationResult;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
-use Drupal\Tests\package_manager\Kernel\TestStage;
+use Psr\Log\Test\TestLogger;
 
 /**
  * @covers \Drupal\auto_updates\Validator\StagedDatabaseUpdateValidator
@@ -26,44 +26,53 @@ class StagedDatabaseUpdateValidatorTest extends AutoUpdatesKernelTestBase {
    */
   private const SUFFIXES = ['install', 'post_update.php'];
 
+  /**
+   * The test logger channel.
+   *
+   * @var \Psr\Log\Test\TestLogger
+   */
+  private $logger;
+
   /**
    * {@inheritdoc}
    */
   protected function setUp(): void {
-    // In this test, we want to disable the lock file validator because, even
-    // though both the active and stage directories will have a valid lock file,
-    // this validator will complain because they don't differ at all.
-    $this->disableValidators[] = 'package_manager.validator.lock_file';
     parent::setUp();
 
-    TestStage::$stagingRoot = $this->vfsRoot->url();
-
-    /** @var \Drupal\Tests\auto_updates\Kernel\TestCronUpdater $updater */
-    $updater = $this->container->get('auto_updates.cron_updater');
-    $updater->begin(['drupal' => '9.8.2']);
-    $updater->stage();
-
-    $stage_dir = $updater->getStageDirectory();
-    mkdir($stage_dir);
-
-    // To satisfy StagedProjectsValidator, copy the active Composer files into
-    // the staging area.
-    $active_dir = $this->getDrupalRoot();
-    @copy("$active_dir/composer.json", "$stage_dir/composer.json");
-    @copy("$active_dir/composer.lock", "$stage_dir/composer.lock");
-    mkdir("$stage_dir/vendor/composer", 0777, TRUE);
-    @copy("$active_dir/vendor/composer/installed.json", "$stage_dir/vendor/composer/installed.json");
+    $this->logger = new TestLogger();
+    $this->container->get('logger.factory')
+      ->get('auto_updates')
+      ->addLogger($this->logger);
+  }
 
-    // Copy the .install and .post_update.php files from every installed module
-    // into the staging directory.
-    $module_list = $this->container->get('module_handler')->getModuleList();
-    foreach ($module_list as $name => $module) {
-      $path = $module->getPath();
-      @mkdir("$stage_dir/$path", 0777, TRUE);
+  /**
+   * {@inheritdoc}
+   */
+  protected function createVirtualProject(?string $source_dir = NULL): void {
+    parent::createVirtualProject($source_dir);
+
+    $drupal_root = $this->getDrupalRoot();
+    $virtual_active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    // Copy the .install and .post_update.php files from all extensions used in
+    // this test class, in the *actual* Drupal code base that is running this
+    // test, into the virtual project (i.e., the active directory).
+    $module_list = $this->container->get('extension.list.module');
+    $extensions = [];
+    $extensions['system'] = $module_list->get('system');
+    $extensions['views'] = $module_list->get('views');
+    $extensions['package_manager_bypass'] = $module_list->get('package_manager_bypass');
+    $theme_list = $this->container->get('extension.list.theme');
+    $extensions['auto_updates_theme'] = $theme_list->get('auto_updates_theme');
+    $extensions['auto_updates_theme_with_updates'] = $theme_list->get('auto_updates_theme_with_updates');
+    foreach ($extensions as $name => $extension) {
+      $path = $extension->getPath();
+      @mkdir("$virtual_active_dir/$path", 0777, TRUE);
 
       foreach (static::SUFFIXES as $suffix) {
         // If the source file doesn't exist, silence the warning it raises.
-        @copy("$active_dir/$path/$name.$suffix", "$stage_dir/$path/$name.$suffix");
+        @copy("$drupal_root/$path/$name.$suffix", "$virtual_active_dir/$path/$name.$suffix");
       }
     }
   }
@@ -72,28 +81,39 @@ protected function setUp(): void {
    * Tests that no errors are raised if staged files have no DB updates.
    */
   public function testNoUpdates(): void {
-    // Since we're testing with a modified version of Views, it should not be
-    // installed.
+    // Since we're testing with a modified version of 'views' and
+    // 'auto_updates_theme_with_updates', these should not be installed.
     $this->assertFalse($this->container->get('module_handler')->moduleExists('views'));
+    $this->assertFalse($this->container->get('theme_handler')->themeExists('auto_updates_theme_with_updates'));
+
+    $listener = function (PreApplyEvent $event): void {
+      // Create bogus staged versions of Views' and
+      // Automatic Updates Theme with Updates .install and .post_update.php
+      // files. Since these extensions are not installed, the changes should not
+      // raise any validation errors.
+      $dir = $event->getStage()->getStageDirectory();
+      $module_list = $this->container->get('extension.list.module')->getList();
+      $theme_list = $this->container->get('extension.list.theme')->getList();
+      $module_dir = $dir . '/' . $module_list['views']->getPath();
+      $theme_dir = $dir . '/' . $theme_list['auto_updates_theme_with_updates']->getPath();
+      foreach (static::SUFFIXES as $suffix) {
+        file_put_contents("$module_dir/views.$suffix", $this->randomString());
+        file_put_contents("$theme_dir/auto_updates_theme_with_updates.$suffix", $this->randomString());
+      }
+    };
+    $this->container->get('event_dispatcher')
+      ->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
 
-    // Create bogus staged versions of Views' .install and .post_update.php
-    // files. Since it's not installed, the changes should not raise any
-    // validation errors.
-    $updater = $this->container->get('auto_updates.cron_updater');
-    $module_dir = $updater->getStageDirectory() . '/core/modules/views';
-    mkdir($module_dir, 0777, TRUE);
-    foreach (static::SUFFIXES as $suffix) {
-      file_put_contents("$module_dir/views.$suffix", $this->randomString());
-    }
-
-    $updater->apply();
+    $this->container->get('cron')->run();
+    // There should not have been any errors.
+    $this->assertFalse($this->logger->hasRecords(RfcLogLevel::ERROR));
   }
 
   /**
-   * Data provider for ::testFileChanged().
+   * Data provider for testFileChanged().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[]
+   *   The test cases.
    */
   public function providerFileChanged(): array {
     $scenarios = [];
@@ -116,60 +136,61 @@ public function providerFileChanged(): array {
    * @dataProvider providerFileChanged
    */
   public function testFileChanged(string $suffix, bool $delete): void {
-    /** @var \Drupal\Tests\auto_updates\Kernel\ReadinessValidation\TestCronUpdater $updater */
-    $updater = $this->container->get('auto_updates.cron_updater');
-
-    $file = $updater->getStageDirectory() . "/core/modules/system/system.$suffix";
-    if ($delete) {
-      unlink($file);
-    }
-    else {
-      file_put_contents($file, $this->randomString());
-    }
-
-    $expected_results = [
-      ValidationResult::createError(['System'], t('The update cannot proceed because possible database updates have been detected in the following modules.')),
-    ];
+    $listener = function (PreApplyEvent $event) use ($suffix, $delete): void {
+      $dir = $event->getStage()->getStageDirectory();
+      $theme_installer = $this->container->get('theme_installer');
+      $theme_installer->install(['auto_updates_theme_with_updates']);
+      $theme = $this->container->get('theme_handler')
+        ->getTheme('auto_updates_theme_with_updates');
+      $module_file = "$dir/core/modules/system/system.$suffix";
+      $theme_file = "$dir/{$theme->getPath()}/{$theme->getName()}.$suffix";
+      if ($delete) {
+        unlink($module_file);
+        unlink($theme_file);
+      }
+      else {
+        file_put_contents($module_file, $this->randomString());
+        file_put_contents($theme_file, $this->randomString());
+      }
+    };
+    $this->container->get('event_dispatcher')
+      ->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
 
-    try {
-      $updater->apply();
-      $this->fail('Expected a validation error.');
-    }
-    catch (StageValidationException $e) {
-      $this->assertValidationResultsEqual($expected_results, $e->getResults());
-    }
+    $this->container->get('cron')->run();
+    $this->assertTrue($this->logger->hasRecordThatContains("The update cannot proceed because possible database updates have been detected in the following extensions.\nSystem\nAutomatic Updates Theme With Updates", RfcLogLevel::ERROR));
   }
 
   /**
    * Tests that an error is raised if install or post-update files are added.
    */
   public function testUpdatesAddedInStage(): void {
-    $module = $this->container->get('module_handler')
-      ->getModule('package_manager_bypass');
+    $listener = function (PreApplyEvent $event): void {
+      $module = $this->container->get('module_handler')
+        ->getModule('package_manager_bypass');
+      $theme_installer = $this->container->get('theme_installer');
+      $theme_installer->install(['auto_updates_theme']);
+      $theme = $this->container->get('theme_handler')
+        ->getTheme('auto_updates_theme');
 
-    /** @var \Drupal\Tests\auto_updates\Kernel\ReadinessValidation\TestCronUpdater $updater */
-    $updater = $this->container->get('auto_updates.cron_updater');
+      $dir = $event->getStage()->getStageDirectory();
 
-    foreach (static::SUFFIXES as $suffix) {
-      $file = sprintf('%s/%s/%s.%s', $updater->getStageDirectory(), $module->getPath(), $module->getName(), $suffix);
-      // The file we're creating shouldn't already exist in the staging area
-      // unless it's a file we actually ship, which is a scenario covered by
-      // ::testFileChanged().
-      $this->assertFileDoesNotExist($file);
-      file_put_contents($file, $this->randomString());
-    }
-
-    $expected_results = [
-      ValidationResult::createError(['Package Manager Bypass'], t('The update cannot proceed because possible database updates have been detected in the following modules.')),
-    ];
+      foreach (static::SUFFIXES as $suffix) {
+        $module_file = sprintf('%s/%s/%s.%s', $dir, $module->getPath(), $module->getName(), $suffix);
+        $theme_file = sprintf('%s/%s/%s.%s', $dir, $theme->getPath(), $theme->getName(), $suffix);
+        // The files we're creating shouldn't already exist in the staging area
+        // unless it's a file we actually ship, which is a scenario covered by
+        // ::testFileChanged().
+        $this->assertFileDoesNotExist($module_file);
+        $this->assertFileDoesNotExist($theme_file);
+        file_put_contents($module_file, $this->randomString());
+        file_put_contents($theme_file, $this->randomString());
+      }
+    };
+    $this->container->get('event_dispatcher')
+      ->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
 
-    try {
-      $updater->apply();
-      $this->fail('Expected a validation error.');
-    }
-    catch (StageValidationException $e) {
-      $this->assertValidationResultsEqual($expected_results, $e->getResults());
-    }
+    $this->container->get('cron')->run();
+    $this->assertTrue($this->logger->hasRecordThatContains("The update cannot proceed because possible database updates have been detected in the following extensions.\nPackage Manager Bypass\nAutomatic Updates Theme", RfcLogLevel::ERROR));
   }
 
 }
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
index f4b86e770db5..089cf95bb407 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/StagedProjectsValidatorTest.php
@@ -4,10 +4,8 @@
 
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
 use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
-use Drupal\Tests\package_manager\Kernel\TestStage;
-use org\bovigo\vfs\vfsStream;
-use Symfony\Component\Filesystem\Filesystem;
 
 /**
  * @covers \Drupal\auto_updates\Validator\StagedProjectsValidator
@@ -25,137 +23,93 @@ class StagedProjectsValidatorTest extends AutoUpdatesKernelTestBase {
    * {@inheritdoc}
    */
   protected function setUp(): void {
-    // This test deals with fake sites that don't necessarily have lock files,
-    // so disable lock file validation.
-    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    // In this test, we don't care whether the updated projects are secure and
+    // supported.
+    $this->disableValidators[] = 'package_manager.validator.supported_releases';
     parent::setUp();
   }
 
   /**
-   * Runs the validator under test against an arbitrary pair of directories.
+   * Asserts a set of validation results when staged changes are applied.
    *
-   * @param string $active_dir
-   *   The active directory to validate.
-   * @param string $stage_dir
-   *   The stage directory to validate.
-   *
-   * @return \Drupal\package_manager\ValidationResult[]
-   *   The validation results.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
    */
-  private function validate(string $active_dir, string $stage_dir): array {
-    $this->mockPathLocator($active_dir, $active_dir);
-
-    $stage_dir_exists = is_dir($stage_dir);
-    if ($stage_dir_exists) {
-      // If we are testing a fixture with existing stage directory then we
-      // need to use a virtual file system directory, so we can create a
-      // subdirectory using the stage ID after it is created below.
-      $vendor = vfsStream::newDirectory('au_stage');
-      $this->vfsRoot->addChild($vendor);
-      TestStage::$stagingRoot = $vendor->url();
-    }
-    else {
-      // If we are testing non-existent staging directory we can use the path
-      // directly.
-      TestStage::$stagingRoot = $stage_dir;
-    }
-
+  private function validate(array $expected_results): void {
+    /** @var \Drupal\auto_updates\Updater $updater */
     $updater = $this->container->get('auto_updates.updater');
-    $stage_id = $updater->begin(['drupal' => '9.8.1']);
-    if ($stage_dir_exists) {
-      // Copy the fixture's staging directory into a subdirectory using the
-      // stage ID as the directory name.
-      $sub_directory = vfsStream::newDirectory($stage_id);
-      $vendor->addChild($sub_directory);
-      (new Filesystem())->mirror($stage_dir, $sub_directory->url());
-    }
+    $updater->begin(['drupal' => '9.8.1']);
+    $updater->stage();
 
-    // The staged projects validator only runs before staged updates are
-    // applied. Since the active and stage directories may not exist, we don't
-    // want to invoke the other stages of the update because they may raise
-    // errors that are outside of the scope of what we're testing here.
     try {
       $updater->apply();
-      return [];
+      // If no exception occurs, ensure we weren't expecting any errors.
+      $this->assertEmpty($expected_results);
     }
     catch (StageValidationException $e) {
-      return $e->getResults();
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
     }
   }
 
   /**
-   * Tests that if an exception is thrown, the event will absorb it.
+   * Tests that exceptions are turned into validation errors.
    */
   public function testEventConsumesExceptionResults(): void {
-    // Prepare a fake site in the virtual file system which contains valid
-    // Composer data.
-    $fixture = __DIR__ . '/../../../fixtures/fake-site';
-    copy("$fixture/composer.json", 'public://composer.json');
-    mkdir('public://vendor/composer', 0777, TRUE);
-    copy("$fixture/vendor/composer/installed.json", 'public://vendor/composer/installed.json');
-
-    $event_dispatcher = $this->container->get('event_dispatcher');
-    // Disable the disk space validator, since it doesn't work with vfsStream,
-    // and the excluded paths subscriber, since it won't deal with this tiny
-    // virtual file system correctly.
-    $disable_subscribers = array_map([$this->container, 'get'], [
-      'package_manager.validator.disk_space',
-      'package_manager.excluded_paths_subscriber',
-    ]);
-    array_walk($disable_subscribers, [$event_dispatcher, 'removeSubscriber']);
-
-    // Just before the staged changes are applied, delete the composer.json file
-    // to trigger an error. This uses the highest possible priority to guarantee
-    // it runs before any other subscribers.
-    $listener = function () {
-      unlink('public://composer.json');
+    $composer_json = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+    $composer_json .= '/composer.json';
+
+    $listener = function (PreApplyEvent $event) use ($composer_json): void {
+      unlink($composer_json);
+      // Directly invoke the validator under test, which should raise a
+      // validation error.
+      $this->container->get('auto_updates.staged_projects_validator')
+        ->validateStagedProjects($event);
+      // Prevent any other event subscribers from running, since they might try
+      // to read the file we just deleted.
+      $event->stopPropagation();
     };
-    $event_dispatcher->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
+    $this->container->get('event_dispatcher')
+      ->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
 
-    $results = $this->validate('public://', '/fake/stage/dir');
-    $this->assertCount(1, $results);
-    $messages = reset($results)->getMessages();
-    $this->assertCount(1, $messages);
-    $this->assertStringContainsString('Composer could not find the config file: public:///composer.json', (string) reset($messages));
+    $this->validate([
+      ValidationResult::createError(["Composer could not find the config file: $composer_json\n"]),
+    ]);
   }
 
   /**
-   * Tests validations errors.
+   * Tests validation errors, or lack thereof.
    *
-   * @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 $root_fixture_directory
+   *   A directory containing to fixtures sub direcotories, 'active' and
+   *   'staged'.
+   * @param string|null $expected_summary
+   *   The expected error summary, or NULL if no errors are expected.
    * @param string[] $expected_messages
-   *   The expected error messages.
+   *   The expected error messages, if any.
    *
    * @dataProvider providerErrors
    */
-  public function testErrors(string $fixtures_dir, string $expected_summary, array $expected_messages): void {
-    $this->assertNotEmpty($fixtures_dir);
-    $this->assertDirectoryExists($fixtures_dir);
-
-    $results = $this->validate("$fixtures_dir/active", "$fixtures_dir/staged");
-    $this->assertCount(1, $results);
-    $result = array_pop($results);
-    $this->assertSame($expected_summary, (string) $result->getSummary());
-    $actual_messages = $result->getMessages();
-    $this->assertCount(count($expected_messages), $actual_messages);
-    foreach ($expected_messages as $message) {
-      $actual_message = array_shift($actual_messages);
-      $this->assertSame($message, (string) $actual_message);
+  public function testErrors(string $root_fixture_directory, ?string $expected_summary, array $expected_messages): void {
+    $this->copyFixtureFolderToActiveDirectory("$root_fixture_directory/active");
+    $this->copyFixtureFolderToStageDirectoryOnApply("$root_fixture_directory/staged");
+
+    $expected_results = [];
+    if ($expected_messages) {
+      // @codingStandardsIgnoreLine
+      $expected_results[] = ValidationResult::createError($expected_messages, t($expected_summary));
     }
+    $this->validate($expected_results);
   }
 
   /**
    * Data provider for testErrors().
    *
    * @return \string[][]
-   *   Test cases for testErrors().
+   *   The test cases.
    */
   public function providerErrors(): array {
-    $fixtures_folder = realpath(__DIR__ . '/../../../fixtures/project_staged_validation');
+    $fixtures_folder = __DIR__ . '/../../../fixtures/StagedProjectsValidatorTest';
     return [
       'new_project_added' => [
         "$fixtures_folder/new_project_added",
@@ -181,17 +135,12 @@ public function providerErrors(): array {
           "module 'drupal/dev-test_module' from 1.3.0 to 1.3.1.",
         ],
       ],
+      'no_errors' => [
+        "$fixtures_folder/no_errors",
+        NULL,
+        [],
+      ],
     ];
   }
 
-  /**
-   * Tests validation when there are no errors.
-   */
-  public function testNoErrors(): void {
-    $fixtures_dir = realpath(__DIR__ . '/../../../fixtures/project_staged_validation/no_errors');
-    $results = $this->validate("$fixtures_dir/active", "$fixtures_dir/staged");
-    $this->assertIsArray($results);
-    $this->assertEmpty($results);
-  }
-
 }
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
deleted file mode 100644
index 7162e24aad70..000000000000
--- a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/UpdateVersionValidatorTest.php
+++ /dev/null
@@ -1,375 +0,0 @@
-<?php
-
-namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
-
-use Drupal\auto_updates\CronUpdater;
-use Drupal\package_manager\ValidationResult;
-use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
-use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
-use Psr\Log\Test\TestLogger;
-
-/**
- * @covers \Drupal\auto_updates\Validator\UpdateVersionValidator
- *
- * @group auto_updates
- */
-class UpdateVersionValidatorTest extends AutoUpdatesKernelTestBase {
-
-  use PackageManagerBypassTestTrait;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['auto_updates'];
-
-  /**
-   * The logger for cron updates.
-   *
-   * @var \Psr\Log\Test\TestLogger
-   */
-  private $logger;
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    parent::setUp();
-
-    $this->logger = new TestLogger();
-    $this->container->get('logger.factory')
-      ->get('auto_updates')
-      ->addLogger($this->logger);
-  }
-
-  /**
-   * Data provider for all possible cron update frequencies.
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerOnCurrentVersion(): array {
-    return [
-      'disabled' => [CronUpdater::DISABLED],
-      'security' => [CronUpdater::SECURITY],
-      'all' => [CronUpdater::ALL],
-    ];
-  }
-
-  /**
-   * Tests an update version that is same major & minor version as the current.
-   *
-   * @param string $cron_setting
-   *   The value of the auto_updates.settings:cron config setting.
-   *
-   * @dataProvider providerOnCurrentVersion
-   */
-  public function testOnCurrentVersion(string $cron_setting): void {
-    $this->setCoreVersion('9.8.2');
-    $this->config('auto_updates.settings')
-      ->set('cron', $cron_setting)
-      ->save();
-
-    $this->assertCheckerResultsFromManager([], TRUE);
-    $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes(0);
-  }
-
-  /**
-   * Data provider for ::testMinorUpdates().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerMinorUpdates(): array {
-    $update_disallowed = ValidationResult::createError([
-      'Drupal cannot be automatically updated from its current version, 9.7.1, to the recommended version, 9.8.1, because automatic updates from one minor version to another are not supported.',
-    ]);
-    $cron_update_disallowed = ValidationResult::createError([
-      'Drupal cannot be automatically updated from its current version, 9.7.1, to the recommended version, 9.8.1, because automatic updates from one minor version to another are not supported during cron.',
-    ]);
-
-    return [
-      'cron disabled, minor updates not allowed' => [
-        FALSE,
-        CronUpdater::DISABLED,
-        [$update_disallowed],
-      ],
-      'cron disabled, minor updates allowed' => [
-        TRUE,
-        CronUpdater::DISABLED,
-        [],
-      ],
-      'security updates during cron, minor updates not allowed' => [
-        FALSE,
-        CronUpdater::SECURITY,
-        [$update_disallowed],
-      ],
-      'security updates during cron, minor updates allowed' => [
-        TRUE,
-        CronUpdater::SECURITY,
-        [$cron_update_disallowed],
-      ],
-      'cron enabled, minor updates not allowed' => [
-        FALSE,
-        CronUpdater::ALL,
-        [$update_disallowed],
-      ],
-      'cron enabled, minor updates allowed' => [
-        TRUE,
-        CronUpdater::ALL,
-        [$cron_update_disallowed],
-      ],
-    ];
-  }
-
-  /**
-   * Tests an update version that is a different minor version than the current.
-   *
-   * @param bool $allow_minor_updates
-   *   Whether or not updates across minor core versions are allowed in config.
-   * @param string $cron_setting
-   *   Whether cron updates are enabled, and how often; should be one of the
-   *   constants in \Drupal\auto_updates\CronUpdater. This determines which
-   *   stage the validator will use; if cron updates are enabled at all,
-   *   it will be an instance of CronUpdater.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The validation results that should be returned from by the validation
-   *   manager, and logged if cron updates are enabled.
-   *
-   * @dataProvider providerMinorUpdates
-   */
-  public function testMinorUpdates(bool $allow_minor_updates, string $cron_setting, array $expected_results): void {
-    $this->config('auto_updates.settings')
-      ->set('allow_core_minor_updates', $allow_minor_updates)
-      ->set('cron', $cron_setting)
-      ->save();
-
-    // In order to test what happens when only security updates are enabled
-    // during cron (the default behavior), ensure that the latest available
-    // release is a security update.
-    $this->setReleaseMetadata(__DIR__ . '/../../../fixtures/release-history/drupal.9.8.1-security.xml');
-
-    $this->setCoreVersion('9.7.1');
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-
-    $this->container->get('cron')->run();
-
-    // If cron updates are disabled, the update shouldn't have been started and
-    // nothing should have been logged.
-    if ($cron_setting === CronUpdater::DISABLED) {
-      $this->assertUpdateStagedTimes(0);
-      $this->assertEmpty($this->logger->records);
-    }
-    // If cron updates are enabled, the validation errors have been logged, and
-    // the update shouldn't have been started.
-    elseif ($expected_results) {
-      $this->assertUpdateStagedTimes(0);
-    }
-    // If cron updates are enabled and no validation errors were expected, the
-    // update should have started and nothing should have been logged.
-    else {
-      $this->assertUpdateStagedTimes(1);
-      $this->assertEmpty($this->logger->records);
-    }
-  }
-
-  /**
-   * Data provider for ::testCronUpdateTwoPatchReleasesAhead().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerCronUpdateTwoPatchReleasesAhead(): array {
-    $update_disallowed = ValidationResult::createError([
-      'Drupal cannot be automatically updated during cron from its current version, 9.8.0, to the recommended version, 9.8.2, because Automatic Updates only supports 1 patch version update during cron.',
-    ]);
-
-    return [
-      'disabled' => [
-        CronUpdater::DISABLED,
-        [],
-      ],
-      'security only' => [
-        CronUpdater::SECURITY,
-        [$update_disallowed],
-      ],
-      'all' => [
-        CronUpdater::ALL,
-        [$update_disallowed],
-      ],
-    ];
-  }
-
-  /**
-   * Tests a cron update two patch releases ahead of the current version.
-   *
-   * @param string $cron_setting
-   *   The value of the auto_updates.settings:cron config setting.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The expected validation results, which should be logged as errors if the
-   *   update is attempted during cron.
-   *
-   * @dataProvider providerCronUpdateTwoPatchReleasesAhead
-   */
-  public function testCronUpdateTwoPatchReleasesAhead(string $cron_setting, array $expected_results): void {
-    $this->setCoreVersion('9.8.0');
-    $this->config('auto_updates.settings')
-      ->set('cron', $cron_setting)
-      ->save();
-
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-    $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes(0);
-  }
-
-  /**
-   * Data provider for ::testCronUpdateOnePatchReleaseAhead().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerCronUpdateOnePatchReleaseAhead(): array {
-    return [
-      'disabled' => [
-        CronUpdater::DISABLED,
-        FALSE,
-      ],
-      // The latest release is not a security update, so the update will only
-      // happen if cron is updates are allowed for any patch release.
-      'security' => [
-        CronUpdater::SECURITY,
-        FALSE,
-      ],
-      'all' => [
-        CronUpdater::ALL,
-        TRUE,
-      ],
-    ];
-  }
-
-  /**
-   * Tests a cron update one patch release ahead of the current version.
-   *
-   * @param string $cron_setting
-   *   The value of the auto_updates.settings:cron config setting.
-   * @param bool $will_update
-   *   TRUE if the update will occur, otherwise FALSE.
-   *
-   * @dataProvider providerCronUpdateOnePatchReleaseAhead
-   */
-  public function testCronUpdateOnePatchReleaseAhead(string $cron_setting, bool $will_update): void {
-    $this->config('auto_updates.settings')
-      ->set('cron', $cron_setting)
-      ->save();
-    if ($cron_setting === CronUpdater::SECURITY) {
-      $expected_result = ValidationResult::createError(['Drupal cannot be automatically updated during cron from its current version, 9.8.1, to the recommended version, 9.8.2, because 9.8.2 is not a security release.']);
-      $this->assertCheckerResultsFromManager([$expected_result], TRUE);
-    }
-    else {
-      $this->assertCheckerResultsFromManager([], TRUE);
-    }
-    $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes((int) $will_update);
-  }
-
-  /**
-   * Data provider for ::testInvalidCronUpdate().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerInvalidCronUpdate(): array {
-    $unstable_current_version = ValidationResult::createError([
-      'Drupal cannot be automatically updated during cron from its current version, 9.8.0-alpha1, because Automatic Updates only supports updating from stable versions during cron.',
-    ]);
-    $dev_current_version = ValidationResult::createError([
-      'Drupal cannot be automatically updated from its current version, 9.8.0-dev, to the recommended version, 9.8.2, because automatic updates from a dev version to any other version are not supported.',
-    ]);
-    $different_major_version = ValidationResult::createError([
-      'Drupal cannot be automatically updated from its current version, 8.9.1, to the recommended version, 9.8.2, because automatic updates from one major version to another are not supported.',
-    ]);
-
-    return [
-      'unstable current version, cron disabled' => [
-        CronUpdater::DISABLED,
-        '9.8.0-alpha1',
-        // If cron updates are disabled, no error should be flagged, because
-        // the validation will be run with the regular updater, not the cron
-        // updater.
-        [],
-      ],
-      'unstable current version, security updates allowed' => [
-        CronUpdater::SECURITY,
-        '9.8.0-alpha1',
-        [$unstable_current_version],
-      ],
-      'unstable current version, all updates allowed' => [
-        CronUpdater::ALL,
-        '9.8.0-alpha1',
-        [$unstable_current_version],
-      ],
-      'dev current version, cron disabled' => [
-        CronUpdater::DISABLED,
-        '9.8.0-dev',
-        [$dev_current_version],
-      ],
-      'dev current version, security updates allowed' => [
-        CronUpdater::SECURITY,
-        '9.8.0-dev',
-        [$dev_current_version],
-      ],
-      'dev current version, all updates allowed' => [
-        CronUpdater::ALL,
-        '9.8.0-dev',
-        [$dev_current_version],
-      ],
-      'different current major, cron disabled' => [
-        CronUpdater::DISABLED,
-        '8.9.1',
-        [$different_major_version],
-      ],
-      'different current major, security updates allowed' => [
-        CronUpdater::SECURITY,
-        '8.9.1',
-        [$different_major_version],
-      ],
-      'different current major, all updates allowed' => [
-        CronUpdater::ALL,
-        '8.9.1',
-        [$different_major_version],
-      ],
-    ];
-  }
-
-  /**
-   * Tests invalid version jumps before and during a cron update.
-   *
-   * @param string $cron_setting
-   *   The value of the auto_updates.settings:cron config setting.
-   * @param string $current_core_version
-   *   The current core version from which we are updating.
-   * @param \Drupal\package_manager\ValidationResult[] $expected_results
-   *   The validation results, if any, that should be flagged during readiness
-   *   checks.
-   *
-   * @dataProvider providerInvalidCronUpdate
-   */
-  public function testInvalidCronUpdate(string $cron_setting, string $current_core_version, array $expected_results): void {
-    $this->setCoreVersion($current_core_version);
-    $this->config('auto_updates.settings')
-      ->set('cron', $cron_setting)
-      ->save();
-
-    $this->assertCheckerResultsFromManager($expected_results, TRUE);
-
-    // Try running the update during cron, regardless of the validation results,
-    // and ensure it doesn't happen. In certain situations, this will be because
-    // of $cron_setting (e.g., if the latest release is a regular patch release
-    // but only security updates are allowed during cron); in other situations,
-    // it will be due to validation errors being raised when the staging area is
-    // created (in which case, we expect the errors to be logged).
-    $this->container->get('cron')->run();
-    $this->assertUpdateStagedTimes(0);
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/VersionPolicy/SupportedBranchInstalledTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/VersionPolicy/SupportedBranchInstalledTest.php
new file mode 100644
index 000000000000..b46215cb1e37
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/VersionPolicy/SupportedBranchInstalledTest.php
@@ -0,0 +1,93 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\SupportedBranchInstalled;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\SupportedBranchInstalled
+ *
+ * @group auto_updates
+ */
+class SupportedBranchInstalledTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['auto_updates'];
+
+  /**
+   * Data provider for testSupportedBranchInstalled().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerSupportedBranchInstalled(): array {
+    return [
+      'supported minor installed' => [
+        '9.8.0',
+        [FALSE, TRUE],
+        [],
+      ],
+      // These two cases test a supported major version, but unsupported minor
+      // version.
+      'supported major installed, minor updates forbidden' => [
+        '9.6.1',
+        [FALSE],
+        [
+          'The currently installed version of Drupal core, 9.6.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.',
+          'See the <a href="/admin/reports/updates">available updates page</a> for available updates.',
+        ],
+      ],
+      'supported major installed, minor updates allowed' => [
+        '9.6.1',
+        [TRUE],
+        [
+          'The currently installed version of Drupal core, 9.6.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.',
+          'Use the <a href="/admin/modules/automatic-update">update form</a> to update to a supported version.',
+        ],
+      ],
+      'unsupported version installed' => [
+        '8.9.0',
+        [FALSE, TRUE],
+        [
+          'The currently installed version of Drupal core, 8.9.0, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.',
+          'See the <a href="/admin/reports/updates">available updates page</a> for available updates.',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the installed version of Drupal must be in a supported branch.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param bool[] $allow_minor_updates
+   *   The values of the `allow_core_minor_updates` config setting that should
+   *   be tested.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerSupportedBranchInstalled
+   */
+  public function testSupportedBranchInstalled(string $installed_version, array $allow_minor_updates, array $expected_errors): void {
+    $this->setCoreVersion($installed_version);
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../../../package_manager/tests/fixtures/release-history/drupal.9.8.2.xml',
+    ]);
+
+    $rule = SupportedBranchInstalled::create($this->container);
+
+    foreach ($allow_minor_updates as $setting) {
+      $this->config('auto_updates.settings')
+        ->set('allow_core_minor_updates', $setting)
+        ->save();
+
+      $actual_errors = array_map('strval', $rule->validate($installed_version));
+      $this->assertSame($expected_errors, $actual_errors);
+    }
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/VersionPolicyValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/VersionPolicyValidatorTest.php
new file mode 100644
index 000000000000..a2c1e8ea6f21
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/VersionPolicyValidatorTest.php
@@ -0,0 +1,415 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\auto_updates\CronUpdater;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Exception\StageException;
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicyValidator
+ *
+ * @group auto_updates
+ */
+class VersionPolicyValidatorTest extends AutoUpdatesKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['auto_updates'];
+
+  /**
+   * Data provider for testReadinessCheck().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerReadinessCheck(): array {
+    $metadata_dir = __DIR__ . '/../../../../package_manager/tests/fixtures/release-history';
+
+    return [
+      // Updating from a dev, alpha, beta, or RC release is not allowed during
+      // cron. The first case is a control to prove that a legitimate
+      // patch-level update from a stable release never raises a readiness
+      // error.
+      'stable release installed' => [
+        '9.8.0',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED, CronUpdater::SECURITY, CronUpdater::ALL],
+        [],
+      ],
+      // This case proves that updating from a dev snapshot is never allowed,
+      // regardless of configuration.
+      'dev snapshot installed' => [
+        '9.8.0-dev',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED, CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createVersionPolicyValidationResult('9.8.0-dev', NULL, [
+            'Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.',
+          ]),
+        ],
+      ],
+      // The next six cases prove that updating from an alpha, beta, or RC
+      // release raises a readiness error if unattended updates are enabled.
+      'alpha installed, cron disabled' => [
+        '9.8.0-alpha1',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED],
+        [],
+      ],
+      'alpha installed, cron enabled' => [
+        '9.8.0-alpha1',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createVersionPolicyValidationResult('9.8.0-alpha1', NULL, [
+            'Drupal cannot be automatically updated during cron from its current version, 9.8.0-alpha1, because it is not a stable version.',
+          ]),
+        ],
+      ],
+      'beta installed, cron disabled' => [
+        '9.8.0-beta2',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED],
+        [],
+      ],
+      'beta installed, cron enabled' => [
+        '9.8.0-beta2',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createVersionPolicyValidationResult('9.8.0-beta2', NULL, [
+            'Drupal cannot be automatically updated during cron from its current version, 9.8.0-beta2, because it is not a stable version.',
+          ]),
+        ],
+      ],
+      'rc installed, cron disabled' => [
+        '9.8.0-rc3',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED],
+        [],
+      ],
+      'rc installed, cron enabled' => [
+        '9.8.0-rc3',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createVersionPolicyValidationResult('9.8.0-rc3', NULL, [
+            'Drupal cannot be automatically updated during cron from its current version, 9.8.0-rc3, because it is not a stable version.',
+          ]),
+        ],
+      ],
+      // This case proves that, if a stable release is installed, there is no
+      // error generated when if the next available release is a normal (i.e.,
+      // non-security) release. If unattended updates are only enabled for
+      // security releases, the next available release will be ignored, and
+      // therefore generate no validation errors, because it's not a security
+      // release.
+      'update to normal release' => [
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        [CronUpdater::DISABLED, CronUpdater::SECURITY, CronUpdater::ALL],
+        [],
+      ],
+      // These three cases prove that updating from an unsupported minor version
+      // will raise a readiness error if unattended updates are enabled.
+      // Furthermore, if an error is raised, the messaging will vary depending
+      // on whether attended updates across minor versions are allowed. (Note
+      // that the target version will not be automatically detected because the
+      // release metadata used in these cases doesn't have any 9.7.x releases.)
+      'update from unsupported minor, cron disabled' => [
+        '9.7.1',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::DISABLED],
+        [],
+      ],
+      'update from unsupported minor, cron enabled, minor updates forbidden' => [
+        '9.7.1',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createVersionPolicyValidationResult('9.7.1', NULL, [
+            'The currently installed version of Drupal core, 9.7.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.',
+            'See the <a href="/admin/reports/updates">available updates page</a> for available updates.',
+          ]),
+        ],
+      ],
+      'update from unsupported minor, cron enabled, minor updates allowed' => [
+        '9.7.1',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        [CronUpdater::SECURITY, CronUpdater::ALL],
+        [
+          $this->createVersionPolicyValidationResult('9.7.1', NULL, [
+            'The currently installed version of Drupal core, 9.7.1, is not in a supported minor version. Your site will not be automatically updated during cron until it is updated to a supported minor version.',
+            'Use the <a href="/admin/modules/automatic-update">update form</a> to update to a supported version.',
+          ]),
+        ],
+        TRUE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests target version validation during readiness checks.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string $release_metadata
+   *   The path of the core release metadata to serve to the update system.
+   * @param string[] $cron_modes
+   *   The modes for unattended updates. Can contain any of
+   *   \Drupal\auto_updates\CronUpdater::DISABLED,
+   *   \Drupal\auto_updates\CronUpdater::SECURITY, and
+   *   \Drupal\auto_updates\CronUpdater::ALL.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param bool $allow_minor_updates
+   *   (optional) Whether or not attended updates across minor updates are
+   *   allowed. Defaults to FALSE.
+   *
+   * @dataProvider providerReadinessCheck
+   */
+  public function testReadinessCheck(string $installed_version, string $release_metadata, array $cron_modes, array $expected_results, bool $allow_minor_updates = FALSE): void {
+    $this->setCoreVersion($installed_version);
+    $this->setReleaseMetadata(['drupal' => $release_metadata]);
+
+    foreach ($cron_modes as $cron_mode) {
+      $this->config('auto_updates.settings')
+        ->set('cron', $cron_mode)
+        ->set('allow_core_minor_updates', $allow_minor_updates)
+        ->save();
+
+      $this->assertCheckerResultsFromManager($expected_results, TRUE);
+    }
+  }
+
+  /**
+   * Data provider for testApi().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerApi(): array {
+    $metadata_dir = __DIR__ . '/../../../../package_manager/tests/fixtures/release-history';
+
+    return [
+      'valid target, dev snapshot installed' => [
+        '9.8.0-dev',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        ['drupal' => '9.8.1'],
+        [
+          $this->createVersionPolicyValidationResult('9.8.0-dev', '9.8.1', [
+            'Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.',
+          ]),
+        ],
+      ],
+      // The following cases can only happen by explicitly supplying the updater
+      // with an invalid target version.
+      'downgrade' => [
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        ['drupal' => '9.8.0'],
+        [
+          $this->createVersionPolicyValidationResult('9.8.1', '9.8.0', [
+            'Update version 9.8.0 is lower than 9.8.1, downgrading is not supported.',
+          ]),
+        ],
+      ],
+      'major version upgrade' => [
+        '8.9.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        ['drupal' => '9.8.2'],
+        [
+          $this->createVersionPolicyValidationResult('8.9.1', '9.8.2', [
+            'Drupal cannot be automatically updated from 8.9.1 to 9.8.2 because automatic updates from one major version to another are not supported.',
+          ]),
+        ],
+      ],
+      'unsupported target version' => [
+        '9.8.0',
+        "$metadata_dir/drupal.9.8.2-unsupported_unpublished.xml",
+        ['drupal' => '9.8.1'],
+        [
+          $this->createVersionPolicyValidationResult('9.8.0', '9.8.1', [
+            'Cannot update Drupal core to 9.8.1 because it is not in the list of installable releases.',
+          ]),
+        ],
+      ],
+      // This case proves that an attended update to a normal non-security
+      // release is allowed regardless of how cron is configured.
+      'attended update to normal release' => [
+        '9.8.1',
+        "$metadata_dir/drupal.9.8.2.xml",
+        ['drupal' => '9.8.2'],
+        [],
+      ],
+      // These two cases prove that updating across minor versions of Drupal
+      // core is only allowed for attended updates when a specific configuration
+      // flag is set.
+      'attended update to next minor not allowed' => [
+        '9.7.9',
+        "$metadata_dir/drupal.9.8.2.xml",
+        ['drupal' => '9.8.2'],
+        [
+          $this->createVersionPolicyValidationResult('9.7.9', '9.8.2', [
+            'Drupal cannot be automatically updated from 9.7.9 to 9.8.2 because automatic updates from one minor version to another are not supported.',
+          ]),
+        ],
+      ],
+      'attended update to next minor allowed' => [
+        '9.7.9',
+        "$metadata_dir/drupal.9.8.2.xml",
+        ['drupal' => '9.8.2'],
+        [],
+        TRUE,
+      ],
+      // If attended updates across minor versions are allowed, it's okay to
+      // update from an unsupported minor version.
+      'attended update from unsupported minor allowed' => [
+        '9.7.9',
+        "$metadata_dir/drupal.9.8.1-security.xml",
+        ['drupal' => '9.8.1'],
+        [],
+        TRUE,
+      ],
+    ];
+  }
+
+  /**
+   * Tests validation of explicitly specified target versions.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string $release_metadata
+   *   The path of the core release metadata to serve to the update system.
+   * @param string[] $project_versions
+   *   The desired project versions that should be passed to the updater.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param bool $allow_minor_updates
+   *   (optional) Whether to allow attended updates across minor versions.
+   *   Defaults to FALSE.
+   *
+   * @dataProvider providerApi
+   */
+  public function testApi(string $installed_version, string $release_metadata, array $project_versions, array $expected_results, bool $allow_minor_updates = FALSE): void {
+    $this->setCoreVersion($installed_version);
+    $this->setReleaseMetadata(['drupal' => $release_metadata]);
+
+    $this->config('auto_updates.settings')
+      ->set('allow_core_minor_updates', $allow_minor_updates)
+      ->save();
+
+    /** @var \Drupal\auto_updates\Updater $updater */
+    $updater = $this->container->get('auto_updates.updater');
+
+    try {
+      $updater->begin($project_versions);
+      // Ensure that we did not, in fact, expect any errors.
+      $this->assertEmpty($expected_results);
+      // Reset the updater for the next iteration of the loop.
+      $updater->destroy();
+    }
+    catch (StageValidationException $e) {
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+  /**
+   * Creates an expected validation result from the version policy validator.
+   *
+   * Results returned from VersionPolicyValidator are always summarized in the
+   * same way, so this method ensures that expected validation results are
+   * summarized accordingly.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if it's not known.
+   * @param string[] $messages
+   *   The error messages that the result should contain.
+   *
+   * @return \Drupal\package_manager\ValidationResult
+   *   A validation error object with the appropriate summary.
+   */
+  private function createVersionPolicyValidationResult(string $installed_version, ?string $target_version, array $messages): ValidationResult {
+    if ($target_version) {
+      $summary = t('Updating from Drupal @installed_version to @target_version is not allowed.', [
+        '@installed_version' => $installed_version,
+        '@target_version' => $target_version,
+      ]);
+    }
+    else {
+      $summary = t('Updating from Drupal @installed_version is not allowed.', [
+        '@installed_version' => $installed_version,
+      ]);
+    }
+    return ValidationResult::createError($messages, $summary);
+  }
+
+  /**
+   * Tests that an error is raised if there are no stored package versions.
+   *
+   * This is a contrived situation that should never happen in real life, but
+   * just in case it does, we need to be sure that it's an error condition.
+   */
+  public function testNoStagedPackageVersions(): void {
+    // Remove the stored package versions from the updater's metadata.
+    $listener = function (PreCreateEvent $event): void {
+      /** @var \Drupal\Tests\auto_updates\Kernel\TestUpdater $updater */
+      $updater = $event->getStage();
+      $updater->setMetadata('packages', [
+        'production' => [],
+      ]);
+    };
+    $this->assertTargetVersionNotDiscoverable($listener);
+  }
+
+  /**
+   * Tests that an error is raised if no core packages are installed.
+   *
+   * This is a contrived situation that should never happen in real life, but
+   * just in case it does, we need to be sure that it's an error condition.
+   */
+  public function testNoCorePackagesInstalled(): void {
+    // Clear the list of packages in the active directory's installed.json.
+    $listener = function (PreCreateEvent $event): void {
+      // We should have staged package versions.
+      /** @var \Drupal\auto_updates\Updater $updater */
+      $updater = $event->getStage();
+      $this->assertNotEmpty($updater->getPackageVersions());
+
+      $active_dir = $this->container->get('package_manager.path_locator')
+        ->getProjectRoot();
+      $installed = $active_dir . '/vendor/composer/installed.json';
+      $this->assertFileIsWritable($installed);
+      file_put_contents($installed, '{"packages": []}');
+    };
+    $this->assertTargetVersionNotDiscoverable($listener);
+  }
+
+  /**
+   * Asserts that an error is raised if the target version of Drupal is unknown.
+   *
+   * @param \Closure $listener
+   *   A pre-create event listener to run before all validators. This should put
+   *   the virtual project and/or updater into a state which will cause
+   *   \Drupal\auto_updates\Validator\VersionPolicyValidator::getTargetVersion()
+   *   to throw an exception because the target version of Drupal core is not
+   *   known.
+   */
+  private function assertTargetVersionNotDiscoverable(\Closure $listener): void {
+    $this->container->get('event_dispatcher')
+      ->addListener(PreCreateEvent::class, $listener, PHP_INT_MAX);
+
+    $this->expectException(StageException::class);
+    $this->expectExceptionMessage('The target version of Drupal core could not be determined.');
+    $this->container->get('auto_updates.updater')
+      ->begin(['drupal' => '9.8.1']);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/XdebugValidatorTest.php b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/XdebugValidatorTest.php
new file mode 100644
index 000000000000..b262d8a23533
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Kernel/ReadinessValidation/XdebugValidatorTest.php
@@ -0,0 +1,76 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Kernel\ReadinessValidation;
+
+use Drupal\auto_updates\CronUpdater;
+use Drupal\Core\Logger\RfcLogLevel;
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\auto_updates\Kernel\AutoUpdatesKernelTestBase;
+use Drupal\Tests\package_manager\Traits\PackageManagerBypassTestTrait;
+use Psr\Log\Test\TestLogger;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\XdebugValidator
+ *
+ * @group auto_updates
+ */
+class XdebugValidatorTest extends AutoUpdatesKernelTestBase {
+
+  use PackageManagerBypassTestTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['auto_updates'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // Ensure the validator will think Xdebug is enabled.
+    if (!function_exists('xdebug_break')) {
+      // @codingStandardsIgnoreLine
+      eval('function xdebug_break() {}');
+    }
+    parent::setUp();
+
+    // The parent class unconditionally disables the Xdebug validator we're
+    // testing, so undo that here.
+    $validator = $this->container->get('auto_updates.validator.xdebug');
+    $this->container->get('event_dispatcher')->addSubscriber($validator);
+  }
+
+  /**
+   * Tests warnings and/or errors if Xdebug is enabled.
+   */
+  public function testXdebugValidation(): void {
+    $message = 'Xdebug is enabled, which may cause timeout errors.';
+
+    $config = $this->config('auto_updates.settings');
+    // If cron updates are disabled, the readiness check message should only be
+    // a warning.
+    $config->set('cron', CronUpdater::DISABLED)->save();
+    $result = ValidationResult::createWarning([$message]);
+    $this->assertCheckerResultsFromManager([$result], TRUE);
+
+    // The parent class' setUp() method simulates an available security update,
+    // so ensure that the cron updater will try to update to it.
+    $config->set('cron', CronUpdater::SECURITY)->save();
+
+    // If cron updates are enabled the readiness check message should be an
+    // error.
+    $result = ValidationResult::createError([$message]);
+    $this->assertCheckerResultsFromManager([$result], TRUE);
+
+    // Trying to do the update during cron should fail with an error.
+    $logger = new TestLogger();
+    $this->container->get('logger.factory')
+      ->get('auto_updates')
+      ->addLogger($logger);
+
+    $this->container->get('cron')->run();
+    $this->assertUpdateStagedTimes(0);
+    $this->assertTrue($logger->hasRecordThatMatches("/$message/", RfcLogLevel::ERROR));
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php b/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php
index bb76a2cdf330..758dd697bdbe 100644
--- a/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/ReleaseChooserTest.php
@@ -21,97 +21,98 @@ class ReleaseChooserTest extends AutoUpdatesKernelTestBase {
    */
   protected function setUp(): void {
     parent::setUp();
-    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.2-older-sec-release.xml');
-
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml',
+    ]);
   }
 
   /**
    * Data provider for testReleases().
    *
-   * @return array[]
+   * @return mixed[][]
    *   The test cases.
    */
   public function providerReleases(): array {
     return [
       'installed 9.8.0, no minor support' => [
-        'chooser' => 'auto_updates.release_chooser',
+        'updater' => 'auto_updates.updater',
         'minor_support' => FALSE,
         'installed_version' => '9.8.0',
         'current_minor' => '9.8.2',
         'next_minor' => NULL,
       ],
       'installed 9.8.0, minor support' => [
-        'chooser' => 'auto_updates.release_chooser',
+        'updater' => 'auto_updates.updater',
         'minor_support' => TRUE,
         'installed_version' => '9.8.0',
         'current_minor' => '9.8.2',
         'next_minor' => NULL,
       ],
       'installed 9.7.0, no minor support' => [
-        'chooser' => 'auto_updates.release_chooser',
+        'updater' => 'auto_updates.updater',
         'minor_support' => FALSE,
         'installed_version' => '9.7.0',
         'current_minor' => '9.7.1',
         'next_minor' => NULL,
       ],
       'installed 9.7.0, minor support' => [
-        'chooser' => 'auto_updates.release_chooser',
+        'updater' => 'auto_updates.updater',
         'minor_support' => TRUE,
         'installed_version' => '9.7.0',
         'current_minor' => '9.7.1',
         'next_minor' => '9.8.2',
       ],
       'installed 9.7.2, no minor support' => [
-        'chooser' => 'auto_updates.release_chooser',
+        'updater' => 'auto_updates.updater',
         'minor_support' => FALSE,
         'installed_version' => '9.7.2',
         'current_minor' => NULL,
         'next_minor' => NULL,
       ],
       'installed 9.7.2, minor support' => [
-        'chooser' => 'auto_updates.release_chooser',
+        'updater' => 'auto_updates.updater',
         'minor_support' => TRUE,
         'installed_version' => '9.7.2',
         'current_minor' => NULL,
         'next_minor' => '9.8.2',
       ],
       'cron, installed 9.8.0, no minor support' => [
-        'chooser' => 'auto_updates.cron_release_chooser',
+        'updater' => 'auto_updates.cron_updater',
         'minor_support' => FALSE,
         'installed_version' => '9.8.0',
         'current_minor' => '9.8.1',
         'next_minor' => NULL,
       ],
       'cron, installed 9.8.0, minor support' => [
-        'chooser' => 'auto_updates.cron_release_chooser',
+        'updater' => 'auto_updates.cron_updater',
         'minor_support' => TRUE,
         'installed_version' => '9.8.0',
         'current_minor' => '9.8.1',
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.0, no minor support' => [
-        'chooser' => 'auto_updates.cron_release_chooser',
+        'updater' => 'auto_updates.cron_updater',
         'minor_support' => FALSE,
         'installed_version' => '9.7.0',
         'current_minor' => '9.7.1',
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.0, minor support' => [
-        'chooser' => 'auto_updates.cron_release_chooser',
+        'updater' => 'auto_updates.cron_updater',
         'minor_support' => TRUE,
         'installed_version' => '9.7.0',
         'current_minor' => '9.7.1',
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.2, no minor support' => [
-        'chooser' => 'auto_updates.cron_release_chooser',
+        'updater' => 'auto_updates.cron_updater',
         'minor_support' => FALSE,
         'installed_version' => '9.7.2',
         'current_minor' => NULL,
         'next_minor' => NULL,
       ],
       'cron, installed 9.7.2, minor support' => [
-        'chooser' => 'auto_updates.cron_release_chooser',
+        'updater' => 'auto_updates.cron_updater',
         'minor_support' => TRUE,
         'installed_version' => '9.7.2',
         'current_minor' => NULL,
@@ -123,8 +124,8 @@ public function providerReleases(): array {
   /**
    * Tests fetching the recommended release when an update is available.
    *
-   * @param string $chooser_service
-   *   The ID of release chooser service to use.
+   * @param string $updater_service
+   *   The ID of the updater service to use.
    * @param bool $minor_support
    *   Whether updates to the next minor will be allowed.
    * @param string $installed_version
@@ -140,14 +141,15 @@ public function providerReleases(): array {
    * @covers ::getLatestInInstalledMinor
    * @covers ::getLatestInNextMinor
    */
-  public function testReleases(string $chooser_service, bool $minor_support, string $installed_version, ?string $current_minor, ?string $next_minor): void {
+  public function testReleases(string $updater_service, bool $minor_support, string $installed_version, ?string $current_minor, ?string $next_minor): void {
     $this->setCoreVersion($installed_version);
     $this->config('auto_updates.settings')->set('allow_core_minor_updates', $minor_support)->save();
     /** @var \Drupal\auto_updates\ReleaseChooser $chooser */
-    $chooser = $this->container->get($chooser_service);
-    $chooser->refresh();
-    $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor());
-    $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor());
+    $chooser = $this->container->get('auto_updates.release_chooser');
+    /** @var \Drupal\auto_updates\Updater $updater */
+    $updater = $this->container->get($updater_service);
+    $this->assertReleaseVersion($current_minor, $chooser->getLatestInInstalledMinor($updater));
+    $this->assertReleaseVersion($next_minor, $chooser->getLatestInNextMinor($updater));
   }
 
   /**
@@ -158,7 +160,7 @@ public function testReleases(string $chooser_service, bool $minor_support, strin
    * @param \Drupal\update\ProjectRelease|null $release
    *   The release to check, or NULL if no release is expected.
    */
-  private function assertReleaseVersion(?string $version, ?ProjectRelease $release) {
+  private function assertReleaseVersion(?string $version, ?ProjectRelease $release): void {
     if (is_null($version)) {
       $this->assertNull($release);
     }
diff --git a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
index d4fe45537c65..8b5f298bab6a 100644
--- a/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
+++ b/core/modules/auto_updates/tests/src/Kernel/UpdaterTest.php
@@ -2,7 +2,11 @@
 
 namespace Drupal\Tests\auto_updates\Kernel;
 
+use Drupal\auto_updates\Exception\UpdateException;
+use Drupal\package_manager\Exception\StageException;
+use Drupal\package_manager_bypass\Committer;
 use Drupal\Tests\user\Traits\UserCreationTrait;
+use PhpTuf\ComposerStager\Domain\Exception\InvalidArgumentException;
 
 /**
  * @coversDefaultClass \Drupal\auto_updates\Updater
@@ -34,18 +38,17 @@ protected function setUp(): void {
    * Tests that correct versions are staged after calling ::begin().
    */
   public function testCorrectVersionsStaged(): void {
-    $this->setReleaseMetadata(__DIR__ . '/../../fixtures/release-history/drupal.9.8.1-security.xml');
+    // Simulate that we're running Drupal 9.8.0 and a security update to 9.8.1
+    // is available.
+    $this->setCoreVersion('9.8.0');
+    $this->setReleaseMetadata([
+      'drupal' => __DIR__ . '/../../../package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml',
+    ]);
 
     // Create a user who will own the stage even after the container is rebuilt.
     $user = $this->createUser([], NULL, TRUE, ['uid' => 2]);
     $this->setCurrentUser($user);
 
-    // Point to a fake site which requires Drupal core via a distribution. The
-    // lock file should be scanned to determine the core packages, which should
-    // result in drupal/core-recommended being updated.
-    $fixture_dir = __DIR__ . '/../../fixtures/fake-site';
-    $locator = $this->mockPathLocator($fixture_dir, $fixture_dir);
-
     $id = $this->container->get('auto_updates.updater')->begin([
       'drupal' => '9.8.1',
     ]);
@@ -54,8 +57,7 @@ public function testCorrectVersionsStaged(): void {
     $kernel = $this->container->get('kernel');
     $kernel->rebuildContainer();
     $this->container = $kernel->getContainer();
-    // Keep using the mocked path locator and current user.
-    $this->container->set('package_manager.path_locator', $locator);
+    // Keep using the user account we created.
     $this->setCurrentUser($user);
 
     /** @var \Drupal\auto_updates\Updater $updater */
@@ -125,8 +127,8 @@ public function testInvalidProjectVersions(array $project_versions): void {
   /**
    * Data provider for testInvalidProjectVersions().
    *
-   * @return array
-   *   The test cases for testInvalidProjectVersions().
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerInvalidProjectVersions(): array {
     return [
@@ -136,4 +138,62 @@ public function providerInvalidProjectVersions(): array {
     ];
   }
 
+  /**
+   * Data provider for testCommitException().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerCommitException(): array {
+    return [
+      'RuntimeException' => [
+        'RuntimeException',
+        UpdateException::class,
+      ],
+      'InvalidArgumentException' => [
+        InvalidArgumentException::class,
+        StageException::class,
+      ],
+      'Exception' => [
+        'Exception',
+        UpdateException::class,
+      ],
+    ];
+  }
+
+  /**
+   * Tests exception handling during calls to Composer Stager commit.
+   *
+   * @param string $thrown_class
+   *   The throwable class that should be thrown by Composer Stager.
+   * @param string|null $expected_class
+   *   The expected exception class.
+   *
+   * @dataProvider providerCommitException
+   */
+  public function testCommitException(string $thrown_class, string $expected_class = NULL): void {
+    $updater = $this->container->get('auto_updates.updater');
+    $updater->begin([
+      'drupal' => '9.8.1',
+    ]);
+    $updater->stage();
+    $thrown_message = 'A very bad thing happened';
+    Committer::setException(new $thrown_class($thrown_message, 123));
+    $this->expectException($expected_class);
+    $expected_message = $expected_class === UpdateException::class ?
+      'The update operation failed to apply. The update may have been partially applied. It is recommended that the site be restored from a code backup.'
+      : $thrown_message;
+    $this->expectExceptionMessage($expected_message);
+    $this->expectExceptionCode(123);
+    $updater->apply();
+  }
+
+  /**
+   * Tests that setLogger is called on the updater service.
+   */
+  public function testLoggerIsSetByContainer(): void {
+    $updater_method_calls = $this->container->getDefinition('auto_updates.updater')->getMethodCalls();
+    $this->assertSame('setLogger', $updater_method_calls[0][0]);
+  }
+
 }
diff --git a/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php b/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php
index d287aa8d80e3..6e0ec295199e 100644
--- a/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php
+++ b/core/modules/auto_updates/tests/src/Traits/ValidationTestTrait.php
@@ -3,7 +3,7 @@
 namespace Drupal\Tests\auto_updates\Traits;
 
 use Drupal\package_manager\ValidationResult;
-
+use Drupal\system\SystemManager;
 use Drupal\Tests\package_manager\Traits\ValidationTestTrait as PackageManagerValidationTestTrait;
 
 /**
@@ -28,67 +28,34 @@ trait ValidationTestTrait {
   protected static $warningsExplanation = 'Your site does not pass some readiness checks for automatic updates. Depending on the nature of the failures, it might affect the eligibility for automatic updates.';
 
   /**
-   * Test validation results.
+   * Creates a unique validation test result.
+   *
+   * @param int $severity
+   *   The severity. Should be one of the SystemManager::REQUIREMENT_*
+   *   constants.
+   * @param int $message_count
+   *   (optional) The number of messages. Defaults to 1.
    *
-   * @var \Drupal\package_manager\ValidationResult[][][]
+   * @return \Drupal\package_manager\ValidationResult
+   *   The validation test result.
    */
-  protected $testResults;
+  protected function createValidationResult(int $severity, int $message_count = 1): ValidationResult {
+    $this->assertNotEmpty($message_count);
+    $messages = [];
+    $random = $this->randomMachineName(64);
+    for ($i = 0; $i < $message_count; $i++) {
+      $messages[] = "Message $i $random";
+    }
+    $summary = t('Summary @random', ['@random' => $random]);
+    switch ($severity) {
+      case SystemManager::REQUIREMENT_ERROR:
+        return ValidationResult::createError($messages, $summary);
 
-  /**
-   * 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.")
-        ),
+      case SystemManager::REQUIREMENT_WARNING:
+        return ValidationResult::createWarning($messages, $summary);
 
-      ];
-      $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.")
-        ),
-      ];
+      default:
+        throw new \InvalidArgumentException("$severity is an invalid value for \$severity; it must be SystemManager::REQUIREMENT_ERROR or SystemManager::REQUIREMENT_WARNING.");
     }
   }
 
diff --git a/core/modules/auto_updates/tests/src/Traits/VersionPolicyTestTrait.php b/core/modules/auto_updates/tests/src/Traits/VersionPolicyTestTrait.php
new file mode 100644
index 000000000000..fb9b76c16a1f
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Traits/VersionPolicyTestTrait.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Traits;
+
+/**
+ * Common methods for testing version policy rules.
+ */
+trait VersionPolicyTestTrait {
+
+  /**
+   * Tests that a policy rule returns a set of errors.
+   *
+   * @param object $rule
+   *   The policy rule under test.
+   * @param string $installed_version
+   *   The installed version of Drupal.
+   * @param string|null $target_version
+   *   The target version of Drupal, or NULL if it's not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   * @param \Drupal\update\ProjectRelease[] $available_releases
+   *   (optional) The available releases of Drupal core, keyed by version.
+   *   Defaults to an empty array.
+   */
+  protected function assertPolicyErrors(object $rule, string $installed_version, ?string $target_version, array $expected_errors, array $available_releases = []): void {
+    $rule->setStringTranslation($this->getStringTranslationStub());
+
+    $actual_errors = array_map('strval', $rule->validate($installed_version, $target_version, $available_releases));
+    $this->assertSame($expected_errors, $actual_errors);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/LegacyVersionUtilityTest.php b/core/modules/auto_updates/tests/src/Unit/LegacyVersionUtilityTest.php
new file mode 100644
index 000000000000..e836606b4994
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/LegacyVersionUtilityTest.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit;
+
+use Drupal\package_manager\LegacyVersionUtility;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\LegacyVersionUtility
+ *
+ * @group auto_updates
+ */
+class LegacyVersionUtilityTest extends UnitTestCase {
+
+  /**
+   * @covers ::convertToSemanticVersion
+   *
+   * @param string $version_number
+   *   The version number to covert.
+   * @param string $expected
+   *   The expected result.
+   *
+   * @dataProvider providerConvertToSemanticVersion
+   */
+  public function testConvertToSemanticVersion(string $version_number, string $expected): void {
+    $this->assertSame($expected, LegacyVersionUtility::convertToSemanticVersion($version_number));
+  }
+
+  /**
+   * Data provider for testConvertToSemanticVersion()
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerConvertToSemanticVersion(): array {
+    return [
+      '8.x-1.2' => ['8.x-1.2', '1.2.0'],
+      '8.x-1.2-alpha1' => ['8.x-1.2-alpha1', '1.2.0-alpha1'],
+      '1.2.0' => ['1.2.0', '1.2.0'],
+      '1.2.0-alpha1' => ['1.2.0-alpha1', '1.2.0-alpha1'],
+    ];
+  }
+
+  /**
+   * @covers ::convertToLegacyVersion
+   *
+   * @param string $version_number
+   *   The version number to covert.
+   * @param string|null $expected
+   *   The expected result.
+   *
+   * @dataProvider providerConvertToLegacyVersion
+   */
+  public function testConvertToLegacyVersion(string $version_number, ?string $expected): void {
+    $this->assertSame($expected, LegacyVersionUtility::convertToLegacyVersion($version_number));
+  }
+
+  /**
+   * Data provider for testConvertToLegacyVersion()
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerConvertToLegacyVersion(): array {
+    return [
+      '1.2.0' => ['1.2.0', '8.x-1.2'],
+      '1.2.0-alpha1' => ['1.2.0-alpha1', '8.x-1.2-alpha1'],
+      '8.x-1.2' => ['8.x-1.2', '8.x-1.2'],
+      '8.x-1.2-alpha1' => ['8.x-1.2-alpha1', '8.x-1.2-alpha1'],
+      '1.2.3' => ['1.2.3', NULL],
+      '1.2.3-alpha1' => ['1.2.3-alpha1', NULL],
+    ];
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php b/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php
deleted file mode 100644
index 0c1c4e82ef23..000000000000
--- a/core/modules/auto_updates/tests/src/Unit/ProjectInfoTest.php
+++ /dev/null
@@ -1,194 +0,0 @@
-<?php
-
-namespace Drupal\Tests\auto_updates\Unit;
-
-use Drupal\auto_updates\ProjectInfo;
-use Drupal\update\ProjectRelease;
-use Drupal\Tests\UnitTestCase;
-use Drupal\update\UpdateManagerInterface;
-
-/**
- * @coversDefaultClass \Drupal\auto_updates\ProjectInfo
- *
- * @group auto_updates
- */
-class ProjectInfoTest extends UnitTestCase {
-
-  /**
-   * Creates release data for testing.
-   *
-   * @return string[][]
-   *   The release information.
-   */
-  private static function createTestReleases(): array {
-    $versions = ['8.2.5', '8.2.4', '8.2.3', '8.2.3-alpha'];
-    foreach ($versions as $version) {
-      $release_arrays[$version] = [
-        'status' => 'published',
-        'version' => $version,
-        'release_link' => "https://example.drupal.org/project/drupal/releases/$version",
-      ];
-    }
-    return $release_arrays;
-  }
-
-  /**
-   * Data provider for testGetInstallableReleases().
-   *
-   * @return array[][]
-   *   The test cases.
-   */
-  public function providerGetInstallableReleases(): array {
-    $release_arrays = static::createTestReleases();
-    foreach ($release_arrays as $version => $release_array) {
-      $release_objects[$version] = ProjectRelease::createFromArray($release_array);
-    }
-    return [
-      'current' => [
-        [
-          'status' => UpdateManagerInterface::CURRENT,
-          'existing_version' => '1.2.3',
-        ],
-        [],
-      ],
-      '1 release' => [
-        [
-          'status' => UpdateManagerInterface::NOT_CURRENT,
-          'existing_version' => '8.2.4',
-          'recommended' => '8.2.5',
-          'releases' => [
-            '8.2.5' => $release_arrays['8.2.5'],
-          ],
-        ],
-        [
-          '8.2.5' => $release_objects['8.2.5'],
-        ],
-      ],
-      '1 releases, also security' => [
-        [
-          'status' => UpdateManagerInterface::NOT_CURRENT,
-          'existing_version' => '8.2.4',
-          'recommended' => '8.2.5',
-          'releases' => [
-            '8.2.5' => $release_arrays['8.2.5'],
-          ],
-          'security updates' => [
-            $release_arrays['8.2.5'],
-          ],
-        ],
-        [
-          '8.2.5' => $release_objects['8.2.5'],
-        ],
-      ],
-      '1 release, other security' => [
-        [
-          'status' => UpdateManagerInterface::NOT_CURRENT,
-          'existing_version' => '8.2.2',
-          'recommended' => '8.2.5',
-          'releases' => [
-            '8.2.5' => $release_arrays['8.2.5'],
-          ],
-          'security updates' => [
-            // Set out of order security releases to ensure results are sorted.
-            $release_arrays['8.2.3-alpha'],
-            $release_arrays['8.2.3'],
-            $release_arrays['8.2.4'],
-          ],
-        ],
-        [
-          '8.2.5' => $release_objects['8.2.5'],
-          '8.2.4' => $release_objects['8.2.4'],
-          '8.2.3' => $release_objects['8.2.3'],
-          '8.2.3-alpha' => $release_objects['8.2.3-alpha'],
-        ],
-      ],
-      '1 releases, other security lower than current version' => [
-        [
-          'status' => UpdateManagerInterface::NOT_CURRENT,
-          'existing_version' => '8.2.3',
-          'recommended' => '8.2.5',
-          'releases' => [
-            '8.2.5' => $release_arrays['8.2.5'],
-          ],
-          'security updates' => [
-            // Set out of order security releases to ensure results are sorted.
-            $release_arrays['8.2.3-alpha'],
-            $release_arrays['8.2.3'],
-            $release_arrays['8.2.4'],
-          ],
-        ],
-        [
-          '8.2.5' => $release_objects['8.2.5'],
-          '8.2.4' => $release_objects['8.2.4'],
-        ],
-      ],
-    ];
-  }
-
-  /**
-   * @covers ::getInstallableReleases
-   *
-   * @param array $project_data
-   *   The project data to return from ::getProjectInfo().
-   * @param \Drupal\update\ProjectRelease[] $expected_releases
-   *   The expected releases.
-   *
-   * @dataProvider providerGetInstallableReleases
-   */
-  public function testGetInstallableReleases(array $project_data, array $expected_releases): void {
-    $project_info = $this->getMockedProjectInfo($project_data);
-    $this->assertEqualsCanonicalizing($expected_releases, $project_info->getInstallableReleases());
-  }
-
-  /**
-   * @covers ::getInstallableReleases
-   */
-  public function testInvalidProjectData(): void {
-    $release_arrays = static::createTestReleases();
-    $project_data = [
-      'status' => UpdateManagerInterface::NOT_CURRENT,
-      'existing_version' => '1.2.3',
-      'releases' => [
-        '8.2.5' => $release_arrays['8.2.5'],
-      ],
-      'security updates' => [
-        $release_arrays['8.2.4'],
-        $release_arrays['8.2.3'],
-        $release_arrays['8.2.3-alpha'],
-      ],
-    ];
-    $project_info = $this->getMockedProjectInfo($project_data);
-    $this->expectException('LogicException');
-    $this->expectExceptionMessage('Drupal core is out of date, but the recommended version could not be determined.');
-    $project_info->getInstallableReleases();
-  }
-
-  /**
-   * @covers ::getInstalledVersion
-   */
-  public function testGetInstalledVersion(): void {
-    $project_info = $this->getMockedProjectInfo(['existing_version' => '1.2.3']);
-    $this->assertSame('1.2.3', $project_info->getInstalledVersion());
-  }
-
-  /**
-   * Mocks a ProjectInfo object.
-   *
-   * @param array $project_data
-   *   The project info that should be returned by the mock's ::getProjectInfo()
-   *   method.
-   *
-   * @return \Drupal\auto_updates\ProjectInfo
-   *   The mocked object.
-   */
-  private function getMockedProjectInfo(array $project_data): ProjectInfo {
-    $project_info = $this->getMockBuilder(ProjectInfo::class)
-      ->onlyMethods(['getProjectInfo'])
-      ->getMock();
-    $project_info->expects($this->any())
-      ->method('getProjectInfo')
-      ->willReturn($project_data);
-    return $project_info;
-  }
-
-}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php
new file mode 100644
index 000000000000..bb70f66fbd8d
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDevSnapshotTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\ForbidDevSnapshot;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\ForbidDevSnapshot
+ *
+ * @group auto_updates
+ */
+class ForbidDevSnapshotTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testForbidDevSnapshot().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerForbidDevSnapshot(): array {
+    return [
+      'stable version installed' => [
+        '9.8.0',
+        [],
+      ],
+      'alpha version installed' => [
+        '9.8.0-alpha3',
+        [],
+      ],
+      'beta version installed' => [
+        '9.8.0-beta7',
+        [],
+      ],
+      'release candidate installed' => [
+        '9.8.0-rc2',
+        [],
+      ],
+      'dev snapshot installed' => [
+        '9.8.0-dev',
+        ['Drupal cannot be automatically updated from the installed version, 9.8.0-dev, because automatic updates from a dev version to any other version are not supported.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update from a dev snapshot raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerForbidDevSnapshot
+   */
+  public function testForbidDevSnapshot(string $installed_version, array $expected_errors): void {
+    $rule = new ForbidDevSnapshot();
+    $this->assertPolicyErrors($rule, $installed_version, '9.8.1', $expected_errors);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php
new file mode 100644
index 000000000000..650145ebd055
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidDowngradeTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\ForbidDowngrade;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\ForbidDowngrade
+ *
+ * @group auto_updates
+ */
+class ForbidDowngradeTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testDowngradeForbidden().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerDowngradeForbidden(): array {
+    return [
+      'unknown target version' => [
+        '9.8.0',
+        NULL,
+        ['Update version  is lower than 9.8.0, downgrading is not supported.'],
+      ],
+      'same versions' => [
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'newer target version' => [
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'older target version' => [
+        '9.8.2',
+        '9.8.0',
+        ['Update version 9.8.0 is lower than 9.8.2, downgrading is not supported.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that downgrading always raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerDowngradeForbidden
+   */
+  public function testDowngradeForbidden(string $installed_version, ?string $target_version, array $expected_errors): void {
+    $rule = new ForbidDowngrade();
+    $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php
new file mode 100644
index 000000000000..19998e464606
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/ForbidMinorUpdatesTest.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\ForbidMinorUpdates;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\ForbidMinorUpdates
+ *
+ * @group auto_updates
+ */
+class ForbidMinorUpdatesTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testMinorUpdateForbidden().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerMinorUpdateForbidden(): array {
+    return [
+      'same versions' => [
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'target version newer in same minor' => [
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'target version older in same minor' => [
+        '9.8.2',
+        '9.8.0',
+        [],
+      ],
+      'target version in older minor' => [
+        '9.8.0',
+        '9.7.2',
+        ['Drupal cannot be automatically updated from 9.8.0 to 9.7.2 because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+
+      'target version in newer minor' => [
+        '9.8.0',
+        '9.9.2',
+        ['Drupal cannot be automatically updated from 9.8.0 to 9.9.2 because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in older major' => [
+        '9.8.0',
+        '8.8.0',
+        ['Drupal cannot be automatically updated from 9.8.0 to 8.8.0 because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in newer major' => [
+        '9.8.0',
+        '10.8.0',
+        ['Drupal cannot be automatically updated from 9.8.0 to 10.8.0 because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in older major and minor' => [
+        '9.8.0',
+        '8.9.9',
+        ['Drupal cannot be automatically updated from 9.8.0 to 8.9.9 because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+      'target version in newer major and minor' => [
+        '9.8.0',
+        '10.9.2',
+        ['Drupal cannot be automatically updated from 9.8.0 to 10.9.2 because automatic updates from one minor version to another are not supported during cron.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update across minor versions raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerMinorUpdateForbidden
+   */
+  public function testMinorUpdateForbidden(string $installed_version, ?string $target_version, array $expected_errors): void {
+    $rule = new ForbidMinorUpdates();
+    $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php
new file mode 100644
index 000000000000..b69e0b1c3370
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MajorVersionMatchTest.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\MajorVersionMatch;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\MajorVersionMatch
+ *
+ * @group auto_updates
+ */
+class MajorVersionMatchTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testMajorVersionMatch().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerMajorVersionMatch(): array {
+    return [
+      'same versions' => [
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'target version newer in same minor' => [
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'target version in newer minor' => [
+        '9.8.0',
+        '9.9.2',
+        [],
+      ],
+      'target version older in same minor' => [
+        '9.8.2',
+        '9.8.0',
+        [],
+      ],
+      'target version in older minor' => [
+        '9.8.0',
+        '9.7.2',
+        [],
+      ],
+      'target version in newer major' => [
+        '9.8.0',
+        '10.0.0',
+        ['Drupal cannot be automatically updated from 9.8.0 to 10.0.0 because automatic updates from one major version to another are not supported.'],
+      ],
+      'target version in older major' => [
+        '9.8.0',
+        '8.9.0',
+        ['Drupal cannot be automatically updated from 9.8.0 to 8.9.0 because automatic updates from one major version to another are not supported.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update across major versions raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerMajorVersionMatch
+   */
+  public function testMajorVersionMatch(string $installed_version, ?string $target_version, array $expected_errors): void {
+    $rule = new MajorVersionMatch();
+    $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MinorUpdatesEnabledTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MinorUpdatesEnabledTest.php
new file mode 100644
index 000000000000..75add1fcb470
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/MinorUpdatesEnabledTest.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\MinorUpdatesEnabled;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\MinorUpdatesEnabled
+ *
+ * @group auto_updates
+ */
+class MinorUpdatesEnabledTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testMinorUpdatesEnabled().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerMinorUpdatesEnabled(): array {
+    return [
+      'same versions, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'same versions, minor updates allowed' => [
+        TRUE,
+        '9.8.0',
+        '9.8.0',
+        [],
+      ],
+      'target version newer in same minor, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'target version newer in same minor, minor updates allowed' => [
+        TRUE,
+        '9.8.0',
+        '9.8.2',
+        [],
+      ],
+      'target version in newer minor, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '9.9.2',
+        ['Drupal cannot be automatically updated from 9.8.0 to 9.9.2 because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in newer minor, minor updates allowed' => [
+        TRUE,
+        '9.8.0',
+        '9.9.2',
+        [],
+      ],
+      'target version older in same minor, minor updates forbidden' => [
+        FALSE,
+        '9.8.2',
+        '9.8.0',
+        [],
+      ],
+      'target version older in same minor, minor updates allowed' => [
+        TRUE,
+        '9.8.2',
+        '9.8.0',
+        [],
+      ],
+      'target version in older minor, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '9.7.2',
+        ['Drupal cannot be automatically updated from 9.8.0 to 9.7.2 because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in older minor, minor updates allowed' => [
+        TRUE,
+        '9.8.0',
+        '9.7.2',
+        [],
+      ],
+      'target version in older major, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '8.8.0',
+        ['Drupal cannot be automatically updated from 9.8.0 to 8.8.0 because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in older major, minor updates allowed' => [
+        FALSE,
+        '9.8.0',
+        '8.8.0',
+        ['Drupal cannot be automatically updated from 9.8.0 to 8.8.0 because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in newer major, minor updates forbidden' => [
+        FALSE,
+        '9.8.0',
+        '10.8.0',
+        ['Drupal cannot be automatically updated from 9.8.0 to 10.8.0 because automatic updates from one minor version to another are not supported.'],
+      ],
+      'target version in newer major, minor updates allowed' => [
+        FALSE,
+        '9.8.0',
+        '10.8.0',
+        ['Drupal cannot be automatically updated from 9.8.0 to 10.8.0 because automatic updates from one minor version to another are not supported.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update across minor versions depends on configuration.
+   *
+   * @param bool $allowed
+   *   Whether or not updating across minor versions is allowed.
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string|null $target_version
+   *   The target version of Drupal core, or NULL if not known.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerMinorUpdatesEnabled
+   */
+  public function testMinorUpdatesEnabled(bool $allowed, string $installed_version, ?string $target_version, array $expected_errors): void {
+    $config_factory = $this->getConfigFactoryStub([
+      'auto_updates.settings' => [
+        'allow_core_minor_updates' => $allowed,
+      ],
+    ]);
+    $rule = new MinorUpdatesEnabled($config_factory);
+    $this->assertPolicyErrors($rule, $installed_version, $target_version, $expected_errors);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php
new file mode 100644
index 000000000000..204c236a6cf3
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/StableReleaseInstalledTest.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\StableReleaseInstalled;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\StableReleaseInstalled
+ *
+ * @group auto_updates
+ */
+class StableReleaseInstalledTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testStableReleaseInstalled().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerStableReleaseInstalled(): array {
+    return [
+      'stable version installed' => [
+        '9.8.0',
+        [],
+      ],
+      'alpha version installed' => [
+        '9.8.0-alpha3',
+        ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-alpha3, because it is not a stable version.'],
+      ],
+      'beta version installed' => [
+        '9.8.0-beta7',
+        ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-beta7, because it is not a stable version.'],
+      ],
+      'release candidate installed' => [
+        '9.8.0-rc2',
+        ['Drupal cannot be automatically updated during cron from its current version, 9.8.0-rc2, because it is not a stable version.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update from a non-stable release raises an error.
+   *
+   * @param string $installed_version
+   *   The installed version of Drupal core.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerStableReleaseInstalled
+   */
+  public function testStableReleaseInstalled(string $installed_version, array $expected_errors): void {
+    $rule = new StableReleaseInstalled();
+    $this->assertPolicyErrors($rule, $installed_version, '9.8.1', $expected_errors);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php
new file mode 100644
index 000000000000..c36a5af9052c
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetSecurityReleaseTest.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\TargetSecurityRelease;
+use Drupal\update\ProjectRelease;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\TargetSecurityRelease
+ *
+ * @group auto_updates
+ */
+class TargetSecurityReleaseTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testTargetSecurityRelease().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerTargetSecurityRelease(): array {
+    return [
+      'target security release' => [
+        [
+          '9.8.1' => ProjectRelease::createFromArray([
+            'status' => 'published',
+            'release_link' => 'http://example.com/drupal-9-8-1-release',
+            'version' => '9.8.1',
+            'terms' => [
+              'Release type' => ['Security update'],
+            ],
+          ]),
+        ],
+        [],
+      ],
+      'target non-security release' => [
+        [
+          '9.8.1' => ProjectRelease::createFromArray([
+            'status' => 'published',
+            'release_link' => 'http://example.com/drupal-9-8-1-release',
+            'version' => '9.8.1',
+          ]),
+        ],
+        ['Drupal cannot be automatically updated during cron from 9.8.0 to 9.8.1 because 9.8.1 is not a security release.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the target version must be a security release.
+   *
+   * @param \Drupal\update\ProjectRelease[] $available_releases
+   *   The available releases of Drupal core, keyed by version.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerTargetSecurityRelease
+   */
+  public function testTargetSecurityRelease(array $available_releases, array $expected_errors): void {
+    $rule = new TargetSecurityRelease();
+    $this->assertPolicyErrors($rule, '9.8.0', '9.8.1', $expected_errors, $available_releases);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php
new file mode 100644
index 000000000000..30eb0eeb6741
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionInstallableTest.php
@@ -0,0 +1,69 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionInstallable;
+use Drupal\update\ProjectRelease;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\TargetVersionInstallable
+ *
+ * @group auto_updates
+ */
+class TargetVersionInstallableTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testTargetVersionInstallable().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerTargetVersionInstallable(): array {
+    return [
+      'no available releases' => [
+        [],
+        ['Cannot update Drupal core to 9.8.2 because it is not in the list of installable releases.'],
+      ],
+      'unknown target' => [
+        [
+          '9.8.1' => ProjectRelease::createFromArray([
+            'status' => 'published',
+            'release_link' => 'http://example.com/drupal-9-8-1-release',
+            'version' => '9.8.1',
+          ]),
+        ],
+        ['Cannot update Drupal core to 9.8.2 because it is not in the list of installable releases.'],
+      ],
+      'valid target' => [
+        [
+          '9.8.2' => ProjectRelease::createFromArray([
+            'status' => 'published',
+            'release_link' => 'http://example.com/drupal-9-8-2-release',
+            'version' => '9.8.2',
+          ]),
+        ],
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the target version must be a known, installable release.
+   *
+   * @param \Drupal\update\ProjectRelease[] $available_releases
+   *   The available releases of Drupal core, keyed by version.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerTargetVersionInstallable
+   */
+  public function testTargetVersionInstallable(array $available_releases, array $expected_errors): void {
+    $rule = new TargetVersionInstallable();
+    $this->assertPolicyErrors($rule, '9.8.1', '9.8.2', $expected_errors, $available_releases);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php
new file mode 100644
index 000000000000..1ea2d6d6a307
--- /dev/null
+++ b/core/modules/auto_updates/tests/src/Unit/VersionPolicy/TargetVersionStableTest.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\Tests\auto_updates\Unit\VersionPolicy;
+
+use Drupal\auto_updates\Validator\VersionPolicy\TargetVersionStable;
+use Drupal\Tests\auto_updates\Traits\VersionPolicyTestTrait;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\auto_updates\Validator\VersionPolicy\TargetVersionStable
+ *
+ * @group auto_updates
+ */
+class TargetVersionStableTest extends UnitTestCase {
+
+  use VersionPolicyTestTrait;
+
+  /**
+   * Data provider for testTargetVersionStable().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerTargetVersionStable(): array {
+    return [
+      'stable target version' => [
+        '9.9.0',
+        [],
+      ],
+      'dev target version' => [
+        '9.9.0-dev',
+        ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-dev, because it is not a stable version.'],
+      ],
+      'alpha target version' => [
+        '9.9.0-alpha3',
+        ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-alpha3, because it is not a stable version.'],
+      ],
+      'beta target version' => [
+        '9.9.0-beta7',
+        ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-beta7, because it is not a stable version.'],
+      ],
+      'release candidate target version' => [
+        '9.9.0-rc2',
+        ['Drupal cannot be automatically updated during cron to the recommended version, 9.9.0-rc2, because it is not a stable version.'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that trying to update to a non-stable version raises an error.
+   *
+   * @param string $target_version
+   *   The target version of Drupal core.
+   * @param string[] $expected_errors
+   *   The expected error messages, if any.
+   *
+   * @dataProvider providerTargetVersionStable
+   */
+  public function testTargetVersionStable(string $target_version, array $expected_errors): void {
+    $rule = new TargetVersionStable();
+    $this->assertPolicyErrors($rule, '9.8.0', $target_version, $expected_errors);
+  }
+
+}
diff --git a/core/modules/auto_updates/tests/themes/auto_updates_theme/auto_updates_theme.info.yml b/core/modules/auto_updates/tests/themes/auto_updates_theme/auto_updates_theme.info.yml
new file mode 100644
index 000000000000..3caac67b43f1
--- /dev/null
+++ b/core/modules/auto_updates/tests/themes/auto_updates_theme/auto_updates_theme.info.yml
@@ -0,0 +1,5 @@
+name: Automatic Updates Theme
+type: theme
+description: 'Empty theme for tests.'
+package: Testing
+base theme: false
diff --git a/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.info.yml b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.info.yml
new file mode 100644
index 000000000000..0f3e8b23f43c
--- /dev/null
+++ b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.info.yml
@@ -0,0 +1,5 @@
+name: Automatic Updates Theme With Updates
+type: theme
+description: 'Empty theme with updates for tests.'
+package: Testing
+base theme: false
diff --git a/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.install b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.install
new file mode 100644
index 000000000000..bee0ada5d942
--- /dev/null
+++ b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.install
@@ -0,0 +1,8 @@
+<?php
+
+/**
+ * @file
+ * Blank .install file.
+ *
+ * @see \Drupal\Tests\auto_updates\Kernel\ReadinessValidation\StagedDatabaseUpdateValidatorTest
+ */
diff --git a/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.post_update.php b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.post_update.php
new file mode 100644
index 000000000000..49dbe99dfa19
--- /dev/null
+++ b/core/modules/auto_updates/tests/themes/auto_updates_theme_with_updates/auto_updates_theme_with_updates.post_update.php
@@ -0,0 +1,8 @@
+<?php
+
+/**
+ * @file
+ * Blank .post_update file.
+ *
+ * @see \Drupal\Tests\auto_updates\Kernel\ReadinessValidation\StagedDatabaseUpdateValidatorTest
+ */
diff --git a/core/modules/package_manager/core_packages.json b/core/modules/package_manager/core_packages.json
deleted file mode 100644
index 1804b96aef83..000000000000
--- a/core/modules/package_manager/core_packages.json
+++ /dev/null
@@ -1,9 +0,0 @@
-[
-  "drupal/core",
-  "drupal/core-composer-scaffold",
-  "drupal/core-dev",
-  "drupal/core-dev-pinned",
-  "drupal/core-project-message",
-  "drupal/core-recommended",
-  "drupal/core-vendor-hardening"
-]
diff --git a/core/modules/package_manager/core_packages.yml b/core/modules/package_manager/core_packages.yml
new file mode 100644
index 000000000000..d1b29edfd196
--- /dev/null
+++ b/core/modules/package_manager/core_packages.yml
@@ -0,0 +1,15 @@
+# This file exists so that \Drupal\package_manager\ComposerUtility can discern
+# which installed packages are considered part of Drupal core. There's no way
+# to tell by package type alone, since these packages have varying types, but
+# are all part of Drupal core's repository. This file is for internal use and
+# may be changed or removed at any time. Code external to Package Manager
+# should not use it in any way.
+[
+  drupal/core,
+  drupal/core-composer-scaffold,
+  drupal/core-dev,
+  drupal/core-dev-pinned,
+  drupal/core-project-message,
+  drupal/core-recommended,
+  drupal/core-vendor-hardening
+]
diff --git a/core/modules/package_manager/package_manager.info.yml b/core/modules/package_manager/package_manager.info.yml
index dd204c7f0f32..33b204415907 100644
--- a/core/modules/package_manager/package_manager.info.yml
+++ b/core/modules/package_manager/package_manager.info.yml
@@ -4,3 +4,6 @@ description: 'API module providing functionality for staging package installs an
 package: Core
 version: VERSION
 lifecycle: experimental
+php: 7.4
+dependencies:
+  - drupal:update
diff --git a/core/modules/package_manager/package_manager.module b/core/modules/package_manager/package_manager.module
index c54269edfcc9..c46e1a20983f 100644
--- a/core/modules/package_manager/package_manager.module
+++ b/core/modules/package_manager/package_manager.module
@@ -14,19 +14,67 @@
 function package_manager_help($route_name, RouteMatchInterface $route_match) {
   switch ($route_name) {
     case 'help.page.package_manager':
-      $output = '<h3>' . t('About') . '</h3>';
+      $output = '<h3 id="package-manager-about">' . t('About') . '</h3>';
       $output .= '<p>' . t('Package Manager is a framework for updating Drupal core and installing contributed modules and themes via Composer. It has no user interface, but it provides an API for creating a temporary copy of the current site, making changes to the copy, and then syncing those changes back into the live site.') . '</p>';
       $output .= '<p>' . t('Package Manager dispatches events before and after various operations, and external code can integrate with it by subscribing to those events. For more information, see <code>package_manager.api.php</code>.') . '</p>';
-      $output .= '<h3>' . t('Requirements') . '</h3>';
-      $output .= '<p>' . t('Package Manager requires Composer @version or later available as an executable, and PHP must have permission to run it. The path to the executable may be set in the <code>package_manager.settings:executables.composer</code> config setting, or it will be automatically detected.', ['@version' => ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION]) . '</p>';
-      $output .= '<h3>' . t('Limitations') . '</h3>';
-      $output .= '<p>' . t("Because Package Manager modifies the current site's code base, it is intentionally limited in certain ways to prevent unexpected changes from being made to the live site:") . '</p>';
+
+      $output .= '<h3 id="package-manager-requirements">' . t('Requirements') . '</h3>';
       $output .= '<ul>';
-      $output .= '<li>' . t('Package Manager can only maintain one copy of the site at any given time. If a copy of the site already exists, another one cannot be created until the existing copy is destroyed.') . '</li>';
-      $output .= '<li>' . t('The temporary copy of the site is associated with the user or session that originally created it, and only that user or session can make changes to it.') . '</li>';
-      $output .= '<li>' . t('Modules cannot be uninstalled while Package Manager is syncing changes into live site.') . '<li>';
+      $output .= '  <li>' . t("The Drupal application's codebase must be writable in order to use Automatic Updates. This includes Drupal core, modules, themes and the Composer dependencies in the <code>vendor</code> directory. This makes Automatic Updates incompatible with some hosting platforms.") . '</li>';
+      $output .= '  <li>' . t('Package Manager requires Composer @version or later available as an executable, and PHP must have permission to run it. It should be detected automatically. If not, see <a href="#package-manager-faq-composer-not-found">What if it says the "composer" executable cannot be found?</a>.', ['@version' => ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION]) . '</li>';
+      $output .= '</ul>';
+
+      $output .= '<h3 id="package-manager-limitations">' . t('Limitations') . '</h3>';
+      $output .= '<p>' . t("Because Package Manager modifies the current site's code base, it is intentionally limited in certain ways to prevent unexpected changes to the live site:") . '</p>';
+      $output .= '<ul>';
+      $output .= '  <li>' . t('It does not support Drupal multi-site installations.') . '</li>';
+      $output .= '  <li>' . t('It does not support symlinks. If you have any, see <a href="#package-manager-faq-composer-not-found">What if it says I have symlinks in my codebase?</a>.') . '</li>';
+      $output .= '  <li>' . t('It does not automatically perform version control operations, e.g., with Git. Site administrators are responsible for committing updates.') . '</li>';
+      $output .= '  <li>' . t('It can only maintain one copy of the site at any given time. If a copy of the site already exists, another one cannot be created until the existing copy is destroyed.') . '</li>';
+      $output .= '  <li>' . t('It associates the temporary copy of the site with the user or session that originally created it, and only that user or session can make changes to it.') . '</li>';
+      $output .= '  <li>' . t('It does not allow modules to be uninstalled while syncing changes into live site.') . '</li>';
       $output .= '</ul>';
       $output .= '<p>' . t('For more information, see the <a href=":package-manager-documentation">online documentation for the Package Manager module</a>.', [':package-manager-documentation' => 'https://www.drupal.org/docs/8/core/modules/package-manager']) . '</p>';
+
+      $output .= '<h3 id="package-manager-faq">' . t('FAQ') . '</h3>';
+
+      $output .= '<h4 id="package-manager-faq-composer-not-found">' . t('What if it says the "composer" executable cannot be found?') . '</h4>';
+      $output .= '<p>' . t('If the <code>composer</code> executable path cannot be automatically determined, it can be explicitly set in by adding the following line to <code>settings.php</code>:') . '</p>';
+      $output .= "<pre><code>\$config['package_manager.settings']['executables']['composer'] = '/full/path/to/composer.phar';</code></pre>";
+
+      $output .= '<h4 id="package-manager-faq-symlinks-found">' . t('What if it says I have symlinks in my codebase?') . '</h4>';
+      $output .= '<p>' . t('A fresh Drupal installation should not have any symlinks, but third party libraries and custom code can add them. If Automatic Updates says you have some, run the following command in your terminal to find them:') . '</p>';
+      $output .= '<pre><code>';
+      $output .= 'cd /var/www # Wherever your active directory is located.' . PHP_EOL;
+      $output .= 'find . -type l';
+      $output .= '</code></pre>';
+      $output .= '<p>' . t("You might see output like the below, indicating symlinks in Drush's <code>docs</code> directory, as an example:") . '</p>';
+      $output .= '<pre><code>';
+      $output .= './vendor/drush/drush/docs/misc/icon_PhpStorm.png' . PHP_EOL;
+      $output .= './vendor/drush/drush/docs/img/favicon.ico' . PHP_EOL;
+      $output .= './vendor/drush/drush/docs/contribute/CONTRIBUTING.md' . PHP_EOL;
+      $output .= './vendor/drush/drush/docs/drush_logo-black.png' . PHP_EOL;
+      $output .= '</code></pre>';
+
+      $output .= '<h5>' . t('Composer libraries') . '</h5>';
+      $output .= '<p>' . t('Symlinks in Composer libraries can be addressed with <a href=":vendor-hardening-composer-plugin-documentation">Drupal\'s Vendor Hardening Composer Plugin</a>, which "removes extraneous directories from the project\'s vendor directory". Use it as follows.', [':vendor-hardening-composer-plugin-documentation' => 'https://www.drupal.org/docs/develop/using-composer/using-drupals-vendor-hardening-composer-plugin']) . '</p>';
+      $output .= '<p>' . t('First, add `drupal/core-vendor-hardening` to your Composer project:') . '</p>';
+      $output .= '<pre><code>composer require drupal/core-vendor-hardening</code></pre>';
+      $output .= '<p>' . t('Then, add the following to the `composer.json` in your site root to handle the most common, known culprits. Add your own as necessary.') . '</p>';
+      $output .= '<pre><code>';
+      $output .= '"extra": {' . PHP_EOL;
+      $output .= '  "drupal-core-vendor-hardening": {' . PHP_EOL;
+      $output .= '    "drush/drush": ["docs"],' . PHP_EOL;
+      $output .= '    "grasmash/yaml-expander": ["scenarios"]' . PHP_EOL;
+      $output .= '  }' . PHP_EOL;
+      $output .= '}' . PHP_EOL;
+      $output .= '</code></pre>';
+      $output .= '<p>' . t('The new configuration will take effect on the next Composer install or update event. Do this to apply it immediately:') . '</p>';
+      $output .= '<pre><code>composer install</code></pre>';
+
+      $output .= '<h5>' . t('Custom code') . '</h5>';
+      $output .= '<p>' . t('Symlinks are seldom truly necessary and should be avoided in your own code. No solution currently exists to get around them--they must be removed in order to use Automatic Updates.') . '</p>';
+
       return $output;
   }
 }
diff --git a/core/modules/package_manager/package_manager.services.yml b/core/modules/package_manager/package_manager.services.yml
index 75ae35d2ece1..3db8249aacce 100644
--- a/core/modules/package_manager/package_manager.services.yml
+++ b/core/modules/package_manager/package_manager.services.yml
@@ -1,85 +1,61 @@
 services:
-  # Underlying Symfony utilities.
-  package_manager.symfony_file_system:
-    class: Symfony\Component\Filesystem\Filesystem
-  package_manager.symfony_executable_finder:
-    class: Symfony\Component\Process\ExecutableFinder
+  # Underlying Symfony utilities for Composer Stager.
+  Symfony\Component\Filesystem\Filesystem:
+    public: false
+  Symfony\Component\Process\ExecutableFinder:
+    public: false
 
-  # Basic infrastructure services.
-  package_manager.process_factory:
-    class: Drupal\package_manager\ProcessFactory
+  # Basic infrastructure services for Composer Stager, overridden by us to
+  # provide additional functionality.
+  Drupal\package_manager\ProcessFactory:
     arguments:
       - '@file_system'
       - '@config.factory'
-  package_manager.file_system:
-    class: PhpTuf\ComposerStager\Infrastructure\Filesystem\Filesystem
-    arguments:
-      - '@package_manager.symfony_file_system'
-  package_manager.executable_finder:
-    class: Drupal\package_manager\ExecutableFinder
-    arguments:
-      - '@package_manager.symfony_executable_finder'
-      - '@config.factory'
-
-  # Executable runners.
-  package_manager.rsync_runner:
-    class: PhpTuf\ComposerStager\Infrastructure\Process\Runner\RsyncRunner
-    arguments:
-      - '@package_manager.executable_finder'
-      - '@package_manager.process_factory'
-  package_manager.composer_runner:
-    class: PhpTuf\ComposerStager\Infrastructure\Process\Runner\ComposerRunner
-    arguments:
-      - '@package_manager.executable_finder'
-      - '@package_manager.process_factory'
-
-  # File syncers.
-  package_manager.file_syncer.rsync:
-    class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\RsyncFileSyncer
-    arguments:
-      - '@package_manager.file_system'
-      - '@package_manager.rsync_runner'
-  package_manager.file_syncer.php:
-    class: PhpTuf\ComposerStager\Infrastructure\FileSyncer\PhpFileSyncer
-    arguments:
-      - '@package_manager.file_system'
-  package_manager.file_syncer.factory:
-    class: Drupal\package_manager\FileSyncerFactory
-    arguments:
-      - '@package_manager.symfony_executable_finder'
-      - '@package_manager.file_syncer.php'
-      - '@package_manager.file_syncer.rsync'
-      - '@config.factory'
-  package_manager.file_syncer:
-    class: PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface
-    factory: ['@package_manager.file_syncer.factory', 'create']
+    public: false
+  Drupal\package_manager\ExecutableFinder:
+    arguments:
+      $config_factory: '@config.factory'
+    autowire: true
+    public: false
+  Drupal\package_manager\FileSyncerFactory:
+    arguments:
+      $config_factory: '@config.factory'
+    autowire: true
+    public: false
+  PhpTuf\ComposerStager\Infrastructure\Service\Finder\ExecutableFinderInterface:
+    alias: 'Drupal\package_manager\ExecutableFinder'
+  PhpTuf\ComposerStager\Infrastructure\Factory\Process\ProcessFactoryInterface:
+    alias: 'Drupal\package_manager\ProcessFactory'
+  PhpTuf\ComposerStager\Domain\Service\FileSyncer\FileSyncerInterface:
+    factory: ['@Drupal\package_manager\FileSyncerFactory', 'create']
 
-  # Domain services.
+  # Services provided to Drupal by Package Manager.
   package_manager.beginner:
-    class: PhpTuf\ComposerStager\Domain\Beginner
-    arguments:
-      - '@package_manager.file_syncer'
-      - '@package_manager.file_system'
+    class: PhpTuf\ComposerStager\Domain\Core\Beginner\Beginner
+    autowire: true
   package_manager.stager:
-    class: PhpTuf\ComposerStager\Domain\Stager
-    arguments:
-      - '@package_manager.composer_runner'
-      - '@package_manager.file_system'
+    class: PhpTuf\ComposerStager\Domain\Core\Stager\Stager
+    autowire: true
   package_manager.committer:
-    class: PhpTuf\ComposerStager\Domain\Committer
-    arguments:
-      - '@package_manager.file_syncer'
-      - '@package_manager.file_system'
+    class: PhpTuf\ComposerStager\Domain\Core\Committer\Committer
+    autowire: true
   package_manager.path_locator:
     class: Drupal\package_manager\PathLocator
     arguments:
       - '%app.root%'
+      - '@config.factory'
+      - '@file_system'
+  package_manager.failure_marker:
+    class: Drupal\package_manager\FailureMarker
+    arguments:
+      - '@package_manager.path_locator'
 
   # Validators.
   package_manager.validator.composer_executable:
     class: Drupal\package_manager\Validator\ComposerExecutableValidator
     arguments:
-      - '@package_manager.composer_runner'
+      - '@PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface'
+      - '@module_handler'
       - '@string_translation'
     tags:
       - { name: event_subscriber }
@@ -110,7 +86,6 @@ services:
     class: Drupal\package_manager\Validator\WritableFileSystemValidator
     arguments:
       - '@package_manager.path_locator'
-      - '%app.root%'
       - '@string_translation'
     tags:
       - { name: event_subscriber }
@@ -127,13 +102,72 @@ services:
       - '@string_translation'
     tags:
       - { name: event_subscriber }
-  package_manager.excluded_paths_subscriber:
-    class: Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
+  package_manager.validator.symlink:
+    class: Drupal\package_manager\Validator\SymlinkValidator
     arguments:
-      - '%site.path%'
-      - '@package_manager.symfony_file_system'
-      - '@stream_wrapper_manager'
+      - '@package_manager.path_locator'
+      - '@PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface'
+      - '@PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface'
+      - '@module_handler'
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.duplicate_info_file:
+    class: Drupal\package_manager\Validator\DuplicateInfoFileValidator
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.overwrite_existing_packages:
+    class: Drupal\package_manager\Validator\OverwriteExistingPackagesValidator
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.staged_database_updates:
+    class: Drupal\package_manager\Validator\StagedDBUpdateValidator
+    arguments:
+      - '@package_manager.path_locator'
+      - '@extension.list.module'
+      - '@extension.list.theme'
+    tags:
+      - { name: event_subscriber }
+  package_manager.test_site_excluder:
+    class: Drupal\package_manager\PathExcluder\TestSiteExcluder
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  package_manager.vendor_hardening_excluder:
+    class: Drupal\package_manager\PathExcluder\VendorHardeningExcluder
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  package_manager.site_files_excluder:
+    class: Drupal\package_manager\PathExcluder\SiteFilesExcluder
+    arguments:
+      $path_locator: '@package_manager.path_locator'
+      $stream_wrapper_manager: '@stream_wrapper_manager'
+    tags:
+      - { name: event_subscriber }
+    autowire: true
+  package_manager.sqlite_excluder:
+    class: Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder
+    arguments:
+      - '@package_manager.path_locator'
       - '@database'
+    tags:
+      - { name: event_subscriber }
+  package_manager.git_excluder:
+    class: Drupal\package_manager\PathExcluder\GitExcluder
+    arguments:
+      - '@package_manager.path_locator'
+    tags:
+      - { name: event_subscriber }
+  package_manager.site_configuration_excluder:
+    class: Drupal\package_manager\PathExcluder\SiteConfigurationExcluder
+    arguments:
+      - '%site.path%'
       - '@package_manager.path_locator'
     tags:
       - { name: event_subscriber }
@@ -145,3 +179,20 @@ services:
     calls:
       - ['setContainer', ['@service_container']]
     lazy: true
+  package_manager.validator.settings:
+    class: Drupal\package_manager\Validator\SettingsValidator
+    arguments:
+      - '@string_translation'
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.patches:
+    class: Drupal\package_manager\Validator\ComposerPatchesValidator
+    tags:
+      - { name: event_subscriber }
+  package_manager.validator.supported_releases:
+    class: Drupal\package_manager\Validator\SupportedReleaseValidator
+    tags:
+      - { name: event_subscriber }
+  package_manager.update_processor:
+    class: Drupal\package_manager\PackageManagerUpdateProcessor
+    arguments: [ '@config.factory', '@queue', '@update.fetcher', '@state', '@private_key', '@keyvalue', '@keyvalue.expirable' ]
diff --git a/core/modules/package_manager/src/ComposerUtility.php b/core/modules/package_manager/src/ComposerUtility.php
index b593bb0f7de5..6e076fcb3770 100644
--- a/core/modules/package_manager/src/ComposerUtility.php
+++ b/core/modules/package_manager/src/ComposerUtility.php
@@ -7,7 +7,7 @@
 use Composer\IO\NullIO;
 use Composer\Package\PackageInterface;
 use Composer\Semver\Comparator;
-use Drupal\Component\Serialization\Json;
+use Drupal\Component\Serialization\Yaml;
 
 /**
  * Defines a utility object to get information from Composer's API.
@@ -97,11 +97,11 @@ public static function createForDirectory(string $dir): self {
    */
   protected static function getCorePackageList(): array {
     if (self::$corePackages === NULL) {
-      $file = __DIR__ . '/../core_packages.json';
+      $file = __DIR__ . '/../core_packages.yml';
       assert(file_exists($file), "$file does not exist.");
 
       $core_packages = file_get_contents($file);
-      $core_packages = Json::decode($core_packages);
+      $core_packages = Yaml::decode($core_packages);
 
       assert(is_array($core_packages), "$file did not contain a list of core packages.");
       self::$corePackages = $core_packages;
@@ -190,4 +190,106 @@ public function getPackagesWithDifferentVersionsIn(self $other): array {
     return array_filter($packages, $filter, ARRAY_FILTER_USE_BOTH);
   }
 
+  /**
+   * Returns installed package data from Composer's `installed.php`.
+   *
+   * @return array
+   *   The installed package data as represented in Composer's `installed.php`,
+   *   keyed by package name.
+   */
+  public function getInstalledPackagesData(): array {
+    $installed_php = implode(DIRECTORY_SEPARATOR, [
+      // Composer returns the absolute path to the vendor directory by default.
+      $this->getComposer()->getConfig()->get('vendor-dir'),
+      'composer',
+      'installed.php',
+    ]);
+    $data = include $installed_php;
+    return $data['versions'];
+  }
+
+  /**
+   * Returns the Drupal project name for a given Composer package.
+   *
+   * @param string $package_name
+   *   The name of the package.
+   *
+   * @return string|null
+   *   The name of the Drupal project installed by the package, or NULL if:
+   *   - The package is not installed.
+   *   - The package is not of a supported type (one of `drupal-module`,
+   *     `drupal-theme`, or `drupal-profile`).
+   *   - The package name does not begin with `drupal/`.
+   *   - The project name could not otherwise be determined.
+   */
+  public function getProjectForPackage(string $package_name): ?string {
+    $data = $this->getInstalledPackagesData();
+
+    if (array_key_exists($package_name, $data)) {
+      $package = $data[$package_name];
+
+      $supported_package_types = [
+        'drupal-module',
+        'drupal-theme',
+        'drupal-profile',
+      ];
+      // Only consider packages which are packaged by drupal.org and will be
+      // known to the core Update module.
+      if (str_starts_with($package_name, 'drupal/') && in_array($package['type'], $supported_package_types, TRUE)) {
+        return $this->scanForProjectName($package['install_path']);
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Returns the package name for a given Drupal project.
+   *
+   * @param string $project_name
+   *   The name of the project.
+   *
+   * @return string|null
+   *   The name of the Composer package which installs the project, or NULL if
+   *   it could not be determined.
+   */
+  public function getPackageForProject(string $project_name): ?string {
+    $installed = $this->getInstalledPackagesData();
+
+    $installed = array_keys($installed);
+    foreach ($installed as $package_name) {
+      if ($this->getProjectForPackage($package_name) === $project_name) {
+        return $package_name;
+      }
+    }
+    return NULL;
+  }
+
+  /**
+   * Scans a given path to determine the Drupal project name.
+   *
+   * The path will be scanned for `.info.yml` files containing a `project` key.
+   *
+   * @param string $path
+   *   The path to scan.
+   *
+   * @return string|null
+   *   The name of the project, as declared in the first found `.info.yml` which
+   *   contains a `project` key, or NULL if none was found.
+   */
+  private function scanForProjectName(string $path): ?string {
+    $iterator = new \RecursiveDirectoryIterator($path);
+    $iterator = new \RecursiveIteratorIterator($iterator);
+    $iterator = new \RegexIterator($iterator, '/.+\.info\.yml$/', \RecursiveRegexIterator::GET_MATCH);
+
+    foreach ($iterator as $match) {
+      $info = file_get_contents($match[0]);
+      $info = Yaml::decode($info);
+
+      if (is_string($info['project']) && !empty($info['project'])) {
+        return $info['project'];
+      }
+    }
+    return NULL;
+  }
+
 }
diff --git a/core/modules/package_manager/src/Event/ExcludedPathsTrait.php b/core/modules/package_manager/src/Event/ExcludedPathsTrait.php
index fc7e6904402a..6e2dfddc31de 100644
--- a/core/modules/package_manager/src/Event/ExcludedPathsTrait.php
+++ b/core/modules/package_manager/src/Event/ExcludedPathsTrait.php
@@ -31,7 +31,7 @@ trait ExcludedPathsTrait {
    * @param string $path
    *   The path to exclude, relative to the project root.
    *
-   * @see \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
+   * @see \Drupal\package_manager\PathExcluder\SiteConfigurationExcluder
    */
   public function excludePath(string $path): void {
     $this->excludedPaths[] = $path;
diff --git a/core/modules/package_manager/src/Event/PostRequireEvent.php b/core/modules/package_manager/src/Event/PostRequireEvent.php
index c35076f5cf90..b35f7918749a 100644
--- a/core/modules/package_manager/src/Event/PostRequireEvent.php
+++ b/core/modules/package_manager/src/Event/PostRequireEvent.php
@@ -6,4 +6,7 @@
  * Event fired after packages are updated to the staging area.
  */
 class PostRequireEvent extends StageEvent {
+
+  use RequireEventTrait;
+
 }
diff --git a/core/modules/package_manager/src/Event/PreOperationStageEvent.php b/core/modules/package_manager/src/Event/PreOperationStageEvent.php
index 226af627bd11..3af2e1a5ad1d 100644
--- a/core/modules/package_manager/src/Event/PreOperationStageEvent.php
+++ b/core/modules/package_manager/src/Event/PreOperationStageEvent.php
@@ -38,6 +38,12 @@ public function getResults(?int $severity = NULL): array {
 
   /**
    * Adds error information to the event.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
+   *   The error messages.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
+   *   (optional) The summary of error messages. Only required if there
+   *   is more than one message.
    */
   public function addError(array $messages, ?TranslatableMarkup $summary = NULL): void {
     $this->results[] = ValidationResult::createError($messages, $summary);
diff --git a/core/modules/package_manager/src/Event/PreRequireEvent.php b/core/modules/package_manager/src/Event/PreRequireEvent.php
index bcb9bfc6337e..ff74da6ad1e9 100644
--- a/core/modules/package_manager/src/Event/PreRequireEvent.php
+++ b/core/modules/package_manager/src/Event/PreRequireEvent.php
@@ -6,4 +6,7 @@
  * Event fired before packages are updated to the staging area.
  */
 class PreRequireEvent extends PreOperationStageEvent {
+
+  use RequireEventTrait;
+
 }
diff --git a/core/modules/package_manager/src/Event/RequireEventTrait.php b/core/modules/package_manager/src/Event/RequireEventTrait.php
new file mode 100644
index 000000000000..f93853967311
--- /dev/null
+++ b/core/modules/package_manager/src/Event/RequireEventTrait.php
@@ -0,0 +1,97 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+use Drupal\package_manager\Stage;
+
+/**
+ * Common methods for pre- and post-require events.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and should only be used by
+ *   \Drupal\package_manager\Event\PreRequireEvent and
+ *   \Drupal\package_manager\Event\PostRequireEvent.
+ */
+trait RequireEventTrait {
+
+  /**
+   * The runtime packages, in the form 'vendor/name:constraint'.
+   *
+   * @var string[]
+   */
+  private $runtimePackages;
+
+  /**
+   * The dev packages to be required, in the form 'vendor/name:constraint'.
+   *
+   * @var string[]
+   */
+  private $devPackages;
+
+  /**
+   * Constructs the object.
+   *
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage.
+   * @param string[] $runtime_packages
+   *   The runtime (i.e., non-dev) packages to be required, in the form
+   *   'vendor/name:constrant'.
+   * @param string[] $dev_packages
+   *   The dev packages to be required, in the form 'vendor/name:constrant'.
+   */
+  public function __construct(Stage $stage, array $runtime_packages, array $dev_packages = []) {
+    $this->runtimePackages = $runtime_packages;
+    $this->devPackages = $dev_packages;
+    parent::__construct($stage);
+  }
+
+  /**
+   * Gets the runtime (i.e., non-dev) packages.
+   *
+   * @return string[]
+   *   An array of packages where the values are version constraints and keys
+   *   are package names in the form `vendor/name`. Packages without a version
+   *   constraint will default to `*`.
+   */
+  public function getRuntimePackages(): array {
+    return $this->getKeyedPackages($this->runtimePackages);
+  }
+
+  /**
+   * Gets the dev packages.
+   *
+   * @return string[]
+   *   An array of packages where the values are version constraints and keys
+   *   are package names in the form `vendor/name`. Packages without a version
+   *   constraint will default to `*`.
+   */
+  public function getDevPackages(): array {
+    return $this->getKeyedPackages($this->devPackages);
+  }
+
+  /**
+   * Gets packages as a keyed array.
+   *
+   * @param string[] $packages
+   *   The packages, in the form 'vendor/name:version'.
+   *
+   * @return string[]
+   *   An array of packages where the values are version constraints and keys
+   *   are package names in the form `vendor/name`. Packages without a version
+   *   constraint will default to `*`.
+   */
+  private function getKeyedPackages(array $packages): array {
+    $keyed_packages = [];
+    foreach ($packages as $package) {
+      if (strpos($package, ':') > 0) {
+        [$name, $constraint] = explode(':', $package);
+      }
+      else {
+        [$name, $constraint] = [$package, '*'];
+      }
+      $keyed_packages[$name] = $constraint;
+    }
+    return $keyed_packages;
+  }
+
+}
diff --git a/core/modules/package_manager/src/Event/StatusCheckEvent.php b/core/modules/package_manager/src/Event/StatusCheckEvent.php
new file mode 100644
index 000000000000..7c4250443a2d
--- /dev/null
+++ b/core/modules/package_manager/src/Event/StatusCheckEvent.php
@@ -0,0 +1,29 @@
+<?php
+
+namespace Drupal\package_manager\Event;
+
+use Drupal\Core\StringTranslation\TranslatableMarkup;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * Event fired to check the status of the system to use Package Manager.
+ *
+ * The event's stage will be set with the type of stage that will perform the
+ * operations. The stage may or may not be currently in use.
+ */
+class StatusCheckEvent extends PreOperationStageEvent {
+
+  /**
+   * Adds warning information to the event.
+   *
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup[] $messages
+   *   One or more warning messages.
+   * @param \Drupal\Core\StringTranslation\TranslatableMarkup|null $summary
+   *   A summary of warning messages. Required if there is more than one
+   *   message, optional otherwise.
+   */
+  public function addWarning(array $messages, ?TranslatableMarkup $summary = NULL): void {
+    $this->results[] = ValidationResult::createWarning($messages, $summary);
+  }
+
+}
diff --git a/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php b/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
deleted file mode 100644
index 6b781d44e4ef..000000000000
--- a/core/modules/package_manager/src/EventSubscriber/ExcludedPathsSubscriber.php
+++ /dev/null
@@ -1,250 +0,0 @@
-<?php
-
-namespace Drupal\package_manager\EventSubscriber;
-
-use Drupal\Core\Database\Connection;
-use Drupal\Core\StreamWrapper\LocalStream;
-use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
-use Drupal\package_manager\Event\PreApplyEvent;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Event\StageEvent;
-use Drupal\package_manager\PathLocator;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\Filesystem\Filesystem;
-use Symfony\Component\Finder\Finder;
-
-/**
- * Defines an event subscriber to exclude certain paths from staging areas.
- */
-class ExcludedPathsSubscriber implements EventSubscriberInterface {
-
-  /**
-   * The current site path, relative to the Drupal root.
-   *
-   * @var string
-   */
-  protected $sitePath;
-
-  /**
-   * The Symfony file system service.
-   *
-   * @var \Symfony\Component\Filesystem\Filesystem
-   */
-  protected $fileSystem;
-
-  /**
-   * The stream wrapper manager service.
-   *
-   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
-   */
-  protected $streamWrapperManager;
-
-  /**
-   * The database connection.
-   *
-   * @var \Drupal\Core\Database\Connection
-   */
-  protected $database;
-
-  /**
-   * The path locator service.
-   *
-   * @var \Drupal\package_manager\PathLocator
-   */
-  protected $pathLocator;
-
-  /**
-   * Constructs an ExcludedPathsSubscriber.
-   *
-   * @param string $site_path
-   *   The current site path, relative to the Drupal root.
-   * @param \Symfony\Component\Filesystem\Filesystem $file_system
-   *   The Symfony file system service.
-   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
-   *   The stream wrapper manager service.
-   * @param \Drupal\Core\Database\Connection $database
-   *   The database connection.
-   * @param \Drupal\package_manager\PathLocator $path_locator
-   *   The path locator service.
-   */
-  public function __construct(string $site_path, Filesystem $file_system, StreamWrapperManagerInterface $stream_wrapper_manager, Connection $database, PathLocator $path_locator) {
-    $this->sitePath = $site_path;
-    $this->fileSystem = $file_system;
-    $this->streamWrapperManager = $stream_wrapper_manager;
-    $this->database = $database;
-    $this->pathLocator = $path_locator;
-  }
-
-  /**
-   * Flags paths to be excluded, relative to the web root.
-   *
-   * This should only be used for paths that, if they exist at all, are
-   * *guaranteed* to exist within the web root.
-   *
-   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
-   *   The event object.
-   * @param string[] $paths
-   *   The paths to exclude. These should be relative to the web root, and will
-   *   be made relative to the project root.
-   */
-  protected function excludeInWebRoot(StageEvent $event, array $paths): void {
-    $web_root = $this->pathLocator->getWebRoot();
-    if ($web_root) {
-      $web_root .= '/';
-    }
-
-    foreach ($paths as $path) {
-      // Make the path relative to the project root by prefixing the web root.
-      $event->excludePath($web_root . $path);
-    }
-  }
-
-  /**
-   * Flags paths to be excluded, relative to the project root.
-   *
-   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
-   *   The event object.
-   * @param string[] $paths
-   *   The paths to exclude. Absolute paths will be made relative to the project
-   *   root; relative paths will be assumed to already be relative to the
-   *   project root, and excluded as given.
-   */
-  protected function excludeInProjectRoot(StageEvent $event, array $paths): void {
-    $project_root = $this->pathLocator->getProjectRoot();
-
-    foreach ($paths as $path) {
-      // Make absolute paths relative to the project root.
-      $path = str_replace($project_root, NULL, $path);
-      $path = ltrim($path, '/');
-      $event->excludePath($path);
-    }
-  }
-
-  /**
-   * Excludes common paths from staging operations.
-   *
-   * @param \Drupal\package_manager\Event\PreApplyEvent|\Drupal\package_manager\Event\PreCreateEvent $event
-   *   The event object.
-   *
-   * @see \Drupal\package_manager\Event\ExcludedPathsTrait::excludePath()
-   */
-  public function ignoreCommonPaths(StageEvent $event): void {
-    // Compile two lists of paths to exclude: paths that are relative to the
-    // project root, and paths that are relative to the web root.
-    $web = $project = [];
-
-    // Always ignore automated test directories. If they exist, they will be in
-    // the web root.
-    $web[] = 'sites/simpletest';
-
-    // If the core-vendor-hardening plugin (used in the legacy-project template)
-    // is present, it may have written security hardening files in the vendor
-    // directory. They should always be ignored.
-    $vendor_dir = $this->pathLocator->getVendorDirectory();
-    $project[] = $vendor_dir . '/web.config';
-    $project[] = $vendor_dir . '/.htaccess';
-
-    // Ignore public and private files. These paths could be either absolute or
-    // relative, depending on site settings. If they are absolute, treat them
-    // as relative to the project root. Otherwise, treat them as relative to
-    // the web root.
-    $files = array_filter([
-      $this->getFilesPath('public'),
-      $this->getFilesPath('private'),
-    ]);
-    foreach ($files as $path) {
-      if ($this->fileSystem->isAbsolutePath($path)) {
-        $project[] = $path;
-      }
-      else {
-        $web[] = $path;
-      }
-    }
-
-    // Ignore site-specific settings files, which are always in the web root.
-    $settings_files = [
-      'settings.php',
-      'settings.local.php',
-      'services.yml',
-    ];
-    foreach ($settings_files as $settings_file) {
-      $web[] = $this->sitePath . '/' . $settings_file;
-      $web[] = 'sites/default/' . $settings_file;
-    }
-
-    // If the database is SQLite, it might be located in the active directory
-    // and we should ignore it. Always treat it as relative to the project root.
-    if ($this->database->driver() === 'sqlite') {
-      $options = $this->database->getConnectionOptions();
-      $project[] = $options['database'];
-      $project[] = $options['database'] . '-shm';
-      $project[] = $options['database'] . '-wal';
-    }
-
-    // Find all .git directories in the project and exclude them. We cannot do
-    // this with FileSystemInterface::scanDirectory() because it unconditionally
-    // excludes anything starting with a dot.
-    $finder = Finder::create()
-      ->in($this->pathLocator->getProjectRoot())
-      ->directories()
-      ->name('.git')
-      ->ignoreVCS(FALSE)
-      ->ignoreDotFiles(FALSE)
-      ->ignoreUnreadableDirs();
-
-    foreach ($finder as $git_directory) {
-      $project[] = $git_directory->getPathname();
-    }
-
-    $this->excludeInWebRoot($event, $web);
-    $this->excludeInProjectRoot($event, $project);
-  }
-
-  /**
-   * Reacts before staged changes are committed the active directory.
-   *
-   * @param \Drupal\package_manager\Event\PreApplyEvent $event
-   *   The event object.
-   */
-  public function preApply(PreApplyEvent $event): void {
-    // Don't copy anything from the staging area's sites/default.
-    // @todo Make this a lot smarter in https://www.drupal.org/i/3228955.
-    $this->excludeInWebRoot($event, ['sites/default']);
-
-    $this->ignoreCommonPaths($event);
-  }
-
-  /**
-   * Returns the storage path for a stream wrapper.
-   *
-   * This will only work for stream wrappers that extend
-   * \Drupal\Core\StreamWrapper\LocalStream, which includes the stream wrappers
-   * for public and private files.
-   *
-   * @param string $scheme
-   *   The stream wrapper scheme.
-   *
-   * @return string|null
-   *   The storage path for files using the given scheme, relative to the Drupal
-   *   root, or NULL if the stream wrapper does not extend
-   *   \Drupal\Core\StreamWrapper\LocalStream.
-   */
-  private function getFilesPath(string $scheme): ?string {
-    $wrapper = $this->streamWrapperManager->getViaScheme($scheme);
-    if ($wrapper instanceof LocalStream) {
-      return $wrapper->getDirectoryPath();
-    }
-    return NULL;
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PreCreateEvent::class => 'ignoreCommonPaths',
-      PreApplyEvent::class => 'preApply',
-    ];
-  }
-
-}
diff --git a/core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php b/core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php
index 5b422b4be762..8d6490bccaf0 100644
--- a/core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php
+++ b/core/modules/package_manager/src/EventSubscriber/UpdateDataSubscriber.php
@@ -8,8 +8,13 @@
 
 /**
  * Clears stale update data once staged changes have been applied.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class UpdateDataSubscriber implements EventSubscriberInterface {
+final class UpdateDataSubscriber implements EventSubscriberInterface {
 
   /**
    * The update manager service.
diff --git a/core/modules/package_manager/src/Exception/ApplyFailedException.php b/core/modules/package_manager/src/Exception/ApplyFailedException.php
new file mode 100644
index 000000000000..3b27b757b23f
--- /dev/null
+++ b/core/modules/package_manager/src/Exception/ApplyFailedException.php
@@ -0,0 +1,16 @@
+<?php
+
+namespace Drupal\package_manager\Exception;
+
+/**
+ * Exception thrown if a stage encounters an error applying an update.
+ *
+ * If this exception is thrown it indicates that an update of the active
+ * codebase was attempted but failed. If this happens the site code is in an
+ * indeterminate state. Package Manager does not provide a method for recovering
+ * from this state. The site code should be restored from a backup.
+ *
+ * Should not be thrown by external code.
+ */
+final class ApplyFailedException extends StageException {
+}
diff --git a/core/modules/package_manager/src/Exception/StageValidationException.php b/core/modules/package_manager/src/Exception/StageValidationException.php
index 2654615bf906..fb07184cff6c 100644
--- a/core/modules/package_manager/src/Exception/StageValidationException.php
+++ b/core/modules/package_manager/src/Exception/StageValidationException.php
@@ -21,12 +21,15 @@ class StageValidationException extends StageException {
    *
    * @param \Drupal\package_manager\ValidationResult[] $results
    *   Any relevant validation results.
+   * @param string $message
+   *   (optional) The exception message. Defaults to a plain text representation
+   *   of the validation results.
    * @param mixed ...$arguments
-   *   Arguments to pass to the parent constructor.
+   *   Additional arguments to pass to the parent constructor.
    */
-  public function __construct(array $results = [], ...$arguments) {
+  public function __construct(array $results = [], string $message = '', ...$arguments) {
     $this->results = $results;
-    parent::__construct(...$arguments);
+    parent::__construct($message ?: $this->getResultsAsText(), ...$arguments);
   }
 
   /**
@@ -39,4 +42,24 @@ public function getResults(): array {
     return $this->results;
   }
 
+  /**
+   * Formats the validation results as plain text.
+   *
+   * @return string
+   *   The results, formatted as plain text.
+   */
+  protected function getResultsAsText(): string {
+    $text = '';
+
+    foreach ($this->getResults() as $result) {
+      $messages = $result->getMessages();
+      $summary = $result->getSummary();
+      if ($summary) {
+        array_unshift($messages, $summary);
+      }
+      $text .= implode("\n", $messages) . "\n";
+    }
+    return $text;
+  }
+
 }
diff --git a/core/modules/package_manager/src/ExecutableFinder.php b/core/modules/package_manager/src/ExecutableFinder.php
index d18f45be1327..24da0ee57c02 100644
--- a/core/modules/package_manager/src/ExecutableFinder.php
+++ b/core/modules/package_manager/src/ExecutableFinder.php
@@ -3,19 +3,24 @@
 namespace Drupal\package_manager;
 
 use Drupal\Core\Config\ConfigFactoryInterface;
-use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinderInterface;
-use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinder as StagerExecutableFinder;
+use PhpTuf\ComposerStager\Infrastructure\Service\Finder\ExecutableFinder as StagerExecutableFinder;
+use PhpTuf\ComposerStager\Infrastructure\Service\Finder\ExecutableFinderInterface;
 use Symfony\Component\Process\ExecutableFinder as SymfonyExecutableFinder;
 
 /**
  * An executable finder which looks for executable paths in configuration.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class ExecutableFinder implements ExecutableFinderInterface {
+final class ExecutableFinder implements ExecutableFinderInterface {
 
   /**
    * The decorated executable finder.
    *
-   * @var \PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinder
+   * @var \PhpTuf\ComposerStager\Infrastructure\Service\Finder\ExecutableFinder
    */
   private $decorated;
 
diff --git a/core/modules/package_manager/src/FailureMarker.php b/core/modules/package_manager/src/FailureMarker.php
new file mode 100644
index 000000000000..14b5ee90e51c
--- /dev/null
+++ b/core/modules/package_manager/src/FailureMarker.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Component\Serialization\Json;
+use Drupal\package_manager\Exception\ApplyFailedException;
+
+/**
+ * Handles failure marker file operation.
+ *
+ * The failure marker is a file placed in the active directory while staged
+ * code is copied back into it, and then removed afterwards. This allows us to
+ * know if a commit operation failed midway through, which could leave the site
+ * code base in an indeterminate state -- which, in the worst case scenario,
+ * might render Drupal unbootable.
+ */
+final class FailureMarker {
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a FailureMarker object.
+   *
+   * @param \Drupal\package_manager\PathLocator $pathLocator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $pathLocator) {
+    $this->pathLocator = $pathLocator;
+  }
+
+  /**
+   * Gets the marker file path.
+   *
+   * @return string
+   *   The absolute path of the marker file.
+   */
+  public function getPath(): string {
+    return $this->pathLocator->getProjectRoot() . '/PACKAGE_MANAGER_FAILURE.json';
+  }
+
+  /**
+   * Deletes the marker file.
+   */
+  public function clear(): void {
+    unlink($this->getPath());
+  }
+
+  /**
+   * Writes data to marker file.
+   *
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage.
+   * @param string $message
+   *   Failure message to be added.
+   */
+  public function write(Stage $stage, string $message): void {
+    $data = [
+      'stage_class' => get_class($stage),
+      'stage_file' => (new \ReflectionObject($stage))->getFileName(),
+      'message' => $message,
+    ];
+    file_put_contents($this->getPath(), Json::encode($data));
+  }
+
+  /**
+   * Asserts the failure file doesn't exist.
+   *
+   * @throws \Drupal\package_manager\Exception\ApplyFailedException
+   *   Thrown if the marker file exists.
+   */
+  public function assertNotExists(): void {
+    $path = $this->getPath();
+
+    if (file_exists($path)) {
+      $data = file_get_contents($path);
+      try {
+        $data = json_decode($data, TRUE, 512, JSON_THROW_ON_ERROR);
+      }
+      catch (\JsonException $exception) {
+        throw new ApplyFailedException('Failure marker file exists but cannot be decoded.', $exception->getCode(), $exception);
+      }
+
+      throw new ApplyFailedException($data['message']);
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/src/FileSyncerFactory.php b/core/modules/package_manager/src/FileSyncerFactory.php
index f93e31bfecab..6d2d928206cc 100644
--- a/core/modules/package_manager/src/FileSyncerFactory.php
+++ b/core/modules/package_manager/src/FileSyncerFactory.php
@@ -3,34 +3,40 @@
 namespace Drupal\package_manager;
 
 use Drupal\Core\Config\ConfigFactoryInterface;
-use PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerFactoryInterface;
-use PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface;
-use PhpTuf\ComposerStager\Infrastructure\FileSyncer\FileSyncerFactory as StagerFileSyncerFactory;
+use PhpTuf\ComposerStager\Domain\Service\FileSyncer\FileSyncerInterface;
+use PhpTuf\ComposerStager\Infrastructure\Factory\FileSyncer\FileSyncerFactory as StagerFileSyncerFactory;
+use PhpTuf\ComposerStager\Infrastructure\Service\FileSyncer\PhpFileSyncer;
+use PhpTuf\ComposerStager\Infrastructure\Service\FileSyncer\RsyncFileSyncer;
 use Symfony\Component\Process\ExecutableFinder;
 
 /**
  * A file syncer factory which creates a file syncer according to configuration.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class FileSyncerFactory implements FileSyncerFactoryInterface {
+final class FileSyncerFactory {
 
   /**
    * The decorated file syncer factory.
    *
-   * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerFactoryInterface
+   * @var \PhpTuf\ComposerStager\Infrastructure\Factory\FileSyncer\FileSyncerFactory
    */
   protected $decorated;
 
   /**
    * The PHP file syncer service.
    *
-   * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface
+   * @var \PhpTuf\ComposerStager\Infrastructure\Service\FileSyncer\PhpFileSyncer
    */
   protected $phpFileSyncer;
 
   /**
    * The rsync file syncer service.
    *
-   * @var \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface
+   * @var \PhpTuf\ComposerStager\Infrastructure\Service\FileSyncer\RsyncFileSyncer
    */
   protected $rsyncFileSyncer;
 
@@ -46,14 +52,14 @@ class FileSyncerFactory implements FileSyncerFactoryInterface {
    *
    * @param \Symfony\Component\Process\ExecutableFinder $executable_finder
    *   The Symfony executable finder.
-   * @param \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface $php_file_syncer
+   * @param \PhpTuf\ComposerStager\Infrastructure\Service\FileSyncer\PhpFileSyncer $php_file_syncer
    *   The PHP file syncer service.
-   * @param \PhpTuf\ComposerStager\Domain\FileSyncer\FileSyncerInterface $rsync_file_syncer
+   * @param \PhpTuf\ComposerStager\Infrastructure\Service\FileSyncer\RsyncFileSyncer $rsync_file_syncer
    *   The rsync file syncer service.
    * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
    *   The config factory service.
    */
-  public function __construct(ExecutableFinder $executable_finder, FileSyncerInterface $php_file_syncer, FileSyncerInterface $rsync_file_syncer, ConfigFactoryInterface $config_factory) {
+  public function __construct(ExecutableFinder $executable_finder, PhpFileSyncer $php_file_syncer, RsyncFileSyncer $rsync_file_syncer, ConfigFactoryInterface $config_factory) {
     $this->decorated = new StagerFileSyncerFactory($executable_finder, $php_file_syncer, $rsync_file_syncer);
     $this->phpFileSyncer = $php_file_syncer;
     $this->rsyncFileSyncer = $rsync_file_syncer;
diff --git a/core/modules/package_manager/src/LegacyVersionUtility.php b/core/modules/package_manager/src/LegacyVersionUtility.php
new file mode 100644
index 000000000000..9de721094562
--- /dev/null
+++ b/core/modules/package_manager/src/LegacyVersionUtility.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\Extension\ExtensionVersion;
+
+/**
+ * A utility class for dealing with legacy version numbers.
+ *
+ * @internal
+ *   This is an internal utility class that could be changed or removed in any
+ *   release and should not be used by external code.
+ */
+final class LegacyVersionUtility {
+
+  /**
+   * Converts a version number to a semantic version if needed.
+   *
+   * @param string $version
+   *   The version number.
+   *
+   * @return string
+   *   The version number, converted if needed.
+   */
+  public static function convertToSemanticVersion(string $version): string {
+    if (self::isLegacyVersion($version)) {
+      $version = substr($version, 4);
+      $version_parts = explode('-', $version);
+      $version = $version_parts[0] . '.0';
+      if (count($version_parts) === 2) {
+        $version .= '-' . $version_parts[1];
+      }
+      return $version;
+    }
+    else {
+      return $version;
+    }
+  }
+
+  /**
+   * Converts a version number to a legacy version if needed and possible.
+   *
+   * @param string $version_string
+   *   The version number.
+   *
+   * @return string
+   *   The version number, converted if needed, or NULL if not possible. Only
+   *   semantic version numbers that have patch level of 0 can be converted into
+   *   legacy version numbers.
+   */
+  public static function convertToLegacyVersion($version_string): ?string {
+    if (self::isLegacyVersion($version_string)) {
+      return $version_string;
+    }
+    $version = ExtensionVersion::createFromVersionString($version_string);
+    if ($extra = $version->getVersionExtra()) {
+      $version_string_without_extra = str_replace("-$extra", '', $version_string);
+    }
+    else {
+      $version_string_without_extra = $version_string;
+    }
+    [,, $patch] = explode('.', $version_string_without_extra);
+    // A semantic version can only be converted to legacy if it's patch level is
+    // '0'.
+    if ($patch !== '0') {
+      return NULL;
+    }
+    return '8.x-' . $version->getMajorVersion() . '.' . $version->getMinorVersion() . ($extra ? "-$extra" : '');
+  }
+
+  /**
+   * Determines if a version is legacy.
+   *
+   * @param string $version
+   *   The version number.
+   *
+   * @return bool
+   *   TRUE if the version is a legacy version number, otherwise FALSE.
+   */
+  private static function isLegacyVersion(string $version): bool {
+    return stripos($version, '8.x-') === 0;
+  }
+
+}
diff --git a/core/modules/package_manager/src/PackageManagerServiceProvider.php b/core/modules/package_manager/src/PackageManagerServiceProvider.php
index 3d74d54d355c..91960594e574 100644
--- a/core/modules/package_manager/src/PackageManagerServiceProvider.php
+++ b/core/modules/package_manager/src/PackageManagerServiceProvider.php
@@ -5,12 +5,21 @@
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\DependencyInjection\ServiceProviderBase;
 use Drupal\package_manager\EventSubscriber\UpdateDataSubscriber;
+use PhpTuf\ComposerStager\Domain\Core\Beginner\BeginnerInterface;
+use Symfony\Component\Config\FileLocator;
+use Symfony\Component\DependencyInjection\Definition;
+use Symfony\Component\DependencyInjection\Loader\DirectoryLoader;
 use Symfony\Component\DependencyInjection\Reference;
 
 /**
  * Defines dynamic container services for Package Manager.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class PackageManagerServiceProvider extends ServiceProviderBase {
+final class PackageManagerServiceProvider extends ServiceProviderBase {
 
   /**
    * {@inheritdoc}
@@ -18,6 +27,24 @@ class PackageManagerServiceProvider extends ServiceProviderBase {
   public function register(ContainerBuilder $container) {
     parent::register($container);
 
+    // Use an interface that we know exists to determine the absolute path where
+    // Composer Stager is installed.
+    $mirror = new \ReflectionClass(BeginnerInterface::class);
+    $path = dirname($mirror->getFileName(), 4);
+
+    // Recursively register all classes and interfaces under that directory,
+    // relative to the \PhpTuf\ComposerStager namespace.
+    $loader = new DirectoryLoader($container, new FileLocator());
+    // All the registered services should be auto-wired and private by default.
+    $default_definition = new Definition();
+    $default_definition->setAutowired(TRUE);
+    $default_definition->setPublic(FALSE);
+    $loader->registerClasses($default_definition, 'PhpTuf\ComposerStager\\', $path, [
+      // Ignore classes which we don't want to register as services.
+      $path . '/Domain/Exception',
+      $path . '/Infrastructure/Value',
+    ]);
+
     if (array_key_exists('update', $container->getParameter('container.modules'))) {
       $container->register('package_manager.update_data_subscriber')
         ->setClass(UpdateDataSubscriber::class)
diff --git a/core/modules/package_manager/src/PackageManagerUninstallValidator.php b/core/modules/package_manager/src/PackageManagerUninstallValidator.php
index dd9ab876071e..c2de44ff4c68 100644
--- a/core/modules/package_manager/src/PackageManagerUninstallValidator.php
+++ b/core/modules/package_manager/src/PackageManagerUninstallValidator.php
@@ -4,13 +4,19 @@
 
 use Drupal\Core\Extension\ModuleUninstallValidatorInterface;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface;
 use Symfony\Component\DependencyInjection\ContainerAwareInterface;
 use Symfony\Component\DependencyInjection\ContainerAwareTrait;
 
 /**
  * Prevents any module from being uninstalled if update is in process.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class PackageManagerUninstallValidator implements ModuleUninstallValidatorInterface, ContainerAwareInterface {
+final class PackageManagerUninstallValidator implements ModuleUninstallValidatorInterface, ContainerAwareInterface {
 
   use ContainerAwareTrait;
   use StringTranslationTrait;
@@ -28,7 +34,9 @@ public function validate($module) {
       $this->container->get('file_system'),
       $this->container->get('event_dispatcher'),
       $this->container->get('tempstore.shared'),
-      $this->container->get('datetime.time')
+      $this->container->get('datetime.time'),
+      $this->container->get(PathFactoryInterface::class),
+      $this->container->get('package_manager.failure_marker')
     );
     if ($stage->isAvailable() || !$stage->isApplying()) {
       return [];
diff --git a/core/modules/package_manager/src/PackageManagerUpdateProcessor.php b/core/modules/package_manager/src/PackageManagerUpdateProcessor.php
new file mode 100644
index 000000000000..195c510fae8b
--- /dev/null
+++ b/core/modules/package_manager/src/PackageManagerUpdateProcessor.php
@@ -0,0 +1,103 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface;
+use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
+use Drupal\Core\PrivateKey;
+use Drupal\Core\Queue\QueueFactory;
+use Drupal\Core\State\StateInterface;
+use Drupal\update\UpdateFetcherInterface;
+use Drupal\update\UpdateProcessor;
+
+/**
+ * Extends the Update module's update processor allow fetching any project.
+ *
+ * The Update module's update processor service is intended to only fetch
+ * information for projects in the active codebase. Although it would be
+ * possible to use the Update module's update processor service to fetch
+ * information for projects not in the active code base this would add the
+ * project information to Update module's cache which would result in these
+ * projects being returned from the Update module's global functions such as
+ * update_get_available().
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class PackageManagerUpdateProcessor extends UpdateProcessor {
+
+  /**
+   * Constructs an PackageManagerUpdateProcessor object.
+   *
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   * @param \Drupal\Core\Queue\QueueFactory $queue_factory
+   *   The queue factory.
+   * @param \Drupal\update\UpdateFetcherInterface $update_fetcher
+   *   The update fetcher service.
+   * @param \Drupal\Core\State\StateInterface $state_store
+   *   The state service.
+   * @param \Drupal\Core\PrivateKey $private_key
+   *   The private key factory service.
+   * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value_factory
+   *   The key/value factory.
+   * @param \Drupal\Core\KeyValueStore\KeyValueExpirableFactoryInterface $key_value_expirable_factory
+   *   The expirable key/value factory.
+   */
+  public function __construct(ConfigFactoryInterface $config_factory, QueueFactory $queue_factory, UpdateFetcherInterface $update_fetcher, StateInterface $state_store, PrivateKey $private_key, KeyValueFactoryInterface $key_value_factory, KeyValueExpirableFactoryInterface $key_value_expirable_factory) {
+    $this->updateFetcher = $update_fetcher;
+    $this->updateSettings = $config_factory->get('update.settings');
+    $this->fetchQueue = $queue_factory->get('package_manager.update_fetch_tasks');
+    $this->tempStore = $key_value_expirable_factory->get('package_manager.update');
+    $this->fetchTaskStore = $key_value_factory->get('package_manager.update_fetch_task');
+    $this->availableReleasesTempStore = $key_value_expirable_factory->get('package_manager.update_available_releases');
+    $this->stateStore = $state_store;
+    $this->privateKey = $private_key;
+    $this->fetchTasks = [];
+    $this->failed = [];
+  }
+
+  /**
+   * Gets the project data by name.
+   *
+   * @param string $name
+   *   The project name.
+   *
+   * @return mixed[]
+   *   The project data if any is available, otherwise NULL.
+   */
+  public function getProjectData(string $name): ?array {
+    if ($this->availableReleasesTempStore->has($name)) {
+      return $this->availableReleasesTempStore->get($name);
+    }
+    $project_fetch_data = [
+      'name' => $name,
+      'project_type' => 'unknown',
+      'includes' => [],
+    ];
+    $this->createFetchTask($project_fetch_data);
+    if ($this->processFetchTask($project_fetch_data)) {
+      // If the fetch task was successful return the project information.
+      return $this->availableReleasesTempStore->get($name);
+    }
+    return NULL;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function processFetchTask($project) {
+    // The parent method will set 'update.last_check' which will be used to
+    // inform the user when the last time update information was checked. In
+    // order to leave this value unaffected we will reset this to it's previous
+    // value.
+    $last_check = $this->stateStore->get('update.last_check');
+    $success = parent::processFetchTask($project);
+    $this->stateStore->set('update.last_check', $last_check);
+    return $success;
+  }
+
+}
diff --git a/core/modules/package_manager/src/PathExcluder/GitExcluder.php b/core/modules/package_manager/src/PathExcluder/GitExcluder.php
new file mode 100644
index 000000000000..5ec84d0680b9
--- /dev/null
+++ b/core/modules/package_manager/src/PathExcluder/GitExcluder.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * Excludes .git directories from staging operations.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class GitExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * Constructs a GitExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeGitDirectories',
+      PreApplyEvent::class => 'excludeGitDirectories',
+    ];
+  }
+
+  /**
+   * Excludes .git directories from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeGitDirectories(StageEvent $event): void {
+    // Find all .git directories in the project and exclude them. We cannot do
+    // this with FileSystemInterface::scanDirectory() because it unconditionally
+    // excludes anything starting with a dot.
+    $finder = Finder::create()
+      ->in($this->pathLocator->getProjectRoot())
+      ->directories()
+      ->name('.git')
+      ->ignoreVCS(FALSE)
+      ->ignoreDotFiles(FALSE);
+
+    $paths = [];
+    foreach ($finder as $git_directory) {
+      $paths[] = $git_directory->getPathname();
+    }
+    $this->excludeInProjectRoot($event, $paths);
+  }
+
+}
diff --git a/core/modules/package_manager/src/PathExcluder/PathExclusionsTrait.php b/core/modules/package_manager/src/PathExcluder/PathExclusionsTrait.php
new file mode 100644
index 000000000000..5c841417f655
--- /dev/null
+++ b/core/modules/package_manager/src/PathExcluder/PathExclusionsTrait.php
@@ -0,0 +1,64 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\StageEvent;
+
+/**
+ * Contains methods for excluding paths from staging operations.
+ */
+trait PathExclusionsTrait {
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Flags paths to be excluded, relative to the web root.
+   *
+   * This should only be used for paths that, if they exist at all, are
+   * *guaranteed* to exist within the web root.
+   *
+   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   * @param string[] $paths
+   *   The paths to exclude. These should be relative to the web root, and will
+   *   be made relative to the project root.
+   */
+  protected function excludeInWebRoot(StageEvent $event, array $paths): void {
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $web_root .= '/';
+    }
+
+    foreach ($paths as $path) {
+      // Make the path relative to the project root by prefixing the web root.
+      $event->excludePath($web_root . $path);
+    }
+  }
+
+  /**
+   * Flags paths to be excluded, relative to the project root.
+   *
+   * @param \Drupal\package_manager\Event\PreCreateEvent|\Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   * @param string[] $paths
+   *   The paths to exclude. Absolute paths will be made relative to the project
+   *   root; relative paths will be assumed to already be relative to the
+   *   project root, and excluded as given.
+   */
+  protected function excludeInProjectRoot(StageEvent $event, array $paths): void {
+    $project_root = $this->pathLocator->getProjectRoot();
+
+    foreach ($paths as $path) {
+      // Make absolute paths relative to the project root.
+      $path = str_replace($project_root, '', $path);
+      $path = ltrim($path, '/');
+      $event->excludePath($path);
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php b/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php
new file mode 100644
index 000000000000..9b626df17e0b
--- /dev/null
+++ b/core/modules/package_manager/src/PathExcluder/SiteConfigurationExcluder.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Excludes site configuration files from staging areas.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+class SiteConfigurationExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * The current site path, relative to the Drupal root.
+   *
+   * @var string
+   */
+  protected $sitePath;
+
+  /**
+   * Constructs an ExcludedPathsSubscriber.
+   *
+   * @param string $site_path
+   *   The current site path, relative to the Drupal root.
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(string $site_path, PathLocator $path_locator) {
+    $this->sitePath = $site_path;
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * Excludes site configuration files from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent|\Drupal\package_manager\Event\PreCreateEvent $event
+   *   The event object.
+   *
+   * @see \Drupal\package_manager\Event\ExcludedPathsTrait::excludePath()
+   */
+  public function excludeSiteConfiguration(StageEvent $event): void {
+    // Site configuration files are always excluded relative to the web root.
+    $paths = [];
+
+    // Ignore site-specific settings files, which are always in the web root.
+    // By default, Drupal core will always try to write-protect these files.
+    $settings_files = [
+      'settings.php',
+      'settings.local.php',
+      'services.yml',
+    ];
+    foreach ($settings_files as $settings_file) {
+      $paths[] = $this->sitePath . '/' . $settings_file;
+      $paths[] = 'sites/default/' . $settings_file;
+    }
+    $this->excludeInWebRoot($event, $paths);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeSiteConfiguration',
+      PreApplyEvent::class => 'excludeSiteConfiguration',
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php b/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php
new file mode 100644
index 000000000000..13b8ac640beb
--- /dev/null
+++ b/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php
@@ -0,0 +1,92 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\Core\StreamWrapper\LocalStream;
+use Drupal\Core\StreamWrapper\StreamWrapperManagerInterface;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * Excludes public and private files from staging operations.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class SiteFilesExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * The stream wrapper manager service.
+   *
+   * @var \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface
+   */
+  protected $streamWrapperManager;
+
+  /**
+   * The Symfony file system service.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * Constructs a SiteFilesExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\StreamWrapper\StreamWrapperManagerInterface $stream_wrapper_manager
+   *   The stream wrapper manager service.
+   * @param \Symfony\Component\Filesystem\Filesystem $file_system
+   *   The Symfony file system service.
+   */
+  public function __construct(PathLocator $path_locator, StreamWrapperManagerInterface $stream_wrapper_manager, Filesystem $file_system) {
+    $this->pathLocator = $path_locator;
+    $this->streamWrapperManager = $stream_wrapper_manager;
+    $this->fileSystem = $file_system;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeSiteFiles',
+      PreApplyEvent::class => 'excludeSiteFiles',
+    ];
+  }
+
+  /**
+   * Excludes public and private files from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeSiteFiles(StageEvent $event): void {
+    // Ignore public and private files. These paths could be either absolute or
+    // relative, depending on site settings. If they are absolute, treat them
+    // as relative to the project root. Otherwise, treat them as relative to
+    // the web root.
+    foreach (['public', 'private'] as $scheme) {
+      $wrapper = $this->streamWrapperManager->getViaScheme($scheme);
+      if ($wrapper instanceof LocalStream) {
+        $path = $wrapper->getDirectoryPath();
+
+        if ($this->fileSystem->isAbsolutePath($path)) {
+          $this->excludeInProjectRoot($event, [$path]);
+        }
+        else {
+          $this->excludeInWebRoot($event, [$path]);
+        }
+      }
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php b/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
new file mode 100644
index 000000000000..cd8ba76f3df6
--- /dev/null
+++ b/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
@@ -0,0 +1,73 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\Core\Database\Connection;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Excludes SQLite database files from staging operations.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+class SqliteDatabaseExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * The database connection.
+   *
+   * @var \Drupal\Core\Database\Connection
+   */
+  protected $database;
+
+  /**
+   * Constructs a SqliteDatabaseExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\Database\Connection $database
+   *   The database connection.
+   */
+  public function __construct(PathLocator $path_locator, Connection $database) {
+    $this->pathLocator = $path_locator;
+    $this->database = $database;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeDatabaseFiles',
+      PreApplyEvent::class => 'excludeDatabaseFiles',
+    ];
+  }
+
+  /**
+   * Excludes SQLite database files from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeDatabaseFiles(StageEvent $event): void {
+    // If the database is SQLite, it might be located in the active directory
+    // and we should ignore it. Always treat it as relative to the project root.
+    if ($this->database->driver() === 'sqlite') {
+      $options = $this->database->getConnectionOptions();
+      $this->excludeInProjectRoot($event, [
+        $options['database'],
+        $options['database'] . '-shm',
+        $options['database'] . '-wal',
+      ]);
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/src/PathExcluder/TestSiteExcluder.php b/core/modules/package_manager/src/PathExcluder/TestSiteExcluder.php
new file mode 100644
index 000000000000..d796e07e5c0f
--- /dev/null
+++ b/core/modules/package_manager/src/PathExcluder/TestSiteExcluder.php
@@ -0,0 +1,55 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Excludes 'sites/simpletest' from staging operations.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class TestSiteExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * Constructs a TestSiteExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeTestSites',
+      PreApplyEvent::class => 'excludeTestSites',
+    ];
+  }
+
+  /**
+   * Excludes sites/simpletest from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeTestSites(StageEvent $event): void {
+    // Always ignore automated test directories. If they exist, they will be in
+    // the web root.
+    $this->excludeInWebRoot($event, ['sites/simpletest']);
+  }
+
+}
diff --git a/core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php b/core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php
new file mode 100644
index 000000000000..e54ff986f51f
--- /dev/null
+++ b/core/modules/package_manager/src/PathExcluder/VendorHardeningExcluder.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\package_manager\PathExcluder;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Excludes vendor hardening files from staging operations.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class VendorHardeningExcluder implements EventSubscriberInterface {
+
+  use PathExclusionsTrait;
+
+  /**
+   * Constructs a VendorHardeningExcluder object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'excludeVendorHardeningFiles',
+      PreApplyEvent::class => 'excludeVendorHardeningFiles',
+    ];
+  }
+
+  /**
+   * Excludes vendor hardening files from staging operations.
+   *
+   * @param \Drupal\package_manager\Event\StageEvent $event
+   *   The event object.
+   */
+  public function excludeVendorHardeningFiles(StageEvent $event): void {
+    // If the core-vendor-hardening plugin (used in the legacy-project template)
+    // is present, it may have written security hardening files in the vendor
+    // directory. They should always be ignored.
+    $vendor_dir = $this->pathLocator->getVendorDirectory();
+    $this->excludeInProjectRoot($event, [
+      $vendor_dir . '/web.config',
+      $vendor_dir . '/.htaccess',
+    ]);
+  }
+
+}
diff --git a/core/modules/package_manager/src/PathLocator.php b/core/modules/package_manager/src/PathLocator.php
index dabad2e653ed..dc609e3f862f 100644
--- a/core/modules/package_manager/src/PathLocator.php
+++ b/core/modules/package_manager/src/PathLocator.php
@@ -3,6 +3,8 @@
 namespace Drupal\package_manager;
 
 use Composer\Autoload\ClassLoader;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\File\FileSystemInterface;
 
 /**
  * Computes file system paths that are needed to stage code changes.
@@ -16,14 +18,42 @@ class PathLocator {
    */
   protected $appRoot;
 
+  /**
+   * The config factory service.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
   /**
    * Constructs a PathLocator object.
    *
    * @param string $app_root
    *   The absolute path of the running Drupal code base.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory service.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The file system service.
    */
-  public function __construct(string $app_root) {
+  public function __construct(string $app_root, ConfigFactoryInterface $config_factory = NULL, FileSystemInterface $file_system = NULL) {
     $this->appRoot = $app_root;
+    if (empty($config_factory)) {
+      @trigger_error('Calling ' . __METHOD__ . '() without the $config_factory argument is deprecated in auto_updates:8.x-2.1 and will be required before auto_updates:3.0.0. See https://www.drupal.org/node/3300008.', E_USER_DEPRECATED);
+      $config_factory = \Drupal::configFactory();
+    }
+    $this->configFactory = $config_factory;
+    if (empty($file_system)) {
+      @trigger_error('Calling ' . __METHOD__ . '() without the $file_system argument is deprecated in auto_updates:8.x-2.1 and will be required before auto_updates:3.0.0. See https://www.drupal.org/node/3300008.', E_USER_DEPRECATED);
+      $file_system = \Drupal::service('file_system');
+    }
+    $this->fileSystem = $file_system;
   }
 
   /**
@@ -47,8 +77,28 @@ public function getProjectRoot(): string {
    *   The absolute path of the vendor directory.
    */
   public function getVendorDirectory(): string {
-    $reflector = new \ReflectionClass(ClassLoader::class);
-    return dirname($reflector->getFileName(), 2);
+    // There may be multiple class loaders at work.
+    // ClassLoader::getRegisteredLoaders() keeps track of them all, indexed by
+    // the path of the vendor directory they load classes from.
+    $loaders = ClassLoader::getRegisteredLoaders();
+
+    // If there's only one class loader, we don't need to search for the right
+    // one.
+    if (count($loaders) === 1) {
+      return key($loaders);
+    }
+
+    // To determine which class loader is the one for Drupal's vendor directory,
+    // look for the loader whose vendor path starts the same way as the path to
+    // this file.
+    foreach (array_keys($loaders) as $path) {
+      if (str_starts_with(__FILE__, dirname($path))) {
+        return $path;
+      }
+    }
+    // If we couldn't find a match, assume that the first registered class
+    // loader is the one we want.
+    return key($loaders);
   }
 
   /**
@@ -60,8 +110,23 @@ public function getVendorDirectory(): string {
    *   project root and Drupal root are the same.
    */
   public function getWebRoot(): string {
-    $web_root = str_replace($this->getProjectRoot(), NULL, $this->appRoot);
+    $web_root = str_replace(trim($this->getProjectRoot(), DIRECTORY_SEPARATOR), '', trim($this->appRoot, DIRECTORY_SEPARATOR));
     return trim($web_root, DIRECTORY_SEPARATOR);
   }
 
+  /**
+   * Returns the directory where staging areas will be created.
+   *
+   * The staging root may be affected by site settings, so stages may wish to
+   * cache the value returned by this method, to ensure that they use the same
+   * staging root throughout their life cycle.
+   *
+   * @return string
+   *   The absolute path of the directory where staging areas should be created.
+   */
+  public function getStagingRoot(): string {
+    $site_id = $this->configFactory->get('system.site')->get('uuid');
+    return $this->fileSystem->getTempDirectory() . DIRECTORY_SEPARATOR . '.package_manager' . $site_id;
+  }
+
 }
diff --git a/core/modules/package_manager/src/ProcessFactory.php b/core/modules/package_manager/src/ProcessFactory.php
index 78ef902b2d90..e3cfd03514c5 100644
--- a/core/modules/package_manager/src/ProcessFactory.php
+++ b/core/modules/package_manager/src/ProcessFactory.php
@@ -4,19 +4,24 @@
 
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\File\FileSystemInterface;
-use PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactory as StagerProcessFactory;
-use PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactoryInterface;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Process\ProcessFactoryInterface;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Process\ProcessFactory as StagerProcessFactory;
 use Symfony\Component\Process\Process;
 
 /**
  * Defines a process factory which sets the COMPOSER_HOME environment variable.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
 final class ProcessFactory implements ProcessFactoryInterface {
 
   /**
    * The decorated process factory.
    *
-   * @var \PhpTuf\ComposerStager\Infrastructure\Process\ProcessFactoryInterface
+   * @var \PhpTuf\ComposerStager\Infrastructure\Factory\Process\ProcessFactoryInterface
    */
   private $decorated;
 
@@ -74,11 +79,30 @@ public function create(array $command): Process {
     if ($this->isComposerCommand($command)) {
       $env['COMPOSER_HOME'] = $this->getComposerHomePath();
     }
-    // Ensure that the running PHP binary is in the PATH.
-    $env['PATH'] = $this->getEnv('PATH') . ':' . dirname(PHP_BINARY);
+    // Ensure that the current PHP installation is the first place that will be
+    // searched when looking for the PHP interpreter.
+    $env['PATH'] = static::getPhpDirectory() . ':' . $this->getEnv('PATH');
     return $process->setEnv($env);
   }
 
+  /**
+   * Returns the directory which contains the PHP interpreter.
+   *
+   * @return string
+   *   The path of the directory containing the PHP interpreter. If the server
+   *   is running in a command-line interface, the directory portion of
+   *   PHP_BINARY is returned; otherwise, the compile-time PHP_BINDIR is.
+   *
+   * @see php_sapi_name()
+   * @see https://www.php.net/manual/en/reserved.constants.php
+   */
+  protected static function getPhpDirectory(): string {
+    if (PHP_SAPI === 'cli' || PHP_SAPI === 'cli-server') {
+      return dirname(PHP_BINARY);
+    }
+    return PHP_BINDIR;
+  }
+
   /**
    * Returns the path to use as the COMPOSER_HOME environment variable.
    *
diff --git a/core/modules/package_manager/src/ProjectInfo.php b/core/modules/package_manager/src/ProjectInfo.php
new file mode 100644
index 000000000000..fd1343a5596f
--- /dev/null
+++ b/core/modules/package_manager/src/ProjectInfo.php
@@ -0,0 +1,188 @@
+<?php
+
+namespace Drupal\package_manager;
+
+use Composer\Semver\Comparator;
+use Drupal\update\ProjectRelease;
+use Drupal\Core\Extension\ExtensionVersion;
+use Drupal\update\UpdateManagerInterface;
+
+/**
+ * Defines a class for retrieving project information from Update module.
+ *
+ * @internal
+ *   This is an internal part of Automatic Updates and may be changed or removed
+ *   at any time without warning. External code should use the Update API
+ *   directly.
+ */
+final class ProjectInfo {
+
+  /**
+   * The project name.
+   *
+   * @var string
+   */
+  protected $name;
+
+  /**
+   * Constructs a ProjectInfo object.
+   *
+   * @param string $name
+   *   The project name.
+   */
+  public function __construct(string $name) {
+    $this->name = $name;
+  }
+
+  /**
+   * Determines if a release can be installed.
+   *
+   * @param \Drupal\update\ProjectRelease $release
+   *   The project release.
+   * @param string[] $support_branches
+   *   The supported branches.
+   *
+   * @return bool
+   *   TRUE if the release is installable, otherwise FALSE. A release will be
+   *   considered installable if it is secure, published, supported, and in
+   *   a supported branch.
+   */
+  private function isInstallable(ProjectRelease $release, array $support_branches): bool {
+    if ($release->isInsecure() || !$release->isPublished() || $release->isUnsupported()) {
+      return FALSE;
+    }
+    $version = ExtensionVersion::createFromVersionString($release->getVersion());
+    if ($version->getVersionExtra() === 'dev') {
+      return FALSE;
+    }
+    foreach ($support_branches as $support_branch) {
+      $support_branch_version = ExtensionVersion::createFromSupportBranch($support_branch);
+      if ($support_branch_version->getMajorVersion() === $version->getMajorVersion() && $support_branch_version->getMinorVersion() === $version->getMinorVersion()) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+  /**
+   * Returns up-to-date project information.
+   *
+   * @return mixed[]|null
+   *   The retrieved project information.
+   *
+   * @throws \RuntimeException
+   *   If data about available updates cannot be retrieved.
+   */
+  public function getProjectInfo(): ?array {
+    $available_updates = $this->getAvailableProjects();
+    $project_data = update_calculate_project_data($available_updates);
+    if (!isset($project_data[$this->name])) {
+      return $available_updates[$this->name] ?? NULL;
+    }
+    return $project_data[$this->name];
+  }
+
+  /**
+   * Gets all project releases to which the site can update.
+   *
+   * @return \Drupal\update\ProjectRelease[]|null
+   *   If the project information is available, an array of releases that can be
+   *   installed, keyed by version number; otherwise NULL. The releases are in
+   *   descending order by version number (i.e., higher versions are listed
+   *   first). The currently installed version of the project, and any older
+   *   versions, are not considered installable releases.
+   *
+   * @throws \RuntimeException
+   *   Thrown if there are no available releases.
+   *
+   * @todo Remove or simplify this function in https://www.drupal.org/i/3252190.
+   */
+  public function getInstallableReleases(): ?array {
+    $project = $this->getProjectInfo();
+    if (!$project) {
+      return NULL;
+    }
+    $available_updates = $this->getAvailableProjects()[$this->name];
+    if ($available_updates['project_status'] !== 'published') {
+      throw new \RuntimeException("The project '{$this->name}' can not be updated because its status is " . $available_updates['project_status']);
+    }
+    $installed_version = $this->getInstalledVersion();
+
+    if ($installed_version) {
+      // If the project is installed, and we're already up-to-date, there's
+      // nothing else we need to do.
+      if ($project['status'] === UpdateManagerInterface::CURRENT) {
+        return [];
+      }
+
+      if (empty($available_updates['releases'])) {
+        // If project is installed but not current we should always have at
+        // least one release.
+        throw new \RuntimeException('There was a problem getting update information. Try again later.');
+      }
+    }
+
+    $support_branches = explode(',', $available_updates['supported_branches']);
+    $installable_releases = [];
+    foreach ($available_updates['releases'] as $release_info) {
+      $release = ProjectRelease::createFromArray($release_info);
+      $version = $release->getVersion();
+      if ($installed_version) {
+        $semantic_version = LegacyVersionUtility::convertToSemanticVersion($version);
+        $semantic_installed_version = LegacyVersionUtility::convertToSemanticVersion($installed_version);
+        if (Comparator::lessThanOrEqualTo($semantic_version, $semantic_installed_version)) {
+          // If the project is installed stop searching for releases as soon as
+          // we find the installed version.
+          break;
+        }
+      }
+      if ($this->isInstallable($release, $support_branches)) {
+        $installable_releases[$version] = $release;
+      }
+    }
+    return $installable_releases;
+  }
+
+  /**
+   * Returns the installed project version, according to the Update module.
+   *
+   * @return string|null
+   *   The installed project version as known to the Update module or NULL if
+   *   the project information is not available.
+   */
+  public function getInstalledVersion(): ?string {
+    if ($project_data = $this->getProjectInfo()) {
+      return $project_data['existing_version'] ?? NULL;
+    }
+    return NULL;
+  }
+
+  /**
+   * Gets the available projects.
+   *
+   * @return array
+   *   The available projects keyed by project machine name in the format
+   *   returned by update_get_available(). If the project specified in ::name is
+   *   not returned from update_get_available() this project will be explicitly
+   *   fetched and added the return value of this function.
+   *
+   * @see \update_get_available()
+   */
+  private function getAvailableProjects(): array {
+    $available_projects = update_get_available(TRUE);
+    // update_get_available() will only returns projects that are in the active
+    // codebase. If the project specified by ::name is not returned in
+    // $available_projects, it means it is not in the active codebase, therefore
+    // we will retrieve the project information from Package Manager's own
+    // update processor service.
+    if (!isset($available_projects[$this->name])) {
+      /** @var \Drupal\package_manager\PackageManagerUpdateProcessor $update_processor */
+      $update_processor = \Drupal::service('package_manager.update_processor');
+      if ($project_data = $update_processor->getProjectData($this->name)) {
+        $available_projects[$this->name] = $project_data;
+      }
+    }
+    return $available_projects;
+  }
+
+}
diff --git a/core/modules/package_manager/src/Stage.php b/core/modules/package_manager/src/Stage.php
index 810806fe743a..895944bbb04b 100644
--- a/core/modules/package_manager/src/Stage.php
+++ b/core/modules/package_manager/src/Stage.php
@@ -3,11 +3,12 @@
 namespace Drupal\package_manager;
 
 use Drupal\Component\Datetime\TimeInterface;
-use Drupal\Component\FileSystem\FileSystem;
 use Drupal\Component\Utility\Crypt;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\File\Exception\FileException;
 use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\StringTranslation\TranslatableMarkup;
 use Drupal\Core\TempStore\SharedTempStoreFactory;
 use Drupal\package_manager\Event\PostApplyEvent;
 use Drupal\package_manager\Event\PostCreateEvent;
@@ -19,12 +20,21 @@
 use Drupal\package_manager\Event\PreOperationStageEvent;
 use Drupal\package_manager\Event\PreRequireEvent;
 use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\ApplyFailedException;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\Exception\StageOwnershipException;
 use Drupal\package_manager\Exception\StageValidationException;
-use PhpTuf\ComposerStager\Domain\BeginnerInterface;
-use PhpTuf\ComposerStager\Domain\CommitterInterface;
-use PhpTuf\ComposerStager\Domain\StagerInterface;
+use PhpTuf\ComposerStager\Domain\Core\Beginner\BeginnerInterface;
+use PhpTuf\ComposerStager\Domain\Core\Committer\CommitterInterface;
+use PhpTuf\ComposerStager\Domain\Core\Stager\StagerInterface;
+use PhpTuf\ComposerStager\Domain\Exception\InvalidArgumentException;
+use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactory;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface;
+use PhpTuf\ComposerStager\Infrastructure\Value\PathList\PathList;
+use Psr\Log\LoggerAwareInterface;
+use Psr\Log\LoggerAwareTrait;
+use Psr\Log\NullLogger;
 use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
 
 /**
@@ -51,7 +61,10 @@
  * (e.g. `/tmp/.package_managerSITE_UUID`), which is deleted when any stage
  * created by that site is destroyed.
  */
-class Stage {
+class Stage implements LoggerAwareInterface {
+
+  use LoggerAwareTrait;
+  use StringTranslationTrait;
 
   /**
    * The tempstore key under which to store the locking info for this stage.
@@ -67,6 +80,15 @@ class Stage {
    */
   protected const TEMPSTORE_METADATA_KEY = 'metadata';
 
+  /**
+   * The tempstore key under which to store the path of the staging root.
+   *
+   * @var string
+   *
+   * @see ::getStagingRoot()
+   */
+  private const TEMPSTORE_STAGING_ROOT_KEY = 'staging_root';
+
   /**
    * The tempstore key under which to store the time that ::apply() was called.
    *
@@ -94,21 +116,21 @@ class Stage {
   /**
    * The beginner service.
    *
-   * @var \PhpTuf\ComposerStager\Domain\BeginnerInterface
+   * @var \PhpTuf\ComposerStager\Domain\Core\Beginner\BeginnerInterface
    */
   protected $beginner;
 
   /**
    * The stager service.
    *
-   * @var \PhpTuf\ComposerStager\Domain\StagerInterface
+   * @var \PhpTuf\ComposerStager\Domain\Core\Stager\StagerInterface
    */
   protected $stager;
 
   /**
    * The committer service.
    *
-   * @var \PhpTuf\ComposerStager\Domain\CommitterInterface
+   * @var \PhpTuf\ComposerStager\Domain\Core\Committer\CommitterInterface
    */
   protected $committer;
 
@@ -140,6 +162,13 @@ class Stage {
    */
   protected $time;
 
+  /**
+   * The path factory service.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface
+   */
+  protected $pathFactory;
+
   /**
    * The lock info for the stage.
    *
@@ -149,6 +178,13 @@ class Stage {
    */
   private $lock;
 
+  /**
+   * The failure marker service.
+   *
+   * @var \Drupal\package_manager\FailureMarker
+   */
+  protected $failureMarker;
+
   /**
    * Constructs a new Stage object.
    *
@@ -156,11 +192,11 @@ class Stage {
    *   The config factory service.
    * @param \Drupal\package_manager\PathLocator $path_locator
    *   The path locator service.
-   * @param \PhpTuf\ComposerStager\Domain\BeginnerInterface $beginner
+   * @param \PhpTuf\ComposerStager\Domain\Core\Beginner\BeginnerInterface $beginner
    *   The beginner service.
-   * @param \PhpTuf\ComposerStager\Domain\StagerInterface $stager
+   * @param \PhpTuf\ComposerStager\Domain\Core\Stager\StagerInterface $stager
    *   The stager service.
-   * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $committer
+   * @param \PhpTuf\ComposerStager\Domain\Core\Committer\CommitterInterface $committer
    *   The committer service.
    * @param \Drupal\Core\File\FileSystemInterface $file_system
    *   The file system service.
@@ -170,8 +206,12 @@ class Stage {
    *   The shared tempstore factory.
    * @param \Drupal\Component\Datetime\TimeInterface $time
    *   The time service.
+   * @param \PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface $path_factory
+   *   The path factory service.
+   * @param \Drupal\package_manager\FailureMarker $failure_marker
+   *   The failure marker service.
    */
-  public function __construct(ConfigFactoryInterface $config_factory, PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore, TimeInterface $time) {
+  public function __construct(ConfigFactoryInterface $config_factory, PathLocator $path_locator, BeginnerInterface $beginner, StagerInterface $stager, CommitterInterface $committer, FileSystemInterface $file_system, EventDispatcherInterface $event_dispatcher, SharedTempStoreFactory $shared_tempstore, TimeInterface $time, PathFactoryInterface $path_factory = NULL, FailureMarker $failure_marker = NULL) {
     $this->configFactory = $config_factory;
     $this->pathLocator = $path_locator;
     $this->beginner = $beginner;
@@ -181,6 +221,17 @@ public function __construct(ConfigFactoryInterface $config_factory, PathLocator
     $this->eventDispatcher = $event_dispatcher;
     $this->time = $time;
     $this->tempStore = $shared_tempstore->get('package_manager_stage');
+    if (empty($path_factory)) {
+      @trigger_error('Calling ' . __METHOD__ . '() without the $path_factory argument is deprecated in auto_updates:8.x-2.3 and will be required before auto_updates:3.0.0. See https://www.drupal.org/node/3310706.', E_USER_DEPRECATED);
+      $path_factory = new PathFactory();
+    }
+    $this->pathFactory = $path_factory;
+    if (empty($failure_marker)) {
+      @trigger_error('Calling ' . __METHOD__ . '() without the $failure_marker argument is deprecated in auto_updates:8.x-2.3 and will be required before auto_updates:3.0.0. See https://www.drupal.org/node/3311257.', E_USER_DEPRECATED);
+      $failure_marker = \Drupal::service('package_manager.failure_marker');
+    }
+    $this->failureMarker = $failure_marker;
+    $this->setLogger(new NullLogger());
   }
 
   /**
@@ -238,6 +289,11 @@ protected function setMetadata(string $key, $data): void {
    * call ::claim(). However, if it was created during another request, the
    * stage must be claimed before operations can be performed on it.
    *
+   * @param int|null $timeout
+   *   (optional) How long to allow the file copying operation to run before
+   *   timing out, in seconds, or NULL to never time out. Defaults to 300
+   *   seconds.
+   *
    * @return string
    *   Unique ID for the stage, which can be used to claim the stage before
    *   performing other operations on it. Calling code should store this ID for
@@ -248,7 +304,9 @@ protected function setMetadata(string $key, $data): void {
    *
    * @see ::claim()
    */
-  public function create(): string {
+  public function create(?int $timeout = 300): string {
+    $this->failureMarker->assertNotExists();
+
     if (!$this->isAvailable()) {
       throw new StageException('Cannot create a new stage because one already exists.');
     }
@@ -262,15 +320,15 @@ public function create(): string {
     $this->tempStore->set(static::TEMPSTORE_LOCK_KEY, [$id, static::class]);
     $this->claim($id);
 
-    $active_dir = $this->pathLocator->getProjectRoot();
-    $stage_dir = $this->getStageDirectory();
+    $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
+    $stage_dir = $this->pathFactory->create($this->getStageDirectory());
 
     $event = new PreCreateEvent($this);
     // If an error occurs and we won't be able to create the stage, mark it as
     // available.
     $this->dispatch($event, [$this, 'markAsAvailable']);
 
-    $this->beginner->begin($active_dir, $stage_dir, $event->getExcludedPaths());
+    $this->beginner->begin($active_dir, $stage_dir, new PathList($event->getExcludedPaths()), NULL, $timeout);
     $this->dispatch(new PostCreateEvent($this));
     return $id;
   }
@@ -280,59 +338,124 @@ public function create(): string {
    *
    * @param string[] $runtime
    *   The packages to add as regular top-level dependencies, in the form
-   *   'vendor/name:version'.
+   *   'vendor/name' or 'vendor/name:version'.
    * @param string[] $dev
    *   (optional) The packages to add as dev dependencies, in the form
-   *   'vendor/name:version'. Defaults to an empty array.
+   *   'vendor/name' or 'vendor/name:version'. Defaults to an empty array.
+   * @param int|null $timeout
+   *   (optional) How long to allow the Composer operation to run before timing
+   *   out, in seconds, or NULL to never time out. Defaults to 300 seconds.
    */
-  public function require(array $runtime, array $dev = []): void {
+  public function require(array $runtime, array $dev = [], ?int $timeout = 300): void {
     $this->checkOwnership();
 
-    $this->dispatch(new PreRequireEvent($this));
-    $dir = $this->getStageDirectory();
+    $this->dispatch(new PreRequireEvent($this, $runtime, $dev));
+    $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
+    $stage_dir = $this->pathFactory->create($this->getStageDirectory());
 
     // Change the runtime and dev requirements as needed, but don't update
     // the installed packages yet.
     if ($runtime) {
+      $this->validatePackageNames($runtime);
       $command = array_merge(['require', '--no-update'], $runtime);
-      $this->stager->stage($command, $dir);
+      $this->stager->stage($command, $active_dir, $stage_dir, NULL, $timeout);
     }
     if ($dev) {
+      $this->validatePackageNames($dev);
       $command = array_merge(['require', '--dev', '--no-update'], $dev);
-      $this->stager->stage($command, $dir);
+      $this->stager->stage($command, $active_dir, $stage_dir, NULL, $timeout);
     }
 
     // If constraints were changed, update those packages.
     if ($runtime || $dev) {
       $command = array_merge(['update', '--with-all-dependencies'], $runtime, $dev);
-      $this->stager->stage($command, $dir);
+      $this->stager->stage($command, $active_dir, $stage_dir, NULL, $timeout);
     }
 
-    $this->dispatch(new PostRequireEvent($this));
+    $this->dispatch(new PostRequireEvent($this, $runtime, $dev));
   }
 
   /**
    * Applies staged changes to the active directory.
+   *
+   * After the staged changes are applied, the current request should be
+   * terminated as soon as possible. This is because the code loaded into the
+   * PHP runtime may no longer match the code that is physically present in the
+   * active code base, which means that the current request is running in an
+   * unreliable, inconsistent environment. In the next request,
+   * ::postApply() should be called as early as possible after Drupal is
+   * fully bootstrapped, to rebuild the service container, flush caches, and
+   * dispatch the post-apply event.
+   *
+   * @param int|null $timeout
+   *   (optional) How long to allow the file copying operation to run before
+   *   timing out, in seconds, or NULL to never time out. Defaults to 600
+   *   seconds.
+   *
+   * @throws \Drupal\package_manager\Exception\ApplyFailedException
+   *   Thrown if there is an error calling Composer Stager, which may indicate
+   *   a failed commit operation.
    */
-  public function apply(): void {
+  public function apply(?int $timeout = 600): void {
     $this->checkOwnership();
 
-    $active_dir = $this->pathLocator->getProjectRoot();
-    $stage_dir = $this->getStageDirectory();
+    $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
+    $stage_dir = $this->pathFactory->create($this->getStageDirectory());
 
     // If an error occurs while dispatching the events, ensure that ::destroy()
     // doesn't think we're in the middle of applying the staged changes to the
     // active directory.
-    $release_apply = function (): void {
-      $this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY);
-    };
-
     $event = new PreApplyEvent($this);
     $this->tempStore->set(self::TEMPSTORE_APPLY_TIME_KEY, $this->time->getRequestTime());
-    $this->dispatch($event, $release_apply);
+    $this->dispatch($event, $this->setNotApplying());
+
+    // Create a marker file so that we can tell later on if the commit failed.
+    $this->failureMarker->write($this, $this->getFailureMarkerMessage());
+    // Exclude the failure file from the commit operation.
+    $event->excludePath($this->failureMarker->getPath());
 
-    $this->committer->commit($stage_dir, $active_dir, $event->getExcludedPaths());
+    try {
+      $this->committer->commit($stage_dir, $active_dir, new PathList($event->getExcludedPaths()), NULL, $timeout);
+    }
+    catch (InvalidArgumentException | PreconditionException $e) {
+      // The commit operation has not started yet, so we can clear the failure
+      // marker.
+      $this->failureMarker->clear();
+      throw new StageException($e->getMessage(), $e->getCode(), $e);
+    }
+    catch (\Throwable $throwable) {
+      // The commit operation may have failed midway through, and the site code
+      // is in an indeterminate state. Release the flag which says we're still
+      // applying, because in this situation, the site owner should probably
+      // restore everything from a backup.
+      $this->setNotApplying()();
+      throw new ApplyFailedException($throwable->getMessage(), $throwable->getCode(), $throwable);
+    }
+    $this->failureMarker->clear();
+  }
 
+  /**
+   * Returns a closure that marks this stage as no longer being applied.
+   *
+   * @return \Closure
+   *   A closure that, when called, marks this stage as no longer in the process
+   *   of being applied to the active directory.
+   */
+  private function setNotApplying(): \Closure {
+    return function (): void {
+      $this->tempStore->delete(self::TEMPSTORE_APPLY_TIME_KEY);
+    };
+  }
+
+  /**
+   * Performs post-apply tasks.
+   */
+  public function postApply(): void {
+    $this->checkOwnership();
+
+    if ($this->tempStore->get(self::TEMPSTORE_APPLY_TIME_KEY) === $this->time->getRequestTime()) {
+      $this->logger->warning('Post-apply tasks are running in the same request during which staged changes were applied to the active code base. This can result in unpredictable behavior.');
+    }
     // Rebuild the container and clear all caches, to ensure that new services
     // are picked up.
     drupal_flush_all_caches();
@@ -341,6 +464,7 @@ public function apply(): void {
     // unlikely to call newly added code during the current request.
     $this->eventDispatcher = \Drupal::service('event_dispatcher');
 
+    $release_apply = $this->setNotApplying();
     $this->dispatch(new PostApplyEvent($this), $release_apply);
     $release_apply();
   }
@@ -364,18 +488,22 @@ public function destroy(bool $force = FALSE): void {
     }
 
     $this->dispatch(new PreDestroyEvent($this));
-    // Delete the staging root and everything in it.
-    try {
-      $this->fileSystem->deleteRecursive($this->getStagingRoot(), function (string $path): void {
-        $this->fileSystem->chmod($path, 0777);
-      });
-    }
-    catch (FileException $e) {
-      // Deliberately swallow the exception so that the stage will be marked
-      // as available and the post-destroy event will be fired, even if the
-      // staging area can't actually be deleted. The file system service logs
-      // the exception, so we don't need to do anything else here.
+    $staging_root = $this->getStagingRoot();
+    // If the staging root exists, delete it and everything in it.
+    if (file_exists($staging_root)) {
+      try {
+        $this->fileSystem->deleteRecursive($staging_root, function (string $path): void {
+          $this->fileSystem->chmod($path, 0777);
+        });
+      }
+      catch (FileException $e) {
+        // Deliberately swallow the exception so that the stage will be marked
+        // as available and the post-destroy event will be fired, even if the
+        // staging area can't actually be deleted. The file system service logs
+        // the exception, so we don't need to do anything else here.
+      }
     }
+
     $this->markAsAvailable();
     $this->dispatch(new PostDestroyEvent($this));
   }
@@ -386,6 +514,7 @@ public function destroy(bool $force = FALSE): void {
   protected function markAsAvailable(): void {
     $this->tempStore->delete(static::TEMPSTORE_METADATA_KEY);
     $this->tempStore->delete(static::TEMPSTORE_LOCK_KEY);
+    $this->tempStore->delete(self::TEMPSTORE_STAGING_ROOT_KEY);
     $this->lock = NULL;
   }
 
@@ -475,6 +604,8 @@ public function getStageComposer(): ComposerUtility {
    * @see ::create()
    */
   final public function claim(string $unique_id): self {
+    $this->failureMarker->assertNotExists();
+
     if ($this->isAvailable()) {
       throw new StageException('Cannot claim the stage because no stage has been created.');
     }
@@ -537,9 +668,16 @@ public function getStageDirectory(): string {
    *   The absolute path of the directory containing the staging areas managed
    *   by this class.
    */
-  protected function getStagingRoot(): string {
-    $site_id = $this->configFactory->get('system.site')->get('uuid');
-    return FileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . '.package_manager' . $site_id;
+  private function getStagingRoot(): string {
+    // Since the staging root can depend on site settings, store it so that
+    // things won't break if the settings change during this stage's life
+    // cycle.
+    $dir = $this->tempStore->get(self::TEMPSTORE_STAGING_ROOT_KEY);
+    if (empty($dir)) {
+      $dir = $this->pathLocator->getStagingRoot();
+      $this->tempStore->set(self::TEMPSTORE_STAGING_ROOT_KEY, $dir);
+    }
+    return $dir;
   }
 
   /**
@@ -559,4 +697,45 @@ final public function isApplying(): bool {
     return isset($apply_time) && $this->time->getRequestTime() - $apply_time < 3600;
   }
 
+  /**
+   * Returns the failure marker message.
+   *
+   * @return \Drupal\Core\StringTranslation\TranslatableMarkup
+   *   The translated failure marker message.
+   */
+  protected function getFailureMarkerMessage(): TranslatableMarkup {
+    return $this->t('Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.');
+  }
+
+  /**
+   * Validates a set of package names.
+   *
+   * Package names are considered invalid if they look like Drupal project
+   * names. The only exceptions to this are `php` and `composer`, which Composer
+   * treats as legitimate requirements.
+   *
+   * @param string[] $package_versions
+   *   A set of package names (with or without version constraints), as passed
+   *   to ::require().
+   *
+   * @throws \InvalidArgumentException
+   *   Thrown if any of the given package names are invalid.
+   *
+   * @see https://getcomposer.org/doc/articles/composer-platform-dependencies.md
+   */
+  protected function validatePackageNames(array $package_versions): void {
+    foreach ($package_versions as $package_name) {
+      $package_name = trim($package_name);
+
+      // Don't mistake the legitimate `php` and `composer` platform requirements
+      // for Drupal projects.
+      if ($package_name === 'php' || $package_name === 'composer') {
+        continue;
+      }
+      elseif (preg_match('/^[a-z0-9_]+$/i', $package_name)) {
+        throw new \InvalidArgumentException("Invalid package name '$package_name'.");
+      }
+    }
+  }
+
 }
diff --git a/core/modules/package_manager/src/ValidationResult.php b/core/modules/package_manager/src/ValidationResult.php
index 7651548eddad..f5c9b51c023b 100644
--- a/core/modules/package_manager/src/ValidationResult.php
+++ b/core/modules/package_manager/src/ValidationResult.php
@@ -8,7 +8,7 @@
 /**
  * A value object to contain the results of a validation.
  */
-class ValidationResult {
+final class ValidationResult {
 
   /**
    * A succinct summary of the results.
@@ -43,6 +43,9 @@ class ValidationResult {
    *   The errors summary.
    */
   private function __construct(int $severity, array $messages, ?TranslatableMarkup $summary = NULL) {
+    if (empty($messages)) {
+      throw new \InvalidArgumentException('At least one message is required.');
+    }
     if (count($messages) > 1 && !$summary) {
       throw new \InvalidArgumentException('If more than one message is provided, a summary is required.');
     }
diff --git a/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php b/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
index f2e4e46157be..e1c40f7806c5 100644
--- a/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
+++ b/core/modules/package_manager/src/Validator/ComposerExecutableValidator.php
@@ -3,18 +3,26 @@
 namespace Drupal\package_manager\Validator;
 
 use Composer\Semver\Comparator;
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\Url;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
-use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
-use PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface;
-use PhpTuf\ComposerStager\Exception\ExceptionInterface;
+use Drupal\package_manager\Event\StatusCheckEvent;
+use PhpTuf\ComposerStager\Domain\Exception\ExceptionInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface;
 
 /**
  * Validates the Composer executable is the correct version.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class ComposerExecutableValidator implements PreOperationStageValidatorInterface, OutputCallbackInterface {
+final class ComposerExecutableValidator implements PreOperationStageValidatorInterface, ProcessOutputCallbackInterface {
 
   use StringTranslationTrait;
 
@@ -23,15 +31,22 @@ class ComposerExecutableValidator implements PreOperationStageValidatorInterface
    *
    * @var string
    */
-  public const MINIMUM_COMPOSER_VERSION = '2.2.4';
+  public const MINIMUM_COMPOSER_VERSION = '2.3.5';
 
   /**
    * The Composer runner.
    *
-   * @var \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface
+   * @var \PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface
    */
   protected $composer;
 
+  /**
+   * The module handler service.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
   /**
    * The detected version of Composer.
    *
@@ -42,13 +57,16 @@ class ComposerExecutableValidator implements PreOperationStageValidatorInterface
   /**
    * Constructs a ComposerExecutableValidator object.
    *
-   * @param \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface $composer
+   * @param \PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface $composer
    *   The Composer runner.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler service.
    * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
    *   The translation service.
    */
-  public function __construct(ComposerRunnerInterface $composer, TranslationInterface $translation) {
+  public function __construct(ComposerRunnerInterface $composer, ModuleHandlerInterface $module_handler, TranslationInterface $translation) {
     $this->composer = $composer;
+    $this->moduleHandler = $module_handler;
     $this->setStringTranslation($translation);
   }
 
@@ -60,27 +78,47 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
       $this->composer->run(['--version'], $this);
     }
     catch (ExceptionInterface $e) {
-      $event->addError([
-        $e->getMessage(),
-      ]);
+      $this->setError($e->getMessage(), $event);
       return;
     }
 
     if ($this->version) {
       if (Comparator::lessThan($this->version, static::MINIMUM_COMPOSER_VERSION)) {
-        $event->addError([
-          $this->t('Composer @minimum_version or later is required, but version @detected_version was detected.', [
-            '@minimum_version' => static::MINIMUM_COMPOSER_VERSION,
-            '@detected_version' => $this->version,
-          ]),
+        $message = $this->t('Composer @minimum_version or later is required, but version @detected_version was detected.', [
+          '@minimum_version' => static::MINIMUM_COMPOSER_VERSION,
+          '@detected_version' => $this->version,
         ]);
+        $this->setError($message, $event);
       }
     }
     else {
-      $event->addError([
-        $this->t('The Composer version could not be detected.'),
+      $this->setError($this->t('The Composer version could not be detected.'), $event);
+    }
+  }
+
+  /**
+   * Flags a validation error.
+   *
+   * @param string $message
+   *   The error message. If the Help module is enabled, a link to Package
+   *   Manager's online documentation will be appended.
+   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
+   *   The event object.
+   *
+   * @see package_manager_help()
+   */
+  protected function setError(string $message, PreOperationStageEvent $event): void {
+    if ($this->moduleHandler->moduleExists('help')) {
+      $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
+        ->setOption('fragment', 'package-manager-faq-composer-not-found')
+        ->toString();
+
+      $message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to configure the path to Composer.', [
+        '@message' => $message,
+        ':package-manager-help' => $url,
       ]);
     }
+    $event->addError([$message]);
   }
 
   /**
@@ -89,6 +127,7 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
   public static function getSubscribedEvents() {
     return [
       PreCreateEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
     ];
   }
 
diff --git a/core/modules/package_manager/src/Validator/ComposerPatchesValidator.php b/core/modules/package_manager/src/Validator/ComposerPatchesValidator.php
new file mode 100644
index 000000000000..f25296edb093
--- /dev/null
+++ b/core/modules/package_manager/src/Validator/ComposerPatchesValidator.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\package_manager\Validator;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Event\StatusCheckEvent;
+
+/**
+ * Validates the configuration of the cweagans/composer-patches plugin.
+ */
+class ComposerPatchesValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    $stage = $event->getStage();
+    $composer = $stage->getActiveComposer();
+
+    if (array_key_exists('cweagans/composer-patches', $composer->getInstalledPackages())) {
+      $composer = $composer->getComposer();
+
+      $extra = $composer->getPackage()->getExtra();
+      if (empty($extra['composer-exit-on-patch-failure'])) {
+        $event->addError([
+          $this->t('The <code>cweagans/composer-patches</code> plugin is installed, but the <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of @file.', [
+            // If composer.json is in a virtual file system, Composer will not
+            // be able to resolve a real path for it.
+            '@file' => $composer->getConfig()->getConfigSource()->getName() ?: 'composer.json',
+          ]),
+        ]);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/src/Validator/ComposerSettingsValidator.php b/core/modules/package_manager/src/Validator/ComposerSettingsValidator.php
index 0ad1c7c254f7..0e35167e7383 100644
--- a/core/modules/package_manager/src/Validator/ComposerSettingsValidator.php
+++ b/core/modules/package_manager/src/Validator/ComposerSettingsValidator.php
@@ -6,11 +6,17 @@
 use Drupal\Core\StringTranslation\TranslationInterface;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Event\StatusCheckEvent;
 
 /**
  * Validates certain Composer settings.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class ComposerSettingsValidator implements PreOperationStageValidatorInterface {
+final class ComposerSettingsValidator implements PreOperationStageValidatorInterface {
 
   use StringTranslationTrait;
 
@@ -48,6 +54,7 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
   public static function getSubscribedEvents() {
     return [
       PreCreateEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
     ];
   }
 
diff --git a/core/modules/package_manager/src/Validator/DiskSpaceValidator.php b/core/modules/package_manager/src/Validator/DiskSpaceValidator.php
index 7a0a97dc4e7f..59dc1ade35e2 100644
--- a/core/modules/package_manager/src/Validator/DiskSpaceValidator.php
+++ b/core/modules/package_manager/src/Validator/DiskSpaceValidator.php
@@ -8,10 +8,16 @@
 use Drupal\Component\Utility\Bytes;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\package_manager\PathLocator;
 
 /**
  * Validates that there is enough free disk space to do staging operations.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
 class DiskSpaceValidator implements PreOperationStageValidatorInterface {
 
@@ -63,7 +69,7 @@ protected function freeSpace(string $path): float {
    * @param string $path
    *   The path to check.
    *
-   * @return array
+   * @return mixed[]
    *   The statistics for the path.
    *
    * @throws \RuntimeException
@@ -160,6 +166,7 @@ protected function temporaryDirectory(): string {
   public static function getSubscribedEvents() {
     return [
       PreCreateEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
     ];
   }
 
diff --git a/core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php b/core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php
new file mode 100644
index 000000000000..fe1611d330eb
--- /dev/null
+++ b/core/modules/package_manager/src/Validator/DuplicateInfoFileValidator.php
@@ -0,0 +1,138 @@
+<?php
+
+namespace Drupal\package_manager\Validator;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\PathLocator;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * Validates the stage does not have duplicate info.yml not present in active.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+class DuplicateInfoFileValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a DuplicateInfoFileValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * Validates the stage does not have duplicate info.yml not present in active.
+   */
+  public function validateDuplicateInfoFileInStage(PreApplyEvent $event): void {
+    $active_dir = $this->pathLocator->getProjectRoot();
+    $stage_dir = $event->getStage()->getStageDirectory();
+    $active_info_files = $this->findInfoFiles($active_dir);
+    $stage_info_files = $this->findInfoFiles($stage_dir);
+
+    foreach ($stage_info_files as $stage_info_file => $stage_info_count) {
+      if (isset($active_info_files[$stage_info_file])) {
+        // Check if stage directory has more info.yml files matching
+        // $stage_info_file than in the active directory.
+        if ($stage_info_count > $active_info_files[$stage_info_file]) {
+          $event->addError([
+            $this->t('The staging directory has @stage_count instances of @stage_info_file as compared to @active_count in the active directory. This likely indicates that a duplicate extension was installed.', [
+              '@stage_info_file' => $stage_info_file,
+              '@stage_count' => $stage_info_count,
+              '@active_count' => $active_info_files[$stage_info_file],
+            ]),
+          ]);
+        }
+      }
+      // Check if stage directory has two or more info.yml files matching
+      // $stage_info_file which are not in active directory.
+      elseif ($stage_info_count > 1) {
+        $event->addError([
+          $this->t('The staging directory has @stage_count instances of @stage_info_file. This likely indicates that a duplicate extension was installed.', [
+            '@stage_info_file' => $stage_info_file,
+            '@stage_count' => $stage_info_count,
+          ]),
+        ]);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreApplyEvent::class => 'validateDuplicateInfoFileInStage',
+    ];
+  }
+
+  /**
+   * Recursively finds info.yml files in a directory.
+   *
+   * @param string $dir
+   *   The path of the directory to check.
+   *
+   * @return int[]
+   *   Array of count of info.yml files in the directory keyed by file name.
+   */
+  protected function findInfoFiles(string $dir): array {
+    $info_files_finder = Finder::create()
+      ->in($dir)
+      ->ignoreUnreadableDirs()
+      ->name('*.info.yml');
+    $info_files = [];
+    /** @var \Symfony\Component\Finder\SplFileInfo $info_file */
+    foreach (iterator_to_array($info_files_finder) as $info_file) {
+      if ($this->skipInfoFile($info_file->getPath())) {
+        continue;
+      }
+      $file_name = $info_file->getFilename();
+      $info_files[$file_name] = ($info_files[$file_name] ?? 0) + 1;
+    }
+    return $info_files;
+  }
+
+  /**
+   * Determines if an info.yml file should be skipped.
+   *
+   * @param string $info_file_path
+   *   The path of the info.yml file.
+   *
+   * @return bool
+   *   TRUE if the info.yml file should be skipped, FALSE otherwise.
+   */
+  private function skipInfoFile(string $info_file_path): bool {
+    $directories_to_skip = [
+      DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'fixtures',
+      DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'modules',
+      DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'themes',
+      DIRECTORY_SEPARATOR . 'tests' . DIRECTORY_SEPARATOR . 'profiles',
+    ];
+    foreach ($directories_to_skip as $directory_to_skip) {
+      // Skipping info.yml files in tests/fixtures, tests/modules, tests/themes,
+      // tests/profiles because Drupal will not scan these directories when
+      // doing extension discovery.
+      if (str_contains($info_file_path, $directory_to_skip)) {
+        return TRUE;
+      }
+    }
+    return FALSE;
+  }
+
+}
diff --git a/core/modules/package_manager/src/Validator/LockFileValidator.php b/core/modules/package_manager/src/Validator/LockFileValidator.php
index a1676f3e74b2..9ab722607d70 100644
--- a/core/modules/package_manager/src/Validator/LockFileValidator.php
+++ b/core/modules/package_manager/src/Validator/LockFileValidator.php
@@ -14,8 +14,13 @@
 
 /**
  * Checks that the active lock file is unchanged during stage operations.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class LockFileValidator implements PreOperationStageValidatorInterface {
+final class LockFileValidator implements PreOperationStageValidatorInterface {
 
   use StringTranslationTrait;
 
@@ -113,7 +118,7 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
 
     // If we have both hashes, ensure they match.
     if ($active_hash && $stored_hash && !hash_equals($stored_hash, $active_hash)) {
-      $error = $this->t('Stored lock file hash does not match the active lock file.');
+      $error = $this->t('Unexpected changes were detected in composer.lock, which indicates that other Composer operations were performed since this Package Manager operation started. This can put the code base into an unreliable state and therefore is not allowed.');
     }
 
     // Don't allow staged changes to be applied if the staged lock file has no
diff --git a/core/modules/package_manager/src/Validator/MultisiteValidator.php b/core/modules/package_manager/src/Validator/MultisiteValidator.php
index b739c0a0b002..bd9c5a4b1af3 100644
--- a/core/modules/package_manager/src/Validator/MultisiteValidator.php
+++ b/core/modules/package_manager/src/Validator/MultisiteValidator.php
@@ -6,12 +6,18 @@
 use Drupal\Core\StringTranslation\TranslationInterface;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\package_manager\PathLocator;
 
 /**
  * Checks that the current site is not part of a multisite.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class MultisiteValidator implements PreOperationStageValidatorInterface {
+final class MultisiteValidator implements PreOperationStageValidatorInterface {
 
   use StringTranslationTrait;
 
@@ -68,6 +74,7 @@ protected function isMultisite(): bool {
   public static function getSubscribedEvents() {
     return [
       PreCreateEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
     ];
   }
 
diff --git a/core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php b/core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php
new file mode 100644
index 000000000000..eddb465f6ec7
--- /dev/null
+++ b/core/modules/package_manager/src/Validator/OverwriteExistingPackagesValidator.php
@@ -0,0 +1,83 @@
+<?php
+
+namespace Drupal\package_manager\Validator;
+
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\PathLocator;
+
+/**
+ * Validates that newly installed packages don't overwrite existing directories.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class OverwriteExistingPackagesValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * Constructs a OverwriteExistingPackagesValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   */
+  public function __construct(PathLocator $path_locator) {
+    $this->pathLocator = $path_locator;
+  }
+
+  /**
+   * Validates that new installed packages don't overwrite existing directories.
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    $stage = $event->getStage();
+    $active_composer = $stage->getActiveComposer();
+    $stage_composer = $stage->getStageComposer();
+    $active_dir = $this->pathLocator->getProjectRoot();
+    $stage_dir = $stage->getStageDirectory();
+    $new_packages = $stage_composer->getPackagesNotIn($active_composer);
+    $installed_packages_data = $stage_composer->getInstalledPackagesData();
+
+    // Although unlikely, it is possible that package data could be missing for
+    // some new packages.
+    $missing_new_packages = array_diff_key($new_packages, $installed_packages_data);
+    if ($missing_new_packages) {
+      $missing_new_packages = array_keys($missing_new_packages);
+      $event->addError($missing_new_packages, $this->t('Package Manager could not get the data for the following packages:'));
+      return;
+    }
+
+    $new_installed_data = array_intersect_key($installed_packages_data, $new_packages);
+    foreach ($new_installed_data as $package_name => $data) {
+      $relative_path = str_replace($stage_dir, '', $data['install_path']);
+      if (is_dir($active_dir . DIRECTORY_SEPARATOR . $relative_path)) {
+        $event->addError([
+          $this->t('The new package @package will be installed in the directory @path, which already exists but is not managed by Composer.', [
+            '@package' => $package_name,
+            '@path' => $relative_path,
+          ]),
+        ]);
+      }
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreApplyEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php b/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php
index 5dd4f300e2aa..588e073b11fd 100644
--- a/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php
+++ b/core/modules/package_manager/src/Validator/PendingUpdatesValidator.php
@@ -8,11 +8,17 @@
 use Drupal\Core\StringTranslation\TranslationInterface;
 use Drupal\Core\Update\UpdateRegistry;
 use Drupal\Core\Url;
+use Drupal\package_manager\Event\StatusCheckEvent;
 
 /**
  * Validates that there are no pending database updates.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
-class PendingUpdatesValidator implements PreOperationStageValidatorInterface {
+final class PendingUpdatesValidator implements PreOperationStageValidatorInterface {
 
   use StringTranslationTrait;
 
@@ -82,6 +88,7 @@ public function updatesExist(): bool {
   public static function getSubscribedEvents() {
     return [
       PreCreateEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
     ];
   }
 
diff --git a/core/modules/auto_updates/src/Validator/SettingsValidator.php b/core/modules/package_manager/src/Validator/SettingsValidator.php
similarity index 52%
rename from core/modules/auto_updates/src/Validator/SettingsValidator.php
rename to core/modules/package_manager/src/Validator/SettingsValidator.php
index 571bdc3442af..971f477dcf8a 100644
--- a/core/modules/auto_updates/src/Validator/SettingsValidator.php
+++ b/core/modules/package_manager/src/Validator/SettingsValidator.php
@@ -1,17 +1,23 @@
 <?php
 
-namespace Drupal\auto_updates\Validator;
+namespace Drupal\package_manager\Validator;
 
-use Drupal\auto_updates\Event\ReadinessCheckEvent;
-use Drupal\auto_updates\Updater;
 use Drupal\Core\Site\Settings;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
 use Drupal\Core\StringTranslation\TranslationInterface;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-
-class SettingsValidator implements EventSubscriberInterface {
+use Drupal\package_manager\Event\StatusCheckEvent;
+
+/**
+ * Checks that Drupal's settings are valid for Package Manager.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+final class SettingsValidator implements PreOperationStageValidatorInterface {
 
   use StringTranslationTrait;
 
@@ -26,15 +32,12 @@ public function __construct(TranslationInterface $translation) {
   }
 
   /**
-   * Validates site settings before an update starts.
-   *
-   * @param \Drupal\package_manager\Event\PreOperationStageEvent $event
-   *   The event object.
+   * {@inheritdoc}
    */
-  public function checkSettings(PreOperationStageEvent $event): void {
-    if ($event->getStage() instanceof Updater && Settings::get('update_fetch_with_http_fallback')) {
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    if (Settings::get('update_fetch_with_http_fallback')) {
       $event->addError([
-        $this->t('The <code>update_fetch_with_http_fallback</code> setting must be disabled for automatic updates.'),
+        $this->t('The <code>update_fetch_with_http_fallback</code> setting must be disabled.'),
       ]);
     }
   }
@@ -44,8 +47,8 @@ public function checkSettings(PreOperationStageEvent $event): void {
    */
   public static function getSubscribedEvents() {
     return [
-      ReadinessCheckEvent::class => 'checkSettings',
-      PreCreateEvent::class => 'checkSettings',
+      PreCreateEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
     ];
   }
 
diff --git a/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php b/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php
new file mode 100644
index 000000000000..1e9e76cc48b2
--- /dev/null
+++ b/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php
@@ -0,0 +1,185 @@
+<?php
+
+namespace Drupal\package_manager\Validator;
+
+use Drupal\Core\Extension\Extension;
+use Drupal\Core\Extension\ModuleExtensionList;
+use Drupal\Core\Extension\ThemeExtensionList;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\StatusCheckEvent;
+use Drupal\package_manager\PathLocator;
+use Drupal\package_manager\Stage;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Flags a warning if there are database updates in a staged update.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+class StagedDBUpdateValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * The module list service.
+   *
+   * @var \Drupal\Core\Extension\ModuleExtensionList
+   */
+  protected $moduleList;
+
+  /**
+   * The theme list service.
+   *
+   * @var \Drupal\Core\Extension\ThemeExtensionList
+   */
+  protected $themeList;
+
+  /**
+   * Constructs a StagedDBUpdateValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \Drupal\Core\Extension\ModuleExtensionList $module_list
+   *   The module list service.
+   * @param \Drupal\Core\Extension\ThemeExtensionList $theme_list
+   *   The theme list service.
+   */
+  public function __construct(PathLocator $path_locator, ModuleExtensionList $module_list, ThemeExtensionList $theme_list) {
+    $this->pathLocator = $path_locator;
+    $this->moduleList = $module_list;
+    $this->themeList = $theme_list;
+  }
+
+  /**
+   * Checks that the staged update does not have changes to its install files.
+   *
+   * @param \Drupal\package_manager\Event\StatusCheckEvent $event
+   *   The event object.
+   */
+  public function checkForStagedDatabaseUpdates(StatusCheckEvent $event): void {
+    $stage = $event->getStage();
+    if ($stage->isAvailable()) {
+      // No staged updates exist, therefore we don't need to run this check.
+      return;
+    }
+
+    $extensions_with_updates = $this->getExtensionsWithDatabaseUpdates($stage);
+    if ($extensions_with_updates) {
+      $event->addWarning($extensions_with_updates, $this->t('Possible database updates have been detected in the following extensions.'));
+    }
+  }
+
+  /**
+   * Determines if a staged extension has changed update functions.
+   *
+   * @param \Drupal\package_manager\Stage $stage
+   *   The updater which is controlling the update process.
+   * @param \Drupal\Core\Extension\Extension $extension
+   *   The extension to check.
+   *
+   * @return bool
+   *   TRUE if the staged copy of the extension has changed update functions
+   *   compared to the active copy, FALSE otherwise.
+   *
+   * @todo Use a more sophisticated method to detect changes in the staged
+   *   extension. Right now, we just compare hashes of the .install and
+   *   .post_update.php files in both copies of the given extension, but this
+   *   will cause false positives for changes to comments, whitespace, or
+   *   runtime code like requirements checks. It would be preferable to use a
+   *   static analyzer to detect new or changed functions that are actually
+   *   executed during an update. No matter what, this method must NEVER cause
+   *   false negatives, since that could result in code which is incompatible
+   *   with the current database schema being copied to the active directory.
+   *
+   * @see https://www.drupal.org/project/auto_updates/issues/3253828
+   */
+  public function hasStagedUpdates(Stage $stage, Extension $extension): bool {
+    $active_dir = $this->pathLocator->getProjectRoot();
+    $stage_dir = $stage->getStageDirectory();
+
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $active_dir .= DIRECTORY_SEPARATOR . $web_root;
+      $stage_dir .= DIRECTORY_SEPARATOR . $web_root;
+    }
+
+    $active_hashes = $this->getHashes($active_dir, $extension);
+    $staged_hashes = $this->getHashes($stage_dir, $extension);
+
+    return $active_hashes !== $staged_hashes;
+  }
+
+  /**
+   * Returns hashes of the .install and .post-update.php files for a module.
+   *
+   * @param string $root_dir
+   *   The root directory of the Drupal code base.
+   * @param \Drupal\Core\Extension\Extension $extension
+   *   The module to check.
+   *
+   * @return string[]
+   *   The hashes of the module's .install and .post_update.php files, in that
+   *   order, if they exist. The array will be keyed by file extension.
+   */
+  protected function getHashes(string $root_dir, Extension $extension): array {
+    $path = implode(DIRECTORY_SEPARATOR, [
+      $root_dir,
+      $extension->getPath(),
+      $extension->getName(),
+    ]);
+    $hashes = [];
+
+    foreach (['.install', '.post_update.php'] as $suffix) {
+      $file = $path . $suffix;
+
+      if (file_exists($file)) {
+        $hashes[$suffix] = hash_file('sha256', $file);
+      }
+    }
+    return $hashes;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      StatusCheckEvent::class => 'checkForStagedDatabaseUpdates',
+    ];
+  }
+
+  /**
+   * Gets extensions that have database updates.
+   *
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage.
+   *
+   * @return string[]
+   *   The names of the extensions that have possible database updates.
+   */
+  public function getExtensionsWithDatabaseUpdates(Stage $stage): array {
+    $extensions_with_updates = [];
+    // Check all installed extensions for database updates.
+    $lists = [$this->moduleList, $this->themeList];
+    foreach ($lists as $list) {
+      foreach ($list->getAllInstalledInfo() as $name => $info) {
+        if ($this->hasStagedUpdates($stage, $list->get($name))) {
+          $extensions_with_updates[] = $info['name'];
+        }
+      }
+    }
+
+    return $extensions_with_updates;
+  }
+
+}
diff --git a/core/modules/package_manager/src/Validator/SupportedReleaseValidator.php b/core/modules/package_manager/src/Validator/SupportedReleaseValidator.php
new file mode 100644
index 000000000000..780cda00ceb8
--- /dev/null
+++ b/core/modules/package_manager/src/Validator/SupportedReleaseValidator.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace Drupal\package_manager\Validator;
+
+use Drupal\Component\Render\FormattableMarkup;
+use Drupal\package_manager\ProjectInfo;
+use Drupal\package_manager\LegacyVersionUtility;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Symfony\Component\EventDispatcher\EventSubscriberInterface;
+
+/**
+ * Validates that updated projects are secure and supported.
+ *
+ * @internal
+ *   This class is an internal part of the module's update handling and
+ *   should not be used by external code.
+ */
+final class SupportedReleaseValidator implements EventSubscriberInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * Checks if the given version of a project is supported.
+   *
+   * Checks if the given version of the given project is in the core update
+   * system's list of known, secure, installable releases of that project.
+   * considered a supported release by verifying if the project is found in the
+   * core update system's list of known, secure, and installable releases.
+   *
+   * @param string $name
+   *   The name of the project.
+   * @param string $semantic_version
+   *   A semantic version number for the project.
+   *
+   * @return bool
+   *   TRUE if the given version of the project is supported, otherwise FALSE.
+   *   given version is not supported will return FALSE.
+   */
+  protected function isSupportedRelease(string $name, string $semantic_version): bool {
+    $supported_releases = (new ProjectInfo($name))->getInstallableReleases();
+    if (!$supported_releases) {
+      return FALSE;
+    }
+
+    // If this version is found in the list of installable releases, it is
+    // secured and supported.
+    if (array_key_exists($semantic_version, $supported_releases)) {
+      return TRUE;
+    }
+    // If the semantic version number wasn't in the list of
+    // installable releases, convert it to a legacy version number and see
+    // if the version number is in the list.
+    $legacy_version = LegacyVersionUtility::convertToLegacyVersion($semantic_version);
+    if ($legacy_version && array_key_exists($legacy_version, $supported_releases)) {
+      return TRUE;
+    }
+    // Neither the semantic version nor the legacy version are in the list
+    // of installable releases, so the release isn't supported.
+    return FALSE;
+  }
+
+  /**
+   * Checks that the packages are secure and supported.
+   *
+   * @param \Drupal\package_manager\Event\PreApplyEvent $event
+   *   The event object.
+   */
+  public function checkStagedReleases(PreApplyEvent $event): void {
+    $active = $event->getStage()->getActiveComposer();
+    $staged = $event->getStage()->getStageComposer();
+    $updated_packages = array_merge(
+      $staged->getPackagesNotIn($active),
+      $staged->getPackagesWithDifferentVersionsIn($active)
+    );
+    $unknown_packages = [];
+    $unsupported_packages = [];
+    foreach ($updated_packages as $package_name => $staged_package) {
+      // Only packages of the types 'drupal-module' or 'drupal-theme' that
+      // start with 'drupal/' will have update XML from drupal.org.
+      if (!in_array($staged_package->getType(), ['drupal-module', 'drupal-theme'], TRUE)
+         || !str_starts_with($package_name, 'drupal/')) {
+        continue;
+      }
+      $project_name = $staged->getProjectForPackage($package_name);
+      if (empty($project_name)) {
+        $unknown_packages[] = $package_name;
+        continue;
+      }
+      $semantic_version = $staged_package->getPrettyVersion();
+      if (!$this->isSupportedRelease($project_name, $semantic_version)) {
+        $unsupported_packages[] = new FormattableMarkup('@project_name (@package_name) @version', [
+          '@project_name' => $project_name,
+          '@package_name' => $package_name,
+          '@version' => $semantic_version,
+        ]);
+      }
+    }
+    if ($unsupported_packages) {
+      $summary = $this->formatPlural(
+        count($unsupported_packages),
+        'Cannot update because the following project version is not in the list of installable releases.',
+        'Cannot update because the following project versions are not in the list of installable releases.'
+      );
+      $event->addError($unsupported_packages, $summary);
+    }
+    if ($unknown_packages) {
+      $summary = $this->formatPlural(
+        count($unknown_packages),
+        'Cannot update because the following new or updated Drupal package does not have project information.',
+        'Cannot update because the following new or updated Drupal packages do not have project information.',
+      );
+      $event->addError($unknown_packages, $summary);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreApplyEvent::class => 'checkStagedReleases',
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/src/Validator/SymlinkValidator.php b/core/modules/package_manager/src/Validator/SymlinkValidator.php
new file mode 100644
index 000000000000..b8b5550826ad
--- /dev/null
+++ b/core/modules/package_manager/src/Validator/SymlinkValidator.php
@@ -0,0 +1,132 @@
+<?php
+
+namespace Drupal\package_manager\Validator;
+
+use Drupal\Core\Extension\ModuleHandlerInterface;
+use Drupal\Core\StringTranslation\StringTranslationTrait;
+use Drupal\Core\Url;
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Event\PreOperationStageEvent;
+use Drupal\package_manager\Event\StatusCheckEvent;
+use Drupal\package_manager\PathLocator;
+use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
+use PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface;
+
+/**
+ * Flags errors if the project root or staging area contain symbolic links.
+ *
+ * @todo Remove this when Composer Stager's PHP file copier handles symlinks
+ *   without issues.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
+ */
+class SymlinkValidator implements PreOperationStageValidatorInterface {
+
+  use StringTranslationTrait;
+
+  /**
+   * The path locator service.
+   *
+   * @var \Drupal\package_manager\PathLocator
+   */
+  protected $pathLocator;
+
+  /**
+   * The Composer Stager precondition that this validator wraps.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface
+   */
+  protected $precondition;
+
+  /**
+   * The path factory service.
+   *
+   * @var \PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface
+   */
+  protected $pathFactory;
+
+  /**
+   * The module handler service.
+   *
+   * @var \Drupal\Core\Extension\ModuleHandlerInterface
+   */
+  protected $moduleHandler;
+
+  /**
+   * Constructs a SymlinkValidator object.
+   *
+   * @param \Drupal\package_manager\PathLocator $path_locator
+   *   The path locator service.
+   * @param \PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface $precondition
+   *   The Composer Stager precondition that this validator wraps.
+   * @param \PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface $path_factory
+   *   The path factory service.
+   * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler
+   *   The module handler service.
+   */
+  public function __construct(PathLocator $path_locator, CodebaseContainsNoSymlinksInterface $precondition, PathFactoryInterface $path_factory, ModuleHandlerInterface $module_handler) {
+    $this->pathLocator = $path_locator;
+    $this->precondition = $precondition;
+    $this->pathFactory = $path_factory;
+    $this->moduleHandler = $module_handler;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function validateStagePreOperation(PreOperationStageEvent $event): void {
+    $active_dir = $this->pathFactory->create($this->pathLocator->getProjectRoot());
+
+    // The precondition requires us to pass both an active and stage directory,
+    // so if the stage hasn't been created or claimed yet, use the directory
+    // that contains this file, which contains only a few files and no symlinks,
+    // as the stage directory. The precondition itself doesn't care if the
+    // directory actually exists or not.
+    try {
+      $stage_dir = $event->getStage()->getStageDirectory();
+    }
+    catch (\LogicException $e) {
+      $stage_dir = __DIR__;
+    }
+    $stage_dir = $this->pathFactory->create($stage_dir);
+
+    try {
+      $this->precondition->assertIsFulfilled($active_dir, $stage_dir);
+    }
+    catch (PreconditionException $e) {
+      $message = $e->getMessage();
+
+      // If the Help module is enabled, append a link to Package Manager's help
+      // page.
+      // @see package_manager_help()
+      if ($this->moduleHandler->moduleExists('help')) {
+        $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
+          ->setOption('fragment', 'package-manager-faq-symlinks-found')
+          ->toString();
+
+        $message = $this->t('@message See <a href=":package-manager-help">the help page</a> for information on how to resolve the problem.', [
+          '@message' => $message,
+          ':package-manager-help' => $url,
+        ]);
+      }
+      $event->addError([$message]);
+    }
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function getSubscribedEvents() {
+    return [
+      PreCreateEvent::class => 'validateStagePreOperation',
+      PreApplyEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php b/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php
index 24ccc241867f..db2ff02768a7 100644
--- a/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php
+++ b/core/modules/package_manager/src/Validator/WritableFileSystemValidator.php
@@ -2,14 +2,20 @@
 
 namespace Drupal\package_manager\Validator;
 
+use Drupal\Core\StringTranslation\TranslationInterface;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreOperationStageEvent;
 use Drupal\Core\StringTranslation\StringTranslationTrait;
-use Drupal\Core\StringTranslation\TranslationInterface;
+use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\package_manager\PathLocator;
 
 /**
  * Checks that the file system is writable.
+ *
+ * @internal
+ *   This is an internal part of Package Manager and may be changed or removed
+ *   at any time without warning. External code should not interact with this
+ *   class.
  */
 class WritableFileSystemValidator implements PreOperationStageValidatorInterface {
 
@@ -22,26 +28,16 @@ class WritableFileSystemValidator implements PreOperationStageValidatorInterface
    */
   protected $pathLocator;
 
-  /**
-   * The Drupal root.
-   *
-   * @var string
-   */
-  protected $appRoot;
-
   /**
    * Constructs a WritableFileSystemValidator object.
    *
    * @param \Drupal\package_manager\PathLocator $path_locator
    *   The path locator service.
-   * @param string $app_root
-   *   The Drupal root.
    * @param \Drupal\Core\StringTranslation\TranslationInterface $translation
    *   The translation service.
    */
-  public function __construct(PathLocator $path_locator, string $app_root, TranslationInterface $translation) {
+  public function __construct(PathLocator $path_locator, TranslationInterface $translation) {
     $this->pathLocator = $path_locator;
-    $this->appRoot = $app_root;
     $this->setStringTranslation($translation);
   }
 
@@ -56,9 +52,14 @@ public function __construct(PathLocator $path_locator, string $app_root, Transla
   public function validateStagePreOperation(PreOperationStageEvent $event): void {
     $messages = [];
 
-    if (!is_writable($this->appRoot)) {
+    $drupal_root = $this->pathLocator->getProjectRoot();
+    $web_root = $this->pathLocator->getWebRoot();
+    if ($web_root) {
+      $drupal_root .= DIRECTORY_SEPARATOR . $web_root;
+    }
+    if (!is_writable($drupal_root)) {
       $messages[] = $this->t('The Drupal directory "@dir" is not writable.', [
-        '@dir' => $this->appRoot,
+        '@dir' => $drupal_root,
       ]);
     }
 
@@ -67,6 +68,23 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
       $messages[] = $this->t('The vendor directory "@dir" is not writable.', ['@dir' => $dir]);
     }
 
+    // Ensure the staging root is writable. If it doesn't exist, ensure we will
+    // be able to create it.
+    $dir = $this->pathLocator->getStagingRoot();
+    if (!file_exists($dir)) {
+      $dir = dirname($dir);
+      if (!is_writable($dir)) {
+        $messages[] = $this->t('The staging root directory will not able to be created at "@dir".', [
+          '@dir' => $dir,
+        ]);
+      }
+    }
+    elseif (!is_writable($dir)) {
+      $messages[] = $this->t('The staging root directory "@dir" is not writable.', [
+        '@dir' => $dir,
+      ]);
+    }
+
     if ($messages) {
       $event->addError($messages, $this->t('The file system is not writable.'));
     }
@@ -78,6 +96,7 @@ public function validateStagePreOperation(PreOperationStageEvent $event): void {
   public static function getSubscribedEvents() {
     return [
       PreCreateEvent::class => 'validateStagePreOperation',
+      StatusCheckEvent::class => 'validateStagePreOperation',
     ];
   }
 
diff --git a/core/modules/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml.hide b/core/modules/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml.hide
new file mode 100644
index 000000000000..4b12c91c911f
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/alpha/1.0.0/alpha.info.yml.hide
@@ -0,0 +1,4 @@
+name: Alpha
+type: module
+core_version_requirement: ^9
+project: alpha
diff --git a/core/modules/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml.hide b/core/modules/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml.hide
new file mode 100644
index 000000000000..4b12c91c911f
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/alpha/1.1.0/alpha.info.yml.hide
@@ -0,0 +1,4 @@
+name: Alpha
+type: module
+core_version_requirement: ^9
+project: alpha
diff --git a/core/modules/package_manager/tests/fixtures/distro_core/composer.json b/core/modules/package_manager/tests/fixtures/distro_core/composer.json
deleted file mode 100644
index a7a8274cb6e5..000000000000
--- a/core/modules/package_manager/tests/fixtures/distro_core/composer.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-    "require": {
-        "drupal/test-distribution": "*"
-    },
-    "extra": {
-        "_comment": [
-            "This is a fake composer.json simulating a site which requires a distribution.",
-            "The required core packages are determined by scanning the lock file.",
-            "The fake distribution requires Drupal core directly."
-        ]
-    }
-}
diff --git a/core/modules/package_manager/tests/fixtures/distro_core/composer.lock b/core/modules/package_manager/tests/fixtures/distro_core/composer.lock
deleted file mode 100644
index 56572fd029d8..000000000000
--- a/core/modules/package_manager/tests/fixtures/distro_core/composer.lock
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-    "packages": [
-        {
-            "name": "drupal/test-distribution",
-            "version": "1.0.0",
-            "require": {
-                "drupal/core": "*"
-            }
-        },
-        {
-            "name": "drupal/core",
-            "version": "9.8.0"
-        }
-    ],
-    "packages-dev": []
-}
diff --git a/core/modules/package_manager/tests/fixtures/distro_core/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/distro_core/vendor/composer/installed.json
deleted file mode 100644
index 33e328278743..000000000000
--- a/core/modules/package_manager/tests/fixtures/distro_core/vendor/composer/installed.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
-    "packages": [
-        {
-            "name": "drupal/test-distribution",
-            "version": "1.0.0",
-            "require": {
-                "drupal/core": "*"
-            }
-        },
-        {
-            "name": "drupal/core",
-            "version": "9.8.0"
-        }
-    ]
-}
diff --git a/core/modules/package_manager/tests/fixtures/distro_core_recommended/composer.json b/core/modules/package_manager/tests/fixtures/distro_core_recommended/composer.json
deleted file mode 100644
index 3a62074ca237..000000000000
--- a/core/modules/package_manager/tests/fixtures/distro_core_recommended/composer.json
+++ /dev/null
@@ -1,12 +0,0 @@
-{
-    "require": {
-        "drupal/test-distribution": "*"
-    },
-    "extra": {
-        "_comment": [
-            "This is a fake composer.json simulating a site which requires a distribution.",
-            "The required core packages are determined by scanning the lock file.",
-            "The fake distribution uses drupal/core-recommended to require Drupal core."
-        ]
-    }
-}
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt
new file mode 100644
index 000000000000..b70fb51506c2
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/_git/ignore.txt
@@ -0,0 +1,4 @@
+This file should never be staged.
+
+The parent directory will be renamed to .git.
+@see \Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase::createTestProject()
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/composer.json
new file mode 100644
index 000000000000..13d086d70d82
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/composer.json
@@ -0,0 +1,8 @@
+{
+  "require": {
+    "drupal/core-recommended": "9.8.0"
+  },
+  "require-dev": {
+    "drupal/core-dev": "^9"
+  }
+}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json b/core/modules/package_manager/tests/fixtures/fake_site/composer.lock
similarity index 100%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/new_project_added/staged/composer.json
rename to core/modules/package_manager/tests/fixtures/fake_site/composer.lock
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt
new file mode 100644
index 000000000000..b70fb51506c2
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/_git/ignore.txt
@@ -0,0 +1,4 @@
+This file should never be staged.
+
+The parent directory will be renamed to .git.
+@see \Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase::createTestProject()
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml
new file mode 100644
index 000000000000..046fc058cf87
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/modules/example/example.info.yml
@@ -0,0 +1,3 @@
+# This file should be staged.
+name: Example
+type: module
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/private/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/private/ignore.txt
new file mode 100644
index 000000000000..08874eba8bb9
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/private/ignore.txt
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml
new file mode 100644
index 000000000000..95dde1725d47
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.services.yml
@@ -0,0 +1,2 @@
+# This file should be staged because it's scaffolded into place by Drupal core.
+services: {}
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php
new file mode 100644
index 000000000000..0d23e8400696
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/default.settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should be staged because it's scaffolded into place by Drupal core.
+ */
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml
new file mode 100644
index 000000000000..cbc4434e8f2b
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/services.yml
@@ -0,0 +1,2 @@
+# This file should never be staged.
+must_not_be: 'empty'
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php
new file mode 100644
index 000000000000..15b43d28125c
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.local.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php
new file mode 100644
index 000000000000..15b43d28125c
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/files/staged.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt
similarity index 100%
rename from core/modules/auto_updates/tests/fixtures/fake-site/files/staged.txt
rename to core/modules/package_manager/tests/fixtures/fake_site/sites/default/stage.txt
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite
new file mode 100644
index 000000000000..08874eba8bb9
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm
new file mode 100644
index 000000000000..08874eba8bb9
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-shm
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal
new file mode 100644
index 000000000000..08874eba8bb9
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/db.sqlite-wal
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt
new file mode 100644
index 000000000000..08874eba8bb9
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/files/ignore.txt
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml
new file mode 100644
index 000000000000..f408d89e28e9
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/services.yml
@@ -0,0 +1,2 @@
+# This file should never be staged.
+key: "value"
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php
new file mode 100644
index 000000000000..15b43d28125c
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.local.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php
new file mode 100644
index 000000000000..15b43d28125c
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/example.com/settings.php
@@ -0,0 +1,6 @@
+<?php
+
+/**
+ * @file
+ * This file should never be staged.
+ */
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt b/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt
new file mode 100644
index 000000000000..08874eba8bb9
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/sites/simpletest/ignore.txt
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess
new file mode 100644
index 000000000000..e11552b41d40
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/.htaccess
@@ -0,0 +1 @@
+# This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
new file mode 100644
index 000000000000..dd37230ed630
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.json
@@ -0,0 +1,24 @@
+{
+  "packages": [
+    {
+      "name": "drupal/core",
+      "version": "9.8.0",
+      "extra": {
+        "drupal-scaffold": {
+          "file-mapping": {
+            "[web-root]/sites/default/default.settings.php": "",
+            "[web-root]/sites/default/default.services.yml": ""
+          }
+        }
+      }
+    },
+    {
+      "name": "drupal/core-recommended",
+      "version": "9.8.0"
+    },
+    {
+      "name": "drupal/core-dev",
+      "version": "9.8.0"
+    }
+  ]
+}
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php
new file mode 100644
index 000000000000..4963cf582b10
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/composer/installed.php
@@ -0,0 +1,10 @@
+<?php
+
+/**
+ * @file
+ */
+
+// Composer Utility needs the versions key to be present.
+return [
+  'versions' => [],
+];
diff --git a/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config b/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config
new file mode 100644
index 000000000000..08874eba8bb9
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/fake_site/vendor/web.config
@@ -0,0 +1 @@
+This file should never be staged.
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_1/module_1.info.yml.hide b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_1/module_1.info.yml.hide
new file mode 100644
index 000000000000..ab706bde15da
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_1/module_1.info.yml.hide
@@ -0,0 +1 @@
+project: module_1
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_2/module_2.info.yml.hide b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_2/module_2.info.yml.hide
new file mode 100644
index 000000000000..e6bd53b3dc1c
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_2/module_2.info.yml.hide
@@ -0,0 +1 @@
+project: module_2
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_5/module_5.info.yml.hide b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_5/module_5.info.yml.hide
new file mode 100644
index 000000000000..e92200afd97f
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/modules/module_5/module_5.info.yml.hide
@@ -0,0 +1 @@
+project: module_5
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/vendor/composer/installed.json
new file mode 100644
index 000000000000..8c0d9b4f6878
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/vendor/composer/installed.json
@@ -0,0 +1,6 @@
+{
+  "_readme": [
+    "See \\Drupal\\Tests\\package_manager\\Kernel\\OverwriteExistingPackagesValidatorTest::testNewPackagesOverwriteExisting()."
+  ],
+  "packages": []
+}
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/vendor/composer/installed.php
new file mode 100644
index 000000000000..7c0185fa90d3
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/active/vendor/composer/installed.php
@@ -0,0 +1,12 @@
+<?php
+
+/**
+ * @file
+ * Simulates that no packages are installed by Composer.
+ *
+ * @see \Drupal\Tests\package_manager\Kernel\OverwriteExistingPackagesValidatorTest::testNewPackagesOverwriteExisting()
+ */
+
+return [
+  'versions' => [],
+];
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_1/module_1.info.yml.hide b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_1/module_1.info.yml.hide
new file mode 100644
index 000000000000..ab706bde15da
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_1/module_1.info.yml.hide
@@ -0,0 +1 @@
+project: module_1
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_2/module_2.info.yml.hide b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_2/module_2.info.yml.hide
new file mode 100644
index 000000000000..e6bd53b3dc1c
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_2/module_2.info.yml.hide
@@ -0,0 +1 @@
+project: module_2
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_3/module_3.info.yml.hide b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_3/module_3.info.yml.hide
new file mode 100644
index 000000000000..6bdc02f113d0
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_3/module_3.info.yml.hide
@@ -0,0 +1 @@
+project: module_3
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_5_different_path/module_5_different_path.info.yml.hide b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_5_different_path/module_5_different_path.info.yml.hide
new file mode 100644
index 000000000000..f0d071821893
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/modules/module_5_different_path/module_5_different_path.info.yml.hide
@@ -0,0 +1 @@
+project: module_5_different_path
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/vendor/composer/installed.json
new file mode 100644
index 000000000000..258a6d574544
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/vendor/composer/installed.json
@@ -0,0 +1,32 @@
+{
+  "_readme": [
+    "See \\Drupal\\Tests\\package_manager\\Kernel\\OverwriteExistingPackagesValidatorTest::testNewPackagesOverwriteExisting()."
+  ],
+  "packages": [
+    {
+      "name": "drupal/module_1",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/module_2",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/module_3",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/module_4",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/module_5",
+      "version": "1.3.0",
+      "type": "drupal-module"
+    }
+  ]
+}
diff --git a/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/vendor/composer/installed.php
new file mode 100644
index 000000000000..04afd79ef324
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/overwrite_existing_validation/staged/vendor/composer/installed.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @file
+ * Simulates several modules installed by Composer.
+ *
+ * @see \Drupal\Tests\package_manager\Kernel\OverwriteExistingPackagesValidatorTest::testNewPackagesOverwriteExisting()
+ */
+
+$modules_dir = __DIR__ . '/../../modules';
+
+return [
+  'versions' => [
+    'drupal/module_1' => [
+      'type' => 'drupal-module',
+      'install_path' => $modules_dir . '/module_1',
+    ],
+    'drupal/module_2' => [
+      'type' => 'drupal-module',
+      'install_path' => $modules_dir . '/module_2',
+    ],
+    'drupal/module_3' => [
+      'type' => 'drupal-module',
+      'install_path' => $modules_dir . '/module_3',
+    ],
+    'drupal/module_4' => [
+      'type' => 'drupal-module',
+      'install_path' => $modules_dir . '/module_1',
+    ],
+    'drupal/module_5' => [
+      'type' => 'drupal-module',
+      'install_path' => $modules_dir . '/module_5_different_path',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/packages_comparison/active/composer.json b/core/modules/package_manager/tests/fixtures/packages_comparison/active/composer.json
deleted file mode 100644
index 0967ef424bce..000000000000
--- a/core/modules/package_manager/tests/fixtures/packages_comparison/active/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/core/modules/package_manager/tests/fixtures/packages_comparison/active/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/packages_comparison/active/vendor/composer/installed.json
deleted file mode 100644
index 2152ef82fb7b..000000000000
--- a/core/modules/package_manager/tests/fixtures/packages_comparison/active/vendor/composer/installed.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "packages": [
-    {
-      "name": "drupal/existing",
-      "version": "1.0.0"
-    },
-    {
-      "name": "drupal/updated",
-      "version": "1.0.0"
-    },
-    {
-      "name": "drupal/removed",
-      "version": "1.0.0"
-    }
-  ]
-}
diff --git a/core/modules/package_manager/tests/fixtures/packages_comparison/stage/composer.json b/core/modules/package_manager/tests/fixtures/packages_comparison/stage/composer.json
deleted file mode 100644
index 0967ef424bce..000000000000
--- a/core/modules/package_manager/tests/fixtures/packages_comparison/stage/composer.json
+++ /dev/null
@@ -1 +0,0 @@
-{}
diff --git a/core/modules/package_manager/tests/fixtures/packages_comparison/stage/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/packages_comparison/stage/vendor/composer/installed.json
deleted file mode 100644
index 562b5f560150..000000000000
--- a/core/modules/package_manager/tests/fixtures/packages_comparison/stage/vendor/composer/installed.json
+++ /dev/null
@@ -1,16 +0,0 @@
-{
-  "packages": [
-    {
-      "name": "drupal/existing",
-      "version": "1.0.0"
-    },
-    {
-      "name": "drupal/updated",
-      "version": "1.1.0"
-    },
-    {
-      "name": "drupal/added",
-      "version": "1.0.0"
-    }
-  ]
-}
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.json b/core/modules/package_manager/tests/fixtures/project_package_conversion/composer.json
similarity index 100%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/active/composer.json
rename to core/modules/package_manager/tests/fixtures/project_package_conversion/composer.json
diff --git a/core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json b/core/modules/package_manager/tests/fixtures/project_package_conversion/composer.lock
similarity index 100%
rename from core/modules/auto_updates/tests/fixtures/project_staged_validation/no_errors/composer.json
rename to core/modules/package_manager/tests/fixtures/project_package_conversion/composer.lock
diff --git a/core/modules/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json
new file mode 100644
index 000000000000..216c981a15b7
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.json
@@ -0,0 +1,34 @@
+{
+  "packages": [
+    {
+      "name": "drupal/package_project_match",
+      "version": "6.1.3",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/not_match_package",
+      "version": "6.1.3",
+      "type": "drupal-theme"
+    },
+    {
+      "name": "drupal/not_match_path_project",
+      "version": "6.1.3",
+      "type": "drupal-module"
+    },
+    {
+      "name": "non_drupal/other_project",
+      "version": "6.1.3",
+      "type": "drupal-module"
+    },
+    {
+      "name": "drupal/nested_no_match_package",
+      "version": "6.1.3",
+      "type": "drupal-profile"
+    },
+    {
+      "name": "drupal/custom_module",
+      "version": "6.1.3",
+      "type": "drupal-custom-module"
+    }
+  ]
+}
diff --git a/core/modules/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php
new file mode 100644
index 000000000000..301eb95cf769
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/project_package_conversion/vendor/composer/installed.php
@@ -0,0 +1,35 @@
+<?php
+
+/**
+ * @file
+ */
+
+$projects_dir = __DIR__ . '/../../web/projects';
+return [
+  'versions' => [
+    'drupal/package_project_match' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/package_project_match',
+    ],
+    'drupal/not_match_package' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/not_match_project',
+    ],
+    'drupal/not_match_path_project' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/not_match_project',
+    ],
+    'drupal/nested_no_match_package' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/any_folder_name',
+    ],
+    'non_drupal/other_project' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/other_project',
+    ],
+    'drupal/custom_module' => [
+      'type' => 'drupal-custom-module',
+      'install_path' => $projects_dir . '/custom_module',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide
new file mode 100644
index 000000000000..5b69176b90da
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/any_folder_name/any_sub_folder/any_yml_file.info.yml.hide
@@ -0,0 +1,3 @@
+# A test info.yml file where the folder names and info.yml file names do not match the project or package.
+# Only the project key in this file need to match.
+project: nested_no_match_project
diff --git a/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide
new file mode 100644
index 000000000000..93021a1460bf
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/custom_module/custom_module.info.yml.hide
@@ -0,0 +1 @@
+project: custom_module
diff --git a/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_path_project/not_match_path_project.info.yml.hide b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_path_project/not_match_path_project.info.yml.hide
new file mode 100644
index 000000000000..af58278b9d1b
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_path_project/not_match_path_project.info.yml.hide
@@ -0,0 +1 @@
+project: not_match_path_project
diff --git a/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide
new file mode 100644
index 000000000000..7838d71de598
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/not_match_project/not_match_project.info.yml.hide
@@ -0,0 +1 @@
+project: not_match_project
diff --git a/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide
new file mode 100644
index 000000000000..ca54a40db9f6
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/other_project/other_project.info.yml.hide
@@ -0,0 +1 @@
+project: other_project
diff --git a/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide
new file mode 100644
index 000000000000..84896e4f27f7
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/project_package_conversion/web/projects/package_project_match/packge_project_match.info.yml.hide
@@ -0,0 +1 @@
+project: package_project_match
diff --git a/core/modules/package_manager/tests/fixtures/release-history/aaa_auto_updates_test.9.8.2.xml b/core/modules/package_manager/tests/fixtures/release-history/aaa_auto_updates_test.9.8.2.xml
new file mode 100644
index 000000000000..05c8471ffd4a
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/aaa_auto_updates_test.9.8.2.xml
@@ -0,0 +1,122 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>AAA</title>
+<short_name>aaa_auto_updates_test</short_name>
+<dc:creator>AAA</dc:creator>
+<supported_branches>7.0.,8.x-6.</supported_branches>
+<project_status>published</project_status>
+<link>http://example.com/project/aaa_auto_updates_test</link>
+  <terms>
+   <term><name>Projects</name><value>AAA project</value></term>
+  </terms>
+<releases>
+    <release>
+        <name>AAA 7.0.1</name>
+        <version>7.0.1</version>
+        <status>published</status>
+        <release_link>http://example.com/aaa_auto_updates_test-9-7-1-release</release_link>
+        <download_link>http://example.com/aaa_auto_updates_test-9-7-1.tar.gz</download_link>
+        <date>1250425521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>AAA 7.0.0</name>
+        <version>7.0.0</version>
+        <status>published</status>
+        <release_link>http://example.com/aaa_auto_updates_test-9-7-0-release</release_link>
+        <download_link>http://example.com/aaa_auto_updates_test-9-7-0.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>AAA 7.0.0-alpha1</name>
+        <version>7.0.0-alpha1</version>
+        <status>published</status>
+        <release_link>http://example.com/aaa_auto_updates_test-9-7-0-alpha1-release</release_link>
+        <download_link>http://example.com/aaa_auto_updates_test-9-7-0-alpha1.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
+  <release>
+    <name>AAA 8.x-6.2</name>
+    <version>8.x-6.2</version>
+    <status>published</status>
+    <release_link>http://example.com/aaa_auto_updates_test-9-8-2-release</release_link>
+    <download_link>http://example.com/aaa_auto_updates_test-9-8-2.tar.gz</download_link>
+    <date>1250425521</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+ <release>
+  <name>AAA 8.x-6.1</name>
+  <version>8.x-6.1</version>
+  <status>published</status>
+  <release_link>http://example.com/aaa_auto_updates_test-9-8-1-release</release_link>
+  <download_link>http://example.com/aaa_auto_updates_test-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>AAA 8.x-6.0</name>
+   <version>8.x-6.0</version>
+   <status>published</status>
+   <release_link>http://example.com/aaa_auto_updates_test-9-8-0-release</release_link>
+   <download_link>http://example.com/aaa_auto_updates_test-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>
+  <release>
+    <name>AAA 8.x-6.0-alpha1</name>
+    <version>8.x-6.0-alpha1</version>
+    <status>published</status>
+    <release_link>http://example.com/aaa_auto_updates_test-9-8-0-alpha1-release</release_link>
+    <download_link>http://example.com/aaa_auto_updates_test-9-8-0-alpha1.tar.gz</download_link>
+    <date>1250424521</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+    <release>
+        <name>AAA 7.0.0-dev</name>
+        <version>7.0.x-dev</version>
+        <status>published</status>
+        <release_link>http://example.com/aaa_auto_updates_test-9-7-0-dev-release</release_link>
+        <download_link>http://example.com/aaa_auto_updates_test-9-7-0-dev.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
+    <release>
+        <name>AAA 8.x-6.x-dev</name>
+        <version>8.x-6.x-dev</version>
+        <status>published</status>
+        <release_link>http://example.com/aaa_auto_updates_test-9-8-0-dev-release</release_link>
+        <download_link>http://example.com/aaa_auto_updates_test-9-8-0-dev.tar.gz</download_link>
+        <date>1250424521</date>
+        <terms>
+            <term><name>Release type</name><value>New features</value></term>
+            <term><name>Release type</name><value>Bug fixes</value></term>
+        </terms>
+    </release>
+</releases>
+</project>
diff --git a/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml b/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml
new file mode 100644
index 000000000000..2451a4bd4f03
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/aaa_update_test.1.1.xml
@@ -0,0 +1,184 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Test legacy versions -->
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>AAA Update test</title>
+<short_name>aaa_update_test</short_name>
+<dc:creator>Drupal</dc:creator>
+<supported_branches>8.x-2.,8.x-1.</supported_branches>
+<project_status>published</project_status>
+<link>http://example.com/project/aaa_update_test</link>
+  <terms>
+   <term><name>Projects</name><value>AAA Update test project</value></term>
+  </terms>
+<releases>
+  <release>
+    <name>AAA Update test 8.x-3.0</name>
+    <version>8.x-3.0</version>
+    <tag>8.x-3.0</tag>
+    <status>published</status>
+    <release_link>http://example.com/aaa_update_test-8-3-0-release</release_link>
+    <download_link>http://example.com/aaa_update_test-8-3-0.tar.gz</download_link>
+    <date>1584195300</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+ <release>
+   <name>AAA Update test 8.x-2.1</name>
+   <version>8.x-2.1</version>
+   <tag>8.x-2.1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-2-1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-2-1.tar.gz</download_link>
+   <date>1581603300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-2.1-beta1</name>
+   <version>8.x-2.1-beta1</version>
+   <tag>8.x-2.1-beta1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-2-1-beta1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-2-1-beta1.tar.gz</download_link>
+   <date>1579011300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-2.1-alpha1</name>
+   <version>8.x-2.1-alpha1</version>
+   <tag>8.x-2.1-alpha1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-2-1-alpha1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-2-1-alpha1.tar.gz</download_link>
+   <date>1576419300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-2.0</name>
+   <version>8.x-2.0</version>
+   <tag>8.x-2.0</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-2-0-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-2-0.tar.gz</download_link>
+   <date>1573827300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-2.0-beta1</name>
+   <version>8.x-2.0-beta1</version>
+   <tag>8.x-2.0-beta1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-2-0-beta1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-2-0-beta1.tar.gz</download_link>
+   <date>1571235300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-2.0-alpha1</name>
+   <version>8.x-2.0-alpha1</version>
+   <tag>8.x-2.0-alpha1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-2-0-alpha1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-2-0-alpha1.tar.gz</download_link>
+   <date>1568643300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-1.1</name>
+   <version>8.x-1.1</version>
+   <tag>8.x-1.1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-1-1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-1-1.tar.gz</download_link>
+   <date>1566051300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-1.1-beta1</name>
+   <version>8.x-1.1-beta1</version>
+   <tag>8.x-1.1-beta1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-1-1-beta1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-1-1-beta1.tar.gz</download_link>
+   <date>1563459300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-1.1-alpha1</name>
+   <version>8.x-1.1-alpha1</version>
+   <tag>8.x-1.1-alpha1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-1-1-alpha1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-1-1-alpha1.tar.gz</download_link>
+   <date>1560867300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-1.0</name>
+   <version>8.x-1.0</version>
+   <tag>8.x-1.0</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-1-0-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-1-0.tar.gz</download_link>
+   <date>1558275300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-1.0-beta1</name>
+   <version>8.x-1.0-beta1</version>
+   <tag>8.x-1.0-beta1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-1-0-beta1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-1-0-beta1.tar.gz</download_link>
+   <date>1555683300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+ <release>
+   <name>AAA Update test 8.x-1.0-alpha1</name>
+   <version>8.x-1.0-alpha1</version>
+   <tag>8.x-1.0-alpha1</tag>
+   <status>published</status>
+   <release_link>http://example.com/aaa_update_test-8-x-1-0-alpha1-release</release_link>
+   <download_link>http://example.com/aaa_update_test-8-x-1-0-alpha1.tar.gz</download_link>
+   <date>1553091300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml b/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml
new file mode 100644
index 000000000000..1bcdbbad8014
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/alpha.1.1.0.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Alpha</title>
+<short_name>alpha</short_name>
+<dc:creator>Drupal</dc:creator>
+<supported_branches>1.1.,1.0.</supported_branches>
+<project_status>published</project_status>
+<link>http://example.com/project/alpha</link>
+  <terms>
+   <term><name>Projects</name><value>Alpha project</value></term>
+  </terms>
+<releases>
+  <release>
+    <name>Alpha 1.1.0</name>
+    <version>1.1.0</version>
+    <tag>1.1.0</tag>
+    <status>published</status>
+    <release_link>http://example.com/alpha-1-1-0-release</release_link>
+    <download_link>http://example.com/alpha-1-1-0.tar.gz</download_link>
+    <date>1584195300</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+ <release>
+   <name>Alpha 1.0.0</name>
+   <version>1.0.0</version>
+   <tag>1.0.0</tag>
+   <status>published</status>
+   <release_link>http://example.com/alpha-1-0-0-release</release_link>
+   <download_link>http://example.com/alpha-1-0-0.tar.gz</download_link>
+   <date>1581603300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml
new file mode 100644
index 000000000000..215fba4bcc46
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.1-security.xml
@@ -0,0 +1,108 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Contains metadata about the following (fake) releases of Drupal core, in order:
+* 9.8.1, which is a security release
+* 9.8.0, which is insecure
+* 9.8.0-alpha1, which is insecure
+* 9.8.x-dev
+-->
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+    <title>Drupal</title>
+    <short_name>drupal</short_name>
+    <dc:creator>Drupal</dc:creator>
+    <supported_branches>9.8.</supported_branches>
+    <project_status>published</project_status>
+    <link>http://example.com/project/drupal</link>
+    <terms>
+        <term>
+            <name>Projects</name>
+            <value>Drupal project</value>
+        </term>
+    </terms>
+    <releases>
+        <release>
+            <name>Drupal 9.8.1</name>
+            <version>9.8.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Security update</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.0</name>
+            <version>9.8.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-0-release</release_link>
+            <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Insecure</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.0-alpha1</name>
+            <version>9.8.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-0-alpha1-.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Insecure</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.x-dev</name>
+            <version>9.8.x-dev</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-x-dex-release</release_link>
+            <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+    </releases>
+</project>
diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
new file mode 100644
index 000000000000..f643da92f7ba
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-older-sec-release.xml
@@ -0,0 +1,211 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Contains metadata about the following (fake) releases of Drupal core, in order:
+* 9.8.2
+* 9.8.1, which is a security update
+* 9.8.1-beta1, which is a security update
+* 9.8.0, which is insecure
+* 9.8.0-alpha1
+* 9.7.1, which is a security update
+* 9.7.0, which is insecure
+* 9.7.0-alpha1
+* 9.8.x-dev
+-->
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+    <title>Drupal</title>
+    <short_name>drupal</short_name>
+    <dc:creator>Drupal</dc:creator>
+    <supported_branches>9.7.,9.8.</supported_branches>
+    <project_status>published</project_status>
+    <link>http://example.com/project/drupal</link>
+    <terms>
+        <term>
+            <name>Projects</name>
+            <value>Drupal project</value>
+        </term>
+    </terms>
+    <releases>
+        <release>
+            <name>Drupal 9.8.2</name>
+            <version>9.8.2</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-2-release</release_link>
+            <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.1</name>
+            <version>9.8.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Security update</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.1-beta1</name>
+            <version>9.8.1-beta1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-1-beta1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-1-beta1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Security release</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.0</name>
+            <version>9.8.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-0-release</release_link>
+            <download_link>http://example.com/drupal-9-8-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Insecure</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.0-alpha1</name>
+            <version>9.8.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.1</name>
+            <version>9.7.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-1-release</release_link>
+            <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Security update</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.0</name>
+            <version>9.7.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-0-release</release_link>
+            <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Insecure</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.0-alpha1</name>
+            <version>9.7.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.x-dev</name>
+            <version>9.8.x-dev</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-x-dex-release</release_link>
+            <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+    </releases>
+</project>
diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml
new file mode 100644
index 000000000000..cfe1402cd129
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2-unsupported_unpublished.xml
@@ -0,0 +1,233 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Contains metadata about the following (fake) releases of Drupal core, in order:
+* 9.8.2
+* 9.8.1, which is unsupported
+* 9.8.0
+* 9.8.0-alpha1
+* 9.7.1
+* 9.7.0
+* 9.7.0-alpha1
+* 9.6.1, which is in an unsupported branch
+* 9.6.0, which is in an unsupported branch
+* 9.6.0, which is in an unsupported branch
+* 9.8.x-dev
+-->
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+    <title>Drupal</title>
+    <short_name>drupal</short_name>
+    <dc:creator>Drupal</dc:creator>
+    <supported_branches>9.7.,9.8.</supported_branches>
+    <project_status>published</project_status>
+    <link>http://example.com/project/drupal</link>
+    <terms>
+        <term>
+            <name>Projects</name>
+            <value>Drupal project</value>
+        </term>
+    </terms>
+    <releases>
+        <release>
+            <name>Drupal 9.8.2</name>
+            <version>9.8.2</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-2-release</release_link>
+            <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.1</name>
+            <version>9.8.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Unsupported</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.0</name>
+            <version>9.8.0</version>
+            <status>unpublished</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>
+        <release>
+            <name>Drupal 9.8.0-alpha1</name>
+            <version>9.8.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.1</name>
+            <version>9.7.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-1-release</release_link>
+            <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.0</name>
+            <version>9.7.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-0-release</release_link>
+            <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.0-alpha1</name>
+            <version>9.7.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.6.1</name>
+            <version>9.6.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-1-release</release_link>
+            <download_link>http://example.com/drupal-9-6-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.6.0</name>
+            <version>9.6.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-0-release</release_link>
+            <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.6.0-alpha1</name>
+            <version>9.6.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.x-dev</name>
+            <version>9.8.x-dev</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-x-dex-release</release_link>
+            <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+    </releases>
+</project>
diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml
new file mode 100644
index 000000000000..8e3da9e5e399
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2.xml
@@ -0,0 +1,229 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Contains metadata about the following (fake) releases of Drupal core, all of which are secure, in order:
+* 9.8.2
+* 9.8.1
+* 9.8.0
+* 9.8.0-alpha1
+* 9.7.1
+* 9.7.0
+* 9.7.0-alpha1
+* 9.6.1, which is in an unsupported branch
+* 9.6.0, which is in an unsupported branch
+* 9.6.0, which is in an unsupported branch
+* 9.8.x-dev
+-->
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+    <title>Drupal</title>
+    <short_name>drupal</short_name>
+    <dc:creator>Drupal</dc:creator>
+    <supported_branches>9.7.,9.8.</supported_branches>
+    <project_status>published</project_status>
+    <link>http://example.com/project/drupal</link>
+    <terms>
+        <term>
+            <name>Projects</name>
+            <value>Drupal project</value>
+        </term>
+    </terms>
+    <releases>
+        <release>
+            <name>Drupal 9.8.2</name>
+            <version>9.8.2</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-2-release</release_link>
+            <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.1</name>
+            <version>9.8.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </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>
+        <release>
+            <name>Drupal 9.8.0-alpha1</name>
+            <version>9.8.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.1</name>
+            <version>9.7.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-1-release</release_link>
+            <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.0</name>
+            <version>9.7.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-0-release</release_link>
+            <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.0-alpha1</name>
+            <version>9.7.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.6.1</name>
+            <version>9.6.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-1-release</release_link>
+            <download_link>http://example.com/drupal-9-6-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.6.0</name>
+            <version>9.6.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-0-release</release_link>
+            <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.6.0-alpha1</name>
+            <version>9.6.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.x-dev</name>
+            <version>9.8.x-dev</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-x-dex-release</release_link>
+            <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+    </releases>
+</project>
diff --git a/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml
new file mode 100644
index 000000000000..8836e06ac462
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/drupal.9.8.2_unknown_status.xml
@@ -0,0 +1,231 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!--
+Contains metadata about the following (fake) releases of Drupal core, in order:
+* 9.8.2
+* 9.8.1
+* 9.8.0
+* 9.8.0-alpha1
+* 9.7.1
+* 9.7.0
+* 9.7.0-alpha1
+* 9.6.1, which is in an unsupported branch
+* 9.6.0, which is in an unsupported branch
+* 9.6.0, which is in an unsupported branch
+* 9.8.x-dev
+
+What's special about this file is that the project as a whole has a status other than "published".
+-->
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+    <title>Drupal</title>
+    <short_name>drupal</short_name>
+    <dc:creator>Drupal</dc:creator>
+    <supported_branches>9.7.,9.8.</supported_branches>
+    <project_status>any status besides published</project_status>
+    <link>http://example.com/project/drupal</link>
+    <terms>
+        <term>
+            <name>Projects</name>
+            <value>Drupal project</value>
+        </term>
+    </terms>
+    <releases>
+        <release>
+            <name>Drupal 9.8.2</name>
+            <version>9.8.2</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-2-release</release_link>
+            <download_link>http://example.com/drupal-9-8-2.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.1</name>
+            <version>9.8.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </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>
+        <release>
+            <name>Drupal 9.8.0-alpha1</name>
+            <version>9.8.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-8-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.1</name>
+            <version>9.7.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-1-release</release_link>
+            <download_link>http://example.com/drupal-9-7-1.tar.gz</download_link>
+            <date>1250425521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.0</name>
+            <version>9.7.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-0-release</release_link>
+            <download_link>http://example.com/drupal-9-7-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.7.0-alpha1</name>
+            <version>9.7.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-7-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-7-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.6.1</name>
+            <version>9.6.1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-1-release</release_link>
+            <download_link>http://example.com/drupal-9-6-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.6.0</name>
+            <version>9.6.0</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-0-release</release_link>
+            <download_link>http://example.com/drupal-9-6-0.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.6.0-alpha1</name>
+            <version>9.6.0-alpha1</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-6-0-alpha1-release</release_link>
+            <download_link>http://example.com/drupal-9-6-0-alpha1.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+        <release>
+            <name>Drupal 9.8.x-dev</name>
+            <version>9.8.x-dev</version>
+            <status>published</status>
+            <release_link>http://example.com/drupal-9-8-x-dex-release</release_link>
+            <download_link>http://example.com/drupal-9-8-x-dex.tar.gz</download_link>
+            <date>1250424521</date>
+            <terms>
+                <term>
+                    <name>Release type</name>
+                    <value>New features</value>
+                </term>
+                <term>
+                    <name>Release type</name>
+                    <value>Bug fixes</value>
+                </term>
+            </terms>
+        </release>
+    </releases>
+</project>
diff --git a/core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml b/core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml
similarity index 98%
rename from core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml
rename to core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml
index cdb353fd4208..addca0b25be6 100644
--- a/core/modules/auto_updates/tests/fixtures/release-history/semver_test.1.1.xml
+++ b/core/modules/package_manager/tests/fixtures/release-history/semver_test.1.1.xml
@@ -11,7 +11,6 @@
   </terms>
 <releases>
   <release>
-    <!-- This release is not in a supported branch; therefore it should not be recommended. -->
     <name>Semver Test 8.2.0</name>
     <version>8.2.0</version>
     <tag>8.2.0</tag>
diff --git a/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml b/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml
new file mode 100644
index 000000000000..b8aaf22c6e50
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/release-history/updated_module.1.1.0.xml
@@ -0,0 +1,40 @@
+<?xml version="1.0" encoding="utf-8"?>
+<project xmlns:dc="http://purl.org/dc/elements/1.1/">
+<title>Updated Module</title>
+<short_name>updated_module</short_name>
+<dc:creator>Drupal</dc:creator>
+<supported_branches>1.1.,1.0.</supported_branches>
+<project_status>published</project_status>
+<link>http://example.com/project/alpha</link>
+  <terms>
+   <term><name>Projects</name><value>Updated Module project</value></term>
+  </terms>
+<releases>
+  <release>
+    <name>Updated Module 1.1.0</name>
+    <version>1.1.0</version>
+    <tag>1.1.0</tag>
+    <status>published</status>
+    <release_link>http://example.com/updated_module-1-1-0-release</release_link>
+    <download_link>http://example.com/updated_module-1-1-0.tar.gz</download_link>
+    <date>1584195300</date>
+    <terms>
+      <term><name>Release type</name><value>New features</value></term>
+      <term><name>Release type</name><value>Bug fixes</value></term>
+    </terms>
+  </release>
+ <release>
+   <name>Updated Module 1.0.0</name>
+   <version>1.0.0</version>
+   <tag>1.0.0</tag>
+   <status>published</status>
+   <release_link>http://example.com/updated_module-1-0-0-release</release_link>
+   <download_link>http://example.com/updated_module-1-0-0.tar.gz</download_link>
+   <date>1581603300</date>
+   <terms>
+     <term><name>Release type</name><value>New features</value></term>
+     <term><name>Release type</name><value>Bug fixes</value></term>
+   </terms>
+ </release>
+</releases>
+</project>
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml.hide b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml.hide
new file mode 100644
index 000000000000..d20c8629d728
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml.hide
@@ -0,0 +1 @@
+project: aaa_auto_updates_test
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/composer.lock b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/vendor/composer/installed.json
similarity index 54%
rename from core/modules/auto_updates/tests/fixtures/fake-site/composer.lock
rename to core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/vendor/composer/installed.json
index 05715cda77dd..8288ea2de0e9 100644
--- a/core/modules/auto_updates/tests/fixtures/fake-site/composer.lock
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/vendor/composer/installed.json
@@ -1,12 +1,5 @@
 {
     "packages": [
-        {
-            "name": "drupal/test-distribution",
-            "version": "1.0.0",
-            "require": {
-                "drupal/core-recommended": "*"
-            }
-        },
         {
             "name": "drupal/core-recommended",
             "version": "9.8.0",
@@ -17,12 +10,16 @@
         {
             "name": "drupal/core",
             "version": "9.8.0"
-        }
-    ],
-    "packages-dev": [
+        },
         {
-            "name": "drupal/core-dev",
-            "version": "9.8.0"
+          "name": "drupal/dependency",
+          "version": "9.8.1",
+          "type": "drupal-library"
+        },
+        {
+          "name": "drupal/aaa_auto_updates_test",
+          "version": "7.0.1",
+          "type": "drupal-module"
         }
     ]
 }
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/vendor/composer/installed.php
new file mode 100644
index 000000000000..5600fe0afab7
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_supported_update_stage/vendor/composer/installed.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @file
+ * Lists packages installed by Composer.
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/aaa_auto_updates_test' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/aaa_auto_updates_test',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml.hide b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml.hide
new file mode 100644
index 000000000000..d20c8629d728
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/modules/aaa_auto_updates_test/aaa_auto_updates_test.info.yml.hide
@@ -0,0 +1 @@
+project: aaa_auto_updates_test
diff --git a/core/modules/auto_updates/tests/fixtures/fake-site/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/vendor/composer/installed.json
similarity index 51%
rename from core/modules/auto_updates/tests/fixtures/fake-site/vendor/composer/installed.json
rename to core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/vendor/composer/installed.json
index c8ad1ba32e3c..17af9abbcb4b 100644
--- a/core/modules/auto_updates/tests/fixtures/fake-site/vendor/composer/installed.json
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/vendor/composer/installed.json
@@ -1,12 +1,5 @@
 {
     "packages": [
-        {
-            "name": "drupal/test-distribution",
-            "version": "1.0.0",
-            "require": {
-                "drupal/core-recommended": "*"
-            }
-        },
         {
             "name": "drupal/core-recommended",
             "version": "9.8.0",
@@ -19,12 +12,14 @@
             "version": "9.8.0"
         },
         {
-            "name": "drupal/core-dev",
-            "version": "9.8.0"
+          "name": "drupal/dependency",
+          "version": "9.8.1",
+          "type": "drupal-library"
+        },
+        {
+          "name": "drupal/aaa_auto_updates_test",
+          "version": "7.0.1-dev",
+          "type": "drupal-module"
         }
-    ],
-    "dev": true,
-    "dev-package-names": [
-        "drupal/core-dev"
     ]
 }
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/vendor/composer/installed.php
new file mode 100644
index 000000000000..5600fe0afab7
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/aaa_auto_updates_test_unsupported_update_stage/vendor/composer/installed.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @file
+ * Lists packages installed by Composer.
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/aaa_auto_updates_test' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/aaa_auto_updates_test',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/active/modules/aaa_update_test/aaa_update_test.info.yml.hide b/core/modules/package_manager/tests/fixtures/supported_release_validator/active/modules/aaa_update_test/aaa_update_test.info.yml.hide
new file mode 100644
index 000000000000..b2d1f884d4a0
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/active/modules/aaa_update_test/aaa_update_test.info.yml.hide
@@ -0,0 +1 @@
+project: aaa_update_test
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/active/modules/semver_test/semver_test.info.yml.hide b/core/modules/package_manager/tests/fixtures/supported_release_validator/active/modules/semver_test/semver_test.info.yml.hide
new file mode 100644
index 000000000000..6175b81a041a
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/active/modules/semver_test/semver_test.info.yml.hide
@@ -0,0 +1 @@
+project: semver_test
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/active/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/supported_release_validator/active/vendor/composer/installed.json
new file mode 100644
index 000000000000..f243b4a216a8
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/active/vendor/composer/installed.json
@@ -0,0 +1,30 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+          "name": "drupal/dependency",
+          "version": "9.8.0",
+          "type": "drupal-library"
+        },
+        {
+          "name": "drupal/aaa_update_test",
+          "version": "2.0.0",
+          "type": "drupal-module"
+        },
+        {
+          "name": "drupal/semver_test",
+          "version": "8.1.0",
+          "type": "drupal-module"
+        }
+    ]
+}
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/active/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/supported_release_validator/active/vendor/composer/installed.php
new file mode 100644
index 000000000000..4b9371be01a0
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/active/vendor/composer/installed.php
@@ -0,0 +1,20 @@
+<?php
+
+/**
+ * @file
+ * Lists packages installed by Composer.
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/semver_test' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/semver_test',
+    ],
+    'drupal/aaa_update_test' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/aaa_update_test',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/modules/aaa_update_test/aaa_update_test.info.yml.hide b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/modules/aaa_update_test/aaa_update_test.info.yml.hide
new file mode 100644
index 000000000000..b2d1f884d4a0
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/modules/aaa_update_test/aaa_update_test.info.yml.hide
@@ -0,0 +1 @@
+project: aaa_update_test
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/vendor/composer/installed.json
new file mode 100644
index 000000000000..2738b13cd4c4
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/vendor/composer/installed.json
@@ -0,0 +1,25 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+          "name": "drupal/dependency",
+          "version": "9.8.1",
+          "type": "drupal-library"
+        },
+        {
+          "name": "drupal/aaa_update_test",
+          "version": "2.1.0",
+          "type": "drupal-module"
+        }
+    ]
+}
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/vendor/composer/installed.php
new file mode 100644
index 000000000000..badfa21a1959
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_supported_update_stage/vendor/composer/installed.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @file
+ * Lists packages installed by Composer.
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/aaa_update_test' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/aaa_update_test',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/modules/aaa_update_test/aaa_update_test.info.yml.hide b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/modules/aaa_update_test/aaa_update_test.info.yml.hide
new file mode 100644
index 000000000000..b2d1f884d4a0
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/modules/aaa_update_test/aaa_update_test.info.yml.hide
@@ -0,0 +1 @@
+project: aaa_update_test
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/vendor/composer/installed.json
new file mode 100644
index 000000000000..d7ef4fbab2db
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/vendor/composer/installed.json
@@ -0,0 +1,25 @@
+{
+    "packages": [
+        {
+            "name": "drupal/core-recommended",
+            "version": "9.8.0",
+            "require": {
+                "drupal/core": "9.8.0"
+            }
+        },
+        {
+            "name": "drupal/core",
+            "version": "9.8.0"
+        },
+        {
+          "name": "drupal/dependency",
+          "version": "9.8.1",
+          "type": "drupal-library"
+        },
+        {
+          "name": "drupal/aaa_update_test",
+          "version": "3.0.0",
+          "type": "drupal-module"
+        }
+    ]
+}
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/vendor/composer/installed.php
new file mode 100644
index 000000000000..badfa21a1959
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/legacy_unsupported_update_stage/vendor/composer/installed.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @file
+ * Lists packages installed by Composer.
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/aaa_update_test' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/aaa_update_test',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/modules/semver_test/semver_test.info.yml.hide b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/modules/semver_test/semver_test.info.yml.hide
new file mode 100644
index 000000000000..6175b81a041a
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/modules/semver_test/semver_test.info.yml.hide
@@ -0,0 +1 @@
+project: semver_test
diff --git a/core/modules/package_manager/tests/fixtures/distro_core_recommended/vendor/composer/installed.json b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/vendor/composer/installed.json
similarity index 57%
rename from core/modules/package_manager/tests/fixtures/distro_core_recommended/vendor/composer/installed.json
rename to core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/vendor/composer/installed.json
index f68c6fc621d1..2ed8f728c71e 100644
--- a/core/modules/package_manager/tests/fixtures/distro_core_recommended/vendor/composer/installed.json
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/vendor/composer/installed.json
@@ -1,12 +1,5 @@
 {
     "packages": [
-        {
-            "name": "drupal/test-distribution",
-            "version": "1.0.0",
-            "require": {
-                "drupal/core-recommended": "*"
-            }
-        },
         {
             "name": "drupal/core-recommended",
             "version": "9.8.0",
@@ -17,6 +10,16 @@
         {
             "name": "drupal/core",
             "version": "9.8.0"
+        },
+        {
+          "name": "drupal/dependency",
+          "version": "9.8.1",
+          "type": "drupal-library"
+        },
+        {
+          "name": "drupal/semver_test",
+          "version": "8.1.1",
+          "type": "drupal-module"
         }
     ]
 }
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/vendor/composer/installed.php
new file mode 100644
index 000000000000..ed3212d4688a
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_supported_update_stage/vendor/composer/installed.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @file
+ * Lists packages installed by Composer.
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/semver_test' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/semver_test',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/modules/semver_test/semver_test.info.yml.hide b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/modules/semver_test/semver_test.info.yml.hide
new file mode 100644
index 000000000000..6175b81a041a
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/modules/semver_test/semver_test.info.yml.hide
@@ -0,0 +1 @@
+project: semver_test
diff --git a/core/modules/package_manager/tests/fixtures/distro_core_recommended/composer.lock b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/vendor/composer/installed.json
similarity index 56%
rename from core/modules/package_manager/tests/fixtures/distro_core_recommended/composer.lock
rename to core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/vendor/composer/installed.json
index dd29a0514d0e..0d4ca73efa51 100644
--- a/core/modules/package_manager/tests/fixtures/distro_core_recommended/composer.lock
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/vendor/composer/installed.json
@@ -1,12 +1,5 @@
 {
     "packages": [
-        {
-            "name": "drupal/test-distribution",
-            "version": "1.0.0",
-            "require": {
-                "drupal/core-recommended": "*"
-            }
-        },
         {
             "name": "drupal/core-recommended",
             "version": "9.8.0",
@@ -17,7 +10,16 @@
         {
             "name": "drupal/core",
             "version": "9.8.0"
+        },
+        {
+          "name": "drupal/dependency",
+          "version": "9.8.1",
+          "type": "drupal-library"
+        },
+        {
+          "name": "drupal/semver_test",
+          "version": "8.2.0",
+          "type": "drupal-module"
         }
-    ],
-    "packages-dev": []
+    ]
 }
diff --git a/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/vendor/composer/installed.php b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/vendor/composer/installed.php
new file mode 100644
index 000000000000..ed3212d4688a
--- /dev/null
+++ b/core/modules/package_manager/tests/fixtures/supported_release_validator/semver_unsupported_update_stage/vendor/composer/installed.php
@@ -0,0 +1,16 @@
+<?php
+
+/**
+ * @file
+ * Lists packages installed by Composer.
+ */
+
+$projects_dir = __DIR__ . '/../../modules';
+return [
+  'versions' => [
+    'drupal/semver_test' => [
+      'type' => 'drupal-module',
+      'install_path' => $projects_dir . '/semver_test',
+    ],
+  ],
+];
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml.hide
similarity index 86%
rename from core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml
rename to core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml.hide
index ebf1452ec9c8..5d31bbdb5a80 100644
--- a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.info.yml.hide
@@ -2,3 +2,4 @@ name: 'Updated module'
 description: 'A module which will change during an update, to ensure that the changes are picked up.'
 type: module
 package: Testing
+project: updated_module
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module
index 6f61c457881c..0f90596c5ff1 100644
--- a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.module
@@ -8,7 +8,7 @@
 /**
  * Page controller that says hello.
  *
- * @return array
+ * @return string[]
  *   A renderable array of the page content.
  */
 function updated_module_hello(): array {
@@ -16,12 +16,3 @@ function updated_module_hello(): array {
     '#markup' => 'Hello!',
   ];
 }
-
-/**
- * A test function to test if global functions are reloaded during an update.
- *
- * @return string
- */
-function _updated_module_global1(): string {
-  return "pre-update-value";
-}
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml
deleted file mode 100644
index c0236005ef27..000000000000
--- a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.permissions.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-changed permission:
-  title: 'permission'
-deleted permission:
-  title: 'deleted permission'
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml
deleted file mode 100644
index 03a269bb8357..000000000000
--- a/core/modules/package_manager/tests/fixtures/updated_module/1.0.0/updated_module.routing.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-updated_module.changed:
-  path: '/updated-module/changed/pre'
-  defaults:
-    _controller: 'updated_module_hello'
-  requirements:
-    _access: 'TRUE'
-updated_module.deleted:
-  path: '/updated-module/deleted'
-  defaults:
-    _controller: 'updated_module_hello'
-  requirements:
-    _access: 'TRUE'
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml.hide
similarity index 86%
rename from core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml
rename to core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml.hide
index ebf1452ec9c8..5d31bbdb5a80 100644
--- a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.info.yml.hide
@@ -2,3 +2,4 @@ name: 'Updated module'
 description: 'A module which will change during an update, to ensure that the changes are picked up.'
 type: module
 package: Testing
+project: updated_module
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module
index 34d5dce9bb1f..0f90596c5ff1 100644
--- a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module
+++ b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.module
@@ -8,7 +8,7 @@
 /**
  * Page controller that says hello.
  *
- * @return array
+ * @return string[]
  *   A renderable array of the page content.
  */
 function updated_module_hello(): array {
@@ -16,18 +16,3 @@ function updated_module_hello(): array {
     '#markup' => 'Hello!',
   ];
 }
-
-/**
- * A test function to test if global functions are reloaded during an update.
- *
- * @return string
- */
-function _updated_module_global1(): string {
-  return "post-update-value";
-}
-
-/**
- * A test function to test if a new global function will be available after an update.
- */
-function _updated_module_global2(): void {
-}
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml
deleted file mode 100644
index 926a17a41238..000000000000
--- a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.permissions.yml
+++ /dev/null
@@ -1,4 +0,0 @@
-changed permission:
-  title: 'changed permission'
-added permission:
-  title: 'added permission'
diff --git a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml b/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml
deleted file mode 100644
index 525acd210ccb..000000000000
--- a/core/modules/package_manager/tests/fixtures/updated_module/1.1.0/updated_module.routing.yml
+++ /dev/null
@@ -1,12 +0,0 @@
-updated_module.changed:
-  path: '/updated-module/changed/post'
-  defaults:
-    _controller: 'updated_module_hello'
-  requirements:
-    _access: 'TRUE'
-updated_module.added:
-  path: '/updated-module/added'
-  defaults:
-    _controller: 'updated_module_hello'
-  requirements:
-    _access: 'TRUE'
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
index e9ff25509f81..c746f1322ee0 100644
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Beginner.php
@@ -2,19 +2,23 @@
 
 namespace Drupal\package_manager_bypass;
 
-use PhpTuf\ComposerStager\Domain\BeginnerInterface;
-use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Core\Beginner\BeginnerInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ProcessRunnerInterface;
+use PhpTuf\ComposerStager\Domain\Value\Path\PathInterface;
+use PhpTuf\ComposerStager\Domain\Value\PathList\PathListInterface;
 
 /**
  * Defines an update beginner which doesn't do anything.
  */
-class Beginner extends InvocationRecorderBase implements BeginnerInterface {
+class Beginner extends BypassedStagerServiceBase implements BeginnerInterface {
 
   /**
    * {@inheritdoc}
    */
-  public function begin(string $activeDir, string $stagingDir, ?array $exclusions = [], ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
-    $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions);
+  public function begin(PathInterface $activeDir, PathInterface $stagingDir, ?PathListInterface $exclusions = NULL, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = ProcessRunnerInterface::DEFAULT_TIMEOUT): void {
+    $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions, $timeout);
+    $this->copyFixtureFilesTo($stagingDir);
   }
 
 }
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/BypassedStagerServiceBase.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/BypassedStagerServiceBase.php
new file mode 100644
index 000000000000..4457fcf4e916
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/BypassedStagerServiceBase.php
@@ -0,0 +1,100 @@
+<?php
+
+namespace Drupal\package_manager_bypass;
+
+use Drupal\Core\State\StateInterface;
+use PhpTuf\ComposerStager\Domain\Value\Path\PathInterface;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * Records information about method invocations.
+ *
+ * This can be used by functional tests to ensure that the bypassed Composer
+ * Stager services were called as expected. Kernel and unit tests should use
+ * regular mocks instead.
+ */
+abstract class BypassedStagerServiceBase {
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  protected $state;
+
+  /**
+   * The Symfony file system service.
+   *
+   * @var \Symfony\Component\Filesystem\Filesystem
+   */
+  protected $fileSystem;
+
+  /**
+   * Constructs an InvocationRecorderBase object.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param \Symfony\Component\Filesystem\Filesystem $file_system
+   *   The Symfony file system service.
+   */
+  public function __construct(StateInterface $state, Filesystem $file_system) {
+    $this->state = $state;
+    $this->fileSystem = $file_system;
+  }
+
+  /**
+   * Sets a path to be mirrored into a destination by the main class method.
+   *
+   * @param string|null $path
+   *   A path to mirror into a destination directory when the main class method
+   *   is called, or NULL to disable.
+   *
+   * @see ::copyFixtureFilesTo()
+   */
+  public static function setFixturePath(?string $path): void {
+    \Drupal::state()->set(static::class . ' fixture', $path);
+  }
+
+  /**
+   * If a fixture path has been set, mirrors it to the given path.
+   *
+   * Files in the destination directory but not in the source directory will
+   * not be deleted.
+   *
+   * @param \PhpTuf\ComposerStager\Domain\Value\Path\PathInterface $destination
+   *   The path to which the fixture files should be mirrored.
+   */
+  protected function copyFixtureFilesTo(PathInterface $destination): void {
+    $fixture_path = $this->state->get(static::class . ' fixture');
+
+    if ($fixture_path && is_dir($fixture_path)) {
+      $this->fileSystem->mirror($fixture_path, $destination->resolve(), NULL, [
+        'override' => TRUE,
+        'delete' => FALSE,
+      ]);
+    }
+  }
+
+  /**
+   * Returns the arguments from every invocation of the main class method.
+   *
+   * @return mixed[]
+   *   The arguments from every invocation of the main class method.
+   */
+  public function getInvocationArguments(): array {
+    return $this->state->get(static::class . ' arguments', []);
+  }
+
+  /**
+   * Records the arguments from an invocation of the main class method.
+   *
+   * @param mixed ...$arguments
+   *   The arguments that the main class method was called with.
+   */
+  protected function saveInvocationArguments(...$arguments): void {
+    $invocations = $this->getInvocationArguments();
+    $invocations[] = $arguments;
+    $this->state->set(static::class . ' arguments', $invocations);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php
index 2feb0d0f70c1..422249975c3e 100644
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Committer.php
@@ -2,43 +2,46 @@
 
 namespace Drupal\package_manager_bypass;
 
-use PhpTuf\ComposerStager\Domain\CommitterInterface;
-use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Core\Committer\CommitterInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ProcessRunnerInterface;
+use PhpTuf\ComposerStager\Domain\Value\Path\PathInterface;
+use PhpTuf\ComposerStager\Domain\Value\PathList\PathListInterface;
 
 /**
  * Defines an update committer which doesn't do any actual committing.
  */
-class Committer extends InvocationRecorderBase implements CommitterInterface {
+class Committer extends BypassedStagerServiceBase implements CommitterInterface {
 
   /**
-   * The decorated committer service.
-   *
-   * @var \PhpTuf\ComposerStager\Domain\CommitterInterface
-   */
-  private $decorated;
-
-  /**
-   * Constructs a Committer object.
-   *
-   * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $decorated
-   *   The decorated committer service.
+   * {@inheritdoc}
    */
-  public function __construct(CommitterInterface $decorated) {
-    $this->decorated = $decorated;
+  public function commit(PathInterface $stagingDir, PathInterface $activeDir, ?PathListInterface $exclusions = NULL, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = ProcessRunnerInterface::DEFAULT_TIMEOUT): void {
+    $this->saveInvocationArguments($stagingDir, $activeDir, $exclusions, $timeout);
+    if ($exception = $this->state->get(static::class . '-exception')) {
+      throw $exception;
+    }
+    $this->copyFixtureFilesTo($activeDir);
   }
 
   /**
    * {@inheritdoc}
    */
-  public function commit(string $stagingDir, string $activeDir, ?array $exclusions = [], ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
-    $this->saveInvocationArguments($activeDir, $stagingDir, $exclusions);
+  public static function setFixturePath(?string $path): void {
+    // We haven't yet encountered a situation where we need the committer to
+    // copy fixture files to the active directory, but when we do, go ahead and
+    // remove this entire method.
+    throw new \BadMethodCallException('This is not implemented yet.');
   }
 
   /**
-   * {@inheritdoc}
+   * Sets an exception to be thrown during ::commit().
+   *
+   * @param \Throwable $exception
+   *   The throwable.
    */
-  public function directoryExists(string $stagingDir): bool {
-    return $this->decorated->directoryExists($stagingDir);
+  public static function setException(\Throwable $exception): void {
+    \Drupal::state()->set(static::class . '-exception', $exception);
   }
 
 }
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php
deleted file mode 100644
index edc1075a9aaf..000000000000
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/InvocationRecorderBase.php
+++ /dev/null
@@ -1,36 +0,0 @@
-<?php
-
-namespace Drupal\package_manager_bypass;
-
-/**
- * Records information about method invocations.
- *
- * This can be used by functional tests to ensure that the bypassed Composer
- * Stager services were called as expected. Kernel and unit tests should use
- * regular mocks instead.
- */
-abstract class InvocationRecorderBase {
-
-  /**
-   * Returns the arguments from every invocation of the main class method.
-   *
-   * @return array[]
-   *   The arguments from every invocation of the main class method.
-   */
-  public function getInvocationArguments(): array {
-    return \Drupal::state()->get(static::class, []);
-  }
-
-  /**
-   * Records the arguments from an invocation of the main class method.
-   *
-   * @param mixed ...$arguments
-   *   The arguments that the main class method was called with.
-   */
-  protected function saveInvocationArguments(...$arguments): void {
-    $invocations = $this->getInvocationArguments();
-    $invocations[] = $arguments;
-    \Drupal::state()->set(static::class, $invocations);
-  }
-
-}
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php
index 3f7bf47d3873..029b54b5f38d 100644
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PackageManagerBypassServiceProvider.php
@@ -4,7 +4,9 @@
 
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\Core\DependencyInjection\ServiceProviderBase;
+use Drupal\Core\Site\Settings;
 use Symfony\Component\DependencyInjection\Reference;
+use Symfony\Component\Filesystem\Filesystem;
 
 /**
  * Defines services to bypass Package Manager's core functionality.
@@ -17,19 +19,27 @@ class PackageManagerBypassServiceProvider extends ServiceProviderBase {
   public function alter(ContainerBuilder $container) {
     parent::alter($container);
 
-    $container->getDefinition('package_manager.beginner')
-      ->setClass(Beginner::class);
-    $container->getDefinition('package_manager.stager')
-      ->setClass(Stager::class);
+    $state = new Reference('state');
+    $arguments = [
+      $state,
+      new Reference(Filesystem::class),
+    ];
+    if (Settings::get('package_manager_bypass_composer_stager', TRUE)) {
+      $services = [
+        'package_manager.beginner' => Beginner::class,
+        'package_manager.stager' => Stager::class,
+        'package_manager.committer' => Committer::class,
+      ];
+      foreach ($services as $id => $class) {
+        $container->getDefinition($id)->setClass($class)->setArguments($arguments);
+      }
+    }
 
-    $container->register('package_manager_bypass.committer')
-      ->setClass(Committer::class)
-      ->setPublic(FALSE)
-      ->setDecoratedService('package_manager.committer')
-      ->setArguments([
-        new Reference('package_manager_bypass.committer.inner'),
-      ])
-      ->setProperty('_serviceId', 'package_manager.committer');
+    $definition = $container->getDefinition('package_manager.path_locator')
+      ->setClass(PathLocator::class);
+    $arguments = $definition->getArguments();
+    array_unshift($arguments, $state);
+    $definition->setArguments($arguments);
   }
 
 }
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/PathLocator.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PathLocator.php
new file mode 100644
index 000000000000..869e4b9f3785
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/PathLocator.php
@@ -0,0 +1,82 @@
+<?php
+
+namespace Drupal\package_manager_bypass;
+
+use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\PathLocator as BasePathLocator;
+
+/**
+ * Overrides the path locator to return pre-set values for testing purposes.
+ */
+class PathLocator extends BasePathLocator {
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  private $state;
+
+  /**
+   * Constructs a PathLocator object.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   * @param mixed ...$arguments
+   *   Additional arguments to pass to the parent constructor.
+   */
+  public function __construct(StateInterface $state, ...$arguments) {
+    parent::__construct(...$arguments);
+    $this->state = $state;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getProjectRoot(): string {
+    return $this->state->get(static::class . ' root', parent::getProjectRoot());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getVendorDirectory(): string {
+    return $this->state->get(static::class . ' vendor', parent::getVendorDirectory());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getWebRoot(): string {
+    return $this->state->get(static::class . ' web', parent::getWebRoot());
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function getStagingRoot(): string {
+    return $this->state->get(static::class . ' stage', parent::getStagingRoot());
+  }
+
+  /**
+   * Sets the paths to return.
+   *
+   * @param string|null $project_root
+   *   The project root, or NULL to defer to the parent class.
+   * @param string|null $vendor_dir
+   *   The vendor directory, or NULL to defer to the parent class.
+   * @param string|null $web_root
+   *   The web root, relative to the project root, or NULL to defer to the
+   *   parent class.
+   * @param string|null $staging_root
+   *   The absolute path of the staging root, or NULL to defer to the parent
+   *   class.
+   */
+  public function setPaths(?string $project_root, ?string $vendor_dir, ?string $web_root, ?string $staging_root): void {
+    $this->state->set(static::class . ' root', $project_root);
+    $this->state->set(static::class . ' vendor', $vendor_dir);
+    $this->state->set(static::class . ' web', $web_root);
+    $this->state->set(static::class . ' stage', $staging_root);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php
index 237eccab38c1..2cb09ad7fdea 100644
--- a/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php
+++ b/core/modules/package_manager/tests/modules/package_manager_bypass/src/Stager.php
@@ -2,19 +2,45 @@
 
 namespace Drupal\package_manager_bypass;
 
-use PhpTuf\ComposerStager\Domain\Process\OutputCallbackInterface;
-use PhpTuf\ComposerStager\Domain\StagerInterface;
+use Composer\Json\JsonFile;
+use PhpTuf\ComposerStager\Domain\Core\Stager\StagerInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessOutputCallback\ProcessOutputCallbackInterface;
+use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ProcessRunnerInterface;
+use PhpTuf\ComposerStager\Domain\Value\Path\PathInterface;
 
 /**
  * Defines an update stager which doesn't actually do anything.
  */
-class Stager extends InvocationRecorderBase implements StagerInterface {
+class Stager extends BypassedStagerServiceBase implements StagerInterface {
 
   /**
    * {@inheritdoc}
    */
-  public function stage(array $composerCommand, string $stagingDir, ?OutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
-    $this->saveInvocationArguments($composerCommand, $stagingDir);
+  public function stage(array $composerCommand, PathInterface $activeDir, PathInterface $stagingDir, ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = ProcessRunnerInterface::DEFAULT_TIMEOUT): void {
+    $this->saveInvocationArguments($composerCommand, $stagingDir, $timeout);
+    $this->copyFixtureFilesTo($stagingDir);
+
+    // If desired, simulate a change to the lock file (e.g., as a result of
+    // running `composer update`).
+    $lockFile = new JsonFile($stagingDir->resolve() . '/composer.lock');
+    $changeLockFile = $this->state->get(static::class . ' lock', TRUE);
+
+    if ($changeLockFile && $lockFile->exists()) {
+      $data = $lockFile->read();
+      $data['_time'] = microtime();
+      $lockFile->write($data);
+    }
+  }
+
+  /**
+   * Sets whether or not ::stage() should simulate a change in the lock file.
+   *
+   * @param bool $value
+   *   (optional) Whether or not to simulate a change in the lock file when
+   *   ::stage() is called. Defaults to TRUE.
+   */
+  public static function setLockFileShouldChange(bool $value = TRUE): void {
+    \Drupal::state()->set(static::class . ' lock', $value);
   }
 
 }
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
index f400c0f4fcd7..ec3e15596e0e 100644
--- a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
@@ -4,3 +4,9 @@ package_manager_test_api:
     _controller: 'Drupal\package_manager_test_api\ApiController::run'
   requirements:
     _access: 'TRUE'
+package_manager_test_api.finish:
+  path: '/package-manager-test-api/finish/{id}'
+  defaults:
+    _controller: 'Drupal\package_manager_test_api\ApiController::finish'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml
deleted file mode 100644
index 6bf6af37d16d..000000000000
--- a/core/modules/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.services.yml
+++ /dev/null
@@ -1,10 +0,0 @@
-services:
-  package_manager_test_api.system_change_recorder:
-    class: Drupal\package_manager_test_api\SystemChangeRecorder
-    arguments:
-      - '@package_manager.path_locator'
-      - '@state'
-      - '@router.no_access_checks'
-      - '@user.permissions'
-    tags:
-      - { name: event_subscriber }
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
index 45eb27f0f8d1..35269cc57eee 100644
--- a/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
+++ b/core/modules/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
@@ -3,10 +3,12 @@
 namespace Drupal\package_manager_test_api;
 
 use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Url;
 use Drupal\package_manager\PathLocator;
 use Drupal\package_manager\Stage;
 use Symfony\Component\DependencyInjection\ContainerInterface;
 use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\RedirectResponse;
 use Symfony\Component\HttpFoundation\Request;
 
 /**
@@ -63,10 +65,10 @@ public static function create(ContainerInterface $container) {
   }
 
   /**
-   * Runs a complete stage life cycle.
+   * Begins a stage life cycle.
    *
    * Creates a staging area, requires packages into it, applies changes to the
-   * active directory, and destroys the stage.
+   * active directory.
    *
    * @param \Symfony\Component\HttpFoundation\Request $request
    *   The request. The runtime and dev dependencies are expected to be in
@@ -75,18 +77,47 @@ public static function create(ContainerInterface $container) {
    *   contains an array of file paths, relative to the project root, whose
    *   contents should be returned in the response.
    *
-   * @return \Symfony\Component\HttpFoundation\JsonResponse
-   *   A JSON response containing an associative array of the contents of the
-   *   files listed in the 'files_to_return' request key. The array will be
-   *   keyed by path, relative to the project root.
+   * @return \Symfony\Component\HttpFoundation\RedirectResponse
+   *   A response that directs to the ::finish() method.
+   *
+   * @see ::finish()
    */
-  public function run(Request $request): JsonResponse {
-    $this->stage->create();
+  public function run(Request $request): RedirectResponse {
+    $id = $this->stage->create();
     $this->stage->require(
       $request->get('runtime', []),
       $request->get('dev', [])
     );
     $this->stage->apply();
+
+    $redirect_url = Url::fromRoute('package_manager_test_api.finish')
+      ->setRouteParameter('id', $id)
+      ->setOption('query', [
+        'files_to_return' => $request->get('files_to_return', []),
+      ])
+      ->setAbsolute()
+      ->toString();
+
+    return new RedirectResponse($redirect_url);
+  }
+
+  /**
+   * Performs post-apply tasks and destroys the stage.
+   *
+   * @param string $id
+   *   The stage ID.
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request. There may be a 'files_to_return' key in either the query
+   *   string or request body which contains an array of file paths, relative to
+   *   the project root, whose contents should be returned in the response.
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   *   A JSON response containing an associative array of the contents of the
+   *   files listed in the 'files_to_return' request key. The array will be
+   *   keyed by path, relative to the project root.
+   */
+  public function finish(string $id, Request $request): JsonResponse {
+    $this->stage->claim($id)->postApply();
     $this->stage->destroy();
 
     $dir = $this->pathLocator->getProjectRoot();
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php b/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php
deleted file mode 100644
index 4738a3157c36..000000000000
--- a/core/modules/package_manager/tests/modules/package_manager_test_api/src/SystemChangeRecorder.php
+++ /dev/null
@@ -1,129 +0,0 @@
-<?php
-
-namespace Drupal\package_manager_test_api;
-
-use Drupal\Core\State\StateInterface;
-use Drupal\package_manager\Event\PostApplyEvent;
-use Drupal\package_manager\Event\PostDestroyEvent;
-use Drupal\package_manager\Event\PreApplyEvent;
-use Drupal\package_manager\Event\StageEvent;
-use Drupal\package_manager\PathLocator;
-use Drupal\user\PermissionHandlerInterface;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\Routing\RouterInterface;
-
-/**
- * Defines a service for checking system changes during an update.
- */
-class SystemChangeRecorder implements EventSubscriberInterface {
-
-  /**
-   * The path locator service.
-   *
-   * @var \Drupal\package_manager\PathLocator
-   */
-  private $pathLocator;
-
-  /**
-   * The state service.
-   *
-   * @var \Drupal\Core\State\StateInterface
-   */
-  private $state;
-
-  /**
-   * The router service.
-   *
-   * @var \Symfony\Component\Routing\RouterInterface
-   */
-  private $router;
-
-  /**
-   * The permission handler service.
-   *
-   * @var \Drupal\user\PermissionHandlerInterface
-   */
-  private $permissionHandler;
-
-  /**
-   * Constructs a SystemChangeRecorder object.
-   *
-   * @param \Drupal\package_manager\PathLocator $path_locator
-   *   The path locator service.
-   * @param \Drupal\Core\State\StateInterface $state
-   *   The state service.
-   * @param \Symfony\Component\Routing\RouterInterface $router
-   *   The router service.
-   * @param \Drupal\user\PermissionHandlerInterface $permissionHandler
-   *   The permission handler service.
-   */
-  public function __construct(PathLocator $path_locator, StateInterface $state, RouterInterface $router, PermissionHandlerInterface $permissionHandler) {
-    $this->pathLocator = $path_locator;
-    $this->state = $state;
-    $this->router = $router;
-    $this->permissionHandler = $permissionHandler;
-  }
-
-  /**
-   * Records aspects of system state at various points during an update.
-   *
-   * @param \Drupal\package_manager\Event\StageEvent $event
-   *   The stage event.
-   */
-  public function recordSystemState(StageEvent $event): void {
-    $results = [];
-
-    // Call a function in a loaded file to ensure it doesn't get reloaded after
-    // changes are applied.
-    $results['return value of existing global function'] = _updated_module_global1();
-
-    // Check if a new global function exists after changes are applied.
-    $results['new global function exists'] = function_exists('_updated_module_global2') ? "exists" : "not exists";
-
-    $route_collection = $this->router->getRouteCollection();
-    // Check if changes to an existing route are picked up.
-    $results['path of changed route'] = $route_collection->get('updated_module.changed')
-      ->getPath();
-    // Check if a route removed from the updated module is no longer available.
-    $results['deleted route exists'] = $route_collection->get('updated_module.deleted') ? 'exists' : 'not exists';
-    // Check if a route added in the updated module is available.
-    $results['new route exists'] = $route_collection->get('updated_module.added') ? 'exists' : 'not exists';
-
-    $permissions = $this->permissionHandler->getPermissions();
-    // Check if changes to an existing permission are picked up.
-    $results['title of changed permission'] = $permissions['changed permission']['title'];
-    // Check if a permission removed from the updated module is not available.
-    $results['deleted permission exists'] = array_key_exists('deleted permission', $permissions) ? 'exists' : 'not exists';
-    // Check if a permission added in the updated module is available.
-    $results['new permission exists'] = array_key_exists('added permission', $permissions) ? 'exists' : 'not exists';
-    $phase = $event instanceof PreApplyEvent ? 'pre' : 'post';
-    $this->state->set("system_changes:$phase", $results);
-  }
-
-  /**
-   * Writes the results of ::recordSystemState() to file.
-   *
-   * Build tests do not have access to the Drupal API, so write the results to
-   * a file so the build test can check them.
-   */
-  public function writeResultsToFile(): void {
-    $results = [
-      'pre' => $this->state->get('system_changes:pre'),
-      'post' => $this->state->get('system_changes:post'),
-    ];
-    $dir = $this->pathLocator->getProjectRoot();
-    file_put_contents("$dir/system_changes.json", json_encode($results));
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PreApplyEvent::class => 'recordSystemState',
-      PostApplyEvent::class => 'recordSystemState',
-      PostDestroyEvent::class => 'writeResultsToFile',
-    ];
-  }
-
-}
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml
deleted file mode 100644
index cf1308922d4f..000000000000
--- a/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.info.yml
+++ /dev/null
@@ -1,6 +0,0 @@
-name: 'Package Manager Test Fixture'
-description: 'Provides a mechanism for functional tests to stage fixture files.'
-type: module
-package: Testing
-dependencies:
-  - auto_updates:package_manager
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml b/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml
deleted file mode 100644
index aa44e5741e2b..000000000000
--- a/core/modules/package_manager/tests/modules/package_manager_test_fixture/package_manager_test_fixture.services.yml
+++ /dev/null
@@ -1,8 +0,0 @@
-services:
-  package_manager_test_fixture.stager:
-    class: Drupal\package_manager_test_fixture\EventSubscriber\FixtureStager
-    arguments:
-      - '@state'
-      - '@package_manager.symfony_file_system'
-    tags:
-      - { name: event_subscriber }
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php b/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php
deleted file mode 100644
index a83d92799805..000000000000
--- a/core/modules/package_manager/tests/modules/package_manager_test_fixture/src/EventSubscriber/FixtureStager.php
+++ /dev/null
@@ -1,88 +0,0 @@
-<?php
-
-namespace Drupal\package_manager_test_fixture\EventSubscriber;
-
-use Drupal\Core\State\StateInterface;
-use Drupal\package_manager\Event\PostRequireEvent;
-use Symfony\Component\EventDispatcher\EventSubscriberInterface;
-use Symfony\Component\Filesystem\Filesystem;
-
-/**
- * Defines an event subscriber which copies certain files into the staging area.
- *
- * This is most useful in conjunction with package_manager_bypass, which quietly
- * turns all Composer Stager operations into no-ops. In such cases, no staging
- * area will be physically created, but if a test needs to simulate certain
- * conditions in a staging area without actually staging the active code base,
- * this event subscriber is the way to do it.
- */
-class FixtureStager implements EventSubscriberInterface {
-
-  /**
-   * The state service.
-   *
-   * @var \Drupal\Core\State\StateInterface
-   */
-  protected $state;
-
-  /**
-   * The Symfony file system service.
-   *
-   * @var \Symfony\Component\Filesystem\Filesystem
-   */
-  protected $fileSystem;
-
-  /**
-   * Constructs a FixtureStager.
-   *
-   * @param \Drupal\Core\State\StateInterface $state
-   *   The state service.
-   * @param \Symfony\Component\Filesystem\Filesystem $file_system
-   *   The Symfony file system service.
-   */
-  public function __construct(StateInterface $state, Filesystem $file_system) {
-    $this->state = $state;
-    $this->fileSystem = $file_system;
-  }
-
-  /**
-   * Copies files from a fixture into the staging area.
-   *
-   * Tests which use this functionality are responsible for cleaning up the
-   * staging area.
-   *
-   * @param \Drupal\package_manager\Event\PostRequireEvent $event
-   *   The event object.
-   *
-   * @see \Drupal\Tests\auto_updates\Functional\AutoUpdatesFunctionalTestBase::tearDown()
-   */
-  public function copyFilesFromFixture(PostRequireEvent $event): void {
-    $fixturePath = $this->state->get(static::class);
-    if ($fixturePath && is_dir($fixturePath)) {
-      $this->fileSystem->mirror($fixturePath, $event->getStage()->getStageDirectory(), NULL, [
-        'override' => TRUE,
-        'delete' => TRUE,
-      ]);
-    }
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public static function getSubscribedEvents() {
-    return [
-      PostRequireEvent::class => 'copyFilesFromFixture',
-    ];
-  }
-
-  /**
-   * Sets the path of the fixture to copy into the staging area.
-   *
-   * @param string $path
-   *   The path of the fixture to copy into the staging area.
-   */
-  public static function setFixturePath(string $path): void {
-    \Drupal::state()->set(static::class, $path);
-  }
-
-}
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml
new file mode 100644
index 000000000000..ecf8c8e6122e
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.info.yml
@@ -0,0 +1,7 @@
+name: 'Package Manager Test Release history'
+type: module
+description: 'Provides a mechanism for serving fake release history metadata in functional tests.'
+package: Testing
+dependencies:
+  - drupal:update
+  - drupal:update_test
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml
new file mode 100644
index 000000000000..df0b40c61781
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/package_manager_test_release_history.routing.yml
@@ -0,0 +1,9 @@
+package_manager_test_release_history.metadata:
+  path: '/test-release-history/{project_name}/{version}'
+  defaults:
+    _title: 'Update test'
+    _controller: '\Drupal\package_manager_test_release_history\TestController::metadata'
+  requirements:
+    _access: 'TRUE'
+  options:
+    _maintenance_access: TRUE
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php b/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php
new file mode 100644
index 000000000000..b56375abde8a
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_release_history/src/TestController.php
@@ -0,0 +1,44 @@
+<?php
+
+namespace Drupal\package_manager_test_release_history;
+
+use Drupal\Core\Controller\ControllerBase;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\Response;
+
+class TestController 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 metadata($project_name = 'drupal', $version = NULL): Response {
+    $xml_map = $this->config('update_test.settings')->get('xml_map');
+    if (isset($xml_map[$project_name])) {
+      $file = $xml_map[$project_name];
+    }
+    elseif (isset($xml_map['#all'])) {
+      $file = $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.
+      $file = '#broken#';
+    }
+
+    $headers = ['Content-Type' => 'text/xml; charset=utf-8'];
+    if (!is_file($file)) {
+      // Return an empty response.
+      return new Response('', 200, $headers);
+    }
+    return new BinaryFileResponse($file, 200, $headers);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php
index f88eb4f9a4de..6a755fdb08a8 100644
--- a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php
+++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/EventSubscriber/TestSubscriber.php
@@ -12,6 +12,7 @@
 use Drupal\package_manager\Event\PreDestroyEvent;
 use Drupal\package_manager\Event\PreRequireEvent;
 use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\system\SystemManager;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
@@ -54,7 +55,7 @@ public function __construct(StateInterface $state) {
    *   The event class.
    */
   public static function setExit(string $event): void {
-    \Drupal::state()->set(static::STATE_KEY . ".$event", 'exit');
+    \Drupal::state()->set(self::getStateKey($event), 'exit');
   }
 
   /**
@@ -69,7 +70,7 @@ public static function setExit(string $event): void {
    *   The event class.
    */
   public static function setTestResult(?array $results, string $event): void {
-    $key = static::STATE_KEY . '.' . $event;
+    $key = static::getStateKey($event);
 
     $state = \Drupal::state();
     if (isset($results)) {
@@ -92,7 +93,7 @@ public static function setTestResult(?array $results, string $event): void {
    *   The event class.
    */
   public static function setException(?\Throwable $error, string $event): void {
-    $key = static::STATE_KEY . '.' . $event;
+    $key = self::getStateKey($event);
 
     $state = \Drupal::state();
     if (isset($error)) {
@@ -103,6 +104,20 @@ public static function setException(?\Throwable $error, string $event): void {
     }
   }
 
+  /**
+   * Computes the state key to use for a given event class.
+   *
+   * @param string $event
+   *   The event class.
+   *
+   * @return string
+   *   The state key under which to store the results for the given event.
+   */
+  protected static function getStateKey(string $event): string {
+    $parts = explode('\\', $event);
+    return static::STATE_KEY . array_pop($parts);
+  }
+
   /**
    * Adds validation results to a stage event.
    *
@@ -110,7 +125,7 @@ public static function setException(?\Throwable $error, string $event): void {
    *   The event object.
    */
   public function handleEvent(StageEvent $event): void {
-    $results = $this->state->get(static::STATE_KEY . '.' . get_class($event), []);
+    $results = $this->state->get(self::getStateKey(get_class($event)), []);
 
     // Record that value of maintenance mode for each event.
     $this->state->set(get_class($event) . '.' . 'system.maintenance_mode', $this->state->get('system.maintenance_mode'));
@@ -121,6 +136,10 @@ public function handleEvent(StageEvent $event): void {
     elseif ($results === 'exit') {
       exit();
     }
+    elseif (is_string($results)) {
+      \Drupal::messenger()->addStatus($results);
+      return;
+    }
     /** @var \Drupal\package_manager\ValidationResult $result */
     foreach ($results as $result) {
       if ($result->getSeverity() === SystemManager::REQUIREMENT_ERROR) {
@@ -147,7 +166,22 @@ public static function getSubscribedEvents() {
       PostApplyEvent::class => ['handleEvent', $priority],
       PreDestroyEvent::class => ['handleEvent', $priority],
       PostDestroyEvent::class => ['handleEvent', $priority],
+      StatusCheckEvent::class => ['handleEvent', $priority],
     ];
   }
 
+  /**
+   * Sets a status message that will be sent to the messenger for an event.
+   *
+   * @param string $message
+   *   Message text.
+   * @param string $event
+   *   The event class.
+   */
+  public static function setMessage(string $message, string $event): void {
+    $key = static::getStateKey($event);
+    $state = \Drupal::state();
+    $state->set($key, $message);
+  }
+
 }
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php
new file mode 100644
index 000000000000..1d7d206296e4
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/PackageManagerTestValidationServiceProvider.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace Drupal\package_manager_test_validation;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\DependencyInjection\ServiceProviderBase;
+use Symfony\Component\DependencyInjection\Reference;
+
+/**
+ * Modifies container services for testing.
+ */
+class PackageManagerTestValidationServiceProvider extends ServiceProviderBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public function alter(ContainerBuilder $container) {
+    parent::alter($container);
+
+    $service_id = 'package_manager.validator.staged_database_updates';
+    if ($container->hasDefinition($service_id)) {
+      $container->getDefinition($service_id)
+        ->setClass(StagedDatabaseUpdateValidator::class)
+        ->addMethodCall('setState', [
+          new Reference('state'),
+        ]);
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php
new file mode 100644
index 000000000000..8ee4bc71b6d7
--- /dev/null
+++ b/core/modules/package_manager/tests/modules/package_manager_test_validation/src/StagedDatabaseUpdateValidator.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\package_manager_test_validation;
+
+use Drupal\package_manager\Validator\StagedDBUpdateValidator as BaseValidator;
+use Drupal\Core\Extension\Extension;
+use Drupal\Core\State\StateInterface;
+use Drupal\package_manager\Stage;
+
+/**
+ * Allows tests to dictate which extensions have staged database updates.
+ */
+class StagedDatabaseUpdateValidator extends BaseValidator {
+
+  /**
+   * The state service.
+   *
+   * @var \Drupal\Core\State\StateInterface
+   */
+  private $state;
+
+  /**
+   * Sets the state service dependency.
+   *
+   * @param \Drupal\Core\State\StateInterface $state
+   *   The state service.
+   */
+  public function setState(StateInterface $state): void {
+    $this->state = $state;
+  }
+
+  /**
+   * Sets the names of the extensions which should have staged database updates.
+   *
+   * @param string[]|null $extensions
+   *   The machine names of the extensions which should say they have staged
+   *   database updates, or NULL to defer to the parent class.
+   */
+  public static function setExtensionsWithUpdates(?array $extensions): void {
+    \Drupal::state()->set(static::class, $extensions);
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function hasStagedUpdates(Stage $stage, Extension $extension): bool {
+    $extensions = $this->state->get(static::class);
+    if (isset($extensions)) {
+      return in_array($extension->getName(), $extensions, TRUE);
+    }
+    return parent::hasStagedUpdates($stage, $extension);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Build/PackageInstallTest.php b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php
new file mode 100644
index 000000000000..46b2c6289289
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Build/PackageInstallTest.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Build;
+
+/**
+ * Tests installing packages in a staging area.
+ *
+ * @group package_manager
+ */
+class PackageInstallTest extends TemplateProjectTestBase {
+
+  /**
+   * Tests installing packages in a staging area.
+   */
+  public function testPackageInstall(): void {
+    $this->createTestProject('RecommendedProject');
+
+    $this->setReleaseMetadata([
+      'alpha' => __DIR__ . '/../../fixtures/release-history/alpha.1.1.0.xml',
+    ]);
+    $this->addRepository('alpha', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/alpha/1.0.0'));
+
+    // Use the API endpoint to create a stage and install alpha 1.0.0. We ask
+    // the API to return the contents of composer.json file of installed module,
+    // so we can assert that the module was installed with the expected version.
+    // @see \Drupal\package_manager_test_api\ApiController::run()
+    $query = http_build_query([
+      'runtime' => [
+        'drupal/alpha:1.0.0',
+      ],
+      'files_to_return' => [
+        'web/modules/contrib/alpha/composer.json',
+      ],
+    ]);
+    $this->visit("/package-manager-test-api?$query");
+    $mink = $this->getMink();
+    $mink->assertSession()->statusCodeEquals(200);
+
+    $file_contents = $mink->getSession()->getPage()->getContent();
+    $file_contents = json_decode($file_contents, TRUE);
+
+    $this->assertArrayHasKey('web/modules/contrib/alpha/composer.json', $file_contents);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php
index 75c03353b95c..6427cc943a6e 100644
--- a/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php
+++ b/core/modules/package_manager/tests/src/Build/PackageUpdateTest.php
@@ -15,19 +15,20 @@ class PackageUpdateTest extends TemplateProjectTestBase {
   public function testPackageUpdate(): void {
     $this->createTestProject('RecommendedProject');
 
-    $this->addRepository('alpha', __DIR__ . '/../../fixtures/alpha/1.0.0');
-    $this->addRepository('updated_module', __DIR__ . '/../../fixtures/updated_module/1.0.0');
+    $this->addRepository('alpha', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/alpha/1.0.0'));
+    $this->addRepository('updated_module', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/updated_module/1.0.0'));
+    $this->setReleaseMetadata([
+      'updated_module' => __DIR__ . '/../../fixtures/release-history/updated_module.1.1.0.xml',
+    ]);
     $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha drupal/updated_module --update-with-all-dependencies', 'project');
 
-    $this->installQuickStart('minimal');
-    $this->formLogin($this->adminUsername, $this->adminPassword);
     // The updated_module provides actual Drupal-facing functionality that we're
     // testing as well, so we need to install it.
-    $this->installModules(['package_manager_test_api', 'updated_module']);
+    $this->installModules(['updated_module']);
 
     // Change both modules' upstream version.
-    $this->addRepository('alpha', __DIR__ . '/../../fixtures/alpha/1.1.0');
-    $this->addRepository('updated_module', __DIR__ . '/../../fixtures/updated_module/1.1.0');
+    $this->addRepository('alpha', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/alpha/1.1.0'));
+    $this->addRepository('updated_module', $this->copyFixtureToTempDirectory(__DIR__ . '/../../fixtures/updated_module/1.1.0'));
 
     // Use the API endpoint to create a stage and update updated_module to
     // 1.1.0. Even though both modules have version 1.1.0 available, only
@@ -66,60 +67,6 @@ public function testPackageUpdate(): void {
     // created this file.
     // @see \Drupal\updated_module\PostApplySubscriber::postApply()
     $this->assertSame('Bravo!', $file_contents['bravo.txt']);
-
-    $results = json_decode($file_contents['system_changes.json'], TRUE);
-    $expected_pre_apply_results = [
-      'return value of existing global function' => 'pre-update-value',
-      'new global function exists' => 'not exists',
-      'path of changed route' => '/updated-module/changed/pre',
-      'deleted route exists' => 'exists',
-      'new route exists' => 'not exists',
-      'title of changed permission' => 'permission',
-      'deleted permission exists' => 'exists',
-      'new permission exists' => 'not exists',
-    ];
-    $this->assertSame($expected_pre_apply_results, $results['pre']);
-
-    $expected_post_apply_results = [
-      // Existing functions will still use the pre-update version.
-      'return value of existing global function' => 'pre-update-value',
-      // New functions that were added in .module files will not be available.
-      'new global function exists' => 'not exists',
-      // Definitions for existing routes should be updated.
-      'path of changed route' => '/updated-module/changed/post',
-      // Routes deleted from the updated module should not be available.
-      'deleted route exists' => 'not exists',
-      // Routes added to the updated module should be available.
-      'new route exists' => 'exists',
-      // Title of the existing permission should be changed.
-      'title of changed permission' => 'changed permission',
-      // Permissions deleted from the updated module should not be available.
-      'deleted permission exists' => 'not exists',
-      // Permissions added to the updated module should be available.
-      'new permission exists' => 'exists',
-    ];
-    $this->assertSame($expected_post_apply_results, $results['post']);
-  }
-
-  /**
-   * Adds a path repository to the test site.
-   *
-   * @param string $name
-   *   An arbitrary name for the repository.
-   * @param string $path
-   *   The path of the repository. Must exist in the file system.
-   */
-  private function addRepository(string $name, string $path): void {
-    $this->assertDirectoryExists($path);
-
-    $repository = json_encode([
-      'type' => 'path',
-      'url' => $path,
-      'options' => [
-        'symlink' => FALSE,
-      ],
-    ]);
-    $this->runComposer("composer config repo.$name '$repository'", 'project');
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
index d1ecd628c0d5..7d00448b7dd2 100644
--- a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
+++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
@@ -4,6 +4,8 @@
 
 use Drupal\BuildTests\QuickStart\QuickStartTestBase;
 use Drupal\Composer\Composer;
+use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
+use Drupal\Tests\RandomGeneratorTrait;
 
 /**
  * Base class for tests which create a test site from a core project template.
@@ -15,6 +17,9 @@
  */
 abstract class TemplateProjectTestBase extends QuickStartTestBase {
 
+  use FixtureUtilityTrait;
+  use RandomGeneratorTrait;
+
   /**
    * The web root of the test site, relative to the workspace directory.
    *
@@ -22,11 +27,28 @@ abstract class TemplateProjectTestBase extends QuickStartTestBase {
    */
   private $webRoot;
 
+  /**
+   * 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();
+  }
+
   /**
    * Data provider for tests which use all of the core project templates.
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return string[][]
+   *   The test cases.
    */
   public function providerTemplate(): array {
     return [
@@ -113,6 +135,42 @@ protected function getCorePackages(): array {
     return $packages;
   }
 
+  /**
+   * Adds a path repository to the test site.
+   *
+   * @param string $name
+   *   An arbitrary name for the repository.
+   * @param string $path
+   *   The path of the repository. Must exist in the file system.
+   * @param string $working_directory
+   *   (optional) The Composer working directory. Defaults to 'project'.
+   */
+  protected function addRepository(string $name, string $path, $working_directory = 'project'): void {
+    $this->assertDirectoryExists($path);
+
+    $repository = json_encode([
+      'type' => 'path',
+      'url' => $path,
+      'options' => [
+        'symlink' => FALSE,
+      ],
+    ], JSON_UNESCAPED_SLASHES);
+    $this->runComposer("composer config repo.$name '$repository'", $working_directory);
+  }
+
+  /**
+   * Prepares the test site to serve an XML feed of available release metadata.
+   *
+   * @param array $xml_map
+   *   The update XML map, as used by update_test.settings.
+   *
+   * @see \Drupal\auto_updates_test\TestController::metadata()
+   */
+  protected function setReleaseMetadata(array $xml_map): void {
+    $xml_map = var_export($xml_map, TRUE);
+    $this->writeSettings("\$config['update_test.settings']['xml_map'] = $xml_map;");
+  }
+
   /**
    * Creates a test project from a given template and installs Drupal.
    *
@@ -127,6 +185,9 @@ protected function createTestProject(string $template): void {
     $workspace_dir = $this->getWorkspaceDirectory();
     $template_dir = "composer/Template/$template";
 
+    // Allow pre-release versions of dependencies.
+    $this->runComposer('composer config minimum-stability dev', $template_dir);
+
     // Remove the packages.drupal.org entry (and any other custom repository)
     // from the template's repositories section. We have no reliable way of
     // knowing the repositories' names in advance, so we get that information
@@ -142,15 +203,7 @@ protected function createTestProject(string $template): void {
     // symlinking, we need to pass the JSON representations of the repositories
     // to `composer config`.
     foreach ($this->getCorePackages() as $name => $path) {
-      $repository = [
-        'type' => 'path',
-        'url' => $path,
-        'options' => [
-          'symlink' => FALSE,
-        ],
-      ];
-      $repository = json_encode($repository, JSON_UNESCAPED_SLASHES);
-      $this->runComposer("composer config repo.$name '$repository'", $template_dir);
+      $this->addRepository($name, $path, $template_dir);
     }
 
     // Add a local Composer repository with all third-party dependencies.
@@ -196,19 +249,40 @@ protected function createTestProject(string $template): void {
     // web root with confidence.
     $this->webRoot = 'project/' . $this->runComposer('composer config extra.drupal-scaffold.locations.web-root', 'project');
 
+    // Install Drupal.
+    $this->installQuickStart('minimal');
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+
+    // When checking for updates, we need to be able to make sub-requests, but
+    // the built-in PHP server is single-threaded. Therefore, open a second
+    // server instance on another port, which will serve the metadata about
+    // available updates.
+    $port = $this->findAvailablePort();
+    $this->metadataServer = $this->instantiateServer($port);
+    $code = <<<END
+\$config['auto_updates.settings']['cron_port'] = $port;
+\$config['update.settings']['fetch']['url'] = 'http://localhost:$port/test-release-history';
+END;
+    $this->writeSettings($code);
+
+    // Install helpful modules.
+    $this->installModules([
+      'package_manager_test_api',
+      'package_manager_test_release_history',
+    ]);
   }
 
   /**
    * Creates a Composer repository for all installed third-party dependencies.
    *
-   * @return array
+   * @return string[][]
    *   The data that should be written to the repository file.
    */
   protected function createVendorRepository(): array {
     $packages = [];
     $drupal_root = $this->getDrupalRoot();
 
-    foreach ($this->getPackagesFromLockFile() as $package) {
+    foreach ($this->getInstalledPackages() as $package) {
       $name = $package['name'];
       $path = "$drupal_root/vendor/$name";
 
@@ -244,23 +318,19 @@ protected function createVendorRepository(): array {
   }
 
   /**
-   * Returns all package information from the lock file.
+   * Returns all package information from the `installed.json` file.
    *
-   * @return array[]
-   *   All package data from the lock file.
+   * @return mixed[][]
+   *   All package data from the `installed.json` file.
    */
-  private function getPackagesFromLockFile(): array {
-    $lock = $this->getDrupalRoot() . '/composer.lock';
-    $this->assertFileExists($lock);
+  private function getInstalledPackages(): array {
+    $installed = $this->getDrupalRoot() . '/vendor/composer/installed.json';
+    $this->assertFileExists($installed);
 
-    $lock = file_get_contents($lock);
-    $lock = json_decode($lock, TRUE, JSON_THROW_ON_ERROR);
+    $installed = file_get_contents($installed);
+    $installed = json_decode($installed, TRUE, JSON_THROW_ON_ERROR);
 
-    $lock += [
-      'packages' => [],
-      'packages-dev' => [],
-    ];
-    return array_merge($lock['packages'], $lock['packages-dev']);
+    return $installed['packages'];
   }
 
   /**
@@ -341,4 +411,19 @@ protected function installModules(array $modules): void {
     }
   }
 
+  /**
+   * Copies a fixture directory to a temporary directory and returns its path.
+   *
+   * @param string $fixture_directory
+   *   The fixture directory.
+   *
+   * @return string
+   *   The temporary directory.
+   */
+  protected function copyFixtureToTempDirectory(string $fixture_directory): string {
+    $temp_directory = $this->getWorkspaceDirectory() . '/fixtures_temp_' . $this->randomMachineName(20);
+    static::copyFixtureFilesTo($fixture_directory, $temp_directory);
+    return $temp_directory;
+  }
+
 }
diff --git a/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php b/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php
new file mode 100644
index 000000000000..578b1cf85c1e
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Functional/FailureMarkerRequirementTest.php
@@ -0,0 +1,53 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Functional;
+
+use Drupal\package_manager\Stage;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests that Package Manager's requirements check for the failure marker.
+ *
+ * @group package_manager
+ */
+class FailureMarkerRequirementTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'package_manager',
+    'package_manager_bypass',
+  ];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected $defaultTheme = 'stark';
+
+  /**
+   * Tests that error is shown if failure marker already exists.
+   */
+  public function testFailureMarkerExists() {
+    $account = $this->drupalCreateUser([
+      'administer site configuration',
+    ]);
+    $this->drupalLogin($account);
+
+    $this->container->get('package_manager.path_locator')
+      ->setPaths($this->publicFilesDirectory, NULL, NULL, NULL);
+
+    $failure_marker = $this->container->get('package_manager.failure_marker');
+    $message = 'Package Manager is here to wreck your day.';
+    $failure_marker->write($this->createMock(Stage::class), $message);
+    $path = $failure_marker->getPath();
+    $this->assertFileExists($path);
+    $this->assertStringStartsWith($this->publicFilesDirectory, $path);
+
+    $this->drupalGet('/admin/reports/status');
+    $assert_session = $this->assertSession();
+    $assert_session->pageTextContains('Failed update detected');
+    $assert_session->pageTextContains($message);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
index 9b8236a786fd..5bbf64359369 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerExecutableValidatorTest.php
@@ -2,11 +2,13 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Url;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Validator\ComposerExecutableValidator;
 use Drupal\package_manager\ValidationResult;
-use PhpTuf\ComposerStager\Exception\IOException;
-use PhpTuf\ComposerStager\Infrastructure\Process\ExecutableFinderInterface;
+use PhpTuf\ComposerStager\Domain\Exception\IOException;
+use PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface;
 use Prophecy\Argument;
 
 /**
@@ -16,32 +18,59 @@
  */
 class ComposerExecutableValidatorTest extends PackageManagerKernelTestBase {
 
+  /**
+   * The mocked Composer runner.
+   *
+   * @var \Prophecy\Prophecy\ObjectProphecy|\PhpTuf\ComposerStager\Domain\Service\ProcessRunner\ComposerRunnerInterface
+   */
+  private $composerRunner;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    $this->composerRunner = $this->prophesize(ComposerRunnerInterface::class);
+    parent::setUp();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('package_manager.validator.composer_executable')
+      ->setArgument('$composer', $this->composerRunner->reveal());
+  }
+
   /**
    * Tests that an error is raised if the Composer executable isn't found.
    */
   public function testErrorIfComposerNotFound(): void {
     $exception = new IOException("This is your regularly scheduled error.");
 
-    // The executable finder throws an exception if it can't find the requested
-    // executable.
-    $exec_finder = $this->prophesize(ExecutableFinderInterface::class);
-    $exec_finder->find('composer')
+    // If the Composer executable isn't found, the executable finder will throw
+    // an exception, which will not be caught by the Composer runner.
+    $this->composerRunner->run(Argument::cetera())
       ->willThrow($exception)
       ->shouldBeCalled();
-    $this->container->set('package_manager.executable_finder', $exec_finder->reveal());
 
     // The validator should translate that exception into an error.
     $error = ValidationResult::createError([
       $exception->getMessage(),
     ]);
+    $this->assertStatusCheckResults([$error]);
     $this->assertResults([$error], PreCreateEvent::class);
+
+    $this->enableModules(['help']);
+    $this->assertResultsWithHelp([$error], PreCreateEvent::class);
   }
 
   /**
-   * Data provider for ::testComposerVersionValidation().
+   * Data provider for testComposerVersionValidation().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerComposerVersionValidation(): array {
     // Invalid or undetectable Composer versions will always produce the same
@@ -60,43 +89,43 @@ public function providerComposerVersionValidation(): array {
     };
 
     return [
-      [
+      'Minimum version' => [
         ComposerExecutableValidator::MINIMUM_COMPOSER_VERSION,
         [],
       ],
-      [
+      '2.1.6' => [
         '2.1.6',
         [$unsupported_version('2.1.6')],
       ],
-      [
+      '1.10.22' => [
         '1.10.22',
         [$unsupported_version('1.10.22')],
       ],
-      [
+      '1.7.3' => [
         '1.7.3',
         [$unsupported_version('1.7.3')],
       ],
-      [
+      '2.0.0-alpha3' => [
         '2.0.0-alpha3',
         [$unsupported_version('2.0.0-alpha3')],
       ],
-      [
+      '2.1.0-RC1' => [
         '2.1.0-RC1',
         [$unsupported_version('2.1.0-RC1')],
       ],
-      [
+      '1.0.0-RC' => [
         '1.0.0-RC',
         [$unsupported_version('1.0.0-RC')],
       ],
-      [
+      '1.0.0-beta1' => [
         '1.0.0-beta1',
         [$unsupported_version('1.0.0-beta1')],
       ],
-      [
+      '1.9-dev' => [
         '1.9-dev',
         [$invalid_version],
       ],
-      [
+      'Invalid version' => [
         '@package_version@',
         [$invalid_version],
       ],
@@ -117,10 +146,7 @@ public function testComposerVersionValidation(string $reported_version, array $e
     // Mock the output of `composer --version`, will be passed to the validator,
     // which is itself a callback function that gets called repeatedly as
     // Composer produces output.
-    /** @var \PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface|\Prophecy\Prophecy\ObjectProphecy $runner */
-    $runner = $this->prophesize('\PhpTuf\ComposerStager\Domain\Process\Runner\ComposerRunnerInterface');
-
-    $runner->run(['--version'], Argument::type(ComposerExecutableValidator::class))
+    $this->composerRunner->run(['--version'], Argument::type(ComposerExecutableValidator::class))
       // Whatever is passed to ::run() will be passed to this mock callback in
       // $arguments, and we know exactly what that will contain: an array of
       // command arguments for Composer, and the validator object.
@@ -132,11 +158,41 @@ public function testComposerVersionValidation(string $reported_version, array $e
         // recognized, supported version number out of this output.
         $validator($validator::OUT, "Composer version $reported_version");
       });
-    $this->container->set('package_manager.composer_runner', $runner->reveal());
 
     // If the validator can't find a recognized, supported version of Composer,
     // it should produce errors.
+    $this->assertStatusCheckResults($expected_results);
     $this->assertResults($expected_results, PreCreateEvent::class);
+
+    $this->enableModules(['help']);
+    $this->assertResultsWithHelp($expected_results, PreCreateEvent::class);
+  }
+
+  /**
+   * Asserts that a set of validation results link to the Package Manager help.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param string|null $event_class
+   *   (optional) The class of the event which should return the results. Must
+   *   be passed if $expected_results is not empty.
+   */
+  private function assertResultsWithHelp(array $expected_results, string $event_class = NULL): void {
+    $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
+      ->setOption('fragment', 'package-manager-faq-composer-not-found')
+      ->toString();
+
+    // Reformat the provided results so that they all have the link to the
+    // online documentation appended to them.
+    $map = function (string $message) use ($url): string {
+      return $message . ' See <a href="' . $url . '">the help page</a> for information on how to configure the path to Composer.';
+    };
+    foreach ($expected_results as $index => $result) {
+      $messages = array_map($map, $result->getMessages());
+      $expected_results[$index] = ValidationResult::createError($messages);
+    }
+    $this->assertStatusCheckResults($expected_results);
+    $this->assertResults($expected_results, $event_class);
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php
new file mode 100644
index 000000000000..482f0a167d92
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerPatchesValidatorTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Composer\Json\JsonFile;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * @covers \Drupal\package_manager\Validator\ComposerPatchesValidator
+ *
+ * @group package_manager
+ */
+class ComposerPatchesValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Tests that the patcher configuration is validated during pre-create.
+   */
+  public function testError(): void {
+    // Simulate an active directory where the patcher is installed, but there's
+    // no composer-exit-on-patch-failure flag.
+    $dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    // Simulate that the patcher is installed in the active directory.
+    $file = new JsonFile($dir . '/vendor/composer/installed.json');
+    $this->assertTrue($file->exists());
+    $data = $file->read();
+    $data['packages'][] = [
+      'name' => 'cweagans/composer-patches',
+      'version' => '1.0.0',
+    ];
+    $file->write($data);
+
+    $error = ValidationResult::createError([
+      'The <code>cweagans/composer-patches</code> plugin is installed, but the <code>composer-exit-on-patch-failure</code> key is not set to <code>true</code> in the <code>extra</code> section of composer.json.',
+    ]);
+    $this->assertStatusCheckResults([$error]);
+    $this->assertResults([$error], PreCreateEvent::class);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
index 071bbfc823e4..a2b60d2d32aa 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerSettingsValidatorTest.php
@@ -3,7 +3,7 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\Component\Serialization\Json;
-use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\ValidationResult;
 
 /**
@@ -14,10 +14,10 @@
 class ComposerSettingsValidatorTest extends PackageManagerKernelTestBase {
 
   /**
-   * Data provider for ::testSecureHttpValidation().
+   * Data provider for testSecureHttpValidation().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerSecureHttpValidation(): array {
     $error = ValidationResult::createError([
@@ -59,18 +59,11 @@ public function providerSecureHttpValidation(): array {
    * @dataProvider providerSecureHttpValidation
    */
   public function testSecureHttpValidation(string $contents, array $expected_results): void {
-    $this->createTestProject();
     $active_dir = $this->container->get('package_manager.path_locator')
       ->getProjectRoot();
     file_put_contents("$active_dir/composer.json", $contents);
-
-    try {
-      $this->createStage()->create();
-      $this->assertSame([], $expected_results);
-    }
-    catch (StageValidationException $e) {
-      $this->assertValidationResultsEqual($expected_results, $e->getResults());
-    }
+    $this->assertStatusCheckResults($expected_results);
+    $this->assertResults($expected_results, PreCreateEvent::class);
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerUtilityTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerUtilityTest.php
index 3c5816ef8a42..5433200767ef 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerUtilityTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerUtilityTest.php
@@ -4,6 +4,7 @@
 
 use Drupal\KernelTests\KernelTestBase;
 use Drupal\package_manager\ComposerUtility;
+use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
 use org\bovigo\vfs\vfsStream;
 
 /**
@@ -13,10 +14,23 @@
  */
 class ComposerUtilityTest extends KernelTestBase {
 
+  use FixtureUtilityTrait;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = ['package_manager', 'update'];
+
   /**
    * {@inheritdoc}
    */
-  protected static $modules = ['package_manager'];
+  protected function setUp(): void {
+    parent::setUp();
+
+    $fixture = vfsStream::newDirectory('fixture');
+    $this->vfsRoot->addChild($fixture);
+    static::copyFixtureFilesTo(__DIR__ . '/../../fixtures/project_package_conversion', $fixture->url());
+  }
 
   /**
    * Tests that ComposerUtility disables automatic creation of .htaccess files.
@@ -30,65 +44,103 @@ public function testHtaccessProtectionDisabled(): void {
   }
 
   /**
-   * Data provider for ::testCorePackagesFromLockFile().
+   * @covers ::getProjectForPackage
+   *
+   * @param string $package
+   *   The package name.
+   * @param string|null $expected_project
+   *   The expected project if any, otherwise NULL.
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @dataProvider providerGetProjectForPackage
    */
-  public function providerCorePackagesFromLockFile(): array {
-    $fixtures_dir = __DIR__ . '/../../fixtures';
+  public function testGetProjectForPackage(string $package, ?string $expected_project): void {
+    $dir = $this->vfsRoot->getChild('fixture')->url();
+    $this->assertSame($expected_project, ComposerUtility::createForDirectory($dir)->getProjectForPackage($package));
+  }
 
+  /**
+   * Data provider for ::testGetProjectForPackage().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerGetProjectForPackage(): array {
     return [
-      'distro with drupal/core-recommended' => [
-        // This fixture's lock file mentions drupal/core, which is considered a
-        // canonical core package, but it will be ignored in favor of
-        // drupal/core-recommended, which always requires drupal/core as one of
-        // its direct dependencies.
-        "$fixtures_dir/distro_core_recommended",
-        ['drupal/core-recommended'],
+      'package and project match' => [
+        'drupal/package_project_match',
+        'package_project_match',
+      ],
+      'package and project do not match' => [
+        'drupal/not_match_package',
+        'not_match_project',
+      ],
+      'vendor is not drupal' => [
+        'non_drupal/other_project',
+        NULL,
+      ],
+      'missing package' => [
+        'drupal/missing',
+        NULL,
       ],
-      'distro with drupal/core' => [
-        "$fixtures_dir/distro_core",
-        ['drupal/core'],
+      'nested_no_match' => [
+        'drupal/nested_no_match_package',
+        'nested_no_match_project',
+      ],
+      'unsupported package type' => [
+        'drupal/custom_module',
+        NULL,
       ],
     ];
   }
 
   /**
-   * Tests that required core packages are found by scanning the lock file.
-   *
-   * @param string $dir
-   *   The path of the fake site fixture.
-   * @param string[] $expected_packages
-   *   The names of the core packages which should be detected.
+   * @covers ::getPackageForProject
    *
-   * @covers ::getCorePackages
+   * @param string $project
+   *   The project name.
+   * @param string|null $expected_package
+   *   The expected package if any, otherwise NULL.
    *
-   * @dataProvider providerCorePackagesFromLockFile
+   * @dataProvider providerGetPackageForProject
    */
-  public function testCorePackagesFromLockFile(string $dir, array $expected_packages): void {
-    $packages = ComposerUtility::createForDirectory($dir)
-      ->getCorePackages();
-    $this->assertSame($expected_packages, array_keys($packages));
+  public function testGetPackageForProject(string $project, ?string $expected_package): void {
+    $dir = $this->vfsRoot->getChild('fixture')->url();
+    $this->assertSame($expected_package, ComposerUtility::createForDirectory($dir)->getPackageForProject($project));
   }
 
   /**
-   * @covers ::getPackagesNotIn
-   * @covers ::getPackagesWithDifferentVersionsIn
+   * Data provider for ::testGetPackageForProject().
+   *
+   * @return mixed[][]
+   *   The test cases.
    */
-  public function testPackageComparison(): void {
-    $fixture_dir = __DIR__ . '/../../fixtures/packages_comparison';
-    $active = ComposerUtility::createForDirectory($fixture_dir . '/active');
-    $staged = ComposerUtility::createForDirectory($fixture_dir . '/stage');
-
-    $added = $staged->getPackagesNotIn($active);
-    $this->assertSame(['drupal/added'], array_keys($added));
-
-    $removed = $active->getPackagesNotIn($staged);
-    $this->assertSame(['drupal/removed'], array_keys($removed));
-
-    $updated = $active->getPackagesWithDifferentVersionsIn($staged);
-    $this->assertSame(['drupal/updated'], array_keys($updated));
+  public function providerGetPackageForProject(): array {
+    return [
+      'package and project match' => [
+        'package_project_match',
+        'drupal/package_project_match',
+      ],
+      'package and project do not match' => [
+        'not_match_project',
+        'drupal/not_match_package',
+      ],
+      'package and project match + wrong installed path' => [
+        'not_match_path_project',
+        NULL,
+      ],
+      'vendor is not drupal' => [
+        'other_project',
+        NULL,
+      ],
+      'missing package' => [
+        'missing',
+        NULL,
+      ],
+      'nested_no_match' => [
+        'nested_no_match_project',
+        'drupal/nested_no_match_package',
+      ],
+    ];
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/CorePackageManifestTest.php b/core/modules/package_manager/tests/src/Kernel/CorePackageManifestTest.php
index 5d82264e8afc..f6b8e4ed299c 100644
--- a/core/modules/package_manager/tests/src/Kernel/CorePackageManifestTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/CorePackageManifestTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\Component\Serialization\Json;
+use Drupal\Component\Serialization\Yaml;
 use Drupal\KernelTests\KernelTestBase;
 use Symfony\Component\Finder\Finder;
 
@@ -44,8 +45,8 @@ public function testCorePackagesMatchManifest(): void {
     sort($packages);
 
     // Ensure that the packages we detected matches the hard-coded list we ship.
-    $manifest = file_get_contents(__DIR__ . '/../../../core_packages.json');
-    $manifest = Json::decode($manifest);
+    $manifest = file_get_contents(__DIR__ . '/../../../core_packages.yml');
+    $manifest = Yaml::decode($manifest);
     $this->assertSame($packages, $manifest);
   }
 
diff --git a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
index a972c7e8a417..6392fa585767 100644
--- a/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/DiskSpaceValidatorTest.php
@@ -14,13 +14,13 @@
 class DiskSpaceValidatorTest extends PackageManagerKernelTestBase {
 
   /**
-   * Data provider for ::testDiskSpaceValidation().
+   * Data provider for testDiskSpaceValidation().
    *
    * @return mixed[][]
-   *   Sets of arguments to pass to the test method.
+   *   The test cases.
    */
   public function providerDiskSpaceValidation(): array {
-    // These will be defined by ::createTestProject().
+    // These are defined by ::createVirtualProject().
     $root = 'vfs://root/active';
     $vendor = "$root/vendor";
 
@@ -143,13 +143,12 @@ public function providerDiskSpaceValidation(): array {
    * @dataProvider providerDiskSpaceValidation
    */
   public function testDiskSpaceValidation(bool $shared_disk, array $free_space, array $expected_results): void {
-    $this->createTestProject();
-
     /** @var \Drupal\Tests\package_manager\Kernel\TestDiskSpaceValidator $validator */
     $validator = $this->container->get('package_manager.validator.disk_space');
     $validator->sharedDisk = $shared_disk;
     $validator->freeSpace = array_map([Bytes::class, 'toNumber'], $free_space);
 
+    $this->assertStatusCheckResults($expected_results);
     $this->assertResults($expected_results, PreCreateEvent::class);
   }
 
diff --git a/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php
new file mode 100644
index 000000000000..b8d9a743c501
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/DuplicateInfoFileValidatorTest.php
@@ -0,0 +1,240 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
+use Symfony\Component\Filesystem\Filesystem;
+
+/**
+ * @covers \Drupal\package_manager\Validator\DuplicateInfoFileValidator
+ *
+ * @group package_manager
+ */
+class DuplicateInfoFileValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Data provider for testDuplicateInfoFilesInStage.
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerDuplicateInfoFilesInStage(): array {
+    return [
+      'Duplicate info.yml files in stage' => [
+        [
+          '/module.info.yml',
+        ],
+        [
+          '/module.info.yml',
+          '/modules/module.info.yml',
+        ],
+        [
+          ValidationResult::createError([
+            'The staging directory has 2 instances of module.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.',
+          ]),
+        ],
+      ],
+      // Duplicate files in stage but having different extension which we don't
+      // care about.
+      'Duplicate info files in stage' => [
+        [
+          '/my_file.info',
+        ],
+        [
+          '/my_file.info',
+          '/modules/my_file.info',
+        ],
+        [],
+      ],
+      'Duplicate info.yml files in stage with one file in tests/fixtures folder' => [
+        [
+          '/tests/fixtures/module.info.yml',
+        ],
+        [
+          '/tests/fixtures/module.info.yml',
+          '/modules/module.info.yml',
+        ],
+        [],
+      ],
+      'Duplicate info.yml files in stage with one file in tests/modules folder' => [
+        [
+          '/tests/modules/module.info.yml',
+        ],
+        [
+          '/tests/modules/module.info.yml',
+          '/modules/module.info.yml',
+        ],
+        [],
+      ],
+      'Duplicate info.yml files in stage with one file in tests/themes folder' => [
+        [
+          '/tests/themes/theme.info.yml',
+        ],
+        [
+          '/tests/themes/theme.info.yml',
+          '/themes/theme.info.yml',
+        ],
+        [],
+      ],
+      'Duplicate info.yml files in stage with one file in tests/profiles folder' => [
+        [
+          '/tests/profiles/profile.info.yml',
+        ],
+        [
+          '/tests/profiles/profile.info.yml',
+          '/profiles/profile.info.yml',
+        ],
+        [],
+      ],
+      'Duplicate info.yml files in stage not present in active' => [
+        [],
+        [
+          '/module.info.yml',
+          '/modules/module.info.yml',
+        ],
+        [
+          ValidationResult::createError([
+            'The staging directory has 2 instances of module.info.yml. This likely indicates that a duplicate extension was installed.',
+          ]),
+        ],
+      ],
+      'Duplicate info.yml files in active' => [
+        [
+          '/module.info.yml',
+          '/modules/module.info.yml',
+        ],
+        [
+          '/module.info.yml',
+        ],
+        [],
+      ],
+      'Same number of info.yml files in active and stage' => [
+        [
+          '/module.info.yml',
+          '/modules/module.info.yml',
+        ],
+        [
+          '/module.info.yml',
+          '/modules/module.info.yml',
+        ],
+        [],
+      ],
+      'Multiple duplicate info.yml files in stage' => [
+        [
+          '/module1.info.yml',
+          '/module2.info.yml',
+        ],
+        [
+          '/module1.info.yml',
+          '/modules/module1.info.yml',
+          '/module2.info.yml',
+          '/modules/module2.info.yml',
+          '/module2/module2.info.yml',
+        ],
+        [
+          ValidationResult::createError([
+            'The staging directory has 2 instances of module1.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.',
+          ]),
+          ValidationResult::createError([
+            'The staging directory has 3 instances of module2.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.',
+          ]),
+        ],
+      ],
+      'Multiple duplicate info.yml files in stage not present in active' => [
+        [],
+        [
+          '/module1.info.yml',
+          '/modules/module1.info.yml',
+          '/module2.info.yml',
+          '/modules/module2.info.yml',
+          '/module2/module2.info.yml',
+        ],
+        [
+          ValidationResult::createError([
+            'The staging directory has 2 instances of module1.info.yml. This likely indicates that a duplicate extension was installed.',
+          ]),
+          ValidationResult::createError([
+            'The staging directory has 3 instances of module2.info.yml. This likely indicates that a duplicate extension was installed.',
+          ]),
+        ],
+      ],
+      'Multiple duplicate info.yml files in stage with one info.yml file not present in active' => [
+        [
+          '/module1.info.yml',
+        ],
+        [
+          '/module1.info.yml',
+          '/modules/module1.info.yml',
+          '/module2.info.yml',
+          '/modules/module2.info.yml',
+          '/module2/module2.info.yml',
+        ],
+        [
+          ValidationResult::createError([
+            'The staging directory has 2 instances of module1.info.yml as compared to 1 in the active directory. This likely indicates that a duplicate extension was installed.',
+          ]),
+          ValidationResult::createError([
+            'The staging directory has 3 instances of module2.info.yml. This likely indicates that a duplicate extension was installed.',
+          ]),
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests that duplicate info.yml in stage raise an error.
+   *
+   * @param string[] $active_info_files
+   *   An array of info.yml files in active directory.
+   * @param string[] $stage_info_files
+   *   An array of info.yml files in stage directory.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   An array of expected results.
+   *
+   * @dataProvider providerDuplicateInfoFilesInStage
+   */
+  public function testDuplicateInfoFilesInStage(array $active_info_files, array $stage_info_files, array $expected_results): void {
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['composer/semver:^3']);
+
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+    $stage_dir = $stage->getStageDirectory();
+    foreach ($active_info_files as $active_info_file) {
+      $this->createFileAtPath($active_dir, $active_info_file);
+    }
+    foreach ($stage_info_files as $stage_info_file) {
+      $this->createFileAtPath($stage_dir, $stage_info_file);
+    }
+    try {
+      $stage->apply();
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertNotEmpty($expected_results);
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+  /**
+   * Creates the file at the root directory.
+   *
+   * @param string $root_directory
+   *   The base directory in which the file will be created.
+   * @param string $file_path
+   *   The path of the file to create.
+   */
+  private function createFileAtPath(string $root_directory, string $file_path): void {
+    $parts = explode(DIRECTORY_SEPARATOR, $file_path);
+    $filename = array_pop($parts);
+    $file_dir = str_replace($filename, '', $file_path);
+    $fs = new Filesystem();
+    if (!file_exists($file_dir)) {
+      $fs->mkdir($root_directory . $file_dir);
+    }
+    file_put_contents($root_directory . $file_path, ' ');
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php b/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
deleted file mode 100644
index eecde71a0a25..000000000000
--- a/core/modules/package_manager/tests/src/Kernel/ExcludedPathsTest.php
+++ /dev/null
@@ -1,243 +0,0 @@
-<?php
-
-namespace Drupal\Tests\package_manager\Kernel;
-
-use Drupal\Core\Database\Connection;
-use Drupal\Core\DependencyInjection\ContainerBuilder;
-use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber;
-
-/**
- * @covers \Drupal\package_manager\EventSubscriber\ExcludedPathsSubscriber
- *
- * @group package_manager
- */
-class ExcludedPathsTest extends PackageManagerKernelTestBase {
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    // In this test, we want to disable the lock file validator because, even
-    // though both the active and stage directories will have a valid lock file,
-    // this validator will complain because they don't differ at all.
-    $this->disableValidators[] = 'package_manager.validator.lock_file';
-    parent::setUp();
-  }
-
-  /**
-   * {@inheritdoc}
-   */
-  public function register(ContainerBuilder $container) {
-    parent::register($container);
-
-    $container->getDefinition('package_manager.excluded_paths_subscriber')
-      ->setClass(TestExcludedPathsSubscriber::class);
-  }
-
-  /**
-   * Tests that certain paths are excluded from staging operations.
-   */
-  public function testExcludedPaths(): void {
-    // The private stream wrapper is only registered if this setting is set.
-    // @see \Drupal\Core\CoreServiceProvider::register()
-    $this->setSetting('file_private_path', 'private');
-    // In this test, we want to perform the actual staging operations so that we
-    // can be sure that files are staged as expected. This will also rebuild
-    // the container, enabling the private stream wrapper.
-    $this->container->get('module_installer')->uninstall([
-      'package_manager_bypass',
-    ]);
-    // Ensure we have an up-to-date container.
-    $this->container = $this->container->get('kernel')->getContainer();
-
-    $this->createTestProject();
-    $active_dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-
-    $site_path = 'sites/example.com';
-    // Ensure that we are using directories within the fake site fixture for
-    // public and private files.
-    $this->setSetting('file_public_path', "$site_path/files");
-
-    // Mock a SQLite database connection to a file in the active directory. The
-    // file should not be staged.
-    $database = $this->prophesize(Connection::class);
-    $database->driver()->willReturn('sqlite');
-    $database->getConnectionOptions()->willReturn([
-      'database' => $site_path . '/db.sqlite',
-    ]);
-
-    // Update the event subscriber's dependencies.
-    /** @var \Drupal\Tests\package_manager\Kernel\TestExcludedPathsSubscriber $subscriber */
-    $subscriber = $this->container->get('package_manager.excluded_paths_subscriber');
-    $subscriber->sitePath = $site_path;
-    $subscriber->database = $database->reveal();
-
-    $stage = $this->createStage();
-    $stage->create();
-    $stage_dir = $stage->getStageDirectory();
-
-    $ignore = [
-      'sites/simpletest',
-      'vendor/.htaccess',
-      'vendor/web.config',
-      "$site_path/files/ignore.txt",
-      'private/ignore.txt',
-      "$site_path/settings.php",
-      "$site_path/settings.local.php",
-      "$site_path/services.yml",
-      // SQLite databases and their support files should always be ignored.
-      "$site_path/db.sqlite",
-      "$site_path/db.sqlite-shm",
-      "$site_path/db.sqlite-wal",
-      // Default site-specific settings files should be ignored.
-      'sites/default/settings.php',
-      'sites/default/settings.local.php',
-      'sites/default/services.yml',
-      // No git directories should be staged.
-      '.git/ignore.txt',
-      'modules/example/.git/ignore.txt',
-    ];
-    foreach ($ignore as $path) {
-      $this->assertFileExists("$active_dir/$path");
-      $this->assertFileDoesNotExist("$stage_dir/$path");
-    }
-    // A non-excluded file in the default site directory should be staged.
-    $this->assertFileExists("$stage_dir/sites/default/stage.txt");
-    // Regular module files should be staged.
-    $this->assertFileExists("$stage_dir/modules/example/example.info.yml");
-    // Files that start with .git, but aren't actually .git, should be staged.
-    $this->assertFileExists("$stage_dir/.gitignore");
-
-    // A new file added to the staging area in an excluded directory, should not
-    // be copied to the active directory.
-    $file = "$stage_dir/sites/default/no-copy.txt";
-    touch($file);
-    $this->assertFileExists($file);
-    $stage->apply();
-    $this->assertFileDoesNotExist("$active_dir/sites/default/no-copy.txt");
-
-    // The ignored files should still be in the active directory.
-    foreach ($ignore as $path) {
-      $this->assertFileExists("$active_dir/$path");
-    }
-  }
-
-  /**
-   * Data provider for ::testSqliteDatabaseExcluded().
-   *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
-   */
-  public function providerSqliteDatabaseExcluded(): array {
-    $drupal_root = $this->getDrupalRoot();
-
-    return [
-      'relative path, in site directory' => [
-        'sites/example.com/db.sqlite',
-        [
-          'sites/example.com/db.sqlite',
-          'sites/example.com/db.sqlite-shm',
-          'sites/example.com/db.sqlite-wal',
-        ],
-      ],
-      'relative path, at root' => [
-        'db.sqlite',
-        [
-          'db.sqlite',
-          'db.sqlite-shm',
-          'db.sqlite-wal',
-        ],
-      ],
-      'absolute path, in site directory' => [
-        $drupal_root . '/sites/example.com/db.sqlite',
-        [
-          'sites/example.com/db.sqlite',
-          'sites/example.com/db.sqlite-shm',
-          'sites/example.com/db.sqlite-wal',
-        ],
-      ],
-      'absolute path, at root' => [
-        $drupal_root . '/db.sqlite',
-        [
-          'db.sqlite',
-          'db.sqlite-shm',
-          'db.sqlite-wal',
-        ],
-      ],
-    ];
-  }
-
-  /**
-   * Tests that SQLite database paths are excluded from the staging area.
-   *
-   * This test ensures that SQLite database paths are processed properly (e.g.,
-   * converting an absolute path to a relative path) before being flagged for
-   * exclusion.
-   *
-   * @param string $database_path
-   *   The path of the SQLite database, as set in the database connection
-   *   options.
-   * @param string[] $expected_exclusions
-   *   The database paths which should be flagged for exclusion.
-   *
-   * @dataProvider providerSqliteDatabaseExcluded
-   */
-  public function testSqliteDatabaseExcluded(string $database_path, array $expected_exclusions): void {
-    $database = $this->prophesize(Connection::class);
-    $database->driver()->willReturn('sqlite');
-    $database->getConnectionOptions()->willReturn([
-      'database' => $database_path,
-    ]);
-
-    // Update the event subscriber to use the mocked database.
-    /** @var \Drupal\Tests\package_manager\Kernel\TestExcludedPathsSubscriber $subscriber */
-    $subscriber = $this->container->get('package_manager.excluded_paths_subscriber');
-    $subscriber->database = $database->reveal();
-
-    $event = new PreCreateEvent($this->createStage());
-    // Invoke the event subscriber directly, so we can check that the database
-    // was correctly excluded.
-    $subscriber->ignoreCommonPaths($event);
-    // All of the expected exclusions should be flagged.
-    $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths()));
-  }
-
-  /**
-   * Tests that unreadable directories are ignored by the event subscriber.
-   */
-  public function testUnreadableDirectoriesAreIgnored(): void {
-    $this->createTestProject();
-    $active_dir = $this->container->get('package_manager.path_locator')
-      ->getProjectRoot();
-
-    // Create an unreadable directory within the active directory, which will
-    // raise an exception as the event subscriber tries to scan for .git
-    // directories...unless unreadable directories are being ignored, as they
-    // should be.
-    $unreadable_dir = $active_dir . '/unreadable';
-    mkdir($unreadable_dir, 0000);
-    $this->assertDirectoryIsNotReadable($unreadable_dir);
-
-    $this->createStage()->create();
-  }
-
-}
-
-/**
- * A test-only version of the excluded paths event subscriber.
- */
-class TestExcludedPathsSubscriber extends ExcludedPathsSubscriber {
-
-  /**
-   * {@inheritdoc}
-   */
-  public $sitePath;
-
-  /**
-   * {@inheritdoc}
-   */
-  public $database;
-
-}
diff --git a/core/modules/package_manager/tests/src/Kernel/ExecutableFinderTest.php b/core/modules/package_manager/tests/src/Kernel/ExecutableFinderTest.php
index fb59c652ca49..ff7ed9933a83 100644
--- a/core/modules/package_manager/tests/src/Kernel/ExecutableFinderTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ExecutableFinderTest.php
@@ -2,6 +2,9 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\ExecutableFinder;
+use PhpTuf\ComposerStager\Infrastructure\Service\Finder\ExecutableFinderInterface;
 use Symfony\Component\Process\ExecutableFinder as SymfonyExecutableFinder;
 
 /**
@@ -12,10 +15,11 @@
 class ExecutableFinderTest extends PackageManagerKernelTestBase {
 
   /**
-   * Tests that the executable finder looks for paths in configuration.
+   * {@inheritdoc}
    */
-  public function testCheckConfigurationForExecutablePath(): void {
-    $symfony_executable_finder = new class () extends SymfonyExecutableFinder {
+  public function register(ContainerBuilder $container) {
+    // Mock a Symfony executable finder that always returns /dev/null.
+    $symfony_executable_finder = new class extends SymfonyExecutableFinder {
 
       /**
        * {@inheritdoc}
@@ -25,13 +29,19 @@ public function find($name, $default = NULL, array $extraDirs = []) {
       }
 
     };
-    $this->container->set('package_manager.symfony_executable_finder', $symfony_executable_finder);
+    $container->getDefinition(ExecutableFinder::class)
+      ->setArgument('$symfony_executable_finder', $symfony_executable_finder);
+  }
 
+  /**
+   * Tests that the executable finder looks for paths in configuration.
+   */
+  public function testCheckConfigurationForExecutablePath(): void {
     $this->config('package_manager.settings')
       ->set('executables.composer', '/path/to/composer')
       ->save();
 
-    $executable_finder = $this->container->get('package_manager.executable_finder');
+    $executable_finder = $this->container->get(ExecutableFinderInterface::class);
     $this->assertSame('/path/to/composer', $executable_finder->find('composer'));
     $this->assertSame('/dev/null', $executable_finder->find('rsync'));
   }
diff --git a/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php b/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php
new file mode 100644
index 000000000000..931fa047d920
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/FailureMarkerTest.php
@@ -0,0 +1,41 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Exception\ApplyFailedException;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\FailureMarker
+ *
+ * @group package_manager
+ */
+class FailureMarkerTest extends PackageManagerKernelTestBase {
+
+  /**
+   * @covers ::assertNotExists
+   */
+  public function testExceptionIfExists(): void {
+    $failure_marker = $this->container->get('package_manager.failure_marker');
+    $failure_marker->write($this->createStage(), 'Disastrous catastrophe!');
+
+    $this->expectException(ApplyFailedException::class);
+    $this->expectExceptionMessage('Disastrous catastrophe!');
+    $failure_marker->assertNotExists();
+  }
+
+  /**
+   * Tests that an exception is thrown if the marker file contains invalid JSON.
+   *
+   * @covers ::assertNotExists
+   */
+  public function testExceptionForInvalidJson(): void {
+    $failure_marker = $this->container->get('package_manager.failure_marker');
+    // Write the failure marker with invalid JSON.
+    file_put_contents($failure_marker->getPath(), '{}}');
+
+    $this->expectException(ApplyFailedException::class);
+    $this->expectExceptionMessage('Failure marker file exists but cannot be decoded.');
+    $failure_marker->assertNotExists();
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php b/core/modules/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php
index d3005e2f7a9b..69634777c5f8 100644
--- a/core/modules/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/FileSyncerFactoryTest.php
@@ -3,6 +3,9 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\KernelTests\KernelTestBase;
+use PhpTuf\ComposerStager\Domain\Service\FileSyncer\FileSyncerInterface;
+use PhpTuf\ComposerStager\Infrastructure\Service\FileSyncer\PhpFileSyncer;
+use PhpTuf\ComposerStager\Infrastructure\Service\FileSyncer\RsyncFileSyncer;
 
 /**
  * @covers \Drupal\package_manager\FileSyncerFactory
@@ -14,13 +17,13 @@ class FileSyncerFactoryTest extends KernelTestBase {
   /**
    * {@inheritdoc}
    */
-  protected static $modules = ['package_manager'];
+  protected static $modules = ['package_manager', 'update'];
 
   /**
-   * Data provider for ::testFactory().
+   * Data provider for testFactory().
    *
    * @return mixed[][]
-   *   Sets of arguments to pass to the test method.
+   *   The test cases.
    */
   public function providerFactory(): array {
     return [
@@ -40,19 +43,17 @@ public function providerFactory(): array {
    * @dataProvider providerFactory
    */
   public function testFactory(?string $configured_syncer): void {
-    $factory = $this->container->get('package_manager.file_syncer.factory');
-
     switch ($configured_syncer) {
       case 'rsync':
-        $expected_syncer = $this->container->get('package_manager.file_syncer.rsync');
+        $expected_syncer = RsyncFileSyncer::class;
         break;
 
       case 'php':
-        $expected_syncer = $this->container->get('package_manager.file_syncer.php');
+        $expected_syncer = PhpFileSyncer::class;
         break;
 
       default:
-        $expected_syncer = $factory->create();
+        $expected_syncer = FileSyncerInterface::class;
         break;
     }
 
@@ -60,7 +61,7 @@ public function testFactory(?string $configured_syncer): void {
       ->set('file_syncer', $configured_syncer)
       ->save();
 
-    $this->assertSame($expected_syncer, $factory->create());
+    $this->assertInstanceOf($expected_syncer, $this->container->get(FileSyncerInterface::class));
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
index 757481d81b81..bd68bd57fe44 100644
--- a/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/LockFileValidatorTest.php
@@ -2,12 +2,12 @@
 
 namespace Drupal\Tests\package_manager\Kernel;
 
-use Drupal\package_manager\Event\PostRequireEvent;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Event\PreRequireEvent;
 use Drupal\package_manager\Validator\LockFileValidator;
 use Drupal\package_manager\ValidationResult;
+use Drupal\package_manager_bypass\Stager;
 
 /**
  * @coversDefaultClass \Drupal\package_manager\Validator\LockFileValidator
@@ -28,7 +28,6 @@ class LockFileValidatorTest extends PackageManagerKernelTestBase {
    */
   protected function setUp(): void {
     parent::setUp();
-    $this->createTestProject();
     $this->activeDir = $this->container->get('package_manager.path_locator')
       ->getProjectRoot();
   }
@@ -56,7 +55,7 @@ public function testCreateWithLock(): void {
 
     // Change the lock file to ensure the stored hash of the previous version
     // has been deleted.
-    file_put_contents($this->activeDir . '/composer.lock', 'changed');
+    file_put_contents($this->activeDir . '/composer.lock', '{"changed": true}');
     $this->assertResults([]);
   }
 
@@ -74,7 +73,7 @@ public function testLockFileChanged(string $event_class): void {
       file_put_contents($this->activeDir . '/composer.lock', 'changed');
     });
     $result = ValidationResult::createError([
-      'Stored lock file hash does not match the active lock file.',
+      'Unexpected changes were detected in composer.lock, which indicates that other Composer operations were performed since this Package Manager operation started. This can put the code base into an unreliable state and therefore is not allowed.',
     ]);
     $this->assertResults([$result], $event_class);
   }
@@ -124,11 +123,9 @@ public function testNoStoredHash(string $event_class): void {
    * Tests validation when the staged and active lock files are identical.
    */
   public function testApplyWithNoChange(): void {
-    $this->addListener(PostRequireEvent::class, function (PostRequireEvent $event) {
-      $stage_dir = $event->getStage()->getStageDirectory();
-      mkdir($stage_dir);
-      copy("$this->activeDir/composer.lock", "$stage_dir/composer.lock");
-    });
+    // Leave the staged lock file alone.
+    Stager::setLockFileShouldChange(FALSE);
+
     $result = ValidationResult::createError([
       'There are no pending Composer operations.',
     ]);
@@ -138,8 +135,8 @@ public function testApplyWithNoChange(): void {
   /**
    * Data provider for test methods that validate the staging area.
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return string[][]
+   *   The test cases.
    */
   public function providerValidateStageEvents(): array {
     return [
diff --git a/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php
index b83fe43af333..f8f8763d78a1 100644
--- a/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/MultisiteValidatorTest.php
@@ -13,10 +13,10 @@
 class MultisiteValidatorTest extends PackageManagerKernelTestBase {
 
   /**
-   * Data provider for ::testMultisite().
+   * Data provider for testMultisite().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerMultisite(): array {
     return [
@@ -46,8 +46,6 @@ public function providerMultisite(): array {
    * @dataProvider providerMultisite
    */
   public function testMultisite(bool $is_multisite, array $expected_results = []): void {
-    $this->createTestProject();
-
     // If we should simulate a multisite, ensure there is a sites.php in the
     // test project.
     // @see \Drupal\package_manager\Validator\MultisiteValidator::isMultisite()
@@ -56,6 +54,7 @@ public function testMultisite(bool $is_multisite, array $expected_results = []):
         ->getProjectRoot();
       touch($project_root . '/sites/sites.php');
     }
+    $this->assertStatusCheckResults($expected_results);
     $this->assertResults($expected_results, PreCreateEvent::class);
   }
 
diff --git a/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php
new file mode 100644
index 000000000000..b8ba5daa156b
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/OverwriteExistingPackagesValidatorTest.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * @covers \Drupal\package_manager\Validator\OverwriteExistingPackagesValidator
+ *
+ * @group package_manager
+ */
+class OverwriteExistingPackagesValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we don't care whether the updated projects are secure and
+    // supported.
+    $this->disableValidators[] = 'package_manager.validator.supported_releases';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that new installed packages overwrite existing directories.
+   *
+   * The fixture simulates a scenario where the active directory has three
+   * modules installed: module_1, module_2, and module_5. None of them are
+   * managed by Composer.
+   *
+   * The staging area has four modules: module_1, module_2, module_3, and
+   * module_5_different_path. All of them are managed by Composer. We expect the
+   * following outcomes:
+   *
+   * - module_1 and module_2 will raise errors because they would overwrite
+   *   non-Composer managed paths in the active directory.
+   * - module_3 will cause no problems, since it doesn't exist in the active
+   *   directory at all.
+   * - module_4, which is defined only in the staged installed.json and
+   *   installed.php, will cause an error because its path collides with
+   *   module_1.
+   * - module_5_different_path will not cause a problem, even though its package
+   *   name is drupal/module_5, because its project name and path in the staging
+   *   area differ from the active directory.
+   */
+  public function testNewPackagesOverwriteExisting(): void {
+    $fixtures_dir = __DIR__ . '/../../fixtures/overwrite_existing_validation';
+    $this->copyFixtureFolderToActiveDirectory("$fixtures_dir/active");
+    $this->copyFixtureFolderToStageDirectoryOnApply("$fixtures_dir/staged");
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['drupal/core:9.8.1']);
+
+    $expected_results = [
+      ValidationResult::createError([
+        'The new package drupal/module_1 will be installed in the directory /vendor/composer/../../modules/module_1, which already exists but is not managed by Composer.',
+      ]),
+      ValidationResult::createError([
+        'The new package drupal/module_2 will be installed in the directory /vendor/composer/../../modules/module_2, which already exists but is not managed by Composer.',
+      ]),
+      ValidationResult::createError([
+        'The new package drupal/module_4 will be installed in the directory /vendor/composer/../../modules/module_1, which already exists but is not managed by Composer.',
+      ]),
+    ];
+
+    try {
+      $stage->apply();
+      // If no exception occurs, ensure we weren't expecting any errors.
+      $this->assertEmpty($expected_results);
+    }
+    catch (StageValidationException $e) {
+      $this->assertNotEmpty($expected_results);
+      $this->assertValidationResultsEqual($expected_results, $e->getResults());
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
index abe8c33145ba..a46ba08a35ee 100644
--- a/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
+++ b/core/modules/package_manager/tests/src/Kernel/PackageManagerKernelTestBase.php
@@ -4,28 +4,58 @@
 
 use Drupal\Core\DependencyInjection\ContainerBuilder;
 use Drupal\KernelTests\KernelTestBase;
+use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Event\StatusCheckEvent;
 use Drupal\package_manager\Validator\DiskSpaceValidator;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\Exception\StageValidationException;
-use Drupal\package_manager\PathLocator;
 use Drupal\package_manager\Stage;
+use Drupal\package_manager_bypass\Beginner;
+use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
 use Drupal\Tests\package_manager\Traits\ValidationTestTrait;
+use GuzzleHttp\Client;
+use GuzzleHttp\Handler\MockHandler;
+use GuzzleHttp\HandlerStack;
+use GuzzleHttp\Psr7\Response;
+use GuzzleHttp\Psr7\Utils;
 use org\bovigo\vfs\vfsStream;
+use PhpTuf\ComposerStager\Domain\Value\Path\PathInterface;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Path\PathFactoryInterface;
+use PhpTuf\ComposerStager\Infrastructure\Value\Path\AbstractPath;
+use Psr\Http\Message\RequestInterface;
+use Symfony\Component\DependencyInjection\Definition;
 
 /**
  * Base class for kernel tests of Package Manager's functionality.
  */
 abstract class PackageManagerKernelTestBase extends KernelTestBase {
 
+  use FixtureUtilityTrait;
   use ValidationTestTrait;
 
+  /**
+   * The mocked HTTP client that returns metadata about available updates.
+   *
+   * We need to preserve this as a class property so that we can re-inject it
+   * into the container when a rebuild is triggered by module installation.
+   *
+   * @var \GuzzleHttp\Client
+   *
+   * @see ::register()
+   */
+  private $client;
+
+
   /**
    * {@inheritdoc}
    */
   protected static $modules = [
     'package_manager',
     'package_manager_bypass',
+    'system',
+    'update',
+    'update_test',
   ];
 
   /**
@@ -33,15 +63,7 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase {
    *
    * @var string[]
    */
-  protected $disableValidators = [
-    // Disable the filesystem permissions validator, since we cannot guarantee
-    // that the current code base will be writable in all testing situations.
-    // We test this validator functionally in Automatic Updates' build tests,
-    // since those do give us control over the filesystem permissions.
-    // @see \Drupal\Tests\auto_updates\Build\CoreUpdateTest::assertReadOnlyFileSystemError()
-    // @see \Drupal\Tests\package_manager\Kernel\WritableFileSystemValidatorTest
-    'package_manager.validator.file_system',
-  ];
+  protected $disableValidators = [];
 
   /**
    * {@inheritdoc}
@@ -49,6 +71,16 @@ abstract class PackageManagerKernelTestBase extends KernelTestBase {
   protected function setUp(): void {
     parent::setUp();
     $this->installConfig('package_manager');
+
+    $this->createVirtualProject();
+
+    // The Update module's default configuration must be installed for our
+    // fake release metadata to be fetched.
+    $this->installConfig('update');
+
+    // Make the update system think that all of System's post-update functions
+    // have run.
+    $this->registerPostUpdateFunctions();
   }
 
   /**
@@ -57,6 +89,28 @@ protected function setUp(): void {
   public function register(ContainerBuilder $container) {
     parent::register($container);
 
+    // If we previously set up a mock HTTP client in ::setReleaseMetadata(),
+    // re-inject it into the container.
+    if ($this->client) {
+      $container->set('http_client', $this->client);
+    }
+
+    // Ensure that Composer Stager uses the test path factory, which is aware
+    // of the virtual file system.
+    $definition = new Definition(TestPathFactory::class);
+    $class = $definition->getClass();
+    $container->setDefinition($class, $definition->setPublic(FALSE));
+    $container->setAlias(PathFactoryInterface::class, $class);
+
+    // When a virtual project is used, the disk space validator is replaced with
+    // a mock. When staged changes are applied, the container is rebuilt, which
+    // destroys the mocked service and can cause unexpected side effects. The
+    // 'persist' tag prevents the mock from being destroyed during a container
+    // rebuild.
+    // @see ::createVirtualProject()
+    $container->getDefinition('package_manager.validator.disk_space')
+      ->addTag('persist');
+
     foreach ($this->disableValidators as $service_id) {
       if ($container->hasDefinition($service_id)) {
         $container->getDefinition($service_id)->clearTag('event_subscriber');
@@ -80,7 +134,9 @@ protected function createStage(): TestStage {
       $this->container->get('file_system'),
       $this->container->get('event_dispatcher'),
       $this->container->get('tempstore.shared'),
-      $this->container->get('datetime.time')
+      $this->container->get('datetime.time'),
+      new TestPathFactory(),
+      $this->container->get('package_manager.failure_marker')
     );
   }
 
@@ -100,6 +156,7 @@ protected function assertResults(array $expected_results, string $event_class =
       $stage->create();
       $stage->require(['drupal/core:9.8.1']);
       $stage->apply();
+      $stage->postApply();
       $stage->destroy();
 
       // If we did not get an exception, ensure we didn't expect any results.
@@ -115,6 +172,21 @@ protected function assertResults(array $expected_results, string $event_class =
     }
   }
 
+  /**
+   * Asserts validation results are returned from the status check event.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   * @param \Drupal\package_manager\Stage|null $stage
+   *   (optional) The stage to use to create the status check event. If none is
+   *   provided a new stage will be created.
+   */
+  protected function assertStatusCheckResults(array $expected_results, Stage $stage = NULL): void {
+    $event = new StatusCheckEvent($stage ?? $this->createStage());
+    $this->container->get('event_dispatcher')->dispatch($event);
+    $this->assertValidationResultsEqual($expected_results, $event->getResults());
+  }
+
   /**
    * Marks all pending post-update functions as completed.
    *
@@ -138,101 +210,41 @@ protected function registerPostUpdateFunctions(): void {
    * 'active', which is the active directory containing a fake Drupal code base,
    * and 'stage', which is the root directory used to stage changes. The path
    * locator service will also be mocked so that it points to the test project.
+   *
+   * @param string|null $source_dir
+   *   (optional) The path of a directory which should be copied into the
+   *   virtual file system and used as the active directory.
    */
-  protected function createTestProject(): void {
-    $tree = [
-      'active' => [
-        'composer.json' => '{}',
-        'composer.lock' => '{}',
-        '.git' => [
-          'ignore.txt' => 'This file should never be staged.',
-        ],
-        '.gitignore' => 'This file should be staged.',
-        'private' => [
-          'ignore.txt' => 'This file should never be staged.',
-        ],
-        'modules' => [
-          'example' => [
-            'example.info.yml' => 'This file should be staged.',
-            '.git' => [
-              'ignore.txt' => 'This file should never be staged.',
-            ],
-          ],
-        ],
-        'sites' => [
-          'default' => [
-            'services.yml' => <<<END
-# This file should never be staged.
-must_not_be: 'empty'
-END,
-            'settings.local.php' => <<<END
-<?php
+  protected function createVirtualProject(?string $source_dir = NULL): void {
+    $source_dir = $source_dir ?? __DIR__ . '/../../fixtures/fake_site';
 
-/**
- * @file
- * This file should never be staged.
- */
-END,
-            'settings.php' => <<<END
-<?php
+    // Create the active directory and copy its contents from a fixture.
+    $active_dir = vfsStream::newDirectory('active');
+    $this->vfsRoot->addChild($active_dir);
+    $active_dir = $active_dir->url();
+    static::copyFixtureFilesTo($source_dir, $active_dir);
 
-/**
- * @file
- * This file should never be staged.
- */
-END,
-            'stage.txt' => 'This file should be staged.',
-          ],
-          'example.com' => [
-            'files' => [
-              'ignore.txt' => 'This file should never be staged.',
-            ],
-            'db.sqlite' => 'This file should never be staged.',
-            'db.sqlite-shm' => 'This file should never be staged.',
-            'db.sqlite-wal' => 'This file should never be staged.',
-            'services.yml' => <<<END
-# This file should never be staged.
-key: "value"
-END,
-            'settings.local.php' => <<<END
-<?php
+    // Create a staging root directory alongside the active directory.
+    $stage_dir = vfsStream::newDirectory('stage');
+    $this->vfsRoot->addChild($stage_dir);
 
-/**
- * @file
- * This file should never be staged.
- */
-END,
-            'settings.php' => <<<END
-<?php
-
-/**
- * @file
- * This file should never be staged.
- */
-END,
-          ],
-          'simpletest' => [
-            'ignore.txt' => 'This file should never be staged.',
-          ],
-        ],
-        'vendor' => [
-          '.htaccess' => '# This file should never be staged.',
-          'web.config' => 'This file should never be staged.',
-        ],
-      ],
-      'stage' => [],
-    ];
-    $root = vfsStream::create($tree, $this->vfsRoot)->url();
-    TestStage::$stagingRoot = "$root/stage";
+    // Ensure the path locator points to the virtual active directory. We assume
+    // that is its own web root and that the vendor directory is at its top
+    // level.
+    /** @var \Drupal\package_manager_bypass\PathLocator $path_locator */
+    $path_locator = $this->container->get('package_manager.path_locator');
+    $path_locator->setPaths($active_dir, $active_dir . '/vendor', '', $stage_dir->url());
 
-    $path_locator = $this->mockPathLocator("$root/active");
+    // Ensure the active directory will be copied into the virtual staging area.
+    Beginner::setFixturePath($active_dir);
 
     // Since the path locator now points to a virtual file system, we need to
     // replace the disk space validator with a test-only version that bypasses
     // system calls, like disk_free_space() and stat(), which aren't supported
-    // by vfsStream.
+    // by vfsStream. This validator will persist through container rebuilds.
+    // @see ::register()
     $validator = new TestDiskSpaceValidator(
-      $this->container->get('package_manager.path_locator'),
+      $path_locator,
       $this->container->get('string_translation')
     );
     // By default, the validator should report that the root, vendor, and
@@ -246,56 +258,80 @@ protected function createTestProject(): void {
   }
 
   /**
-   * Mocks the path locator and injects it into the service container.
+   * Copies a fixture directory into the active directory.
    *
-   * @param string $project_root
-   *   The project root.
-   * @param string|null $vendor_dir
-   *   (optional) The vendor directory. Defaults to `$project_root/vendor`.
-   * @param string $web_root
-   *   (optional) The web root, relative to the project root. Defaults to ''
-   *   (i.e., same as the project root).
-   *
-   * @return \Drupal\package_manager\PathLocator
-   *   The mocked path locator.
+   * @param string $active_fixture_dir
+   *   Path to fixture active directory from which the files will be copied.
    */
-  protected function mockPathLocator(string $project_root, string $vendor_dir = NULL, string $web_root = ''): PathLocator {
-    if (empty($vendor_dir)) {
-      $vendor_dir = $project_root . '/vendor';
-    }
-    $path_locator = $this->prophesize(PathLocator::class);
-    $path_locator->getProjectRoot()->willReturn($project_root);
-    $path_locator->getVendorDirectory()->willReturn($vendor_dir);
-    $path_locator->getWebRoot()->willReturn($web_root);
-
-    // We don't need the prophet anymore.
-    $path_locator = $path_locator->reveal();
-    $this->container->set('package_manager.path_locator', $path_locator);
-
-    return $path_locator;
+  protected function copyFixtureFolderToActiveDirectory(string $active_fixture_dir) {
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+    static::copyFixtureFilesTo($active_fixture_dir, $active_dir);
   }
 
-}
-
-/**
- * Defines a stage specifically for testing purposes.
- */
-class TestStage extends Stage {
+  /**
+   * Copies a fixture directory into the stage directory on apply.
+   *
+   * @param string $fixture_dir
+   *   Path to fixture directory from which the files will be copied.
+   */
+  protected function copyFixtureFolderToStageDirectoryOnApply(string $fixture_dir) {
+    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher */
+    $event_dispatcher = $this->container->get('event_dispatcher');
+
+    $listener = function (PreApplyEvent $event) use ($fixture_dir): void {
+      static::copyFixtureFilesTo($fixture_dir, $event->getStage()->getStageDirectory());
+    };
+    $event_dispatcher->addListener(PreApplyEvent::class, $listener, PHP_INT_MAX);
+  }
 
   /**
-   * The directory where staging areas will be created.
+   * Sets the current (running) version of core, as known to the Update module.
    *
-   * @var string
+   * @param string $version
+   *   The current version of core.
    */
-  public static $stagingRoot;
+  protected function setCoreVersion(string $version): void {
+    $this->config('update_test.settings')
+      ->set('system_info.#all.version', $version)
+      ->save();
+  }
 
   /**
-   * {@inheritdoc}
+   * Sets the release metadata file to use when fetching available updates.
+   *
+   * @param string[] $files
+   *   The paths of the XML metadata files to use, keyed by project name.
    */
-  public function getStagingRoot(): string {
-    return static::$stagingRoot ?: parent::getStagingRoot();
+  protected function setReleaseMetadata(array $files): void {
+    $responses = [];
+
+    foreach ($files as $project => $file) {
+      $metadata = Utils::tryFopen($file, 'r');
+      $responses["/release-history/$project/current"] = new Response(200, [], Utils::streamFor($metadata));
+    }
+    $callable = function (RequestInterface $request) use ($responses): Response {
+      return $responses[$request->getUri()->getPath()] ?? new Response(404);
+    };
+
+    // The mock handler's queue consist of same callable as many times as the
+    // number of requests we expect to be made for update XML because it will
+    // retrieve one item off the queue for each request.
+    // @see \GuzzleHttp\Handler\MockHandler::__invoke()
+    $handler = new MockHandler(array_fill(0, 100, $callable));
+    $this->client = new Client([
+      'handler' => HandlerStack::create($handler),
+    ]);
+    $this->container->set('http_client', $this->client);
   }
 
+}
+
+/**
+ * Common functions for test stages.
+ */
+trait TestStageTrait {
+
   /**
    * {@inheritdoc}
    */
@@ -313,6 +349,46 @@ protected function dispatch(StageEvent $event, callable $on_error = NULL): void
 
 }
 
+/**
+ * Defines a path value object that is aware of the virtual file system.
+ */
+class TestPath extends AbstractPath {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function doResolve(string $basePath): string {
+    if (str_starts_with($this->path, vfsStream::SCHEME . '://')) {
+      return $this->path;
+    }
+    return implode(DIRECTORY_SEPARATOR, [$basePath, $this->path]);
+  }
+
+}
+
+/**
+ * Defines a path factory that is aware of the virtual file system.
+ */
+class TestPathFactory implements PathFactoryInterface {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(string $path): PathInterface {
+    return new TestPath($path);
+  }
+
+}
+
+/**
+ * Defines a stage specifically for testing purposes.
+ */
+class TestStage extends Stage {
+
+  use TestStageTrait;
+
+}
+
 /**
  * A test version of the disk space validator to bypass system-level functions.
  */
diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php
new file mode 100644
index 000000000000..4da701a076e2
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/GitExcluderTest.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\GitExcluder
+ *
+ * @group package_manager
+ */
+class GitExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that Git directories are excluded from staging operations.
+   */
+  public function testGitDirectoriesExcluded(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->setSetting('package_manager_bypass_composer_stager', FALSE);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->rebuildContainer();
+
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      '.git/ignore.txt',
+      'modules/example/.git/ignore.txt',
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+    // Files that start with .git, but aren't actually .git, should be staged.
+    $this->assertFileExists("$stage_dir/.gitignore");
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php
new file mode 100644
index 000000000000..385e6132e6ab
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteConfigurationExcluderTest.php
@@ -0,0 +1,102 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\PathExcluder\SiteConfigurationExcluder;
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\SiteConfigurationExcluder
+ *
+ * @group package_manager
+ */
+class SiteConfigurationExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('package_manager.site_configuration_excluder')
+      ->setClass(TestSiteConfigurationExcluder::class);
+  }
+
+  /**
+   * Tests that certain paths are excluded from staging operations.
+   */
+  public function testExcludedPaths(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->setSetting('package_manager_bypass_composer_stager', FALSE);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->rebuildContainer();
+
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $site_path = 'sites/example.com';
+
+    // Update the event subscribers' dependencies.
+    $this->container->get('package_manager.site_configuration_excluder')->sitePath = $site_path;
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignore = [
+      "$site_path/settings.php",
+      "$site_path/settings.local.php",
+      "$site_path/services.yml",
+      // Default site-specific settings files should be ignored.
+      'sites/default/settings.php',
+      'sites/default/settings.local.php',
+      'sites/default/services.yml',
+    ];
+    foreach ($ignore as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+    // A non-excluded file in the default site directory should be staged.
+    $this->assertFileExists("$stage_dir/sites/default/stage.txt");
+    // Regular module files should be staged.
+    $this->assertFileExists("$stage_dir/modules/example/example.info.yml");
+
+    // A new file added to the site directory in the staging area should be
+    // copied to the active directory.
+    $file = "$stage_dir/sites/default/new.txt";
+    touch($file);
+    $stage->apply();
+    $this->assertFileExists("$active_dir/sites/default/new.txt");
+
+    // The ignored files should still be in the active directory.
+    foreach ($ignore as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
+
+/**
+ * A test version of the site configuration excluder, to expose internals.
+ */
+class TestSiteConfigurationExcluder extends SiteConfigurationExcluder {
+
+  /**
+   * {@inheritdoc}
+   */
+  public $sitePath;
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php
new file mode 100644
index 000000000000..059c3115eb45
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SiteFilesExcluderTest.php
@@ -0,0 +1,66 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\SiteFilesExcluder
+ *
+ * @group package_manager
+ */
+class SiteFilesExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that public and private files are excluded from staging operations.
+   */
+  public function testSiteFilesExcluded(): void {
+    // The private stream wrapper is only registered if this setting is set.
+    // @see \Drupal\Core\CoreServiceProvider::register()
+    $this->setSetting('file_private_path', 'private');
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected. This will also rebuild
+    // the container, enabling the private stream wrapper.
+    $this->setSetting('package_manager_bypass_composer_stager', FALSE);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->rebuildContainer();
+
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    // Ensure that we are using directories within the fake site fixture for
+    // public and private files.
+    $this->setSetting('file_public_path', "sites/example.com/files");
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      "sites/example.com/files/ignore.txt",
+      'private/ignore.txt',
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php
new file mode 100644
index 000000000000..997cdc991cb4
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/SqliteDatabaseExcluderTest.php
@@ -0,0 +1,184 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Core\Database\Connection;
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder;
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\SqliteDatabaseExcluder
+ *
+ * @group package_manager
+ */
+class SqliteDatabaseExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('package_manager.sqlite_excluder')
+      ->setClass(TestSqliteDatabaseExcluder::class);
+  }
+
+  /**
+   * Mocks a SQLite database connection for the event subscriber.
+   *
+   * @param array $connection_options
+   *   The connection options for the mocked database connection.
+   */
+  private function mockDatabase(array $connection_options): void {
+    $database = $this->prophesize(Connection::class);
+    $database->driver()->willReturn('sqlite');
+    $database->getConnectionOptions()->willReturn($connection_options);
+
+    $this->container->get('package_manager.sqlite_excluder')
+      ->database = $database->reveal();
+  }
+
+  /**
+   * Tests that SQLite database files are excluded from staging operations.
+   */
+  public function testSqliteDatabaseFilesExcluded(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->setSetting('package_manager_bypass_composer_stager', FALSE);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->rebuildContainer();
+
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    // Mock a SQLite database connection to a file in the active directory. The
+    // file should not be staged.
+    $this->mockDatabase([
+      'database' => 'sites/example.com/db.sqlite',
+    ]);
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      "sites/example.com/db.sqlite",
+      "sites/example.com/db.sqlite-shm",
+      "sites/example.com/db.sqlite-wal",
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+  /**
+   * Data provider for testPathProcessing().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerPathProcessing(): array {
+    return [
+      'relative path, in site directory' => [
+        'sites/example.com/db.sqlite',
+        [
+          'sites/example.com/db.sqlite',
+          'sites/example.com/db.sqlite-shm',
+          'sites/example.com/db.sqlite-wal',
+        ],
+      ],
+      'relative path, at root' => [
+        'db.sqlite',
+        [
+          'db.sqlite',
+          'db.sqlite-shm',
+          'db.sqlite-wal',
+        ],
+      ],
+      'absolute path, in site directory' => [
+        '/sites/example.com/db.sqlite',
+        [
+          'sites/example.com/db.sqlite',
+          'sites/example.com/db.sqlite-shm',
+          'sites/example.com/db.sqlite-wal',
+        ],
+      ],
+      'absolute path, at root' => [
+        '/db.sqlite',
+        [
+          'db.sqlite',
+          'db.sqlite-shm',
+          'db.sqlite-wal',
+        ],
+      ],
+    ];
+  }
+
+  /**
+   * Tests SQLite database path processing.
+   *
+   * This test ensures that SQLite database paths are processed properly (e.g.,
+   * converting an absolute path to a relative path) before being flagged for
+   * exclusion.
+   *
+   * @param string $database_path
+   *   The path of the SQLite database, as set in the database connection
+   *   options. If it begins with a slash, it will be prefixed with the path of
+   *   the active directory.
+   * @param string[] $expected_exclusions
+   *   The database paths which should be flagged for exclusion.
+   *
+   * @dataProvider providerPathProcessing
+   */
+  public function testPathProcessing(string $database_path, array $expected_exclusions): void {
+    // If the database path should be treated as absolute, prefix it with the
+    // path of the active directory.
+    if (str_starts_with($database_path, '/')) {
+      $database_path = $this->container->get('package_manager.path_locator')->getProjectRoot() . $database_path;
+    }
+    $this->mockDatabase([
+      'database' => $database_path,
+    ]);
+
+    $event = new PreCreateEvent($this->createStage());
+    // Invoke the event subscriber directly, so we can check that the database
+    // was correctly excluded.
+    $this->container->get('package_manager.sqlite_excluder')
+      ->excludeDatabaseFiles($event);
+    // All of the expected exclusions should be flagged.
+    $this->assertEmpty(array_diff($expected_exclusions, $event->getExcludedPaths()));
+  }
+
+}
+
+/**
+ * A test-only version of the SQLite database excluder, to expose internals.
+ */
+class TestSqliteDatabaseExcluder extends SqliteDatabaseExcluder {
+
+  /**
+   * {@inheritdoc}
+   */
+  public $database;
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php
new file mode 100644
index 000000000000..4b53ba79ef58
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/TestSiteExcluderTest.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\TestSiteExcluder
+ *
+ * @group package_manager
+ */
+class TestSiteExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that test site directories are excluded from staging operations.
+   */
+  public function testTestSitesExcluded(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->setSetting('package_manager_bypass_composer_stager', FALSE);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->rebuildContainer();
+
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      'sites/simpletest',
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php b/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php
new file mode 100644
index 000000000000..93218424562e
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/PathExcluder/VendorHardeningExcluderTest.php
@@ -0,0 +1,58 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\PathExcluder;
+
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\PathExcluder\VendorHardeningExcluder
+ *
+ * @group package_manager
+ */
+class VendorHardeningExcluderTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // In this test, we want to disable the lock file validator because, even
+    // though both the active and stage directories will have a valid lock file,
+    // this validator will complain because they don't differ at all.
+    $this->disableValidators[] = 'package_manager.validator.lock_file';
+    parent::setUp();
+  }
+
+  /**
+   * Tests that vendor hardening files are excluded from staging operations.
+   */
+  public function testVendorHardeningFilesExcluded(): void {
+    // In this test, we want to perform the actual staging operations so that we
+    // can be sure that files are staged as expected.
+    $this->setSetting('package_manager_bypass_composer_stager', FALSE);
+    // Ensure we have an up-to-date container.
+    $this->container = $this->container->get('kernel')->rebuildContainer();
+
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    $stage = $this->createStage();
+    $stage->create();
+    $stage_dir = $stage->getStageDirectory();
+
+    $ignored = [
+      'vendor/.htaccess',
+      'vendor/web.config',
+    ];
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+      $this->assertFileDoesNotExist("$stage_dir/$path");
+    }
+
+    $stage->apply();
+    // The ignored files should still be in the active directory.
+    foreach ($ignored as $path) {
+      $this->assertFileExists("$active_dir/$path");
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php
index 35605045ea2b..e41ccd8c9e8d 100644
--- a/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/PendingUpdatesValidatorTest.php
@@ -21,7 +21,7 @@ class PendingUpdatesValidatorTest extends PackageManagerKernelTestBase {
    * Tests that no error is raised if there are no pending updates.
    */
   public function testNoPendingUpdates(): void {
-    $this->registerPostUpdateFunctions();
+    $this->assertStatusCheckResults([]);
     $this->assertResults([], PreCreateEvent::class);
   }
 
@@ -31,10 +31,6 @@ public function testNoPendingUpdates(): void {
    * @depends testNoPendingUpdates
    */
   public function testPendingUpdateHook(): void {
-    // Register the System module's post-update functions, so that any detected
-    // pending updates are guaranteed to be schema updates.
-    $this->registerPostUpdateFunctions();
-
     // Set the installed schema version of Package Manager to its default value
     // and import an empty update hook which is numbered much higher than will
     // ever exist in the real world.
@@ -47,6 +43,7 @@ public function testPendingUpdateHook(): void {
     $result = ValidationResult::createError([
       'Some modules have database schema updates to install. You should run the <a href="/update.php">database update script</a> immediately.',
     ]);
+    $this->assertStatusCheckResults([$result]);
     $this->assertResults([$result], PreCreateEvent::class);
   }
 
@@ -54,11 +51,13 @@ public function testPendingUpdateHook(): void {
    * Tests that an error is raised if there are pending post-updates.
    */
   public function testPendingPostUpdate(): void {
+    $this->registerPostUpdateFunctions();
     // The System module's post-update functions have not been registered, so
     // the update registry will think they're pending.
     $result = ValidationResult::createError([
       'Some modules have database schema updates to install. You should run the <a href="/update.php">database update script</a> immediately.',
     ]);
+    $this->assertStatusCheckResults([$result]);
     $this->assertResults([$result], PreCreateEvent::class);
   }
 
diff --git a/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php b/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php
new file mode 100644
index 000000000000..d7a82fdc8a1d
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/ProjectInfoTest.php
@@ -0,0 +1,179 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\ProjectInfo;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\ProjectInfo
+ *
+ * @group auto_updates
+ */
+class ProjectInfoTest extends PackageManagerKernelTestBase {
+
+  /**
+   * @covers ::getInstallableReleases()
+   *
+   * @param string $fixture
+   *   The fixture file name.
+   * @param string $installed_version
+   *   The installed version core version to set.
+   * @param string[] $expected_versions
+   *   The expected versions.
+   *
+   * @dataProvider providerGetInstallableReleases
+   */
+  public function testGetInstallableReleases(string $fixture, string $installed_version, array $expected_versions): void {
+    [$project] = explode('.', $fixture);
+    $fixtures_directory = __DIR__ . '/../../fixtures/release-history/';
+    if ($project === 'drupal') {
+      $this->setCoreVersion($installed_version);
+    }
+    else {
+      // Update the version and the project of the project.
+      $this->enableModules(['aaa_auto_updates_test']);
+      $extension_info_update = [
+        'version' => $installed_version,
+        'project' => 'aaa_auto_updates_test',
+      ];
+      $this->config('update_test.settings')
+        ->set("system_info.$project", $extension_info_update)
+        ->save();
+      // The Update module will always request Drupal core's update XML.
+      $metadata_fixtures['drupal'] = $fixtures_directory . 'drupal.9.8.2.xml';
+    }
+    $metadata_fixtures[$project] = "$fixtures_directory$fixture";
+    $this->setReleaseMetadata($metadata_fixtures);
+    $project_info = new ProjectInfo($project);
+    $actual_releases = $project_info->getInstallableReleases();
+    // Assert that we returned the correct releases in the expected order.
+    $this->assertSame($expected_versions, array_keys($actual_releases));
+    // Assert that we version keys match the actual releases.
+    foreach ($actual_releases as $version => $release) {
+      $this->assertSame($version, $release->getVersion());
+    }
+  }
+
+  /**
+   * Data provider for testGetInstallableReleases().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerGetInstallableReleases(): array {
+    return [
+      'core, no updates' => [
+        'drupal.9.8.2.xml',
+        '9.8.2',
+        [],
+      ],
+      'core, on unsupported branch, updates in multiple supported branches' => [
+        'drupal.9.8.2.xml',
+        '9.6.0-alpha1',
+        ['9.8.2', '9.8.1', '9.8.0', '9.8.0-alpha1', '9.7.1', '9.7.0', '9.7.0-alpha1'],
+      ],
+      // A test case with an unpublished release, 9.8.0, and unsupported
+      // release, 9.8.1, both of these releases should not be returned.
+      'core, filter out unsupported and unpublished releases' => [
+        'drupal.9.8.2-unsupported_unpublished.xml',
+        '9.6.0-alpha1',
+        ['9.8.2', '9.8.0-alpha1', '9.7.1', '9.7.0', '9.7.0-alpha1'],
+      ],
+      'core, supported branches before and after installed release' => [
+        'drupal.9.8.2.xml',
+        '9.8.0-alpha1',
+        ['9.8.2', '9.8.1', '9.8.0'],
+      ],
+      'core, one insecure release filtered out' => [
+        'drupal.9.8.1-security.xml',
+        '9.8.0-alpha1',
+        ['9.8.1'],
+      ],
+      'core, skip insecure releases and return secure releases' => [
+        'drupal.9.8.2-older-sec-release.xml',
+        '9.7.0-alpha1',
+        ['9.8.2', '9.8.1', '9.8.1-beta1', '9.8.0-alpha1', '9.7.1'],
+      ],
+      'contrib, semver and legacy' => [
+        'aaa_auto_updates_test.9.8.2.xml',
+        '8.x-6.0-alpha1',
+        ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2', '8.x-6.1', '8.x-6.0'],
+      ],
+      'contrib, semver and legacy, some lower' => [
+        'aaa_auto_updates_test.9.8.2.xml',
+        '8.x-6.1',
+        ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2'],
+      ],
+      'contrib, semver and legacy, on semantic dev' => [
+        'aaa_auto_updates_test.9.8.2.xml',
+        '7.0.x-dev',
+        ['7.0.1', '7.0.0', '7.0.0-alpha1'],
+      ],
+      'contrib, semver and legacy, on legacy dev' => [
+        'aaa_auto_updates_test.9.8.2.xml',
+        '8.x-6.x-dev',
+        ['7.0.1', '7.0.0', '7.0.0-alpha1', '8.x-6.2', '8.x-6.1', '8.x-6.0', '8.x-6.0-alpha1'],
+      ],
+    ];
+  }
+
+  /**
+   * Tests a project that is not in the codebase.
+   */
+  public function testNewProject(): void {
+    $fixtures_directory = __DIR__ . '/../../fixtures/release-history/';
+    $metadata_fixtures['drupal'] = $fixtures_directory . 'drupal.9.8.2.xml';
+    $metadata_fixtures['aaa_auto_updates_test'] = $fixtures_directory . 'aaa_auto_updates_test.9.8.2.xml';
+    $this->setReleaseMetadata($metadata_fixtures);
+    $available = update_get_available(TRUE);
+    $this->assertSame(['drupal'], array_keys($available));
+    $this->setReleaseMetadata($metadata_fixtures);
+    $state = $this->container->get('state');
+    // Set the state that the update module uses to store last checked time
+    // ensure our calls do not affect it.
+    $state->set('update.last_check', 123);
+    $project_info = new ProjectInfo('aaa_auto_updates_test');
+    $project_data = $project_info->getProjectInfo();
+    // Ensure the project information is correct.
+    $this->assertSame('AAA', $project_data['title']);
+    $all_releases = [
+      '7.0.1',
+      '7.0.0',
+      '7.0.0-alpha1',
+      '8.x-6.2',
+      '8.x-6.1',
+      '8.x-6.0',
+      '8.x-6.0-alpha1',
+      '7.0.x-dev',
+      '8.x-6.x-dev',
+    ];
+    $uninstallable_releases = ['7.0.x-dev', '8.x-6.x-dev'];
+    $installable_releases = array_values(array_diff($all_releases, $uninstallable_releases));
+    $this->assertSame(
+      $all_releases,
+      array_keys($project_data['releases'])
+    );
+    $this->assertSame(
+      $installable_releases,
+      array_keys($project_info->getInstallableReleases())
+    );
+    $this->assertNull($project_info->getInstalledVersion());
+    // Ensure we have not changed the state the update module uses to store
+    // the last checked time.
+    $this->assertSame(123, $state->get('update.last_check'));
+  }
+
+  /**
+   * Tests a project with a status other than "published".
+   *
+   * @covers ::getInstallableReleases()
+   */
+  public function testNotPublishedProject(): void {
+    $this->setReleaseMetadata(['drupal' => __DIR__ . '/../../fixtures/release-history/drupal.9.8.2_unknown_status.xml']);
+    $project_info = new ProjectInfo('drupal');
+    $this->expectException('RuntimeException');
+    $this->expectExceptionMessage("The project 'drupal' can not be updated because its status is any status besides published");
+    $project_info->getInstallableReleases();
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/ReadinessValidation/StagedDBUpdateValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/ReadinessValidation/StagedDBUpdateValidatorTest.php
new file mode 100644
index 000000000000..77a67f54ae81
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/ReadinessValidation/StagedDBUpdateValidatorTest.php
@@ -0,0 +1,166 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel\ReadinessValidation;
+
+use Drupal\package_manager\ValidationResult;
+use Drupal\Tests\package_manager\Kernel\PackageManagerKernelTestBase;
+
+/**
+ * @covers \Drupal\package_manager\Validator\StagedDBUpdateValidator
+ *
+ * @group package_manager
+ */
+class StagedDBUpdateValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * The suffixes of the files that can contain database updates.
+   *
+   * @var string[]
+   */
+  private const SUFFIXES = ['install', 'post_update.php'];
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function createVirtualProject(?string $source_dir = NULL): void {
+    parent::createVirtualProject($source_dir);
+
+    $drupal_root = $this->getDrupalRoot();
+    $virtual_active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+
+    // Copy the .install and .post_update.php files from all extensions used in
+    // this test class, in the *actual* Drupal code base that is running this
+    // test, into the virtual project (i.e., the active directory).
+    $module_list = $this->container->get('extension.list.module');
+    $extensions = [];
+    $extensions['system'] = $module_list->get('system');
+    $extensions['views'] = $module_list->get('views');
+    $extensions['package_manager_bypass'] = $module_list->get('package_manager_bypass');
+    $theme_list = $this->container->get('extension.list.theme');
+    // Theme with updates.
+    $extensions['olivero'] = $theme_list->get('olivero');
+    // Theme without updates.
+    $extensions['stark'] = $theme_list->get('stark');
+    foreach ($extensions as $name => $extension) {
+      $path = $extension->getPath();
+      @mkdir("$virtual_active_dir/$path", 0777, TRUE);
+
+      foreach (static::SUFFIXES as $suffix) {
+        if ($name === 'olivero') {
+          @touch("$virtual_active_dir/$path/$name.$suffix");
+          continue;
+        }
+        // If the source file doesn't exist, silence the warning it raises.
+        @copy("$drupal_root/$path/$name.$suffix", "$virtual_active_dir/$path/$name.$suffix");
+      }
+    }
+  }
+
+  /**
+   * Data provider for testFileChanged().
+   *
+   * @return mixed[]
+   *   The test cases.
+   */
+  public function providerFileChanged(): array {
+    $scenarios = [];
+    foreach (static::SUFFIXES as $suffix) {
+      $scenarios["$suffix kept"] = [$suffix, FALSE];
+      $scenarios["$suffix deleted"] = [$suffix, TRUE];
+    }
+    return $scenarios;
+  }
+
+  /**
+   * Tests that an error is raised if install or post-update files are changed.
+   *
+   * @param string $suffix
+   *   The suffix of the file to change. Can be either 'install' or
+   *   'post_update.php'.
+   * @param bool $delete
+   *   Whether or not to delete the file.
+   *
+   * @dataProvider providerFileChanged
+   */
+  public function testFileChanged(string $suffix, bool $delete): void {
+    $stage = $this->createStage();
+    $stage->create();
+    $dir = $stage->getStageDirectory();
+    $this->container->get('theme_installer')->install(['olivero']);
+    $theme = $this->container->get('theme_handler')
+      ->getTheme('olivero');
+    $module_file = "$dir/core/modules/system/system.$suffix";
+    $theme_file = "$dir/{$theme->getPath()}/{$theme->getName()}.$suffix";
+    if ($delete) {
+      unlink($module_file);
+      unlink($theme_file);
+    }
+    else {
+      file_put_contents($module_file, $this->randomString());
+      file_put_contents($theme_file, $this->randomString());
+    }
+    $error = ValidationResult::createWarning(['System', 'Olivero'], t('Possible database updates have been detected in the following extensions.'));
+    $this->assertStatusCheckResults([$error], $stage);
+
+  }
+
+  /**
+   * Tests that no errors are raised if staged files have no DB updates.
+   */
+  public function testNoUpdates(): void {
+    // Since we're testing with a modified version of 'views' and
+    // 'olivero', these should not be installed.
+    $this->assertFalse($this->container->get('module_handler')->moduleExists('views'));
+    $this->assertFalse($this->container->get('theme_handler')->themeExists('olivero'));
+
+    // Create bogus staged versions of Views' and
+    // Package Manager Theme with Updates .install and .post_update.php
+    // files. Since these extensions are not installed, the changes should not
+    // raise any validation errors.
+    $stage = $this->createStage();
+    $stage->create();
+    $dir = $stage->getStageDirectory();
+    $module_list = $this->container->get('extension.list.module')->getList();
+    $theme_list = $this->container->get('extension.list.theme')->getList();
+    $module_dir = $dir . '/' . $module_list['views']->getPath();
+    $theme_dir = $dir . '/' . $theme_list['olivero']->getPath();
+    foreach (static::SUFFIXES as $suffix) {
+      file_put_contents("$module_dir/views.$suffix", $this->randomString());
+      file_put_contents("$theme_dir/olivero.$suffix", $this->randomString());
+    }
+    // There should not have been any errors.
+    $this->assertStatusCheckResults([], $stage);
+  }
+
+  /**
+   * Tests that an error is raised if install or post-update files are added.
+   */
+  public function testUpdatesAddedInStage(): void {
+    $module = $this->container->get('module_handler')
+      ->getModule('package_manager_bypass');
+    $theme_installer = $this->container->get('theme_installer');
+    $theme_installer->install(['stark']);
+    $theme = $this->container->get('theme_handler')
+      ->getTheme('stark');
+
+    $stage = $this->createStage();
+    $stage->create();
+    $dir = $stage->getStageDirectory();
+    foreach (static::SUFFIXES as $suffix) {
+      $module_file = sprintf('%s/%s/%s.%s', $dir, $module->getPath(), $module->getName(), $suffix);
+      $theme_file = sprintf('%s/%s/%s.%s', $dir, $theme->getPath(), $theme->getName(), $suffix);
+      // The files we're creating shouldn't already exist in the staging area
+      // unless it's a file we actually ship, which is a scenario covered by
+      // ::testFileChanged().
+      $this->assertFileDoesNotExist($module_file);
+      $this->assertFileDoesNotExist($theme_file);
+      file_put_contents($module_file, $this->randomString());
+      file_put_contents($theme_file, $this->randomString());
+    }
+    $error = ValidationResult::createWarning(['Package Manager Bypass', 'Stark'], t('Possible database updates have been detected in the following extensions.'));
+
+    $this->assertStatusCheckResults([$error], $stage);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/ServicesTest.php b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php
index ffa23a8a9cb6..db6f8628f1bc 100644
--- a/core/modules/package_manager/tests/src/Kernel/ServicesTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ServicesTest.php
@@ -3,6 +3,10 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\KernelTests\KernelTestBase;
+use Drupal\package_manager\ExecutableFinder;
+use Drupal\package_manager\ProcessFactory;
+use PhpTuf\ComposerStager\Infrastructure\Factory\Process\ProcessFactoryInterface;
+use PhpTuf\ComposerStager\Infrastructure\Service\Finder\ExecutableFinderInterface;
 
 /**
  * Tests that Package Manager services are wired correctly.
@@ -14,7 +18,7 @@ class ServicesTest extends KernelTestBase {
   /**
    * {@inheritdoc}
    */
-  protected static $modules = ['package_manager'];
+  protected static $modules = ['package_manager', 'update'];
 
   /**
    * Tests that Package Manager's public services can be instantiated.
@@ -28,6 +32,16 @@ public function testPackageManagerServices(): void {
     foreach ($services as $service) {
       $this->assertIsObject($this->container->get($service));
     }
+
+    // Ensure that any overridden Composer Stager services were overridden
+    // correctly.
+    $overrides = [
+      ExecutableFinderInterface::class => ExecutableFinder::class,
+      ProcessFactoryInterface::class => ProcessFactory::class,
+    ];
+    foreach ($overrides as $interface => $expected_class) {
+      $this->assertInstanceOf($expected_class, $this->container->get($interface));
+    }
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php
new file mode 100644
index 000000000000..3a4df2b11927
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/SettingsValidatorTest.php
@@ -0,0 +1,46 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * @covers \Drupal\package_manager\Validator\SettingsValidator
+ *
+ * @group package_manager
+ */
+class SettingsValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Data provider for testSettingsValidation().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerSettingsValidation(): array {
+    $result = ValidationResult::createError(['The <code>update_fetch_with_http_fallback</code> setting must be disabled.']);
+
+    return [
+      'HTTP fallback enabled' => [TRUE, [$result]],
+      'HTTP fallback disabled' => [FALSE, []],
+    ];
+  }
+
+  /**
+   * Tests settings validation before starting an update.
+   *
+   * @param bool $setting
+   *   The value of the update_fetch_with_http_fallback setting.
+   * @param \Drupal\package_manager\ValidationResult[] $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerSettingsValidation
+   */
+  public function testSettingsValidation(bool $setting, array $expected_results): void {
+    $this->setSetting('update_fetch_with_http_fallback', $setting);
+    $this->assertStatusCheckResults($expected_results);
+    $this->assertResults($expected_results, PreCreateEvent::class);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
index e154e95d975c..78c998441772 100644
--- a/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/StageEventsTest.php
@@ -13,7 +13,7 @@
 use Drupal\package_manager\Event\PreOperationStageEvent;
 use Drupal\package_manager\Event\PreRequireEvent;
 use Drupal\package_manager\Event\StageEvent;
-use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 
 /**
@@ -97,6 +97,7 @@ public function testEvents(): void {
     $this->stage->create();
     $this->stage->require(['ext-json:*']);
     $this->stage->apply();
+    $this->stage->postApply();
     $this->stage->destroy();
 
     $this->assertSame($this->events, [
@@ -112,10 +113,10 @@ public function testEvents(): void {
   }
 
   /**
-   * Data provider for ::testValidationResults().
+   * Data provider for testValidationResults().
    *
    * @return string[][]
-   *   Sets of arguments to pass to the test method.
+   *   The test cases.
    */
   public function providerValidationResults(): array {
     return [
@@ -135,29 +136,42 @@ public function providerValidationResults(): array {
    * @dataProvider providerValidationResults
    */
   public function testValidationResults(string $event_class): void {
+    $error_messages = ['Burn, baby, burn'];
     // Set up an event listener which will only flag an error for the event
     // class under test.
-    $handler = function (StageEvent $event) use ($event_class): void {
+    $handler = function (StageEvent $event) use ($event_class, $error_messages): void {
       if (get_class($event) === $event_class) {
         if ($event instanceof PreOperationStageEvent) {
-          $event->addError([['Burn, baby, burn']]);
+          $event->addError($error_messages);
         }
       }
     };
     $this->container->get('event_dispatcher')
       ->addListener($event_class, $handler);
 
-    try {
-      $this->stage->create();
-      $this->stage->require(['ext-json:*']);
-      $this->stage->apply();
-      $this->stage->destroy();
-
-      $this->fail('Expected \Drupal\package_manager\Exception\StageValidationException to be thrown.');
-    }
-    catch (StageValidationException $e) {
-      $this->assertCount(1, $e->getResults());
-    }
+    $result = ValidationResult::createError($error_messages);
+    $this->assertResults([$result], $event_class);
+  }
+
+  /**
+   * Tests that pre- and post-require events have access to the package lists.
+   */
+  public function testPackageListsAvailableToRequireEvents(): void {
+    $listener = function (object $event): void {
+      $expected_runtime = ['drupal/core' => '9.8.2'];
+      $expected_dev = ['drupal/core-dev' => '9.8.2'];
+
+      /** @var \Drupal\package_manager\Event\PreRequireEvent|\Drupal\package_manager\Event\PostRequireEvent $event */
+      $this->assertSame($expected_runtime, $event->getRuntimePackages());
+      $this->assertSame($expected_dev, $event->getDevPackages());
+    };
+    /** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher */
+    $event_dispatcher = $this->container->get('event_dispatcher');
+    $event_dispatcher->addListener(PreRequireEvent::class, $listener);
+    $event_dispatcher->addListener(PostRequireEvent::class, $listener);
+
+    $this->stage->create();
+    $this->stage->require(['drupal/core:9.8.2'], ['drupal/core-dev:9.8.2']);
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php b/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php
index d3f73d5c6dcc..cfd06a1a6fc0 100644
--- a/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/StageOwnershipTest.php
@@ -8,15 +8,15 @@
 use Drupal\package_manager\Event\PreCreateEvent;
 use Drupal\package_manager\Exception\StageException;
 use Drupal\package_manager\Exception\StageOwnershipException;
+use Drupal\package_manager_bypass\Stager;
 use Drupal\package_manager_test_validation\EventSubscriber\TestSubscriber;
 use Drupal\Tests\user\Traits\UserCreationTrait;
-use Prophecy\Argument;
-use Psr\Log\LoggerInterface;
+use Psr\Log\Test\TestLogger;
 
 /**
  * Tests that ownership of the stage is enforced.
  *
- * @group package_manger
+ * @group package_manager
  */
 class StageOwnershipTest extends PackageManagerKernelTestBase {
 
@@ -39,7 +39,6 @@ protected function setUp(): void {
     $this->installSchema('system', ['sequences']);
     $this->installSchema('user', ['users_data']);
     $this->installEntitySchema('user');
-    $this->registerPostUpdateFunctions();
   }
 
   /**
@@ -112,6 +111,7 @@ private function assertOwnershipIsEnforced(TestStage $will_create, TestStage $ne
         ['vendor/lib:0.0.1'],
       ],
       'apply' => [],
+      'postApply' => [],
       'destroy' => [],
     ];
     foreach ($callbacks as $method => $arguments) {
@@ -177,8 +177,15 @@ public function testClaim(): void {
         ['vendor/lib:0.0.1'],
       ],
       'apply' => [],
+      'postApply' => [],
       'destroy' => [],
     ];
+    // Since we deliberately don't call create() on the stages we create as
+    // we loop through the life cycle methods, ensure that the active directory
+    // is mirrored into the staging area when a package is required.
+    $active_dir = $this->container->get('package_manager.path_locator')
+      ->getProjectRoot();
+    Stager::setFixturePath($active_dir);
     foreach ($callbacks as $method => $arguments) {
       // Create a new stage instance for each method.
       $this->createStage()->claim($stage_id)->$method(...$arguments);
@@ -231,15 +238,6 @@ public function testForceDestroy(): void {
    * Tests that the stage is available if ::destroy() has a file system error.
    */
   public function testStageDestroyedWithFileSystemError(): void {
-    // Enable the Composer Stager library, since we will actually want to create
-    // the stage directory.
-    $this->container->get('module_installer')->uninstall([
-      'package_manager_bypass',
-    ]);
-    // Ensure we have an up-to-date container.
-    $this->container = $this->container->get('kernel')->getContainer();
-    $this->createTestProject();
-
     $logger_channel = $this->container->get('logger.channel.file');
     $arguments = [
       $this->container->get('stream_wrapper_manager'),
@@ -272,15 +270,8 @@ public function chmod($uri, $mode = NULL) {
     chmod($dir, 0400);
     $this->assertDirectoryIsNotWritable($dir);
 
-    // Mock a logger to prove that a file system error was raised while trying
-    // to delete the stage directory.
-    $logger = $this->prophesize(LoggerInterface::class);
-    $logger->log(
-      RfcLogLevel::ERROR,
-      "Failed to unlink file '%path'.",
-      Argument::withEntry('%path', "$dir/composer.json")
-    )->shouldBeCalled();
-    $logger_channel->addLogger($logger->reveal());
+    $logger = new TestLogger();
+    $logger_channel->addLogger($logger);
 
     // Listen to the post-destroy event so we can confirm that it was fired, and
     // the stage was made available, despite the file system error.
@@ -291,6 +282,17 @@ public function chmod($uri, $mode = NULL) {
       });
     $stage->destroy();
     $this->assertTrue($stage_available);
+
+    // A file system error should have been logged while trying to delete the
+    // stage directory.
+    $predicate = function (array $record) use ($dir): bool {
+      return (
+        $record['message'] === "Failed to unlink file '%path'." &&
+        isset($record['context']['%path']) &&
+        str_contains($record['context']['%path'], $dir)
+      );
+    };
+    $this->assertTrue($logger->hasRecordThatPasses($predicate, RfcLogLevel::ERROR));
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/StageTest.php b/core/modules/package_manager/tests/src/Kernel/StageTest.php
index bf68cc8f39bc..a343fb66edd5 100644
--- a/core/modules/package_manager/tests/src/Kernel/StageTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/StageTest.php
@@ -8,7 +8,16 @@
 use Drupal\package_manager\Event\PostApplyEvent;
 use Drupal\package_manager\Event\PreApplyEvent;
 use Drupal\package_manager\Event\StageEvent;
+use Drupal\package_manager\Exception\ApplyFailedException;
 use Drupal\package_manager\Exception\StageException;
+use Drupal\package_manager\Stage;
+use Drupal\package_manager_bypass\Committer;
+use PhpTuf\ComposerStager\Domain\Exception\InvalidArgumentException;
+use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
+use Drupal\package_manager_bypass\Beginner;
+use PhpTuf\ComposerStager\Domain\Service\Precondition\PreconditionInterface;
+use Psr\Log\LogLevel;
+use Psr\Log\Test\TestLogger;
 
 /**
  * @coversDefaultClass \Drupal\package_manager\Stage
@@ -19,24 +28,6 @@
  */
 class StageTest extends PackageManagerKernelTestBase {
 
-  /**
-   * {@inheritdoc}
-   */
-  protected static $modules = ['system'];
-
-  /**
-   * {@inheritdoc}
-   */
-  protected function setUp(): void {
-    parent::setUp();
-
-    $this->installConfig('system');
-    $this->config('system.site')->set('uuid', $this->randomMachineName())->save();
-    // Ensure that the core update system thinks that System's post-update
-    // functions have run.
-    $this->registerPostUpdateFunctions();
-  }
-
   /**
    * {@inheritdoc}
    */
@@ -54,24 +45,39 @@ public function register(ContainerBuilder $container) {
 
   /**
    * @covers ::getStageDirectory
-   * @covers ::getStagingRoot
    */
   public function testGetStageDirectory(): void {
-    // Ensure that a site ID was generated in ::setUp().
-    $site_id = $this->config('system.site')->get('uuid');
-    $this->assertNotEmpty($site_id);
+    // In this test, we're working with paths that (probably) don't exist in
+    // the file system at all, so we don't want to validate that the file system
+    // is writable when creating stages.
+    $validator = $this->container->get('package_manager.validator.file_system');
+    $this->container->get('event_dispatcher')->removeSubscriber($validator);
+
+    // Don't mirror the active directory from the virtual project into the
+    // real file system.
+    Beginner::setFixturePath(NULL);
+
+    /** @var \Drupal\package_manager_bypass\PathLocator $path_locator */
+    $path_locator = $this->container->get('package_manager.path_locator');
 
     $stage = $this->createStage();
     $id = $stage->create();
-    $this->assertStringEndsWith("/.package_manager$site_id/$id", $stage->getStageDirectory());
+    $stage_dir = $stage->getStageDirectory();
+    $this->assertStringStartsWith($path_locator->getStagingRoot() . '/', $stage_dir);
+    $this->assertStringEndsWith("/$id", $stage_dir);
+    // If the staging root is changed, the existing stage shouldn't be
+    // affected...
+    $active_dir = $path_locator->getProjectRoot();
+    $path_locator->setPaths($active_dir, "$active_dir/vendor", '', '/junk/drawer');
+    $this->assertSame($stage_dir, $stage->getStageDirectory());
     $stage->destroy();
-
+    // ...but a new stage should be.
     $stage = $this->createStage();
     $another_id = $stage->create();
-    // The new stage ID should be unique, but the parent directory should be
-    // unchanged.
     $this->assertNotSame($id, $another_id);
-    $this->assertStringEndsWith("/.package_manager$site_id/$another_id", $stage->getStageDirectory());
+    $stage_dir = $stage->getStageDirectory();
+    $this->assertStringStartsWith('/junk/drawer/', $stage_dir);
+    $this->assertStringEndsWith("/$another_id", $stage_dir);
   }
 
   /**
@@ -84,10 +90,10 @@ public function testUncreatedGetStageDirectory(): void {
   }
 
   /**
-   * Data provider for ::testDestroyDuringApply().
+   * Data provider for testDestroyDuringApply().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerDestroyDuringApply(): array {
     return [
@@ -175,11 +181,21 @@ public function testDestroyDuringApply(string $event_class, bool $force, int $ti
 
     $stage = $this->createStage();
     $stage->create();
+    $stage->require(['ext-json:*']);
     if ($expect_exception) {
       $this->expectException(StageException::class);
       $this->expectExceptionMessage('Cannot destroy the staging area while it is being applied to the active directory.');
     }
     $stage->apply();
+
+    // If the stage was successfully destroyed by the event handler (i.e., the
+    // stage has been applying for too long and is therefore considered stale),
+    // the postApply() method should fail because the stage is not claimed.
+    if ($stage->isAvailable()) {
+      $this->expectException('LogicException');
+      $this->expectExceptionMessage('Stage must be claimed before performing any operations on it.');
+    }
+    $stage->postApply();
   }
 
   /**
@@ -205,7 +221,275 @@ public function testUninstallModuleDuringApply(): void {
 
     $stage = $this->createStage();
     $stage->create();
+    $stage->require(['ext-json:*']);
+    $stage->apply();
+  }
+
+  /**
+   * Tests that Composer Stager is invoked with a long timeout.
+   */
+  public function testTimeouts(): void {
+    $stage = $this->createStage();
+    $stage->create(420);
+    $stage->require(['ext-json:*']);
     $stage->apply();
+
+    $timeouts = [
+      // The beginner was given an explicit timeout.
+      'package_manager.beginner' => 420,
+      // The stager should be called with a timeout of 300 seconds, which is
+      // longer than Composer Stager's default timeout of 120 seconds.
+      'package_manager.stager' => 300,
+      // The committer should have been called with an even longer timeout,
+      // since it's the most failure-sensitive operation.
+      'package_manager.committer' => 600,
+    ];
+    foreach ($timeouts as $service_id => $expected_timeout) {
+      $invocations = $this->container->get($service_id)->getInvocationArguments();
+
+      // The services should have been called with the expected timeouts.
+      $expected_count = 1;
+      if ($service_id === 'package_manager.stager') {
+        // Stage::require() calls Stager::stage() twice, once to change the
+        // version constraints in composer.json, and again to actually update
+        // the installed dependencies.
+        $expected_count = 2;
+      }
+      $this->assertCount($expected_count, $invocations);
+      $this->assertSame($expected_timeout, end($invocations[0]));
+    }
+  }
+
+  /**
+   * Data provider for testCommitException().
+   *
+   * @return \string[][]
+   *   The test cases.
+   */
+  public function providerCommitException(): array {
+    return [
+      'RuntimeException to ApplyFailedException' => [
+        'RuntimeException',
+        ApplyFailedException::class,
+      ],
+      'InvalidArgumentException' => [
+        InvalidArgumentException::class,
+        StageException::class,
+      ],
+      'PreconditionException' => [
+        PreconditionException::class,
+        StageException::class,
+      ],
+      'Exception' => [
+        'Exception',
+        ApplyFailedException::class,
+      ],
+    ];
+  }
+
+  /**
+   * Tests exception handling during calls to Composer Stager commit.
+   *
+   * @param string $thrown_class
+   *   The throwable class that should be thrown by Composer Stager.
+   * @param string|null $expected_class
+   *   The expected exception class, if different from $thrown_class.
+   *
+   * @dataProvider providerCommitException
+   */
+  public function testCommitException(string $thrown_class, string $expected_class): void {
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['drupal/core:9.8.1']);
+
+    $thrown_message = 'A very bad thing happened';
+    // PreconditionException requires a preconditions object.
+    if ($thrown_class === PreconditionException::class) {
+      $throwable = new PreconditionException($this->prophesize(PreconditionInterface::class)->reveal(), $thrown_message, 123);
+    }
+    else {
+      $throwable = new $thrown_class($thrown_message, 123);
+    }
+    Committer::setException($throwable);
+
+    try {
+      $stage->apply();
+      $this->fail('Expected an exception.');
+    }
+    catch (\Throwable $exception) {
+      $this->assertInstanceOf($expected_class, $exception);
+      $this->assertSame($thrown_message, $exception->getMessage());
+      $this->assertSame(123, $exception->getCode());
+
+      $failure_marker = $this->container->get('package_manager.failure_marker');
+      if ($exception instanceof ApplyFailedException) {
+        $this->assertFileExists($failure_marker->getPath());
+        $this->assertFalse($stage->isApplying());
+      }
+      else {
+        $failure_marker->assertNotExists();
+      }
+    }
+  }
+
+  /**
+   * Tests that if a stage fails to apply, another stage cannot be created.
+   */
+  public function testFailureMarkerPreventsCreate(): void {
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['ext-json:*']);
+
+    // Make the committer throw an exception, which should cause the failure
+    // marker to be present.
+    $thrown = new \Exception('Disastrous catastrophe!');
+    Committer::setException($thrown);
+    try {
+      $stage->apply();
+      $this->fail('Expected an exception.');
+    }
+    catch (ApplyFailedException $e) {
+      $this->assertSame($thrown->getMessage(), $e->getMessage());
+      $this->assertFalse($stage->isApplying());
+    }
+    $stage->destroy();
+
+    // Even through the previous stage was destroyed, we cannot create a new one
+    // because the failure marker is still there.
+    $stage = $this->createStage();
+    try {
+      $stage->create();
+      $this->fail('Expected an exception.');
+    }
+    catch (ApplyFailedException $e) {
+      $this->assertSame('Staged changes failed to apply, and the site is in an indeterminate state. It is strongly recommended to restore the code and database from a backup.', $e->getMessage());
+      $this->assertFalse($stage->isApplying());
+    }
+
+    // If the failure marker is cleared, we should be able to create the stage
+    // without issue.
+    $this->container->get('package_manager.failure_marker')->clear();
+    $stage->create();
+  }
+
+  /**
+   * Tests that the failure marker file doesn't exist if apply succeeds.
+   *
+   * @see ::testCommitException
+   */
+  public function testNoFailureFileOnSuccess(): void {
+    $stage = $this->createStage();
+    $stage->create();
+    $stage->require(['ext-json:*']);
+    $stage->apply();
+
+    $this->container->get('package_manager.failure_marker')
+      ->assertNotExists();
+  }
+
+  /**
+   * Tests enforcing that certain services must be passed to the constructor.
+   *
+   * @group legacy
+   */
+  public function testConstructorDeprecations(): void {
+    $this->expectDeprecation('Calling Drupal\package_manager\Stage::__construct() without the $path_factory argument is deprecated in auto_updates:8.x-2.3 and will be required before auto_updates:3.0.0. See https://www.drupal.org/node/3310706.');
+    $this->expectDeprecation('Calling Drupal\package_manager\Stage::__construct() without the $failure_marker argument is deprecated in auto_updates:8.x-2.3 and will be required before auto_updates:3.0.0. See https://www.drupal.org/node/3311257.');
+    new Stage(
+      $this->container->get('config.factory'),
+      $this->container->get('package_manager.path_locator'),
+      $this->container->get('package_manager.beginner'),
+      $this->container->get('package_manager.stager'),
+      $this->container->get('package_manager.committer'),
+      $this->container->get('file_system'),
+      $this->container->get('event_dispatcher'),
+      $this->container->get('tempstore.shared'),
+      $this->container->get('datetime.time')
+    );
+  }
+
+  /**
+   * Tests running apply and post-apply in the same request.
+   */
+  public function testApplyAndPostApplyInSameRequest(): void {
+    $stage = $this->createStage();
+
+    $logger = new TestLogger();
+    $stage->setLogger($logger);
+    $warning_message = 'Post-apply tasks are running in the same request during which staged changes were applied to the active code base. This can result in unpredictable behavior.';
+
+    // Run apply and post-apply in the same request (i.e., the same request
+    // time), and ensure the warning is logged.
+    $stage->create();
+    $stage->require(['drupal/core:9.8.1']);
+    $stage->apply();
+    $stage->postApply();
+    $this->assertTrue($logger->hasRecord($warning_message, LogLevel::WARNING));
+    $stage->destroy();
+
+    $logger->reset();
+    $stage->create();
+    $stage->require(['drupal/core:9.8.2']);
+    $stage->apply();
+    // Simulate post-apply taking place in another request by simulating a
+    // request time 30 seconds after apply started.
+    TestTime::$offset = 30;
+    $stage->postApply();
+    $this->assertFalse($logger->hasRecord($warning_message, LogLevel::WARNING));
+  }
+
+  /**
+   * @covers ::validatePackageNames
+   *
+   * @param string $package_name
+   *   The package name.
+   * @param bool $is_invalid
+   *   TRUE if the given package name is invalid and will cause an exception,
+   *   FALSE otherwise.
+   *
+   * @dataProvider providerValidatePackageNames
+   */
+  public function testValidatePackageNames(string $package_name, bool $is_invalid): void {
+    $stage = $this->createStage();
+    $stage->create();
+    if ($is_invalid) {
+      $this->expectException('InvalidArgumentException');
+      $this->expectExceptionMessage("Invalid package name '$package_name'.");
+    }
+    $stage->require([$package_name]);
+    // If we got here, the package name is valid and we just need to assert something so PHPUnit doesn't complain.
+    $this->assertTrue(TRUE);
+  }
+
+  /**
+   * Data provider for testValidatePackageNames.
+   *
+   * @return array[]
+   *   The test cases.
+   */
+  public function providerValidatePackageNames(): array {
+    return [
+      // White space is trimmed out of package names during validation.
+      'empty string' => ['', FALSE],
+      'white space' => [' ', FALSE],
+      // The `composer` and `php` requirements are special, since they could
+      // otherwise be mistaken for Drupal project names.
+      'Composer runtime, unconstrained' => ['composer', FALSE],
+      'Composer runtime, constrained' => ['composer:^2.4', FALSE],
+      'Composer runtime API, unconstrained' => ['composer-runtime-api', FALSE],
+      'Composer runtime API, constrained' => ['composer-runtime-api:~2.2.0', FALSE],
+      'PHP runtime, unconstrained' => ['php', FALSE],
+      'PHP runtime, constrained' => ['php:>=7.4', FALSE],
+      'PHP runtime variant, unconstrained' => ['php-zts', FALSE],
+      'PHP runtime variant, constrained' => ['php-zts:8.1', FALSE],
+      'PHP extension, unconstrained' => ['ext-json', FALSE],
+      'PHP extension, constrained' => ['ext-json:7.4.1', FALSE],
+      'PHP library, unconstrained' => ['lib-curl', FALSE],
+      'PHP library, constrained' => ['lib-curl:^7', FALSE],
+      'Drupal package, unconstrained' => ['drupal/semver_test', FALSE],
+      'Drupal package, constrained' => ['drupal/semver_test:^1.10', FALSE],
+      'Drupal project' => ['semver_test', TRUE],
+    ];
   }
 
 }
diff --git a/core/modules/package_manager/tests/src/Kernel/StageValidationExceptionTest.php b/core/modules/package_manager/tests/src/Kernel/StageValidationExceptionTest.php
new file mode 100644
index 000000000000..71e6f89785e0
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/StageValidationExceptionTest.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\auto_updates_test\EventSubscriber\TestSubscriber1;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\Exception\StageValidationException;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\Exception\StageValidationException
+ *
+ * @group package_manager
+ */
+class StageValidationExceptionTest extends PackageManagerKernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected static $modules = [
+    'package_manager_test_validation',
+  ];
+
+  /**
+   * Data provider for testErrors().
+   *
+   * @return string[][]
+   *   The test cases.
+   */
+  public function providerResultsAsText(): array {
+    $messages = ['Blam!', 'Kapow!'];
+    $summary = t('There was sadness.');
+
+    $result_no_summary = ValidationResult::createError([$messages[0]]);
+    $result_with_summary = ValidationResult::createError($messages, $summary);
+    $result_with_summary_message = "{$summary->getUntranslatedString()}\n{$messages[0]}\n{$messages[1]}\n";
+
+    return [
+      '1 result with summary' => [
+        [$result_with_summary],
+        $result_with_summary_message,
+      ],
+      '2 results, with summaries' => [
+        [$result_with_summary, $result_with_summary],
+        "$result_with_summary_message$result_with_summary_message",
+      ],
+      '1 result without summary' => [
+        [$result_no_summary],
+        $messages[0],
+      ],
+      '2 results without summaries' => [
+        [$result_no_summary, $result_no_summary],
+        $messages[0] . "\n" . $messages[0],
+      ],
+      '1 result with summary, 1 result without summary' => [
+        [$result_with_summary, $result_no_summary],
+        $result_with_summary_message . $messages[0] . "\n",
+      ],
+    ];
+  }
+
+  /**
+   * Tests formatting a set of validation results as plain text.
+   *
+   * @param \Drupal\package_manager\ValidationResult[] $validation_results
+   *   The expected validation results which should be logged.
+   * @param string $expected_message
+   *   The expected exception message.
+   *
+   * @dataProvider providerResultsAsText
+   *
+   * @covers ::getResultsAsText()
+   */
+  public function testResultsAsText(array $validation_results, string $expected_message): void {
+    TestSubscriber1::setTestResult($validation_results, PreCreateEvent::class);
+    $this->expectException(StageValidationException::class);
+    $this->expectExceptionMessage($expected_message);
+    $this->createStage()->create();
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php
new file mode 100644
index 000000000000..36665897f358
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/SupportedReleaseValidatorTest.php
@@ -0,0 +1,98 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\package_manager\Event\PreApplyEvent;
+use Drupal\package_manager\ValidationResult;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\Validator\SupportedReleaseValidator
+ *
+ * @group package_manager
+ */
+class SupportedReleaseValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * Data provider for testException().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerException(): array {
+    $fixtures_folder = __DIR__ . '/../../fixtures/supported_release_validator';
+    $release_fixture_folder = __DIR__ . '/../../fixtures/release-history';
+    $summary = t('Cannot update because the following project version is not in the list of installable releases.');
+    return [
+      'semver, supported update' => [
+        'semver_test',
+        "$release_fixture_folder/semver_test.1.1.xml",
+        "$fixtures_folder/semver_supported_update_stage",
+        [],
+      ],
+      'semver, update to unsupported branch' => [
+        'semver_test',
+        "$release_fixture_folder/semver_test.1.1.xml",
+        "$fixtures_folder/semver_unsupported_update_stage",
+        [
+          ValidationResult::createError(['semver_test (drupal/semver_test) 8.2.0'], $summary),
+        ],
+      ],
+      'legacy, supported update' => [
+        'aaa_update_test',
+        "$release_fixture_folder/aaa_update_test.1.1.xml",
+        "$fixtures_folder/legacy_supported_update_stage",
+        [],
+      ],
+      'legacy, update to unsupported branch' => [
+        'aaa_update_test',
+        "$release_fixture_folder/aaa_update_test.1.1.xml",
+        "$fixtures_folder/legacy_unsupported_update_stage",
+        [
+          ValidationResult::createError(['aaa_update_test (drupal/aaa_update_test) 3.0.0'], $summary),
+        ],
+      ],
+      'aaa_auto_updates_test(not in active), update to unsupported branch' => [
+        'aaa_auto_updates_test',
+        "$release_fixture_folder/aaa_auto_updates_test.9.8.2.xml",
+        "$fixtures_folder/aaa_auto_updates_test_unsupported_update_stage",
+        [
+          ValidationResult::createError(['aaa_auto_updates_test (drupal/aaa_auto_updates_test) 7.0.1-dev'], $summary),
+        ],
+      ],
+      'aaa_auto_updates_test(not in active), update to supported branch' => [
+        'aaa_auto_updates_test',
+        "$release_fixture_folder/aaa_auto_updates_test.9.8.2.xml",
+        "$fixtures_folder/aaa_auto_updates_test_supported_update_stage",
+        [],
+      ],
+    ];
+  }
+
+  /**
+   * Tests exceptions when updating to unsupported or insecure releases.
+   *
+   * @param string $project
+   *   The project to update.
+   * @param string $release_xml
+   *   Path of release xml for project.
+   * @param string $stage_dir
+   *   Path of fixture stage directory. It will be used as the virtual project's
+   *   stage directory.
+   * @param array $expected_results
+   *   The expected validation results.
+   *
+   * @dataProvider providerException
+   */
+  public function testException(string $project, string $release_xml, string $stage_dir, array $expected_results): void {
+    $this->setReleaseMetadata([
+      $project => $release_xml,
+      'drupal' => __DIR__ . '/../../fixtures/release-history/drupal.9.8.2.xml',
+    ]);
+    $active_dir = __DIR__ . '/../../fixtures/supported_release_validator/active';
+    $this->copyFixtureFolderToActiveDirectory($active_dir);
+    $this->copyFixtureFolderToStageDirectoryOnApply($stage_dir);
+
+    $this->assertResults($expected_results, PreApplyEvent::class);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php
new file mode 100644
index 000000000000..acf84ca7f16a
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Kernel/SymlinkValidatorTest.php
@@ -0,0 +1,107 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Kernel;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\Url;
+use Drupal\package_manager\Event\PreCreateEvent;
+use Drupal\package_manager\ValidationResult;
+use PhpTuf\ComposerStager\Domain\Exception\PreconditionException;
+use PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface;
+use Prophecy\Argument;
+
+/**
+ * @covers \Drupal\package_manager\Validator\SymlinkValidator
+ *
+ * @group package_manager
+ */
+class SymlinkValidatorTest extends PackageManagerKernelTestBase {
+
+  /**
+   * The mocked precondition that checks for symlinks.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\Service\Precondition\CodebaseContainsNoSymlinksInterface|\Prophecy\Prophecy\ObjectProphecy
+   */
+  private $precondition;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    $this->precondition = $this->prophesize(CodebaseContainsNoSymlinksInterface::class);
+    parent::setUp();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function register(ContainerBuilder $container) {
+    parent::register($container);
+
+    $container->getDefinition('package_manager.validator.symlink')
+      ->setArgument('$precondition', $this->precondition->reveal());
+  }
+
+  /**
+   * Data provider for ::testSymlink().
+   *
+   * @return array[]
+   *   The test cases.
+   */
+  public function providerSymlink(): array {
+    return [
+      'no symlinks' => [FALSE],
+      'symlinks' => [TRUE],
+    ];
+  }
+
+  /**
+   * Tests that the validator invokes Composer Stager's symlink precondition.
+   *
+   * @param bool $symlinks_exist
+   *   Whether or not the precondition will detect symlinks.
+   *
+   * @dataProvider providerSymlink
+   */
+  public function testSymlink(bool $symlinks_exist): void {
+    $arguments = Argument::cetera();
+    // The precondition should always be invoked.
+    $this->precondition->assertIsFulfilled($arguments)->shouldBeCalled();
+
+    if ($symlinks_exist) {
+      $exception = new PreconditionException($this->precondition->reveal(), 'Symlinks were found.');
+      $this->precondition->assertIsFulfilled($arguments)->willThrow($exception);
+
+      $expected_results = [
+        ValidationResult::createError([
+          $exception->getMessage(),
+        ]),
+      ];
+    }
+    else {
+      $expected_results = [];
+    }
+
+    $this->assertStatusCheckResults($expected_results);
+    $this->assertResults($expected_results, PreCreateEvent::class);
+
+    $this->enableModules(['help']);
+
+    $url = Url::fromRoute('help.page', ['name' => 'package_manager'])
+      ->setOption('fragment', 'package-manager-faq-symlinks-found')
+      ->toString();
+
+    // Reformat the provided results so that they all have the link to the
+    // online documentation appended to them.
+    $map = function (string $message) use ($url): string {
+      return $message . ' See <a href="' . $url . '">the help page</a> for information on how to resolve the problem.';
+    };
+    foreach ($expected_results as $index => $result) {
+      $messages = array_map($map, $result->getMessages());
+      $expected_results[$index] = ValidationResult::createError($messages);
+    }
+    $this->assertStatusCheckResults($expected_results);
+    $this->assertResults($expected_results, PreCreateEvent::class);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
index ec1ed270acf4..d2d40555d4d4 100644
--- a/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/WritableFileSystemValidatorTest.php
@@ -3,9 +3,8 @@
 namespace Drupal\Tests\package_manager\Kernel;
 
 use Drupal\package_manager\Event\PreCreateEvent;
-use Drupal\package_manager\Validator\WritableFileSystemValidator;
 use Drupal\package_manager\ValidationResult;
-use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Symfony\Component\Filesystem\Filesystem;
 
 /**
  * Unit tests the file system permissions validator.
@@ -22,33 +21,13 @@
 class WritableFileSystemValidatorTest extends PackageManagerKernelTestBase {
 
   /**
-   * {@inheritdoc}
-   */
-  protected $disableValidators = [
-    // The parent class disables the validator we're testing, so prevent that
-    // here with an empty array.
-  ];
-
-  /**
-   * {@inheritdoc}
-   */
-  public function register(ContainerBuilder $container) {
-    parent::register($container);
-
-    // Replace the file system permissions validator with our test-only
-    // implementation.
-    $container->getDefinition('package_manager.validator.file_system')
-      ->setClass(TestWritableFileSystemValidator::class);
-  }
-
-  /**
-   * Data provider for ::testWritable().
+   * Data provider for testWritable().
    *
-   * @return array[]
-   *   Sets of arguments to pass to the test method.
+   * @return mixed[][]
+   *   The test cases.
    */
   public function providerWritable(): array {
-    // The root and vendor paths are defined by ::createTestProject().
+    // The root and vendor paths are defined by ::createVirtualProject().
     $root_error = 'The Drupal directory "vfs://root/active" is not writable.';
     $vendor_error = 'The vendor directory "vfs://root/active/vendor" is not writable.';
     $summary = t('The file system is not writable.');
@@ -98,30 +77,75 @@ public function providerWritable(): array {
    * @dataProvider providerWritable
    */
   public function testWritable(int $root_permissions, int $vendor_permissions, array $expected_results): void {
-    $this->createTestProject();
-    // For reasons unclear, the built-in chmod() function doesn't seem to work
-    // when changing vendor permissions, so just call vfsStream's API directly.
-    $active_dir = $this->vfsRoot->getChild('active');
-    $active_dir->chmod($root_permissions);
-    $active_dir->getChild('vendor')->chmod($vendor_permissions);
+    $path_locator = $this->container->get('package_manager.path_locator');
 
-    /** @var \Drupal\Tests\package_manager\Kernel\TestWritableFileSystemValidator $validator */
-    $validator = $this->container->get('package_manager.validator.file_system');
-    $validator->appRoot = $active_dir->url();
+    // We need to set the vendor directory's permissions first because, in the
+    // virtual project, it's located inside the project root.
+    $this->assertTrue(chmod($path_locator->getVendorDirectory(), $vendor_permissions));
+    $this->assertTrue(chmod($path_locator->getProjectRoot(), $root_permissions));
 
+    $this->assertStatusCheckResults($expected_results);
     $this->assertResults($expected_results, PreCreateEvent::class);
   }
 
-}
-
-/**
- * A test version of the file system permissions validator.
- */
-class TestWritableFileSystemValidator extends WritableFileSystemValidator {
+  /**
+   * Data provider for ::testStagingRootPermissions().
+   *
+   * @return mixed[][]
+   *   The test cases.
+   */
+  public function providerStagingRootPermissions(): array {
+    $writable_permission = 0777;
+    $non_writable_permission = 0444;
+    $summary = t('The file system is not writable.');
+    return [
+      'writable staging root exists' => [
+        $writable_permission,
+        [],
+        FALSE,
+      ],
+      'write-protected staging root exists' => [
+        $non_writable_permission,
+        [
+          ValidationResult::createError(['The staging root directory "vfs://root/stage" is not writable.'], $summary),
+        ],
+        FALSE,
+      ],
+      'staging root does not exist, parent directory not writable' => [
+        $non_writable_permission,
+        [
+          ValidationResult::createError(['The staging root directory will not able to be created at "vfs://root".'], $summary),
+        ],
+        TRUE,
+      ],
+    ];
+  }
 
   /**
-   * {@inheritdoc}
+   * Tests that the staging root's permissions are validated.
+   *
+   * @param int $permissions
+   *   The file permissions to apply to the staging root, or its parent
+   *   directory, depending on the value of $delete_staging_root.
+   * @param array $expected_results
+   *   The expected validation results.
+   * @param bool $delete_staging_root
+   *   Whether the staging root directory will exist at all.
+   *
+   * @dataProvider providerStagingRootPermissions
    */
-  public $appRoot;
+  public function testStagingRootPermissions(int $permissions, array $expected_results, bool $delete_staging_root): void {
+    $dir = $this->container->get('package_manager.path_locator')
+      ->getStagingRoot();
+
+    if ($delete_staging_root) {
+      $fs = new Filesystem();
+      $fs->remove($dir);
+      $dir = dirname($dir);
+    }
+    $this->assertTrue(chmod($dir, $permissions));
+    $this->assertStatusCheckResults($expected_results);
+    $this->assertResults($expected_results, PreCreateEvent::class);
+  }
 
 }
diff --git a/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php b/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php
new file mode 100644
index 000000000000..e7b66acb6b94
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Traits/FixtureUtilityTrait.php
@@ -0,0 +1,79 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Traits;
+
+use Symfony\Component\Filesystem\Filesystem;
+use Symfony\Component\Finder\Iterator\RecursiveDirectoryIterator;
+
+/**
+ * A utility for all things fixtures.
+ */
+trait FixtureUtilityTrait {
+
+  /**
+   * Mirrors a fixture directory to the given path.
+   *
+   * Files not in the source fixture directory will not be deleted from
+   * destination directory. After copying the files to the destination directory
+   * the files and folders will be converted so that can be used in the tests.
+   * The conversion includes:
+   * - Renaming '_git' directories to '.git'
+   * - Renaming files ending in '.info.yml.hide' to remove '.hide'.
+   *
+   * @param string $source_path
+   *   The source path.
+   * @param string $destination_path
+   *   The path to which the fixture files should be mirrored.
+   */
+  protected static function copyFixtureFilesTo(string $source_path, string $destination_path): void {
+    (new Filesystem())->mirror($source_path, $destination_path, NULL, [
+      'override' => TRUE,
+      'delete' => FALSE,
+    ]);
+    static::renameInfoYmlFiles($destination_path);
+    static::renameGitDirectories($destination_path);
+  }
+
+  /**
+   * Renames all files that end with .info.yml.hide.
+   *
+   * @param string $dir
+   *   The directory to be iterated through.
+   */
+  protected static function renameInfoYmlFiles(string $dir) {
+    // Construct the iterator.
+    $it = new RecursiveDirectoryIterator($dir, \RecursiveIteratorIterator::SELF_FIRST);
+
+    // Loop through files and rename them.
+    foreach (new \RecursiveIteratorIterator($it) as $file) {
+      if ($file->getExtension() == 'hide') {
+        rename($file->getPathname(), $dir . DIRECTORY_SEPARATOR .
+          $file->getRelativePath() . DIRECTORY_SEPARATOR . str_replace(".hide", "", $file->getFilename()));
+      }
+    }
+  }
+
+  /**
+   * Renames _git directories to .git.
+   *
+   * @param string $dir
+   *   The directory to be iterated through.
+   */
+  private static function renameGitDirectories(string $dir) {
+    $iter = new \RecursiveIteratorIterator(
+      new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
+      \RecursiveIteratorIterator::SELF_FIRST,
+      \RecursiveIteratorIterator::CATCH_GET_CHILD
+    );
+    /** @var \Symfony\Component\Finder\SplFileInfo $file */
+    foreach ($iter as $file) {
+      if ($file->isDir() && $file->getFilename() === '_git' && $file->getRelativePathname()) {
+        rename(
+          $file->getPathname(),
+          $file->getPath() . DIRECTORY_SEPARATOR . '.git'
+        );
+      }
+    }
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Traits/InfoYmlConverterTrait.php b/core/modules/package_manager/tests/src/Traits/InfoYmlConverterTrait.php
new file mode 100644
index 000000000000..4f186a53faf6
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Traits/InfoYmlConverterTrait.php
@@ -0,0 +1,48 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Traits;
+
+use org\bovigo\vfs\vfsStream;
+use org\bovigo\vfs\vfsStreamDirectory;
+use org\bovigo\vfs\vfsStreamFile;
+use org\bovigo\vfs\visitor\vfsStreamAbstractVisitor;
+
+/**
+ * Common methods to convert info.yml file that will pass core coding standards.
+ */
+trait InfoYmlConverterTrait {
+
+  /**
+   * Renames all files that end with .info.yml.hide.
+   */
+  protected function renameVfsInfoYmlFiles(): void {
+    // Strip the `.hide` suffix from all `.info.yml.hide` files. Drupal's coding
+    // standards don't allow info files to have the `project` key, but we need
+    // it to be present for testing.
+    vfsStream::inspect(new class () extends vfsStreamAbstractVisitor {
+
+      /**
+       * {@inheritdoc}
+       */
+      public function visitFile(vfsStreamFile $file) {
+        $name = $file->getName();
+
+        if (str_ends_with($name, '.info.yml.hide')) {
+          $new_name = basename($name, '.hide');
+          $file->rename($new_name);
+        }
+      }
+
+      /**
+       * {@inheritdoc}
+       */
+      public function visitDirectory(vfsStreamDirectory $dir) {
+        foreach ($dir->getChildren() as $child) {
+          $this->visit($child);
+        }
+      }
+
+    });
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php b/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php
index 3d80b39d7421..7e52efa8a072 100644
--- a/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php
+++ b/core/modules/package_manager/tests/src/Traits/PackageManagerBypassTestTrait.php
@@ -14,18 +14,20 @@ trait PackageManagerBypassTestTrait {
    *   The expected number of times an update was staged.
    */
   private function assertUpdateStagedTimes(int $attempted_times): void {
-    /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $beginner */
+    /** @var \Drupal\package_manager_bypass\BypassedStagerServiceBase $beginner */
     $beginner = $this->container->get('package_manager.beginner');
     $this->assertCount($attempted_times, $beginner->getInvocationArguments());
 
-    /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $stager */
+    /** @var \Drupal\package_manager_bypass\BypassedStagerServiceBase $stager */
     $stager = $this->container->get('package_manager.stager');
-    // If an update was attempted, then there will be two calls to the stager:
-    // one to change the constraints in composer.json, and another to actually
-    // update the installed dependencies.
-    $this->assertCount($attempted_times * 2, $stager->getInvocationArguments());
+    // If an update was attempted, then there will be at least two calls to the
+    // stager: one to change the runtime constraints in composer.json, and
+    // another to actually update the installed dependencies. If any dev
+    // packages (like `drupal/core-dev`) are installed, there may also be an
+    // additional call to change the dev constraints.
+    $this->assertGreaterThanOrEqual($attempted_times * 2, count($stager->getInvocationArguments()));
 
-    /** @var \Drupal\package_manager_bypass\InvocationRecorderBase $committer */
+    /** @var \Drupal\package_manager_bypass\BypassedStagerServiceBase $committer */
     $committer = $this->container->get('package_manager.committer');
     $this->assertEmpty($committer->getInvocationArguments());
   }
diff --git a/core/modules/package_manager/tests/src/Unit/ComposerUtilityTest.php b/core/modules/package_manager/tests/src/Unit/ComposerUtilityTest.php
new file mode 100644
index 000000000000..b81c053503f8
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Unit/ComposerUtilityTest.php
@@ -0,0 +1,111 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Unit;
+
+use Composer\Package\PackageInterface;
+use Drupal\package_manager\ComposerUtility;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\ComposerUtility
+ *
+ * @group package_manager
+ */
+class ComposerUtilityTest extends UnitTestCase {
+
+  /**
+   * Data provider for ::testCorePackages().
+   *
+   * @return \string[][][]
+   *   The test cases.
+   */
+  public function providerCorePackages(): array {
+    return [
+      'core-recommended not installed' => [
+        ['drupal/core'],
+        ['drupal/core'],
+      ],
+      'core-recommended installed' => [
+        ['drupal/core', 'drupal/core-recommended'],
+        ['drupal/core-recommended'],
+      ],
+    ];
+  }
+
+  /**
+   * @covers ::getCorePackages
+   *
+   * @param string[] $installed_package_names
+   *   The names of the packages that are installed.
+   * @param string[] $expected_core_package_names
+   *   The expected core package names that should be returned by
+   *   ::getCorePackages().
+   *
+   * @dataProvider providerCorePackages
+   */
+  public function testCorePackages(array $installed_package_names, array $expected_core_package_names): void {
+    $versions = array_fill(0, count($installed_package_names), '1.0.0');
+    $installed_packages = array_combine($installed_package_names, $versions);
+
+    $core_packages = $this->mockUtilityWithPackages($installed_packages)
+      ->getCorePackages();
+    $this->assertSame($expected_core_package_names, array_keys($core_packages));
+  }
+
+  /**
+   * @covers ::getPackagesNotIn
+   * @covers ::getPackagesWithDifferentVersionsIn
+   */
+  public function testPackageComparison(): void {
+    $active = $this->mockUtilityWithPackages([
+      'drupal/existing' => '1.0.0',
+      'drupal/updated' => '1.0.0',
+      'drupal/removed' => '1.0.0',
+    ]);
+    $staged = $this->mockUtilityWithPackages([
+      'drupal/existing' => '1.0.0',
+      'drupal/updated' => '1.1.0',
+      'drupal/added' => '1.0.0',
+    ]);
+
+    $added = $staged->getPackagesNotIn($active);
+    $this->assertSame(['drupal/added'], array_keys($added));
+
+    $removed = $active->getPackagesNotIn($staged);
+    $this->assertSame(['drupal/removed'], array_keys($removed));
+
+    $updated = $active->getPackagesWithDifferentVersionsIn($staged);
+    $this->assertSame(['drupal/updated'], array_keys($updated));
+  }
+
+  /**
+   * Mocks a ComposerUtility object to return a set of installed packages.
+   *
+   * @param string[]|null[] $installed_packages
+   *   The installed packages that the mocked object should return. The keys are
+   *   the package names and the values are either a version number or NULL to
+   *   not mock the corresponding package's getVersion() method.
+   *
+   * @return \Drupal\package_manager\ComposerUtility|\PHPUnit\Framework\MockObject\MockObject
+   *   The mocked object.
+   */
+  private function mockUtilityWithPackages(array $installed_packages) {
+    $mock = $this->getMockBuilder(ComposerUtility::class)
+      ->disableOriginalConstructor()
+      ->onlyMethods(['getInstalledPackages'])
+      ->getMock();
+
+    $packages = [];
+    foreach ($installed_packages as $name => $version) {
+      $package = $this->createMock(PackageInterface::class);
+      if (isset($version)) {
+        $package->method('getVersion')->willReturn($version);
+      }
+      $packages[$name] = $package;
+    }
+    $mock->method('getInstalledPackages')->willReturn($packages);
+
+    return $mock;
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Unit/InstalledPackagesDataTest.php b/core/modules/package_manager/tests/src/Unit/InstalledPackagesDataTest.php
new file mode 100644
index 000000000000..606d2e2fa739
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Unit/InstalledPackagesDataTest.php
@@ -0,0 +1,42 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Unit;
+
+use Composer\Autoload\ClassLoader;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * Tests retrieval of package data from Composer's `installed.php`.
+ *
+ * ComposerUtility relies on the internal structure of `installed.php` for
+ * certain operations. This test is intended as an early warning if the file's
+ * internal structure changes in a way that would break our functionality.
+ *
+ * @group package_manager
+ */
+class InstalledPackagesDataTest extends UnitTestCase {
+
+  /**
+   * Tests that Composer's `installed.php` file looks how we expect.
+   */
+  public function testinstalledPackagesData(): void {
+    $loaders = ClassLoader::getRegisteredLoaders();
+    $installed_php = key($loaders) . '/composer/installed.php';
+    $this->assertFileIsReadable($installed_php);
+    $data = include $installed_php;
+
+    // There should be a `versions` array whose keys are package names.
+    $this->assertIsArray($data['versions']);
+    $this->assertMatchesRegularExpression('|^[a-z0-9\-_]+/[a-z0-9\-_]+$|', key($data['versions']));
+
+    // The values of `versions` should be arrays of package information that
+    // includes a non-empty `install_path` string and a non-empty `type` string.
+    $package = reset($data['versions']);
+    $this->assertIsArray($package);
+    $this->assertNotEmpty($package['install_path']);
+    $this->assertIsString($package['install_path']);
+    $this->assertNotEmpty($package['type']);
+    $this->assertIsString($package['type']);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php b/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php
new file mode 100644
index 000000000000..540a35900f6a
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Unit/PathLocatorTest.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Unit;
+
+use Drupal\Core\DependencyInjection\ContainerBuilder;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\package_manager\PathLocator;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\PathLocator
+ *
+ * @group package_manager
+ */
+class PathLocatorTest extends UnitTestCase {
+
+  /**
+   * @covers ::getStagingRoot
+   */
+  public function testStagingRoot(): void {
+    $config_factory = $this->getConfigFactoryStub([
+      'system.site' => [
+        'uuid' => 'my_site_id',
+      ],
+    ]);
+    $file_system = $this->prophesize(FileSystemInterface::class);
+    $file_system->getTempDirectory()->willReturn('/path/to/temp');
+
+    $path_locator = new PathLocator(
+      '/path/to/drupal',
+      $config_factory,
+      $file_system->reveal()
+    );
+    $this->assertSame('/path/to/temp/.package_managermy_site_id', $path_locator->getStagingRoot());
+  }
+
+  /**
+   * Data provider for ::testWebRoot().
+   *
+   * @return string[][]
+   *   Sets of arguments to pass to the test method.
+   */
+  public function providerWebRoot(): array {
+    // In certain sites (like those created by drupal/recommended-project), the
+    // web root is a subdirectory of the project, and exists next to the
+    // vendor directory.
+    return [
+      'recommended project' => [
+        '/path/to/project/www',
+        '/path/to/project',
+        'www',
+      ],
+      'recommended project with trailing slash on app root' => [
+        '/path/to/project/www/',
+        '/path/to/project',
+        'www',
+      ],
+      'recommended project with trailing slash on project root' => [
+        '/path/to/project/www',
+        '/path/to/project/',
+        'www',
+      ],
+      'recommended project with trailing slashes' => [
+        '/path/to/project/www/',
+        '/path/to/project/',
+        'www',
+      ],
+      // In legacy projects (i.e., created by drupal/legacy-project), the
+      // web root is the project root.
+      'legacy project' => [
+        '/path/to/drupal',
+        '/path/to/drupal',
+        '',
+      ],
+      'legacy project with trailing slash on app root' => [
+        '/path/to/drupal/',
+        '/path/to/drupal',
+        '',
+      ],
+      'legacy project with trailing slash on project root' => [
+        '/path/to/drupal',
+        '/path/to/drupal/',
+        '',
+      ],
+      'legacy project with trailing slashes' => [
+        '/path/to/drupal/',
+        '/path/to/drupal/',
+        '',
+      ],
+    ];
+  }
+
+  /**
+   * Tests that the web root is computed correctly.
+   *
+   * @param string $app_root
+   *   The absolute path of the Drupal root.
+   * @param string $project_root
+   *   The absolute path of the project root.
+   * @param string $expected_web_root
+   *   The value expected from getWebRoot().
+   *
+   * @covers ::getWebRoot
+   *
+   * @dataProvider providerWebRoot
+   */
+  public function testWebRoot(string $app_root, string $project_root, string $expected_web_root): void {
+    $path_locator = $this->getMockBuilder(PathLocator::class)
+      // Mock all methods except getWebRoot().
+      ->setMethodsExcept(['getWebRoot'])
+      ->setConstructorArgs([
+        $app_root,
+        $this->getConfigFactoryStub(),
+        $this->prophesize(FileSystemInterface::class)->reveal(),
+      ])
+      ->getMock();
+
+    $path_locator->method('getProjectRoot')->willReturn($project_root);
+    $this->assertSame($expected_web_root, $path_locator->getWebRoot());
+  }
+
+  /**
+   * Tests that deprecations are raised for missing constructor arguments.
+   *
+   * @group legacy
+   */
+  public function testConstructorDeprecations(): void {
+    $container = new ContainerBuilder();
+    $container->set('config.factory', $this->getConfigFactoryStub());
+    $container->set('file_system', $this->createMock(FileSystemInterface::class));
+    \Drupal::setContainer($container);
+
+    $this->expectDeprecation('Calling ' . PathLocator::class . '::__construct() without the $config_factory argument is deprecated in auto_updates:8.x-2.1 and will be required before auto_updates:3.0.0. See https://www.drupal.org/node/3300008.');
+    new PathLocator('/path/to/drupal', NULL, $container->get('file_system'));
+
+    $this->expectDeprecation('Calling ' . PathLocator::class . '::__construct() without the $file_system argument is deprecated in auto_updates:8.x-2.1 and will be required before auto_updates:3.0.0. See https://www.drupal.org/node/3300008.');
+    new PathLocator('/path/to/drupal', $container->get('config.factory'));
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Unit/ProcessFactoryTest.php b/core/modules/package_manager/tests/src/Unit/ProcessFactoryTest.php
new file mode 100644
index 000000000000..7961fd3617d2
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Unit/ProcessFactoryTest.php
@@ -0,0 +1,37 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Unit;
+
+use Drupal\package_manager\ProcessFactory;
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @coversDefaultClass \Drupal\package_manager\ProcessFactory
+ *
+ * @group auto_updates
+ */
+class ProcessFactoryTest extends UnitTestCase {
+
+  /**
+   * Tests that the process factory prepends the PHP directory to PATH.
+   */
+  public function testPhpDirectoryPrependedToPath(): void {
+    $factory = new ProcessFactory(
+      $this->prophesize('\Drupal\Core\File\FileSystemInterface')->reveal(),
+      $this->getConfigFactoryStub()
+    );
+
+    // Ensure that the directory of the PHP interpreter can be found.
+    $reflector = new \ReflectionObject($factory);
+    $method = $reflector->getMethod('getPhpDirectory');
+    $method->setAccessible(TRUE);
+    $php_dir = $method->invoke(NULL);
+    $this->assertNotEmpty($php_dir);
+
+    // The process factory should always put the PHP interpreter's directory
+    // at the beginning of the PATH environment variable.
+    $env = $factory->create(['whoami'])->getEnv();
+    $this->assertStringStartsWith("$php_dir:", $env['PATH']);
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php b/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php
new file mode 100644
index 000000000000..f65df4d80a6e
--- /dev/null
+++ b/core/modules/package_manager/tests/src/Unit/RequireEventTraitTest.php
@@ -0,0 +1,67 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Unit;
+
+use Drupal\Tests\UnitTestCase;
+
+/**
+ * @covers \Drupal\package_manager\Event\RequireEventTrait
+ *
+ * @group package_manager
+ */
+class RequireEventTraitTest extends UnitTestCase {
+
+  /**
+   * Tests that runtime and dev packages are keyed correctly.
+   *
+   * @param string[] $runtime_packages
+   *   The runtime package constraints passed to the event constructor.
+   * @param string[] $dev_packages
+   *   The dev package constraints passed to the event constructor.
+   * @param string[] $expected_runtime_packages
+   *   The keyed runtime packages that should be returned by
+   *   ::getRuntimePackages().
+   * @param string[] $expected_dev_packages
+   *   The keyed dev packages that should be returned by ::getDevPackages().
+   *
+   * @dataProvider providerGetPackages
+   */
+  public function testGetPackages(array $runtime_packages, array $dev_packages, array $expected_runtime_packages, array $expected_dev_packages): void {
+    $stage = $this->createMock('\Drupal\package_manager\Stage');
+
+    $events = [
+      '\Drupal\package_manager\Event\PostRequireEvent',
+      '\Drupal\package_manager\Event\PreRequireEvent',
+    ];
+    foreach ($events as $event) {
+      /** @var \Drupal\package_manager\Event\RequireEventTrait $event */
+      $event = new $event($stage, $runtime_packages, $dev_packages);
+      $this->assertSame($expected_runtime_packages, $event->getRuntimePackages());
+      $this->assertSame($expected_dev_packages, $event->getDevPackages());
+    }
+  }
+
+  /**
+   * Data provider for testGetPackages().
+   *
+   * @return mixed[]
+   *   The test cases.
+   */
+  public function providerGetPackages(): array {
+    return [
+      'Package with constraint' => [
+        ['drupal/new_package:^8.1'],
+        ['drupal/dev_package:^9'],
+        ['drupal/new_package' => '^8.1'],
+        ['drupal/dev_package' => '^9'],
+      ],
+      'Package without constraint' => [
+        ['drupal/new_package'],
+        ['drupal/dev_package'],
+        ['drupal/new_package' => '*'],
+        ['drupal/dev_package' => '*'],
+      ],
+    ];
+  }
+
+}
diff --git a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php
index 26de5cd6e78a..67f08161fb78 100644
--- a/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php
+++ b/core/modules/package_manager/tests/src/Unit/ValidationResultTest.php
@@ -38,27 +38,60 @@ public function testCreateErrorResult(array $messages, ?string $summary): void {
 
   /**
    * @covers ::createWarning
+   *
+   * @param string[] $messages
+   *   The warning messages of the validation result.
+   * @param string $expected_exception_message
+   *   The expected exception message.
+   *
+   * @dataProvider providerCreateExceptions
    */
-  public function testCreateWarningResultException(): void {
+  public function testCreateWarningResultException(array $messages, string $expected_exception_message): 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);
+    $this->expectExceptionMessage($expected_exception_message);
+    ValidationResult::createWarning($messages, NULL);
   }
 
   /**
    * @covers ::createError
+   *
+   * @param string[] $messages
+   *   The error messages of the validation result.
+   * @param string $expected_exception_message
+   *   The expected exception message.
+   *
+   * @dataProvider providerCreateExceptions
    */
-  public function testCreateErrorResultException(): void {
+  public function testCreateErrorResultException(array $messages, string $expected_exception_message): 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);
+    $this->expectExceptionMessage($expected_exception_message);
+    ValidationResult::createError($messages, NULL);
+  }
+
+  /**
+   * Data provider for test methods that test create exceptions.
+   *
+   * @return array[]
+   *   The test cases.
+   */
+  public function providerCreateExceptions(): array {
+    return [
+      '2 messages, no summary' => [
+        ['Something is wrong', 'Something else is also wrong'],
+        'If more than one message is provided, a summary is required.',
+      ],
+      'no messages' => [
+        [],
+        'At least one message is required.',
+      ],
+    ];
   }
 
   /**
    * Data provider for testCreateWarningResult().
    *
    * @return mixed[]
-   *   Test cases for testCreateWarningResult().
+   *   The test cases.
    */
   public function providerValidConstructorArguments(): array {
     return [
-- 
GitLab