diff --git a/drupalci.yml b/drupalci.yml
index 61958f038e12097e5064529bdee7a6a1c0567f90..8fac0e8b1e16cdcfb12c81724e3d5ef746c30749 100644
--- a/drupalci.yml
+++ b/drupalci.yml
@@ -43,6 +43,7 @@ build:
           # to work correctly, and disabling it is a known workaround.
           # @see pcre.ini
           - sudo cp modules/contrib/automatic_updates/pcre.ini /usr/local/etc/php/conf.d
+          - composer self-update 2.2.4
         halt-on-fail: true
       # run_tests task is executed several times in order of performance speeds.
       # halt-on-fail can be set on the run_tests tasks in order to fail fast.
diff --git a/package_manager/src/ProcessFactory.php b/package_manager/src/ProcessFactory.php
index bb053beadfb365feb98944988d7218426f036ec8..78ef902b2d909cb6e7dfb460ed3fe9612d5fa0e4 100644
--- a/package_manager/src/ProcessFactory.php
+++ b/package_manager/src/ProcessFactory.php
@@ -87,7 +87,7 @@ final class ProcessFactory implements ProcessFactoryInterface {
    */
   private function getComposerHomePath(): string {
     $home_path = $this->fileSystem->getTempDirectory();
-    $home_path .= '/automatic_updates_composer_home-';
+    $home_path .= '/package_manager_composer_home-';
     $home_path .= $this->configFactory->get('system.site')->get('uuid');
     $this->fileSystem->prepareDirectory($home_path, FileSystemInterface::CREATE_DIRECTORY | FileSystemInterface::MODIFY_PERMISSIONS);
 
diff --git a/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml b/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4ad510c1f437add9d15c5dbe1a9cac7dd6f5a4f5
--- /dev/null
+++ b/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.info.yml
@@ -0,0 +1,6 @@
+name: 'Package Manager Test API'
+description: 'Provides API endpoints for doing stage operations in functional tests.'
+type: module
+package: Testing
+dependencies:
+  - automatic_updates:package_manager
diff --git a/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml b/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
new file mode 100644
index 0000000000000000000000000000000000000000..4b5cf58ca1f5f30d96cb984bb097e2bccdf6fae0
--- /dev/null
+++ b/package_manager/tests/modules/package_manager_test_api/package_manager_test_api.routing.yml
@@ -0,0 +1,6 @@
+package_manager_test_api.require:
+  path: '/package-manager-test-api/require'
+  defaults:
+    _controller: 'Drupal\package_manager_test_api\ApiController::require'
+  requirements:
+    _access: 'TRUE'
diff --git a/package_manager/tests/modules/package_manager_test_api/src/ApiController.php b/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
new file mode 100644
index 0000000000000000000000000000000000000000..a9f116ff2bc82ac62497304e31790a7c1953a208
--- /dev/null
+++ b/package_manager/tests/modules/package_manager_test_api/src/ApiController.php
@@ -0,0 +1,81 @@
+<?php
+
+namespace Drupal\package_manager_test_api;
+
+use Drupal\Core\Controller\ControllerBase;
+use Drupal\package_manager\Stage;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+use Symfony\Component\HttpFoundation\JsonResponse;
+use Symfony\Component\HttpFoundation\Request;
+
+/**
+ * Provides API endpoints to interact with a staging area in functional tests.
+ */
+class ApiController extends ControllerBase {
+
+  /**
+   * The stage.
+   *
+   * @var \Drupal\package_manager\Stage
+   */
+  private $stage;
+
+  /**
+   * Constructs an ApiController object.
+   *
+   * @param \Drupal\package_manager\Stage $stage
+   *   The stage.
+   */
+  public function __construct(Stage $stage) {
+    $this->stage = $stage;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    $stage = new Stage(
+      $container->get('package_manager.path_locator'),
+      $container->get('package_manager.beginner'),
+      $container->get('package_manager.stager'),
+      $container->get('package_manager.committer'),
+      $container->get('file_system'),
+      $container->get('event_dispatcher'),
+      $container->get('tempstore.shared'),
+    );
+    return new static($stage);
+  }
+
+  /**
+   * Creates a staging area and requires packages into it.
+   *
+   * @param \Symfony\Component\HttpFoundation\Request $request
+   *   The request. The runtime and dev dependencies are expected to be in
+   *   either the query string or request body, under the 'runtime' and 'dev'
+   *   keys, respectively. There may also be a 'files_to_return' key, which
+   *   contains an array of file paths, relative to the stage directory, whose
+   *   contents should be returned in the response.
+   *
+   * @return \Symfony\Component\HttpFoundation\JsonResponse
+   *   A JSON response containing an associative array of the contents of the
+   *   staged files listed in the 'files_to_return' request key. The array will
+   *   be keyed by path, relative to the stage directory.
+   */
+  public function require(Request $request): JsonResponse {
+    $this->stage->create();
+    $this->stage->require(
+      $request->get('runtime', []),
+      $request->get('dev', [])
+    );
+
+    $stage_dir = $this->stage->getStageDirectory();
+    $staged_file_contents = [];
+    foreach ($request->get('files_to_return', []) as $path) {
+      $staged_file_contents[$path] = file_get_contents($stage_dir . '/' . $path);
+    }
+    $this->stage->destroy();
+
+    return new JsonResponse($staged_file_contents);
+  }
+
+}
diff --git a/package_manager/tests/src/Build/StagedUpdateTest.php b/package_manager/tests/src/Build/StagedUpdateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..8dbf40363380cadb19729d7d43dc343ae59bdf3e
--- /dev/null
+++ b/package_manager/tests/src/Build/StagedUpdateTest.php
@@ -0,0 +1,87 @@
+<?php
+
+namespace Drupal\Tests\package_manager\Build;
+
+/**
+ * Tests updating packages in a staging area.
+ *
+ * @group package_manager
+ */
+class StagedUpdateTest extends TemplateProjectTestBase {
+
+  /**
+   * Tests that a stage only updates packages with changed constraints.
+   */
+  public function testStagedUpdate(): void {
+    $this->createTestProject('RecommendedProject');
+
+    $this->createModule('alpha');
+    $this->createModule('bravo');
+    $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require drupal/alpha drupal/bravo --update-with-all-dependencies', 'project');
+
+    $this->installQuickStart('minimal');
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->installModules(['package_manager_test_api']);
+
+    // Change both modules' upstream version.
+    $this->runComposer('composer config version 1.1.0', 'alpha');
+    $this->runComposer('composer config version 1.1.0', 'bravo');
+
+    // Use the API endpoint to create a stage and update bravo to 1.1.0. Even
+    // though both modules are at version 1.1.0, only bravo should be updated.
+    // We ask the API to return the contents of both modules' staged
+    // composer.json files, so we can assert that the staged versions are what
+    // we expect.
+    // @see \Drupal\package_manager_test_api\ApiController::require()
+    $query = http_build_query([
+      'runtime' => [
+        'drupal/bravo:1.1.0',
+      ],
+      'files_to_return' => [
+        'web/modules/contrib/alpha/composer.json',
+        'web/modules/contrib/bravo/composer.json',
+      ],
+    ]);
+    $this->visit("/package-manager-test-api/require?$query");
+    $mink = $this->getMink();
+    $mink->assertSession()->statusCodeEquals(200);
+
+    $staged_file_contents = $mink->getSession()->getPage()->getContent();
+    $staged_file_contents = json_decode($staged_file_contents, TRUE);
+
+    $expected_versions = [
+      'alpha' => '1.0.0',
+      'bravo' => '1.1.0',
+    ];
+    foreach ($expected_versions as $module_name => $expected_version) {
+      $path = "web/modules/contrib/$module_name/composer.json";
+      $staged_composer_json = json_decode($staged_file_contents[$path]);
+      $this->assertSame($expected_version, $staged_composer_json->version);
+    }
+  }
+
+  /**
+   * Creates an empty module for testing purposes.
+   *
+   * @param string $name
+   *   The machine name of the module, which can be added to the test site as
+   *   'drupal/$name'.
+   */
+  private function createModule(string $name): void {
+    $dir = $this->getWorkspaceDirectory() . '/' . $name;
+    mkdir($dir);
+    $this->assertDirectoryExists($dir);
+    $this->runComposer("composer init --name drupal/$name --type drupal-module", $name);
+    $this->runComposer('composer config version 1.0.0', $name);
+
+    $repository = json_encode([
+      'type' => 'path',
+      'url' => $dir,
+      'options' => [
+        'symlink' => FALSE,
+      ],
+    ]);
+    $this->runComposer("composer config repo.$name '$repository'", 'project');
+  }
+
+}
diff --git a/tests/src/Build/TemplateProjectSiteTestBase.php b/package_manager/tests/src/Build/TemplateProjectTestBase.php
similarity index 77%
rename from tests/src/Build/TemplateProjectSiteTestBase.php
rename to package_manager/tests/src/Build/TemplateProjectTestBase.php
index f77f19f2b3508b67d830cb48cd417ad924d6c7ec..5b17ceed6b3659615531b99997bc1c7088fa7551 100644
--- a/tests/src/Build/TemplateProjectSiteTestBase.php
+++ b/package_manager/tests/src/Build/TemplateProjectTestBase.php
@@ -1,14 +1,19 @@
 <?php
 
-namespace Drupal\Tests\automatic_updates\Build;
+namespace Drupal\Tests\package_manager\Build;
 
 use Drupal\BuildTests\QuickStart\QuickStartTestBase;
 use Drupal\Composer\Composer;
 
 /**
  * Base class for tests which create a test site from a core project template.
+ *
+ * The test site will be created from one of the core Composer project templates
+ * (drupal/recommended-project or drupal/legacy-project) and contain complete
+ * copies of Drupal core and all installed dependencies, completely independent
+ * of the currently running code base.
  */
-abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
+abstract class TemplateProjectTestBase extends QuickStartTestBase {
 
   /**
    * The web root of the test site, relative to the workspace directory.
@@ -30,6 +35,8 @@ abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
     ];
   }
 
+  // BEGIN: DELETE FROM CORE MERGE REQUEST
+
   /**
    * {@inheritdoc}
    */
@@ -45,6 +52,8 @@ abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
     $this->runComposer('composer remove --no-update drupal/automatic_updates', 'composer/Metapackage/CoreRecommended');
   }
 
+  // END: DELETE FROM CORE MERGE REQUEST
+
   /**
    * {@inheritdoc}
    */
@@ -77,6 +86,14 @@ abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
    */
   public function installQuickStart($profile, $working_dir = NULL) {
     parent::installQuickStart($profile, $working_dir ?: $this->webRoot);
+
+    // Always allow test modules to be installed in the UI and, for easier
+    // debugging, always display errors in their dubious glory.
+    $php = <<<END
+\$settings['extension_discovery_scan_tests'] = TRUE;
+\$config['system.logging']['error_level'] = 'verbose';
+END;
+    $this->writeSettings($php);
   }
 
   /**
@@ -197,6 +214,18 @@ abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
     // Now that we know the project was created successfully, we can set the
     // web root with confidence.
     $this->webRoot = 'project/' . $this->runComposer('composer config extra.drupal-scaffold.locations.web-root', 'project');
+
+    // BEGIN: DELETE FROM CORE MERGE REQUEST
+    // Install Automatic Updates into the test project and ensure it wasn't
+    // symlinked.
+    $automatic_updates_dir = realpath(__DIR__ . '/../../../..');
+    if (str_contains($automatic_updates_dir, 'automatic_updates')) {
+      $dir = 'project';
+      $this->runComposer("composer config repo.automatic_updates path $automatic_updates_dir", $dir);
+      $output = $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer require --update-with-all-dependencies "drupal/automatic_updates:@dev"', $dir);
+      $this->assertStringNotContainsString('Symlinking', $output);
+    }
+    // END: DELETE FROM CORE MERGE REQUEST
   }
 
   /**
@@ -292,4 +321,54 @@ abstract class TemplateProjectSiteTestBase extends QuickStartTestBase {
     return $output;
   }
 
+  /**
+   * Appends PHP code to the test site's settings.php.
+   *
+   * @param string $php
+   *   The PHP code to append to the test site's settings.php.
+   */
+  protected function writeSettings(string $php): void {
+    // Ensure settings are writable, since this is the only way we can set
+    // configuration values that aren't accessible in the UI.
+    $file = $this->getWebRoot() . '/sites/default/settings.php';
+    $this->assertFileExists($file);
+    chmod(dirname($file), 0744);
+    chmod($file, 0744);
+    $this->assertFileIsWritable($file);
+
+    $stream = fopen($file, 'a');
+    $this->assertIsResource($stream);
+    $this->assertIsInt(fwrite($stream, $php));
+    $this->assertTrue(fclose($stream));
+  }
+
+  /**
+   * Installs modules in the UI.
+   *
+   * Assumes that a user with the appropriate permissions is logged in.
+   *
+   * @param string[] $modules
+   *   The machine names of the modules to install.
+   */
+  protected function installModules(array $modules): void {
+    $mink = $this->getMink();
+    $page = $mink->getSession()->getPage();
+    $assert_session = $mink->assertSession();
+
+    $this->visit('/admin/modules');
+    foreach ($modules as $module) {
+      $page->checkField("modules[$module][enable]");
+    }
+    $page->pressButton('Install');
+
+    // If there is a confirmation form warning about additional dependencies
+    // or non-stable modules, submit it.
+    $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]')
+      ->getValue();
+    if (preg_match('/^system_modules_(experimental_|non_stable_)?confirm_form$/', $form_id)) {
+      $page->pressButton('Continue');
+      $assert_session->statusCodeEquals(200);
+    }
+  }
+
 }
