diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index e2477e68f65d9b075c659771c5fe35ed71d33cc9..98731c25627d5ecaa896a8a3e6bde78e6b289c57 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -5,6 +5,8 @@ services:
   automatic_updates.updater:
     class: Drupal\automatic_updates\Updater
     arguments: ['@state', '@string_translation','@package_manager.beginner', '@package_manager.stager', '@package_manager.cleaner', '@package_manager.committer', '@event_dispatcher', '@automatic_updates.path_locator']
+
+  # Package Manager service decorators.
   automatic_updates.cleaner:
     class: Drupal\automatic_updates\ComposerStager\Cleaner
     decorates: package_manager.cleaner
@@ -14,6 +16,15 @@ services:
       - '%site.path%'
       - '@automatic_updates.path_locator'
     properties: { _serviceId: package_manager.cleaner }
+  automatic_updates.committer:
+    class: Drupal\automatic_updates\ComposerStager\Committer
+    decorates: package_manager.committer
+    public: false
+    arguments:
+      - '@automatic_updates.committer.inner'
+      - '@update.manager'
+    properties: { _serviceId: package_manager.committer }
+
   automatic_updates.excluded_paths_subscriber:
     class: Drupal\automatic_updates\Event\ExcludedPathsSubscriber
     arguments: ['%app.root%', '%site.path%', '@file_system', '@stream_wrapper_manager']
