diff --git a/composer/Metapackage/CoreRecommended/composer.json b/composer/Metapackage/CoreRecommended/composer.json
index 57873e9ea44913119b198a61afe1c82dc16c6e7d..edf50f3001f9c5e04a6ded66b822e4986c6a5042 100644
--- a/composer/Metapackage/CoreRecommended/composer.json
+++ b/composer/Metapackage/CoreRecommended/composer.json
@@ -23,7 +23,7 @@
         "pear/console_getopt": "~v1.4.3",
         "pear/pear-core-minimal": "~v1.10.15",
         "pear/pear_exception": "~v1.0.2",
-        "php-tuf/composer-stager": "~v2.0.0-beta5",
+        "php-tuf/composer-stager": "~v2.0.0-rc1",
         "psr/cache": "~3.0.0",
         "psr/container": "~2.0.2",
         "psr/event-dispatcher": "~1.0.0",
diff --git a/core/modules/auto_updates/.gitlab-ci.yml b/core/modules/auto_updates/.gitlab-ci.yml
index 10a630dec94a940fbf4d5a57604ac39e057f4d72..1a371cb9a6fcc95067fdf39fa7b6891e347642aa 100644
--- a/core/modules/auto_updates/.gitlab-ci.yml
+++ b/core/modules/auto_updates/.gitlab-ci.yml
@@ -58,6 +58,7 @@ variables:
   _PHPUNIT_CONCURRENT: '1'
   _PHPUNIT_TESTGROUPS: ''
   # Always test against the previous minor version of core.
+  OPT_IN_TEST_PREVIOUS_MINOR: '1'
   # Test against the next major version of core, with Automatic Updates and
   # Package Manager included as core modules.
   OPT_IN_TEST_NEXT_MAJOR: '1'
@@ -137,6 +138,12 @@ composer:
     # Copy to web as well as \Drupal\Composer\Composer will expect it be there.
     - cp -R /tmp/drupal-core/composer web
 
+composer (previous minor):
+  before_script:
+    - !reference [composer, before_script]
+  after_script:
+    - !reference [composer, after_script]
+
 composer (next major):
   allow_failure: true
   before_script:
@@ -158,6 +165,11 @@ composer (next major):
     - test -d $_WEB_ROOT/core/modules/package_manager
     - rm -r -f $MODULE_DIR
 
+composer (previous minor):
+  before_script:
+    - !reference [composer, before_script]
+  after_script:
+    - !reference [composer, after_script]
 
 phpcs:
   before_script:
@@ -175,6 +187,34 @@ phpstan (next major):
   rules:
     - when: never
 
+phpunit:
+  parallel:
+    matrix:
+      - MODULE:
+          - auto_updates_extensions
+          - auto_updates
+          - package_manager
+        TEST_TYPE:
+          - Unit
+          - Kernel
+          - Functional
+          - FunctionalJavascript
+          - Build
+  rules:
+    # Automatic Updates Extensions has no unit or functional JavaScript tests.
+    - if: $MODULE == "auto_updates_extensions" && ($TEST_TYPE == "Unit" || $TEST_TYPE == "FunctionalJavascript")
+      when: never
+    # Package Manager has no functional JavaScript tests.
+    - if: $MODULE == "package_manager" && $TEST_TYPE == "FunctionalJavascript"
+      when: never
+    - when: on_success
+  variables:
+    _PHPUNIT_EXTRA: '--types PHPUnit-$TEST_TYPE --module $MODULE'
+
+phpunit (previous minor):
+  rules:
+    - !reference [phpunit, rules]
+
 phpunit (next major):
   allow_failure: true
   parallel:
@@ -183,6 +223,10 @@ phpunit (next major):
           - auto_updates
           - package_manager
         TEST_TYPE:
+          - Unit
+          - Kernel
+          - Functional
+          - FunctionalJavascript
           - Build
   rules:
     - if: $OPT_IN_TEST_NEXT_MAJOR != "1" || $SKIP_PHPUNIT == "1"
diff --git a/core/modules/package_manager/src/ComposerInspector.php b/core/modules/package_manager/src/ComposerInspector.php
index 1270c7ce59e60ff285564b36996f8ccba3c4f78b..b8e165321f9adeefc785fff907345059521363a1 100644
--- a/core/modules/package_manager/src/ComposerInspector.php
+++ b/core/modules/package_manager/src/ComposerInspector.php
@@ -263,7 +263,7 @@ public function getConfig(string $key, string $context): ?string {
       }
     }
     $output = $this->processCallback->getOutput();