diff --git a/tests/src/Build/UpdateTestBase.php b/tests/src/Build/UpdateTestBase.php
index 278eeaa2596b1e8315bcb77c77d8b13e64e66bff..eb859abb1d52b6e3bf820d2c4a867c01a11823d8 100644
--- a/tests/src/Build/UpdateTestBase.php
+++ b/tests/src/Build/UpdateTestBase.php
@@ -3,11 +3,12 @@
 namespace Drupal\Tests\automatic_updates\Build;
 
 use Drupal\Component\Utility\Html;
+use Drupal\Tests\package_manager\Build\TemplateProjectTestBase;
 
 /**
  * Base class for tests that perform in-place updates.
  */
-abstract class UpdateTestBase extends TemplateProjectSiteTestBase {
+abstract class UpdateTestBase extends TemplateProjectTestBase {
 
   /**
    * A secondary server instance, to serve XML metadata about available updates.
@@ -32,27 +33,8 @@ abstract class UpdateTestBase extends TemplateProjectSiteTestBase {
   protected function createTestProject(string $template): void {
     parent::createTestProject($template);
 
-    // BEGIN: DELETE FROM CORE MERGE REQUEST
-    // Install Automatic Updates into the test project and ensure it wasn't
-    // symlinked.
-    if (__NAMESPACE__ === 'Drupal\Tests\automatic_updates\Build') {
-      $dir = 'project';
-      $this->runComposer('composer config repo.automatic_updates path ' . __DIR__ . '/../../..', $dir);
-      $this->runComposer('composer require --no-update "drupal/automatic_updates:@dev"', $dir);
-      $output = $this->runComposer('COMPOSER_MIRROR_PATH_REPOS=1 composer update --with-all-dependencies', $dir);
-      $this->assertStringNotContainsString('Symlinking', $output);
-    }
-    // END: DELETE FROM CORE MERGE REQUEST
-    // Install Drupal. Always allow test modules to be installed in the UI and,
-    // for easier debugging, always display errors in their dubious glory.
+    // Install Drupal, Automatic Updates, and other modules needed for testing.
     $this->installQuickStart('minimal');
-    $php = <<<END
-\$settings['extension_discovery_scan_tests'] = TRUE;
-\$config['system.logging']['error_level'] = 'verbose';
-END;
-    $this->writeSettings($php);
-
-    // Install Automatic Updates and other modules needed for testing.
     $this->formLogin($this->adminUsername, $this->adminPassword);
     $this->installModules([
       'automatic_updates',
@@ -61,27 +43,6 @@ END;
     ]);
   }
 
-  /**
-   * Appends PHP code to the test site's settings.php.
-   *
-   * @param string $php
-   *   The PHP code to append to the test site's settings.php.
-   */
-  protected function writeSettings(string $php): void {
-    // Ensure settings are writable, since this is the only way we can set
-    // configuration values that aren't accessible in the UI.
-    $file = $this->getWebRoot() . '/sites/default/settings.php';
-    $this->assertFileExists($file);
-    chmod(dirname($file), 0744);
-    chmod($file, 0744);
-    $this->assertFileIsWritable($file);
-
-    $stream = fopen($file, 'a');
-    $this->assertIsResource($stream);
-    $this->assertIsInt(fwrite($stream, $php));
-    $this->assertTrue(fclose($stream));
-  }
-
   /**
    * Prepares the test site to serve an XML feed of available release metadata.
    *
@@ -110,33 +71,6 @@ END;
     $this->writeSettings($code);
   }
 
-  /**
-   * Installs modules in the UI.
-   *
-   * Assumes that a user with the appropriate permissions is logged in.
-   *
-   * @param string[] $modules
-   *   The machine names of the modules to install.
-   */
-  protected function installModules(array $modules): void {
-    $mink = $this->getMink();
-    $page = $mink->getSession()->getPage();
-    $assert_session = $mink->assertSession();
-
-    $this->visit('/admin/modules');
-    foreach ($modules as $module) {
-      $page->checkField("modules[$module][enable]");
-    }
-    $page->pressButton('Install');
-
-    $form_id = $assert_session->elementExists('css', 'input[type="hidden"][name="form_id"]')
-      ->getValue();
-    if (preg_match('/^system_modules_(experimental_|non_stable_)?confirm_form$/', $form_id)) {
-      $page->pressButton('Continue');
-      $assert_session->statusCodeEquals(200);
-    }
-  }
-
   /**
    * Checks for available updates.
    *
diff --git a/tests/src/Kernel/UpdaterTest.php b/tests/src/Kernel/UpdaterTest.php
index a1c96278ceb624f49fcdce4a43122f27952c0166..70fc49e42e42bf465474b8449d6181a8cd904aa6 100644
--- a/tests/src/Kernel/UpdaterTest.php
+++ b/tests/src/Kernel/UpdaterTest.php
@@ -86,7 +86,9 @@ class UpdaterTest extends AutomaticUpdatesKernelTestBase {
     // invocation recorder, rather than a regular mock, in order to test that
     // the invocation recorder itself works.
     // The production requirements are changed first, followed by the dev
-    // requirements. Then the installed packages are updated.
+    // requirements. Then the installed packages are updated. This is tested
+    // functionally in Package Manager.
+    // @see \Drupal\Tests\package_manager\Build\StagedUpdateTest
     $expected_arguments = [
       [
         'require',