diff --git a/src/ComposerStager/Committer.php b/src/ComposerStager/Committer.php
new file mode 100644
index 0000000000000000000000000000000000000000..d8e73f928ae949e1c461af764657cd9fbf4722ef
--- /dev/null
+++ b/src/ComposerStager/Committer.php
@@ -0,0 +1,57 @@
+<?php
+
+namespace Drupal\automatic_updates\ComposerStager;
+
+use Drupal\update\UpdateManagerInterface;
+use PhpTuf\ComposerStager\Domain\CommitterInterface;
+use PhpTuf\ComposerStager\Domain\Output\ProcessOutputCallbackInterface;
+
+/**
+ * Defines a committer service that clears stored update data.
+ */
+class Committer implements CommitterInterface {
+
+  /**
+   * The decorated committer service.
+   *
+   * @var \PhpTuf\ComposerStager\Domain\CommitterInterface
+   */
+  protected $decorated;
+
+  /**
+   * The update manager service.
+   *
+   * @var \Drupal\update\UpdateManagerInterface
+   */
+  protected $updateManager;
+
+  /**
+   * Constructs a Committer object.
+   *
+   * @param \PhpTuf\ComposerStager\Domain\CommitterInterface $decorated
+   *   The decorated committer service.
+   * @param \Drupal\update\UpdateManagerInterface $update_manager
+   *   The update manager service.
+   */
+  public function __construct(CommitterInterface $decorated, UpdateManagerInterface $update_manager) {
+    $this->decorated = $decorated;
+    $this->updateManager = $update_manager;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function commit(string $stagingDir, string $activeDir, ?array $exclusions = [], ?ProcessOutputCallbackInterface $callback = NULL, ?int $timeout = 120): void {
+    $this->decorated->commit($stagingDir, $activeDir, $exclusions, $callback, $timeout);
+    $this->updateManager->refreshUpdateData();
+    update_storage_clear();
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function directoryExists(string $stagingDir): bool {
+    return $this->decorated->directoryExists($stagingDir);
+  }
+
+}
diff --git a/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml b/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml
index 3f4200e4f9f8b6ec25cec6a43345053751e344d9..bf9090ff7a89b0e9380e6aef0f727cdf6b00c5e8 100644
--- a/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml
+++ b/tests/modules/automatic_updates_test/automatic_updates_test.routing.yml
@@ -1,7 +1,13 @@
-automatic_updates_test.update_test:
+automatic_updates_test.metadata:
   path: '/automatic-update-test/{project_name}/{version}'
   defaults:
     _title: 'Update test'
-    _controller: '\Drupal\automatic_updates_test\MetadataController::updateTest'
+    _controller: '\Drupal\automatic_updates_test\TestController::metadata'
+  requirements:
+    _access: 'TRUE'
+automatic_updates_test.update:
+  path: '/automatic-update-test/update/{to_version}'
+  defaults:
+    _controller: '\Drupal\automatic_updates_test\TestController::update'
   requirements:
     _access: 'TRUE'
diff --git a/tests/modules/automatic_updates_test/src/MetadataController.php b/tests/modules/automatic_updates_test/src/TestController.php
similarity index 59%
rename from tests/modules/automatic_updates_test/src/MetadataController.php
rename to tests/modules/automatic_updates_test/src/TestController.php
index c00cce391a893d75347b32b67c4118b3b53613ee..7772d1f01b4bbdca5844053c41026d89b48fc5b1 100644
--- a/tests/modules/automatic_updates_test/src/MetadataController.php
+++ b/tests/modules/automatic_updates_test/src/TestController.php
@@ -2,11 +2,42 @@
 
 namespace Drupal\automatic_updates_test;
 
+use Drupal\automatic_updates\UpdateRecommender;
+use Drupal\Component\Utility\Environment;
 use Drupal\Core\Controller\ControllerBase;
 use Symfony\Component\HttpFoundation\BinaryFileResponse;
 use Symfony\Component\HttpFoundation\Response;
 
-class MetadataController extends ControllerBase {
+class TestController extends ControllerBase {
+
+  /**
+   * Performs an in-place update to a given version of Drupal core.
+   *
+   * This executes the update immediately, in one request, without using the
+   * batch system or cron as wrappers.
+   *
+   * @param string $to_version
+   *   The version of core to update to.
+   *
+   * @return array
+   *   The renderable array of the page.
+   */
+  public function update(string $to_version): array {
+    // Let it take as long as it needs.
+    Environment::setTimeLimit(0);
+
+    /** @var \Drupal\automatic_updates\Updater $updater */
+    $updater = \Drupal::service('automatic_updates.updater');
+    $updater->begin(['drupal' => $to_version]);
+    $updater->stage();
+    $updater->commit();
+    $updater->clean();
+
+    $project = (new UpdateRecommender())->getProjectInfo();
+    return [
+      '#markup' => $project['existing_version'],
+    ];
+  }
 
   /**
    * Page callback: Prints mock XML for the Update Manager module.
@@ -16,7 +47,7 @@ class MetadataController extends ControllerBase {
    * testing automatic updates. This was done in order to use a different
    * directory of mock XML files.
    */
-  public function updateTest($project_name = 'drupal', $version = NULL): Response {
+  public function metadata($project_name = 'drupal', $version = NULL): Response {
     if ($project_name !== 'drupal') {
       return new Response();
     }
diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php
index 3a7f02c459bcc0eaa185c5299d2d4f1eb8cdb0e2..763d554682f3c8aa26e49114adba11a356d34260 100644
--- a/tests/src/Build/CoreUpdateTest.php
+++ b/tests/src/Build/CoreUpdateTest.php
@@ -103,6 +103,14 @@ class CoreUpdateTest extends UpdateTestBase {
     ];
   }
 
+  /**
+   * Tests an end-to-end core update via the API.
+   */
+  public function testApi(): void {
+    $this->visit('/automatic-update-test/update/9.8.1');
+    $this->getMink()->assertSession()->pageTextContains('9.8.1');
+  }
+
   /**
    * Tests an end-to-end core update via the UI.
    */
diff --git a/tests/src/Build/UpdateTestBase.php b/tests/src/Build/UpdateTestBase.php
index a757c3d173cbcedf0e2b79157c914ad2ee97d665..e995cb804dab7341abd54f64d52b354218870cc9 100644
--- a/tests/src/Build/UpdateTestBase.php
+++ b/tests/src/Build/UpdateTestBase.php
@@ -83,7 +83,7 @@ abstract class UpdateTestBase extends QuickStartTestBase {
    * @param array $xml_map
    *   The update XML map, as used by update_test.settings.
    *
-   * @see \Drupal\automatic_updates_test\MetadataController::updateTest()
+   * @see \Drupal\automatic_updates_test\TestController::metadata()
    */
   protected function setReleaseMetadata(array $xml_map): void {
     $xml_map = var_export($xml_map, TRUE);