diff --git a/automatic_updates.services.yml b/automatic_updates.services.yml
index 61b9e56e5294e73963d683c7f0a8b7ac4709b6ac..8f486c9b19c134dc57e23f6d1b19446947210759 100644
--- a/automatic_updates.services.yml
+++ b/automatic_updates.services.yml
@@ -108,3 +108,12 @@ services:
     class: Drupal\automatic_updates\EventSubscriber\CronOverride
     tags:
       - { name: config.factory.override }
+  automatic_updates.update:
+    class: Drupal\automatic_updates\Services\InPlaceUpdate
+    arguments:
+      - '@logger.channel.automatic_updates'
+      - '@plugin.manager.archiver'
+      - '@config.factory'
+      - '@file_system'
+      - '@http_client'
+      - '@automatic_updates.drupal_finder'
diff --git a/composer.json b/composer.json
index 58ff78dd1076e5b99a75c16e6dee556fb1abc4dc..96dd1871acc2fd313a3d37db6bdd238574cb4ca0 100644
--- a/composer.json
+++ b/composer.json
@@ -12,9 +12,10 @@
   },
   "require": {
     "ext-json": "*",
+    "ext-zip": "*",
     "composer/semver": "^1.0",
     "ocramius/package-versions": "^1.4",
-    "webflo/drupal-finder": "^1.1"
+    "webflo/drupal-finder": "^1.2"
   },
   "require-dev": {
     "drupal/ctools": "3.2.0"
diff --git a/config/install/automatic_updates.settings.yml b/config/install/automatic_updates.settings.yml
index 0e900e9b0a50be2cbf66d9d63ec9da57586ec076..a5dce8b4eb763e7bf54c86976964afdb4af73122 100644
--- a/config/install/automatic_updates.settings.yml
+++ b/config/install/automatic_updates.settings.yml
@@ -5,3 +5,4 @@ check_frequency: 43200
 enable_readiness_checks: true
 hashes_uri: 'https://updates.drupal.org/release-hashes'
 ignored_paths: "modules/custom/*\nthemes/custom/*\nprofiles/custom/*"
+download_uri: 'https://www.drupal.org/in-place-updates'
diff --git a/config/schema/automatic_updates.schema.yml b/config/schema/automatic_updates.schema.yml
index 0a45a08a0e950b49f5fef51eccf0fc87c5a7487a..88a2ed4430fe685fdab2685223291891a646f0aa 100644
--- a/config/schema/automatic_updates.schema.yml
+++ b/config/schema/automatic_updates.schema.yml
@@ -23,3 +23,6 @@ automatic_updates.settings:
     ignored_paths:
       type: string
       label: 'List of files paths to ignore when running readiness checks'
+    download_uri:
+      type: string
+      label: 'URI for downloading in-place update assets'
diff --git a/drupalci.yml b/drupalci.yml
index 8fa0ea2178d7c515b6b6c57373672a8fe88dc23d..82376884f72655b143fd0f32d748633bdc96d537 100644
--- a/drupalci.yml
+++ b/drupalci.yml
@@ -4,19 +4,23 @@ build:
   assessment:
     validate_codebase:
       phplint:
-      container_composer:
       phpcs:
         # phpcs will use core's specified version of Coder.
         sniff-all-files: true
         halt-on-fail: true
     testing:
       container_command:
-        commands: "cd ${SOURCE_DIR} && sudo -u www-data composer require drupal/ctools:3.2.0 --prefer-source --optimize-autoloader"
+        commands:
+          - cd ${SOURCE_DIR} && sudo -u www-data composer require drupal/ctools:3.2.0 --prefer-source --optimize-autoloader
+          - cd ${SOURCE_DIR} && sudo -u www-data curl https://www.drupal.org/files/issues/2019-10-07/3085728.patch | git apply -
+          - cd ${SOURCE_DIR} && sudo -u www-data curl https://www.drupal.org/files/issues/2019-10-07/3086373.patch | git apply -
+
       # run_tests task is executed several times in order of performance speeds.
       # halt-on-fail can be set on the run_tests tasks in order to fail fast.
       # suppress-deprecations is false in order to be alerted to usages of
       # deprecated code.
       run_tests.standard:
-        types: 'Simpletest,PHPUnit-Unit,PHPUnit-Kernel,PHPUnit-Functional'
+        types: 'PHPUnit-Unit,PHPUnit-Kernel,PHPUnit-Functional,PHPUnit-Build'
         testgroups: '--all'
         suppress-deprecations: false
+        halt-on-fail: false
diff --git a/src/Services/InPlaceUpdate.php b/src/Services/InPlaceUpdate.php
new file mode 100644
index 0000000000000000000000000000000000000000..bcb0430a98861d5614c933c6f9d5baca8544b141
--- /dev/null
+++ b/src/Services/InPlaceUpdate.php
@@ -0,0 +1,451 @@
+<?php
+
+namespace Drupal\automatic_updates\Services;
+
+use Drupal\Core\Archiver\ArchiverInterface;
+use Drupal\Core\Archiver\ArchiverManager;
+use Drupal\Core\Config\ConfigFactoryInterface;
+use Drupal\Core\File\Exception\FileException;
+use Drupal\Core\File\FileSystemInterface;
+use Drupal\Core\Url;
+use DrupalFinder\DrupalFinder;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\RequestException;
+use Psr\Log\LoggerInterface;
+
+/**
+ * Class to apply in-place updates.
+ */
+class InPlaceUpdate implements UpdateInterface {
+
+  /**
+   * The manifest file that lists all file deletions.
+   */
+  const DELETION_MANIFEST = 'DELETION_MANIFEST.txt';
+
+  /**
+   * The checksum file with hashes of archive file contents.
+   */
+  const CHECKSUM_LIST = 'checksumlist.csig';
+
+  /**
+   * The directory inside the archive for file additions and modifications.
+   */
+  const ARCHIVE_DIRECTORY = 'files/';
+
+  /**
+   * The logger.
+   *
+   * @var \Psr\Log\LoggerInterface
+   */
+  protected $logger;
+
+  /**
+   * The archive manager.
+   *
+   * @var \Drupal\Core\Archiver\ArchiverManager
+   */
+  protected $archiveManager;
+
+  /**
+   * The config factory.
+   *
+   * @var \Drupal\Core\Config\ConfigFactoryInterface
+   */
+  protected $configFactory;
+
+  /**
+   * The file system service.
+   *
+   * @var \Drupal\Core\File\FileSystemInterface
+   */
+  protected $fileSystem;
+
+  /**
+   * The HTTP client service.
+   *
+   * @var \GuzzleHttp\Client
+   */
+  protected $httpClient;
+
+  /**
+   * The root file path.
+   *
+   * @var string
+   */
+  protected $rootPath;
+
+  /**
+   * The vendor file path.
+   *
+   * @var string
+   */
+  protected $vendorPath;
+
+  /**
+   * The folder where files are backed up.
+   *
+   * @var string
+   */
+  protected $backup;
+
+  /**
+   * The temporary extract directory.
+   *
+   * @var string
+   */
+  protected $tempDirectory;
+
+  /**
+   * Constructs an InPlaceUpdate.
+   *
+   * @param \Psr\Log\LoggerInterface $logger
+   *   The logger.
+   * @param \Drupal\Core\Archiver\ArchiverManager $archive_manager
+   *   The archive manager.
+   * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory
+   *   The config factory.
+   * @param \Drupal\Core\File\FileSystemInterface $file_system
+   *   The filesystem service.
+   * @param \GuzzleHttp\ClientInterface $http_client
+   *   The HTTP client service.
+   * @param \DrupalFinder\DrupalFinder $drupal_finder
+   *   The Drupal finder service.
+   */
+  public function __construct(LoggerInterface $logger, ArchiverManager $archive_manager, ConfigFactoryInterface $config_factory, FileSystemInterface $file_system, ClientInterface $http_client, DrupalFinder $drupal_finder) {
+    $this->logger = $logger;
+    $this->archiveManager = $archive_manager;
+    $this->configFactory = $config_factory;
+    $this->fileSystem = $file_system;
+    $this->httpClient = $http_client;
+    $drupal_finder->locateRoot(getcwd());
+    $this->rootPath = $drupal_finder->getDrupalRoot();
+    $this->vendorPath = rtrim($drupal_finder->getVendorDir(), '/\\') . DIRECTORY_SEPARATOR;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public function update($project_name, $project_type, $from_version, $to_version) {
+    $success = FALSE;
+    if ($project_name === 'drupal') {
+      $project_root = $this->rootPath;
+    }
+    else {
+      $project_root = drupal_get_path($project_type, $project_name);
+    }
+    if ($archive = $this->getArchive($project_name, $from_version, $to_version)) {
+      if ($this->backup($archive, $project_root)) {
+        $success = $this->processUpdate($archive, $project_root);
+      }
+      if (!$success) {
+        $this->rollback($project_root);
+      }
+    }
+    return $success;
+  }
+
+  /**
+   * Get an archive with the quasi-patch contents.
+   *
+   * @param string $project_name
+   *   The project name.
+   * @param string $from_version
+   *   The current project version.
+   * @param string $to_version
+   *   The desired next project version.
+   *
+   * @return \Drupal\Core\Archiver\ArchiverInterface|null
+   *   The archive or NULL if download fails.
+   */
+  protected function getArchive($project_name, $from_version, $to_version) {
+    $url = $this->buildUrl($project_name, $this->getQuasiPatchFileName($project_name, $from_version, $to_version));
+    $destination = $this->fileSystem->realpath($this->fileSystem->getDestinationFilename("temporary://$project_name.zip", FileSystemInterface::EXISTS_RENAME));
+    try {
+      $this->httpClient->get($url, ['sink' => $destination]);
+      /** @var \Drupal\Core\Archiver\ArchiverInterface $archive */
+      return $this->archiveManager->getInstance(['filepath' => $destination]);
+
+    }
+    catch (RequestException $exception) {
+      $this->logger->error('Update for @project to version @version failed for reason @message', [
+        '@project' => $project_name,
+        '@version' => $to_version,
+        '@message' => $exception->getMessage(),
+      ]);
+    }
+  }
+
+  /**
+   * Process update.
+   *
+   * @param \Drupal\Core\Archiver\ArchiverInterface $archive
+   *   The archive.
+   * @param string $project_root
+   *   The project root directory.
+   *
+   * @return bool
+   *   Return TRUE if update succeeds, FALSE otherwise.
+   */
+  protected function processUpdate(ArchiverInterface $archive, $project_root) {
+    $archive->extract($this->getTempDirectory());
+    foreach ($this->getFilesList($this->getTempDirectory()) as $file) {
+      $file_real_path = $this->getFileRealPath($file);
+      $file_path = substr($file_real_path, strlen($this->getTempDirectory() . self::ARCHIVE_DIRECTORY));
+      $project_real_path = $this->getProjectRealPath($file_path, $project_root);
+      try {
+        $directory = dirname($project_real_path);
+        $this->fileSystem->prepareDirectory($directory, FileSystemInterface::CREATE_DIRECTORY);
+        $this->fileSystem->copy($file_real_path, $project_real_path, FileSystemInterface::EXISTS_REPLACE);
+      }
+      catch (FileException $exception) {
+        return FALSE;
+      }
+    }
+    foreach ($this->getDeletions() as $deletion) {
+      try {
+        $this->fileSystem->delete($this->getProjectRealPath($deletion, $project_root));
+      }
+      catch (FileException $exception) {
+        return FALSE;
+      }
+    }
+    return TRUE;
+  }
+
+  /**
+   * Backup before an update.
+   *
+   * @param \Drupal\Core\Archiver\ArchiverInterface $archive
+   *   The archive.
+   * @param string $project_root
+   *   The project root directory.
+   *
+   * @return bool
+   *   Return TRUE if backup succeeds, FALSE otherwise.
+   */
+  protected function backup(ArchiverInterface $archive, $project_root) {
+    $backup = $this->fileSystem->createFilename('automatic_updates-backup', 'temporary://');
+    $this->fileSystem->prepareDirectory($backup);
+    $this->backup = $this->fileSystem->realpath($backup) . DIRECTORY_SEPARATOR;
+    if (!$this->backup) {
+      return FALSE;
+    }
+    foreach ($archive->listContents() as $file) {
+      // Ignore files that aren't in the files directory.
+      if (!$this->stripFileDirectoryPath($file)) {
+        continue;
+      }
+      $success = $this->doBackup($file, $project_root);
+      if (!$success) {
+        return FALSE;
+      }
+    }
+    $archive->extract($this->getTempDirectory(), [self::DELETION_MANIFEST]);
+    foreach ($this->getDeletions() as $deletion) {
+      $success = $this->doBackup($deletion, $project_root);
+      if (!$success) {
+        return FALSE;
+      }
+    }
+    return TRUE;
+  }
+
+  /**
+   * Remove the files directory path from files from the archive.
+   *
+   * @param string $file
+   *   The file path.
+   *
+   * @return bool
+   *   TRUE if path was removed, else FALSE.
+   */
+  protected function stripFileDirectoryPath(&$file) {
+    if (substr($file, 0, 6) === self::ARCHIVE_DIRECTORY) {
+      $file = substr($file, 6);
+      return TRUE;
+    }
+    return FALSE;
+  }
+
+  /**
+   * Execute file backup.
+   *
+   * @param string $file
+   *   The file to backup.
+   * @param string $project_root
+   *   The project root directory.
+   *
+   * @return bool
+   *   Return TRUE if backup succeeds, FALSE otherwise.
+   */
+  protected function doBackup($file, $project_root) {
+    $directory = $this->backup . dirname($file);
+    if (!file_exists($directory) && !$this->fileSystem->mkdir($directory, NULL, TRUE)) {
+      return FALSE;
+    }
+    $project_real_path = $this->getProjectRealPath($file, $project_root);
+    if (file_exists($project_real_path) && !is_dir($project_real_path)) {
+      try {
+        $this->fileSystem->copy($project_real_path, $this->backup . $file, FileSystemInterface::EXISTS_REPLACE);
+      }
+      catch (FileException $exception) {
+        return FALSE;
+      }
+    }
+    return TRUE;
+  }
+
+  /**
+   * Rollback after a failed update.
+   *
+   * @param string $project_root
+   *   The project root directory.
+   */
+  protected function rollback($project_root) {
+    if (!$this->backup) {
+      return;
+    }
+    foreach ($this->getFilesList($this->backup) as $file) {
+      $file_real_path = $this->getFileRealPath($file);
+      $file_path = substr($file_real_path, strlen($this->backup));
+      try {
+        $this->fileSystem->copy($file_real_path, $this->getProjectRealPath($file_path, $project_root), FileSystemInterface::EXISTS_REPLACE);
+      }
+      catch (FileException $exception) {
+        $this->logger->error('@file was not rolled back successfully.', ['@file' => $file_real_path]);
+      }
+    }
+  }
+
+  /**
+   * Provide a recursive list of files, excluding directories.
+   *
+   * @param string $directory
+   *   The directory to recurse for files.
+   *
+   * @return \RecursiveIteratorIterator|\SplFileInfo[]
+   *   The iterator of SplFileInfos.
+   */
+  protected function getFilesList($directory) {
+    $filter = function ($file, $file_name, $iterator) {
+      /** @var \SplFileInfo $file */
+      /** @var string $file_name */
+      /** @var \RecursiveDirectoryIterator $iterator */
+      if ($iterator->hasChildren() && !in_array($file->getFilename(), ['.git'], TRUE)) {
+        return TRUE;
+      }
+      return $file->isFile() && !in_array($file->getFilename(), [self::DELETION_MANIFEST], TRUE);
+    };
+
+    $innerIterator = new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS);
+    return new \RecursiveIteratorIterator(new \RecursiveCallbackFilterIterator($innerIterator, $filter));
+  }
+
+  /**
+   * Build a project quasi-patch download URL.
+   *
+   * @param string $project_name
+   *   The project name.
+   * @param string $file_name
+   *   The file name.
+   *
+   * @return string
+   *   The URL endpoint with for an extension.
+   */
+  protected function buildUrl($project_name, $file_name) {
+    $uri = $this->configFactory->get('automatic_updates.settings')->get('download_uri');
+    return Url::fromUri($uri . "/$project_name/" . $file_name)->toString();
+  }
+
+  /**
+   * Get the quasi-patch file name.
+   *
+   * @param string $project_name
+   *   The project name.
+   * @param string $from_version
+   *   The current project version.
+   * @param string $to_version
+   *   The desired next project version.
+   *
+   * @return string
+   *   The quasi-patch file name.
+   */
+  protected function getQuasiPatchFileName($project_name, $from_version, $to_version) {
+    return "$project_name-$from_version-to-$to_version.zip";
+  }
+
+  /**
+   * Get file real path.
+   *
+   * @param \SplFileInfo $file
+   *   The file to retrieve the real path.
+   *
+   * @return string
+   *   The file real path.
+   */
+  protected function getFileRealPath(\SplFileInfo $file) {
+    $real_path = $file->getRealPath();
+    if (!$real_path) {
+      throw new FileException(sprintf('Could not get real path for "%s"', $file->getFilename()));
+    }
+    return $real_path;
+  }
+
+  /**
+   * Get the real path of a file.
+   *
+   * @param string $file_path
+   *   The file path.
+   * @param string $project_root
+   *   The project root directory.
+   *
+   * @return string
+   *   The real path of a file.
+   */
+  protected function getProjectRealPath($file_path, $project_root) {
+    if (substr($file_path, 0, 6) === 'vendor/') {
+      return $this->vendorPath . substr($file_path, 7);
+    }
+    return rtrim($project_root, '/\\') . DIRECTORY_SEPARATOR . $file_path;
+  }
+
+  /**
+   * Provides the temporary extraction directory.
+   *
+   * @return string
+   *   The temporary directory.
+   */
+  protected function getTempDirectory() {
+    if (!$this->tempDirectory) {
+      $this->tempDirectory = $this->fileSystem->createFilename('automatic_updates-update', 'temporary://');
+      $this->fileSystem->prepareDirectory($this->tempDirectory);
+      $this->tempDirectory = $this->fileSystem->realpath($this->tempDirectory) . DIRECTORY_SEPARATOR;
+    }
+    return $this->tempDirectory;
+  }
+
+  /**
+   * Get an iterator of files to delete.
+   *
+   * @return \ArrayIterator
+   *   Iterator of files to delete.
+   */
+  protected function getDeletions() {
+    $deletions = [];
+    if (!file_exists($this->getTempDirectory() . self::DELETION_MANIFEST)) {
+      return new \ArrayIterator($deletions);
+    }
+    $handle = fopen($this->getTempDirectory() . self::DELETION_MANIFEST, 'r');
+    if ($handle) {
+      while (($deletion = fgets($handle)) !== FALSE) {
+        if ($result = trim($deletion)) {
+          $deletions[] = $result;
+        }
+      }
+      fclose($handle);
+    }
+    return new \ArrayIterator($deletions);
+  }
+
+}
diff --git a/src/Services/UpdateInterface.php b/src/Services/UpdateInterface.php
new file mode 100644
index 0000000000000000000000000000000000000000..ee685a457878ab07cbbc90ed367be493ce593d2c
--- /dev/null
+++ b/src/Services/UpdateInterface.php
@@ -0,0 +1,27 @@
+<?php
+
+namespace Drupal\automatic_updates\Services;
+
+/**
+ * Interface UpdateInterface.
+ */
+interface UpdateInterface {
+
+  /**
+   * Update a project to the next release.
+   *
+   * @param string $project_name
+   *   The project name.
+   * @param string $project_type
+   *   The project type.
+   * @param string $from_version
+   *   The current project version.
+   * @param string $to_version
+   *   The desired next project version.
+   *
+   * @return bool
+   *   TRUE if project was successfully updated, FALSE otherwise.
+   */
+  public function update($project_name, $project_type, $from_version, $to_version);
+
+}
diff --git a/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php b/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php
new file mode 100644
index 0000000000000000000000000000000000000000..2d645eccf01635d3ecfe36f8182dbbffa6049014
--- /dev/null
+++ b/tests/modules/test_automatic_updates/src/Controller/InPlaceUpdateController.php
@@ -0,0 +1,50 @@
+<?php
+
+namespace Drupal\test_automatic_updates\Controller;
+
+use Drupal\automatic_updates\Services\UpdateInterface;
+use Drupal\Core\Controller\ControllerBase;
+use Symfony\Component\DependencyInjection\ContainerInterface;
+
+/**
+ * Returns responses for Test Automatic Updates routes.
+ */
+class InPlaceUpdateController extends ControllerBase {
+
+  /**
+   * Updater service.
+   *
+   * @var \Drupal\automatic_updates\Services\UpdateInterface
+   */
+  protected $updater;
+
+  /**
+   * InPlaceUpdateController constructor.
+   *
+   * @param \Drupal\automatic_updates\Services\UpdateInterface $updater
+   *   The updater service.
+   */
+  public function __construct(UpdateInterface $updater) {
+    $this->updater = $updater;
+  }
+
+  /**
+   * {@inheritdoc}
+   */
+  public static function create(ContainerInterface $container) {
+    return new static(
+      $container->get('automatic_updates.update')
+    );
+  }
+
+  /**
+   * Builds the response.
+   */
+  public function update($project, $type, $from, $to) {
+    $updated = $this->updater->update($project, $type, $from, $to);
+    return [
+      '#markup' => $updated ? $this->t('Update successful') : $this->t('Update Failed'),
+    ];
+  }
+
+}
diff --git a/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php b/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php
new file mode 100644
index 0000000000000000000000000000000000000000..d8222b85a9c11f1a8f433885baa49edfb9c95884
--- /dev/null
+++ b/tests/modules/test_automatic_updates/src/EventSubscriber/DisableThemeCsrfRouteSubscriber.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Drupal\test_automatic_updates\EventSubscriber;
+
+use Drupal\Core\Routing\RouteSubscriberBase;
+use Symfony\Component\Routing\RouteCollection;
+
+/**
+ * Disable theme CSRF route subscriber.
+ */
+class DisableThemeCsrfRouteSubscriber extends RouteSubscriberBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function alterRoutes(RouteCollection $collection) {
+    // Disable CSRF so we can easily enable themes in tests.
+    if ($route = $collection->get('system.theme_set_default')) {
+      $route->setRequirements(['_permission' => 'administer themes']);
+    }
+  }
+
+}
diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.info.yml b/tests/modules/test_automatic_updates/test_automatic_updates.info.yml
index 506856cbe7688780451cf4d8b7993d39c0633c1c..1373e2ceb932dc5ccdf10e5f0cb3761c348cdf2f 100644
--- a/tests/modules/test_automatic_updates/test_automatic_updates.info.yml
+++ b/tests/modules/test_automatic_updates/test_automatic_updates.info.yml
@@ -3,3 +3,5 @@ type: module
 description: 'Tests for Automatic Updates'
 package: Testing
 core: 8.x