-    return isset($output) ? trim($output) : $output;
+    return $output ? trim(implode('', $output)) : NULL;
   }
 
   /**
diff --git a/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php b/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php
index fe9c6302eba1edde1d20a5ad2469560b2182e35c..5795513d95d5d67c70288d8ade8d0c141b2960e2 100644
--- a/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php
+++ b/core/modules/package_manager/src/PathExcluder/SiteFilesExcluder.php
@@ -53,6 +53,7 @@ public function excludeSiteFiles(CollectPathsToExcludeEvent $event): void {
     // 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.
+    // @todo Take into account `assets:://` in https://drupal.org/i/3441926.
     foreach (['public', 'private'] as $scheme) {
       $wrapper = $this->streamWrapperManager->getViaScheme($scheme);
       if ($wrapper instanceof LocalStream) {
diff --git a/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php b/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
index d760a694c4654bb78bfd370537bf76a7a86fdfab..ad71b5561c2f24b5eaf6eea70f7c3584dfbfe77d 100644
--- a/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
+++ b/core/modules/package_manager/src/PathExcluder/SqliteDatabaseExcluder.php
@@ -12,6 +12,9 @@
 /**
  * Excludes SQLite database files from stage operations.
  *
+ * @todo This class should handle all database connections not just the default
+ *   connection in https://drupal.org/i/3441919.
+ *
  * @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
diff --git a/core/modules/package_manager/src/ProcessOutputCallback.php b/core/modules/package_manager/src/ProcessOutputCallback.php
index 0f4096ebdfa0b9494d04a27efa8d6f6835f23da7..86eaabde2f98e731f27d869cc35e98ccd710845d 100644
--- a/core/modules/package_manager/src/ProcessOutputCallback.php
+++ b/core/modules/package_manager/src/ProcessOutputCallback.php
@@ -29,14 +29,14 @@ final class ProcessOutputCallback implements OutputCallbackInterface, LoggerAwar
    *
    * @var string
    */
-  private string $outBuffer = '';
+  private array $outBuffer = [];
 
   /**
    * The error buffer.
    *
    * @var string
    */
-  private string $errorBuffer = '';
+  private array $errorBuffer = [];
 
   /**
    * Constructs a ProcessOutputCallback object.
@@ -51,10 +51,10 @@ public function __construct() {
   public function __invoke(OutputTypeEnum $type, string $buffer): void {
 
     if ($type === OutputTypeEnum::OUT) {
-      $this->outBuffer .= $buffer;
+      $this->outBuffer[] = $buffer;
     }
     elseif ($type === OutputTypeEnum::ERR) {
-      $this->errorBuffer .= $buffer;
+      $this->errorBuffer[] = $buffer;
     }
   }
 
@@ -66,12 +66,12 @@ public function __invoke(OutputTypeEnum $type, string $buffer): void {
    * @return string|null
    *   The output or NULL if there is none.
    */
