diff --git a/composer.json b/composer.json index 51f3afb4875633257396dd688307f3533f55b557..9653416213a998e0e343685afa4bace6930a179e 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "composer-runtime-api": "^2.1" }, "require-dev": { - "colinodell/psr-testlogger": "^1.2" + "colinodell/psr-testlogger": "^1.2", + "drush/drush": "^11" }, "scripts": { "phpcbf": "scripts/phpcbf.sh", @@ -42,5 +43,12 @@ "psr-4": { "Drupal\\automatic_updates\\Development\\": "scripts/src" } + }, + "extra": { + "drush": { + "services": { + "drush.services.yml": "^11" + } + } } -} +} \ No newline at end of file diff --git a/drush.services.yml b/drush.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..8119ead091b74c37d021c046ad5d16b7487721d2 --- /dev/null +++ b/drush.services.yml @@ -0,0 +1,9 @@ +services: + _defaults: + autowire: true + Drupal\automatic_updates\Commands\AutomaticUpdatesCommands: + tags: + - { name: drush.command } + Drupal\automatic_updates\DrushUpdateStage: + calls: + - ['setLogger', ['@logger.channel.automatic_updates']] diff --git a/scripts/install_module.sh b/scripts/install_module.sh index 886f2189c0022f7eb84479c842a98bc5109ee349..04e7b2aa691ecb04d2dbdcf1e0895f391f95f64c 100755 --- a/scripts/install_module.sh +++ b/scripts/install_module.sh @@ -61,6 +61,7 @@ composer require \ # automatic_updates' development dependencies to the root. # @see https://getcomposer.org/doc/04-schema.md#require-dev composer require --dev colinodell/psr-testlogger:^1 +composer require --dev drush/drush:^11 # Revert needless changes to Core Composer metapackages. git checkout -- "$SITE_DIRECTORY/composer/Metapackage" diff --git a/src/Commands/AutomaticUpdatesCommands.php b/src/Commands/AutomaticUpdatesCommands.php new file mode 100644 index 0000000000000000000000000000000000000000..e15219919e43f72a6562a4abeb418798133e0951 --- /dev/null +++ b/src/Commands/AutomaticUpdatesCommands.php @@ -0,0 +1,82 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\automatic_updates\Commands; + +use Drupal\automatic_updates\DrushUpdateStage; +use Drush\Commands\DrushCommands; + +/** + * Contains Drush commands for Automatic Updates. + * + * @internal + * This is an internal part of Automatic Updates and may be changed or removed + * at any time without warning. It should not be called directly, and external + * code should not interact with it. + */ +final class AutomaticUpdatesCommands extends DrushCommands { + + /** + * Constructs a AutomaticUpdatesCommands object. + * + * @param \Drupal\automatic_updates\DrushUpdateStage $stage + * The console cron updater service. + */ + public function __construct(private readonly DrushUpdateStage $stage) { + parent::__construct(); + } + + /** + * Automatically updates Drupal core. + * + * @usage auto-update + * Automatically updates Drupal core, if any updates are available. + * + * @option $post-apply Internal use only. + * @option $stage-id Internal use only. + * @option $from-version Internal use only. + * @option $to-version Internal use only. + * + * @command auto-update + * + * @throws \LogicException + * If the --post-apply option is provided without the --stage-id, + * --from-version, and --to-version options. + */ + public function autoUpdate(array $options = ['post-apply' => FALSE, 'stage-id' => NULL, 'from-version' => NULL, 'to-version' => NULL]) { + $io = $this->io(); + + // The second half of the update process (post-apply etc.) is done by this + // exact same command, with some additional flags, in a separate process to + // ensure that the system is in a consistent state. + // @see \Drupal\automatic_updates\DrushUpdateStage::triggerPostApply() + if ($options['post-apply']) { + if (empty($options['stage-id']) || empty($options['from-version']) || empty($options['to-version'])) { + throw new \LogicException("The post-apply option is for internal use only. It should never be passed directly."); + } + $message = sprintf('Drupal core was successfully updated to %s!', $options['to-version']); + $io->success($message); + + $io->info('Running post-apply tasks and final clean-up...'); + $this->stage->handlePostApply($options['stage-id'], $options['from-version'], $options['to-version']); + } + else { + if ($this->stage->getMode() === DrushUpdateStage::DISABLED) { + $io->error('Automatic updates are disabled.'); + return; + } + + $release = $this->stage->getTargetRelease(); + if ($release) { + $message = sprintf('Updating Drupal core to %s. This may take a while.', $release->getVersion()); + $io->info($message); + $this->stage->performUpdate($release->getVersion(), 300); + } + else { + $io->info("There is no Drupal core update available."); + } + } + } + +} diff --git a/src/CronUpdateStage.php b/src/CronUpdateStage.php index 0c0b3036ef209a93f23d86a4148587834761afad..2f1c90d0964c0523118d6cbfb5f1684f928f9712 100644 --- a/src/CronUpdateStage.php +++ b/src/CronUpdateStage.php @@ -168,7 +168,7 @@ class CronUpdateStage extends UpdateStage { * How long to allow the operation to run before timing out, in seconds, or * NULL to never time out. */ - private function performUpdate(string $target_version, ?int $timeout): void { + protected function performUpdate(string $target_version, ?int $timeout): void { $project_info = new ProjectInfo('drupal'); if (!$this->isAvailable()) { diff --git a/src/DrushUpdateStage.php b/src/DrushUpdateStage.php new file mode 100644 index 0000000000000000000000000000000000000000..caed1d1c16a3198e259a03e9f26704cd4abd7a06 --- /dev/null +++ b/src/DrushUpdateStage.php @@ -0,0 +1,41 @@ +<?php + +declare(strict_types = 1); + +namespace Drupal\automatic_updates; + +use Drush\Drush; + +/** + * An updater that runs via a Drush command. + */ +final class DrushUpdateStage extends CronUpdateStage { + + /** + * {@inheritdoc} + */ + protected function triggerPostApply(string $stage_id, string $start_version, string $target_version): void { + $alias = Drush::aliasManager()->getSelf(); + + $output = Drush::processManager() + ->drush($alias, 'auto-update', [], [ + 'post-apply' => TRUE, + 'stage-id' => $stage_id, + 'from-version' => $start_version, + 'to-version' => $target_version, + ]) + ->mustRun() + ->getOutput(); + // Ensure the output of the sub-process is visible. + Drush::output()->write($output); + } + + /** + * {@inheritdoc} + */ + public function performUpdate(string $target_version, ?int $timeout): void { + // Overridden to expose this method to calling code. + parent::performUpdate($target_version, $timeout); + } + +} diff --git a/tests/src/Build/CoreUpdateTest.php b/tests/src/Build/CoreUpdateTest.php index 87dbc7a134ebd5fb2c01a3b6afcc8462e2f05af4..5629af9e06926afde7e2f2d3d81f2af0e4077adf 100644 --- a/tests/src/Build/CoreUpdateTest.php +++ b/tests/src/Build/CoreUpdateTest.php @@ -5,6 +5,7 @@ declare(strict_types = 1); namespace Drupal\Tests\automatic_updates\Build; use Behat\Mink\Element\DocumentElement; +use Drupal\automatic_updates\DrushUpdateStage; use Drupal\automatic_updates\CronUpdateStage; use Drupal\automatic_updates\UpdateStage; use Drupal\Composer\Composer; @@ -17,6 +18,7 @@ use Drupal\package_manager\Event\PreCreateEvent; use Drupal\package_manager\Event\PreDestroyEvent; use Drupal\package_manager\Event\PreRequireEvent; use Drupal\Tests\WebAssert; +use Symfony\Component\Process\Process; /** * Tests an end-to-end update of Drupal core. @@ -408,4 +410,40 @@ class CoreUpdateTest extends UpdateTestBase { $assert_session->pageTextContains('Ready to update'); } + // BEGIN: DELETE FROM CORE MERGE REQUEST + + /** + * Tests updating via Drush. + */ + public function testDrushUpdate(): void { + $this->createTestProject('RecommendedProject'); + + $this->runComposer('composer require drush/drush', 'project'); + + $dir = $this->getWorkspaceDirectory() . '/project'; + $command = [ + $dir . '/vendor/drush/drush/drush', + 'auto-update', + '--verbose', + ]; + $process = new Process($command, $dir . '/web/sites/default'); + + // Give the update process as much time as it needs to run. + $process->setTimeout(NULL)->mustRun(); + $output = $process->getOutput(); + $this->assertStringContainsString('Updating Drupal core to 9.8.1. This may take a while.', $output); + $this->assertStringContainsString('Drupal core was successfully updated to 9.8.1!', $output); + $this->assertStringContainsString('Running post-apply tasks and final clean-up...', $output); + $this->assertUpdateSuccessful('9.8.1'); + $this->assertExpectedStageEventsFired(DrushUpdateStage::class); + + // Rerunning the command should exit with a message that no newer version + // is available. + $process = new Process($command, $process->getWorkingDirectory()); + $process->mustRun(); + $this->assertStringContainsString("There is no Drupal core update available.", $process->getOutput()); + } + + // END: DELETE FROM CORE MERGE REQUEST + }