+dependencies:
+  - automatic_updates:automatic_updates
diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml
index 7c9ce3413268e95199dd72aef1517776cb2dc40b..4d113f38e2e2ddf68ec41d3213877642247b753e 100644
--- a/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml
+++ b/tests/modules/test_automatic_updates/test_automatic_updates.routing.yml
@@ -19,3 +19,12 @@ test_automatic_updates.hashes_endpoint:
     _title: 'SHA512SUMS'
   requirements:
     _access: 'TRUE'
+test_automatic_updates.inplace-update:
+  path: '/automatic_updates/in-place-update/{project}/{type}/{from}/{to}'
+  defaults:
+    _title: 'Update'
+    _controller: '\Drupal\test_automatic_updates\Controller\InPlaceUpdateController::update'
+  requirements:
+    _access: 'TRUE'
+  options:
+    no_cache: 'TRUE'
diff --git a/tests/modules/test_automatic_updates/test_automatic_updates.services.yml b/tests/modules/test_automatic_updates/test_automatic_updates.services.yml
new file mode 100644
index 0000000000000000000000000000000000000000..da4b2779014a29aa173876e5289b2fc0b4b08df3
--- /dev/null
+++ b/tests/modules/test_automatic_updates/test_automatic_updates.services.yml
@@ -0,0 +1,5 @@
+services:
+  test_automatic_updates.route_subscriber:
+    class: Drupal\test_automatic_updates\EventSubscriber\DisableThemeCsrfRouteSubscriber
+    tags:
+      - { name: event_subscriber }
diff --git a/tests/src/Build/InPlaceUpdateTest.php b/tests/src/Build/InPlaceUpdateTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..20aea7dbade381a1c5fc6ec5c70100b72b640dd2
--- /dev/null
+++ b/tests/src/Build/InPlaceUpdateTest.php
@@ -0,0 +1,288 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Build;
+
+use Drupal\automatic_updates\Services\InPlaceUpdate;
+use Drupal\Component\FileSystem\FileSystem as DrupalFilesystem;
+use Drupal\Component\Utility\Html;
+use Drupal\Tests\automatic_updates\Build\QuickStart\QuickStartTestBase;
+use GuzzleHttp\Client;
+use Symfony\Component\Filesystem\Filesystem as SymfonyFilesystem;
+use Symfony\Component\Finder\Finder;
+
+/**
+ * @coversDefaultClass \Drupal\automatic_updates\Services\InPlaceUpdate
+ *
+ * @group Build
+ * @group Update
+ *
+ * @requires externalCommand composer
+ * @requires externalCommand curl
+ * @requires externalCommand git
+ * @requires externalCommand tar
+ */
+class InPlaceUpdateTest extends QuickStartTestBase {
+
+  /**
+   * The files which are candidates for deletion during an upgrade.
+   *
+   * @var string[]
+   */
+  protected $deletions;
+
+  /**
+   * The directory where the deletion manifest is extracted.
+   *
+   * @var string
+   */
+  protected $deletionsDestination;
+
+  /**
+   * {@inheritdoc}
+   */
+  protected function tearDown() {
+    parent::tearDown();
+    $fs = new SymfonyFilesystem();
+    $fs->remove($this->deletionsDestination);
+  }
+
+  /**
+   * @covers ::update
+   * @dataProvider coreVersionsProvider
+   */
+  public function testCoreUpdate($from_version, $to_version) {
+    $this->copyCodebase();
+    // We have to fetch the tags for this shallow repo. It might not be a
+    // shallow clone, therefore we use executeCommand instead of assertCommand.
+    $this->executeCommand('git fetch --unshallow  --tags');
+    $this->executeCommand("git checkout $from_version -f");
+    $this->assertCommandSuccessful();
+    $fs = new SymfonyFilesystem();
+    $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000);
+    $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction');
+    $this->assertErrorOutputContains('Generating autoload files');
+    $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer require ocramius/package-versions:^1.4 webflo/drupal-finder:^1.1 composer/semver:^1.0 --no-interaction');
+    $this->assertErrorOutputContains('Generating autoload files');
+    $this->installQuickStart('minimal');
+
+    // Currently, this test has to use extension_discovery_scan_tests so we can
+    // enable test modules.
+    $fs->chmod($this->getWorkspaceDirectory() . '/sites/default/settings.php', 0640, 0000);
+    file_put_contents($this->getWorkspaceDirectory() . '/sites/default/settings.php', '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL, FILE_APPEND);
+
+    // Log in so that we can install modules.
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->moduleEnable('automatic_updates');
+    $this->moduleEnable('test_automatic_updates');
+
+    // Confirm we are running correct Drupal version.
+    $finder = new Finder();
+    $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php');
+    $finder->contains("/const VERSION = '$from_version'/");
+    $this->assertTrue($finder->hasResults());
+
+    // Assert files slated for deletion still exist.
+    foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) {
+      $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
+    }
+
+    // Assert that the site is functional before updating.
+    $this->visit();
+    $this->assertDrupalVisit();
+
+    // Update the site.
+    $this->visit("/automatic_updates/in-place-update/drupal/core/$from_version/$to_version");
+    $this->assertDrupalVisit();
+
+    // Assert that the update worked.
+    $assert = $this->getMink()->assertSession();
+    $assert->pageTextContains('Update successful');
+    $finder = new Finder();
+    $finder->files()->in($this->getWorkspaceDirectory())->path('core/lib/Drupal.php');
+    $finder->contains("/const VERSION = '$to_version'/");
+    $this->assertTrue($finder->hasResults());
+    $this->visit('/admin/reports/status');
+    $assert->pageTextContains("Drupal Version $to_version");
+
+    // Assert files slated for deletion are now gone.
+    foreach ($this->getDeletions('drupal', $from_version, $to_version) as $deletion) {
+      $this->assertFileNotExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
+    }
+  }
+
+  /**
+   * @covers ::update
+   * @dataProvider contribProjectsProvider
+   */
+  public function testContribUpdate($project, $project_type, $from_version, $to_version) {
+    $this->copyCodebase();
+    $fs = new SymfonyFilesystem();
+    $fs->chmod($this->getWorkspaceDirectory() . '/sites/default', 0700, 0000);
+    $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer install --no-dev --no-interaction');
+    $this->assertErrorOutputContains('Generating autoload files');
+    $this->executeCommand('COMPOSER_DISCARD_CHANGES=true composer require ocramius/package-versions:^1.4 webflo/drupal-finder:^1.1 composer/semver:^1.0 --no-interaction');
+    $this->assertErrorOutputContains('Generating autoload files');
+    $this->installQuickStart('standard');
+
+    // Download the project.
+    $fs->mkdir($this->getWorkspaceDirectory() . "/{$project_type}s/contrib/$project");
+    $this->executeCommand("curl -fsSL https://ftp.drupal.org/files/projects/$project-$from_version.tar.gz | tar xvz -C {$project_type}s/contrib/$project --strip 1");
+    $this->assertCommandSuccessful();
+    $finder = new Finder();
+    $finder->files()->in($this->getWorkspaceDirectory())->path("{$project_type}s/contrib/$project/$project.info.yml");
+    $finder->contains("/version: '$from_version'/");
+    $this->assertTrue($finder->hasResults());
+
+    // Assert files slated for deletion still exist.
+    foreach ($this->getDeletions($project, $from_version, $to_version) as $deletion) {
+      $this->assertFileExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
+    }
+
+    // Currently, this test has to use extension_discovery_scan_tests so we can
+    // enable test modules.
+    $fs->chmod($this->getWorkspaceDirectory() . '/sites/default/settings.php', 0640, 0000);
+    file_put_contents($this->getWorkspaceDirectory() . '/sites/default/settings.php', '$settings[\'extension_discovery_scan_tests\'] = TRUE;' . PHP_EOL, FILE_APPEND);
+
+    // Log in so that we can install projects.
+    $this->formLogin($this->adminUsername, $this->adminPassword);
+    $this->moduleEnable('automatic_updates');
+    $this->moduleEnable('test_automatic_updates');
+    if (is_callable([$this, "{$project_type}Enable"])) {
+      call_user_func([$this, "{$project_type}Enable"], $project);
+    }
+
+    // Assert that the site is functional before updating.
+    $this->visit();
+    $this->assertDrupalVisit();
+
+    // Update the contrib project.
+    $this->visit("/automatic_updates/in-place-update/$project/$project_type/$from_version/$to_version");
+    $this->assertDrupalVisit();
+
+    // Assert that the update worked.
+    $assert = $this->getMink()->assertSession();
+    $assert->pageTextContains('Update successful');
+    $finder = new Finder();
+    $finder->files()->in($this->getWorkspaceDirectory())->path("{$project_type}s/contrib/$project/$project.info.yml");
+    $finder->contains("/version: '$to_version'/");
+    $this->assertTrue($finder->hasResults());
+    $this->assertDrupalVisit();
+
+    // Assert files slated for deletion are now gone.
+    foreach ($this->getDeletions($project, $from_version, $to_version) as $deletion) {
+      $this->assertFileNotExists($this->getWorkspaceDirectory() . DIRECTORY_SEPARATOR . $deletion);
+    }
+  }
+
+  /**
+   * Core versions data provider.
+   */
+  public function coreVersionsProvider() {
+    $datum[] = [
+      'from' => '8.7.0',
+      'to' => '8.7.1',
+    ];
+    $datum[] = [
+      'from' => '8.7.1',
+      'to' => '8.7.2',
+    ];
+    $datum[] = [
+      'from' => '8.7.2',
+      'to' => '8.7.3',
+    ];
+    $datum[] = [
+      'from' => '8.7.3',
+      'to' => '8.7.4',
+    ];
+    $datum[] = [
+      'from' => '8.7.4',
+      'to' => '8.7.5',
+    ];
+    $datum[] = [
+      'from' => '8.7.5',
+      'to' => '8.7.6',
+    ];
+    $datum[] = [
+      'from' => '8.7.6',
+      'to' => '8.7.7',
+    ];
+    return $datum;
+  }
+
+  /**
+   * Contrib project data provider.
+   */
+  public function contribProjectsProvider() {
+    $datum[] = [
+      'project' => 'bootstrap',
+      'type' => 'theme',
+      'from' => '8.x-3.19',
+      'to' => '8.x-3.20',
+    ];
+    $datum[] = [
+      'project' => 'token',
+      'type' => 'module',
+      'from' => '8.x-1.4',
+      'to' => '8.x-1.5',
+    ];
+    return $datum;
+  }
+
+  /**
+   * Helper method to retrieve files slated for deletion.
+   */
+  protected function getDeletions($project, $from_version, $to_version) {
+    if (isset($this->deletions)) {
+      return $this->deletions;
+    }
+    $this->deletions = [];
+    $http_client = new Client();
+    $filesystem = new SymfonyFilesystem();
+    $this->deletionsDestination = DrupalFileSystem::getOsTemporaryDirectory() . DIRECTORY_SEPARATOR . "$project-" . rand(10000, 99999) . microtime(TRUE);
+    $filesystem->mkdir($this->deletionsDestination);
+    $file_name = "$project-$from_version-to-$to_version.zip";
+    $zip_file = $this->deletionsDestination . DIRECTORY_SEPARATOR . $file_name;
+    $http_client->get("https://www.drupal.org/in-place-updates/$project/$file_name", ['sink' => $zip_file]);
+    $zip = new \ZipArchive();
+    $zip->open($zip_file);
+    $zip->extractTo($this->deletionsDestination, [InPlaceUpdate::DELETION_MANIFEST]);
+    $handle = fopen($this->deletionsDestination . DIRECTORY_SEPARATOR . InPlaceUpdate::DELETION_MANIFEST, 'r');
+    if ($handle) {
+      while (($deletion = fgets($handle)) !== FALSE) {
+        if ($result = trim($deletion)) {
+          $this->deletions[] = $result;
+        }
+      }
+      fclose($handle);
+    }
+    return $this->deletions;
+  }
+
+  /**
+   * Helper method that uses Drupal's module page to enable a module.
+   */
+  protected function moduleEnable($module_name) {
+    $this->visit('/admin/modules');
+    $field = Html::getClass("edit-modules $module_name enable");
+    // No need to enable a module if it is already enabled.
+    if ($this->getMink()->getSession()->getPage()->findField($field)->isChecked()) {
+      return;
+    }
+    $assert = $this->getMink()->assertSession();
+    $assert->fieldExists($field)->check();
+    $session = $this->getMink()->getSession();
+    $session->getPage()->findButton('Install')->submit();
+    $assert->fieldExists($field)->isChecked();
+  }
+
+  /**
+   * Helper method that uses Drupal's theme page to enable a theme.
+   */
+  protected function themeEnable($theme_name) {
+    $this->moduleEnable('test_automatic_updates');
+    $this->visit("/admin/appearance/default?theme=$theme_name");
+    $assert = $this->getMink()->assertSession();
+    $assert->pageTextNotContains('theme was not found');
+  }
+
+}
diff --git a/tests/src/Build/QuickStart/QuickStartTestBase.php b/tests/src/Build/QuickStart/QuickStartTestBase.php
new file mode 100644
index 0000000000000000000000000000000000000000..6ca9930638a9c7504b42415da095a54ef95ec9e3
--- /dev/null
+++ b/tests/src/Build/QuickStart/QuickStartTestBase.php
@@ -0,0 +1,68 @@
+<?php
+
+namespace Drupal\Tests\automatic_updates\Build\QuickStart;
+
+use Drupal\BuildTests\Framework\BuildTestBase;
+use Symfony\Component\Process\PhpExecutableFinder;
+
+/**
+ * Helper methods for using the quickstart feature of Drupal.
+ *
+ * @TODO: remove after https://www.drupal.org/project/drupal/issues/3082230.
+ */
+abstract class QuickStartTestBase extends BuildTestBase {
+
+  /**
+   * User name of the admin account generated during install.
+   *
+   * @var string
+   */
+  protected $adminUsername;
+
+  /**
+   * Password of the admin account generated during install.
+   *
+   * @var string
+   */
+  protected $adminPassword;
+
+  /**
+   * Install a Drupal site using the quick start feature.
+   *
+   * @param string $profile
+   *   Drupal profile to install.
+   * @param string $working_dir
+   *   (optional) A working directory relative to the workspace, within which to
+   *   execute the command. Defaults to the workspace directory.
+   */
+  public function installQuickStart($profile, $working_dir = NULL) {
+    $finder = new PhpExecutableFinder();
+    $process = $this->executeCommand($finder->find() . ' ./core/scripts/drupal install ' . $profile, $working_dir);
+    $this->assertCommandSuccessful();
+    $this->assertCommandOutputContains('Username:');
+    preg_match('/Username: (.+)\vPassword: (.+)/', $process->getOutput(), $matches);
+    $this->assertNotEmpty($this->adminUsername = $matches[1]);
+    $this->assertNotEmpty($this->adminPassword = $matches[2]);
+  }
+
+  /**
+   * Helper that uses Drupal's user/login form to log in.
+   *
+   * @param string $username
+   *   Username.
+   * @param string $password
+   *   Password.
+   * @param string $working_dir
+   *   (optional) A working directory within which to login. Defaults to the
+   *   workspace directory.
+   */
+  public function formLogin($username, $password, $working_dir = NULL) {
+    $mink = $this->visit('/user/login', $working_dir);
+    $this->assertEquals(200, $mink->getSession()->getStatusCode());
+    $assert = $mink->assertSession();
+    $assert->fieldExists('edit-name')->setValue($username);
+    $assert->fieldExists('edit-pass')->setValue($password);
+    $mink->getSession()->getPage()->findButton('Log in')->submit();
+  }
+
+}