-  public function getOutput(): ?string {
+  public function getOutput(): array {
     $error_output = $this->getErrorOutput();
     if ($error_output) {
-      $this->logger->warning($error_output);
+      $this->logger->warning(implode('', $error_output));
     }
-    return trim($this->outBuffer) !== '' ? $this->outBuffer : NULL;
+    return $this->outBuffer;
   }
 
   /**
@@ -82,8 +82,8 @@ public function getOutput(): ?string {
    */
   public function parseJsonOutput(): mixed {
     $output = $this->getOutput();
-    if ($output !== NULL) {
-      return json_decode($output, TRUE, flags: JSON_THROW_ON_ERROR);
+    if ($output) {
+      return json_decode(trim(implode('', $output)), TRUE, flags: JSON_THROW_ON_ERROR);
     }
     return NULL;
   }
@@ -94,8 +94,22 @@ public function parseJsonOutput(): mixed {
    * @return string|null
    *   The error output or NULL if there isn't any.
    */
-  public function getErrorOutput(): ?string {
-    return trim($this->errorBuffer) !== '' ? $this->errorBuffer : NULL;
+  public function getErrorOutput(): array {
+    return $this->errorBuffer;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function clearErrorOutput(): void {
+    $this->errorBuffer = [];
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function clearOutput(): void {
+    $this->outBuffer = [];
   }
 
   /**
@@ -104,8 +118,8 @@ public function getErrorOutput(): ?string {
    * @return self
    */
   public function reset(): self {
-    $this->errorBuffer = '';
-    $this->outBuffer = '';
+    $this->clearErrorOutput();
+    $this->clearOutput();
     return $this;
   }
 
diff --git a/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php b/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php
index 2830cc9b58ca42044816ed0da729a1409c12ae25..91fbdaae2cb5b0e0bef8e6d0b6055ac3cd660b8e 100644
--- a/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php
+++ b/core/modules/package_manager/src/Validator/StagedDBUpdateValidator.php
@@ -70,17 +70,6 @@ public function checkForStagedDatabaseUpdates(StatusCheckEvent $event): void {
    * @return bool
    *   TRUE if the staged copy of the extension has changed update functions
    *   compared to the active copy, FALSE otherwise.
-   *
-   * @todo In https://drupal.org/i/3253828 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.
    */
   public function hasStagedUpdates(string $stage_dir, Extension $extension): bool {
     $active_dir = $this->pathLocator->getProjectRoot();
diff --git a/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php b/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php
index 6de6b6d443baf48ae83064c09184792f3de4396d..43b75169dcb3bf94789e0313858170246a1139ed 100644
--- a/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php
+++ b/core/modules/package_manager/tests/modules/fixture_manipulator/src/FixtureManipulator.php
@@ -445,6 +445,34 @@ public function __invoke(OutputTypeEnum $type, string $buffer): void {
         }
       }
 
+      /**
+       * {@inheritdoc}
+       */
+      public function clearErrorOutput(): void {
+        throw new \LogicException("Unexpected call to clearErrorOutput().");
+      }
+
+      /**
+       * {@inheritdoc}
+       */
+      public function clearOutput(): void {
+        throw new \LogicException("Unexpected call to clearOutput().");
+      }
+
+      /**
+       * {@inheritdoc}
+       */
+      public function getErrorOutput(): array {
+        throw new \LogicException("Unexpected call to getErrorOutput().");
+      }
+
+      /**
+       * {@inheritdoc}
+       */
+      public function getOutput(): array {
+        throw new \LogicException("Unexpected call to getOutput().");
+      }
+
     };
     /** @var \PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface $runner */
     $runner = \Drupal::service(ComposerProcessRunnerInterface::class);
diff --git a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
index d2a0a64992df88a7502e69759953a217455f35f9..5bad6ac6076b2978b48d583dbd2d2c793ea44d5b 100644
--- a/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
+++ b/core/modules/package_manager/tests/src/Build/TemplateProjectTestBase.php
@@ -10,6 +10,7 @@
 use Drupal\Composer\Composer;
 use Drupal\package_manager\Event\CollectPathsToExcludeEvent;
 use Drupal\package_manager_test_event_logger\EventSubscriber\EventLogSubscriber;
+use Drupal\sqlite\Driver\Database\sqlite\Install\Tasks;
 use Drupal\Tests\package_manager\Traits\AssertPreconditionsTrait;
 use Drupal\Tests\package_manager\Traits\FixtureUtilityTrait;
 use Drupal\Tests\RandomGeneratorTrait;
@@ -61,6 +62,18 @@ abstract class TemplateProjectTestBase extends QuickStartTestBase {
    */
   protected const MAX_EXECUTION_TIME = 20;
 
+  /**
+   * {@inheritdoc}
+   */
+  protected function setUp(): void {
+    // Build tests cannot be run if Sqlite minimum version is not met.
+    $sqlite = (new \PDO('sqlite::memory:'))->query('select sqlite_version()')->fetch()[0];
+    if (version_compare($sqlite, Tasks::SQLITE_MINIMUM_VERSION) < 0) {
+      $this->markTestSkipped();
+    }
+    parent::setUp();
+  }
+
   /**
    * {@inheritdoc}
    */
@@ -169,19 +182,7 @@ protected function instantiateServer($port, $working_dir = NULL) {
    * {@inheritdoc}
    */
   public function installQuickStart($profile, $working_dir = NULL) {
-    // parent::installQuickStart("$profile --no-ansi", $working_dir ?: $this->webRoot);.
-    // Change to match our previous call to parent.
-    $profile = "$profile --no-ansi";
-    $working_dir = $working_dir ?: $this->webRoot;
-    // Just for debugging copy parent here.
-    $php_finder = new PhpExecutableFinder();
-    $install_process = $this->executeCommand($php_finder->find() . ' ./core/scripts/drupal install ' . $profile, $working_dir);
-    // This will get the error output.
-    $this->assertCommandSuccessful();
-    $this->assertCommandOutputContains('Username:');
-    preg_match('/Username: (.+)\vPassword: (.+)/', $install_process->getOutput(), $matches);
-    $this->assertNotEmpty($this->adminUsername = $matches[1]);
-    $this->assertNotEmpty($this->adminPassword = $matches[2]);
+    parent::installQuickStart("$profile --no-ansi", $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.
diff --git a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php
index 297498dd48820ac6801b539a97c3c74e1d2594dc..466112f4a99e41e4d4e70cf6ae57ba888b22fdea 100644
--- a/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php
+++ b/core/modules/package_manager/tests/src/Kernel/ComposerInspectorTest.php
@@ -10,7 +10,6 @@
 use Drupal\package_manager\ComposerInspector;
 use Drupal\package_manager\Exception\ComposerNotReadyException;
 use Drupal\package_manager\InstalledPackage;
-use Drupal\package_manager\ProcessOutputCallback;
 use Drupal\package_manager\InstalledPackagesList;
 use Drupal\Tests\package_manager\Traits\InstalledPackagesListTrait;
 use Drupal\package_manager\PathLocator;
@@ -19,6 +18,7 @@
 use PhpTuf\ComposerStager\API\Path\Factory\PathFactoryInterface;
 use PhpTuf\ComposerStager\API\Precondition\Service\ComposerIsAvailableInterface;
 use PhpTuf\ComposerStager\API\Process\Service\ComposerProcessRunnerInterface;
+use PhpTuf\ComposerStager\API\Process\Service\OutputCallbackInterface;
 use PhpTuf\ComposerStager\API\Process\Value\OutputTypeEnum;
 use Prophecy\Argument;
 use Prophecy\Prophecy\ObjectProphecy;
@@ -250,10 +250,18 @@ public function testComposerFilesDoNotExist(string $filename): void {
   public function testVersionCheck(?string $reported_version, ?string $expected_message): void {
     $runner = $this->mockComposerRunner($reported_version);
 
+    // Mock the ComposerIsAvailableInterface so that if it uses the Composer
+    // runner it will not affect the test expectations.
+    $composerPrecondition = $this->prophesize(ComposerIsAvailableInterface::class);
+    $composerPrecondition
+      ->assertIsFulfilled(Argument::cetera())
+      ->shouldBeCalledOnce();
+    $this->container->set(ComposerIsAvailableInterface::class, $composerPrecondition->reveal());
+
     // The result of the version check is statically cached, so the runner
     // should only be called once, even though we call validate() twice in this
     // test.
-    $runner->getMethodProphecies('run')[0]->shouldBeCalledOnce();
+    $runner->getMethodProphecies('run')[0]->withArguments([['--format=json'], NULL, [], Argument::any()])->shouldBeCalledOnce();
     // The runner should be called with `validate` as the first argument, but
     // it won't affect the outcome of this test.
     $runner->run(Argument::withEntry(0, 'validate'));
@@ -535,7 +543,7 @@ private function mockComposerRunner(?string $reported_version): ObjectProphecy {
       ]);
 
       $callback = end($arguments_passed_to_runner);
-      assert($callback instanceof ProcessOutputCallback);
+      assert($callback instanceof OutputCallbackInterface);
       $callback(OutputTypeEnum::OUT, $command_output);
     };
 
diff --git a/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php b/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php
index 84b9ed63e81449c1e7b007e6c8d42bd002d163d4..b401fbd119cb8e477f578a3ad8d4ccfd09e820a9 100644
--- a/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php
+++ b/core/modules/package_manager/tests/src/Unit/ProcessOutputCallbackTest.php
@@ -38,13 +38,13 @@ public function testErrorOutputOnly(): void {
     $error_text = 'What happened?';
     $callback(OutputTypeEnum::ERR, $error_text);
 
-    $this->assertSame($error_text, $callback->getErrorOutput());
+    $this->assertSame([$error_text], $callback->getErrorOutput());
     // The error should not yet be logged.
     $this->assertEmpty($logger->records);
 
     // There should be no output data, but calling getOutput() should log the
     // error.
-    $this->assertNull($callback->getOutput());
+    $this->assertSame([], $callback->getOutput());
     $this->assertNull($callback->parseJsonOutput());
     $this->assertTrue($logger->hasWarning($error_text));
 
@@ -63,8 +63,8 @@ public function testCallback(): void {
     $callback->setLogger($logger);
 
     // The buffers should initially be empty, and nothing should be logged.
-    $this->assertNull($callback->getOutput());
-    $this->assertNull($callback->getErrorOutput());
+    $this->assertSame([], $callback->getOutput());
+    $this->assertSame([], $callback->getErrorOutput());
     $this->assertNull($callback->parseJsonOutput());
     $this->assertEmpty($logger->records);
 
@@ -77,13 +77,15 @@ public function testCallback(): void {
     $json = json_encode($data, JSON_PRETTY_PRINT);
     // Ensure the JSON is a multi-line string.
     $this->assertGreaterThan(1, substr_count($json, "\n"));
+    $expected_output = [];
     foreach (explode("\n", $json) as $line) {
       $callback(OutputTypeEnum::OUT, "$line\n");
+      $expected_output[] = "$line\n";
     }
-    $this->assertSame("$json\n", $callback->getOutput());
+    $this->assertSame($expected_output, $callback->getOutput());
     // Ensure that parseJsonOutput() can parse the data without errors.
     $this->assertSame($data, $callback->parseJsonOutput());
-    $this->assertNull($callback->getErrorOutput());
+    $this->assertSame([], $callback->getErrorOutput());
     $this->assertEmpty($logger->records);
 
     // If we send error output, it should be logged, but we should still be able
@@ -91,34 +93,37 @@ public function testCallback(): void {
     $callback(OutputTypeEnum::ERR, 'Oh no, what happened?');
     $callback(OutputTypeEnum::ERR, 'Really what happened?!');
     $this->assertSame($data, $callback->parseJsonOutput());
-    $this->assertSame('Oh no, what happened?Really what happened?!', $callback->getErrorOutput());
+    $expected_error = ['Oh no, what happened?', 'Really what happened?!'];
+    $this->assertSame($expected_error, $callback->getErrorOutput());
     $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!'));
 
     // Send more output and error data to the callback; they should be appended
     // to the data we previously sent.
     $callback(OutputTypeEnum::OUT, '{}');
+    $expected_output[] = '{}';
     $callback(OutputTypeEnum::ERR, 'new Error 1!');
     $callback(OutputTypeEnum::ERR, 'new Error 2!');
+    $expected_error[] = 'new Error 1!';
+    $expected_error[] = 'new Error 2!';
     // The output buffer will no longer be valid JSON, so don't try to parse it.
-    $this->assertSame("$json\n{}", $callback->getOutput());
-    $expected_error = 'Oh no, what happened?Really what happened?!new Error 1!new Error 2!';
+    $this->assertSame($expected_output, $callback->getOutput());
     $this->assertSame($expected_error, $callback->getErrorOutput());
-    $this->assertTrue($logger->hasWarning($expected_error));
+    $this->assertTrue($logger->hasWarning(implode('', $expected_error)));
     // The previously logged error output should still be there.
     $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!'));
 
     // Clear all stored output and errors.
     $callback->reset();
-    $this->assertNull($callback->getOutput());
-    $this->assertNull($callback->getErrorOutput());
+    $this->assertSame([], $callback->getOutput());
+    $this->assertSame([], $callback->getErrorOutput());
     $this->assertNull($callback->parseJsonOutput());
 
     // Send more output and error data.
     $callback(OutputTypeEnum::OUT, 'Bonjour!');
     $callback(OutputTypeEnum::ERR, 'You continue to annoy me.');
     // We should now only see the stuff we just sent...
-    $this->assertSame('Bonjour!', $callback->getOutput());
-    $this->assertSame('You continue to annoy me.', $callback->getErrorOutput());
+    $this->assertSame(['Bonjour!'], $callback->getOutput());
+    $this->assertSame(['You continue to annoy me.'], $callback->getErrorOutput());
     $this->assertTrue($logger->hasWarning('You continue to annoy me.'));
     // ...but the previously logged errors should still be there.
     $this->assertTrue($logger->hasWarning('Oh no, what happened?Really what happened?!new Error 1!new Error 2!'));