From d72c58368b09e12fcba446c8e293e01a88424a9e Mon Sep 17 00:00:00 2001 From: catch Date: Fri, 16 Oct 2020 15:55:30 +0100 Subject: [PATCH] Issue #3135247 by greg.1.anderson, alexpott, ridhimaabrol24, Mixologic, tedbow, xjm, catch, jwilson3, longwave: Composer's "prefer-stable" setting cannot be relied on to produce a stable release --- composer/Composer.php | 79 ++++++++++++++++++- composer/Template/README.txt | 2 +- core/misc/cspell/dictionary.txt | 1 + .../Template/ComposerProjectTemplatesTest.php | 79 +++++++++++++++---- 4 files changed, 140 insertions(+), 21 deletions(-) diff --git a/composer/Composer.php b/composer/Composer.php index 0a76fc71756..7f83591934c 100644 --- a/composer/Composer.php +++ b/composer/Composer.php @@ -5,7 +5,9 @@ namespace Drupal\Composer; use Composer\Composer as ComposerApp; use Composer\Script\Event; use Composer\Semver\Comparator; +use Composer\Semver\VersionParser; use Drupal\Composer\Generator\PackageGenerator; +use Symfony\Component\Finder\Finder; /** * Provides static functions for composer script events. See also @@ -21,17 +23,71 @@ class Composer { * Update metapackages whenever composer.lock is updated. * * @param \Composer\Script\Event $event + * The Composer event. */ - public static function generateMetapackages(Event $event) { + public static function generateMetapackages(Event $event): void { $generator = new PackageGenerator(); $generator->generate($event->getIO(), getcwd()); } + /** + * Set the version of Drupal; used in release process and by the test suite. + * + * @param string $root + * Path to root of drupal/drupal repository. + * @param string $version + * Semver version to set Drupal's version to. + * + * @return string + * Stability level of the provided version (stable, RC, alpha, etc.) + * + * @throws \UnexpectedValueException + */ + public static function setDrupalVersion(string $root, string $version): void { + // We use VersionParser::normalize to validate that $version is valid. + // It will throw an exception if it is not. + $versionParser = new VersionParser(); + $versionParser->normalize($version); + + // Rewrite Drupal.php with the provided version string. + $drupal_static_path = "$root/core/lib/Drupal.php"; + $drupal_static_source = file_get_contents($drupal_static_path); + $drupal_static_source = preg_replace('#const VERSION = [^;]*#', "const VERSION = '$version'", $drupal_static_source); + file_put_contents($drupal_static_path, $drupal_static_source); + + // Update the template project stability to match the version we set. + static::setTemplateProjectStability($root, $version); + } + + /** + * Set the stability of the template projects to match the Drupal version. + * + * @param string $root + * Path to root of drupal/drupal repository. + * @param string $version + * Semver version that Drupal was set to. + * + * @return string + * Stability level of the provided version (stable, RC, alpha, etc.) + */ + protected static function setTemplateProjectStability(string $root, string $version): void { + $stability = VersionParser::parseStability($version); + + $templateProjectPaths = static::composerSubprojectPaths($root, 'Template'); + foreach ($templateProjectPaths as $path) { + $dir = dirname($path); + exec("composer --working-dir=$dir config minimum-stability $stability", $output, $status); + if ($status) { + throw new \Exception('Could not set minimum-stability for template project ' . basename($dir)); + } + } + } + /** * Ensure that the minimum required version of Composer is running. * Throw an exception if Composer is too old. */ - public static function ensureComposerVersion() { + public static function ensureComposerVersion(): void { $composerVersion = method_exists(ComposerApp::class, 'getVersion') ? ComposerApp::getVersion() : ComposerApp::VERSION; if (Comparator::lessThan($composerVersion, '1.9.0')) { @@ -45,8 +101,25 @@ class Composer { * @return string * A branch name, e.g. 8.9.x or 9.0.x. */ - public static function drupalVersionBranch() { + public static function drupalVersionBranch(): string { return preg_replace('#\.[0-9]+-dev#', '.x-dev', \Drupal::VERSION); } + /** + * Return the list of subprojects of a given type. + * + * @param string $root + * Path to root of drupal/drupal repository. + * @param string $subprojectType + * Type of subproject - one of Metapackage, Plugin, or Template + * + * @return \Symfony\Component\Finder\Finder + */ + public static function composerSubprojectPaths(string $root, string $subprojectType): Finder { + return Finder::create() + ->files() + ->name('composer.json') + ->in("$root/composer/$subprojectType"); + } + } diff --git a/composer/Template/README.txt b/composer/Template/README.txt index 47c95f1c5cc..31c316c19bf 100644 --- a/composer/Template/README.txt +++ b/composer/Template/README.txt @@ -45,7 +45,7 @@ How do I set it up? Use Composer to create a new project using the desired starter template: - composer -n create-project -s dev drupal/recommended-project my-project + composer -n create-project drupal/recommended-project my-project Add new modules and themes with `composer require`: diff --git a/core/misc/cspell/dictionary.txt b/core/misc/cspell/dictionary.txt index 1576f93bea3..eb17e6768c1 100644 --- a/core/misc/cspell/dictionary.txt +++ b/core/misc/cspell/dictionary.txt @@ -1628,6 +1628,7 @@ svgz svibanj swcf symfony's +symlinked symlinking symlinks synchronizable diff --git a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php index 9077e566596..c407c84bf02 100644 --- a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php +++ b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php @@ -5,7 +5,6 @@ namespace Drupal\BuildTests\Composer\Template; use Composer\Json\JsonFile; use Drupal\BuildTests\Framework\BuildTestBase; use Drupal\Composer\Composer; -use Symfony\Component\Finder\Finder; /** * Demonstrate that Composer project templates are buildable as patched. @@ -40,10 +39,7 @@ class ComposerProjectTemplatesTest extends BuildTestBase { */ public function getPathReposForType($workspace_directory, $subdir) { // Find the Composer items that we want to be path repos. - $path_repos = Finder::create() - ->files() - ->name('composer.json') - ->in($workspace_directory . '/composer/' . $subdir); + $path_repos = Composer::composerSubprojectPaths($workspace_directory, $subdir); $data = []; /* @var $path_repo \SplFileInfo */ @@ -78,10 +74,7 @@ class ComposerProjectTemplatesTest extends BuildTestBase { $data = $this->provideTemplateCreateProject($root); // Find all the templates. - $template_files = Finder::create() - ->files() - ->name('composer.json') - ->in($root . '/composer/Template'); + $template_files = Composer::composerSubprojectPaths($root, 'Template'); $this->assertSame(count($template_files), count($data)); @@ -114,6 +107,8 @@ class ComposerProjectTemplatesTest extends BuildTestBase { // Make a working COMPOSER_HOME directory for setting global composer config $composer_home = $this->getWorkspaceDirectory() . '/composer-home'; mkdir($composer_home); + // Create an empty global composer.json file, just to avoid warnings. + file_put_contents("$composer_home/composer.json", '{}'); // Disable packagist globally (but only in our own custom COMPOSER_HOME). // It is necessary to do this globally rather than in our SUT composer.json @@ -126,14 +121,25 @@ class ComposerProjectTemplatesTest extends BuildTestBase { // 8.9.x-dev for the 8.9.x branch. $core_version = Composer::drupalVersionBranch(); + // In order to use create-project on our template, we must have stable + // versions of drupal/core and our other SUT repositories. Since we have + // provided these as path repositories, they will take on the version of + // the root project. We'll make a simulated version number that is stable + // to fulfill this role. + $simulated_stable_version = str_replace('.x-dev', '.99', $core_version); + // Create a "Composer"-type repository containing one entry for every // package in the vendor directory. $vendor_packages_path = $this->getWorkspaceDirectory() . '/vendor_packages/packages.json'; $this->makeVendorPackage($vendor_packages_path); - // Make a copy of the code to alter. + // Make a copy of the code to alter in the workspace directory. $this->copyCodebase(); + // Set the Drupal version and minimum stability of the template projects + Composer::setDrupalVersion($this->getWorkspaceDirectory(), $simulated_stable_version); + $this->assertDrupalVersion($simulated_stable_version, $this->getWorkspaceDirectory()); + // Remove the packages.drupal.org entry (and any other custom repository) // from the SUT's repositories section. There is no way to do this via // `composer config --unset`, so we read and rewrite composer.json. @@ -146,6 +152,7 @@ class ComposerProjectTemplatesTest extends BuildTestBase { // Set up the template to use our path repos. Inclusion of metapackages is // reported differently, so we load up a separate set for them. $metapackage_path_repos = $this->getPathReposForType($this->getWorkspaceDirectory(), 'Metapackage'); + $this->assertArrayHasKey('drupal/core-recommended', $metapackage_path_repos); $path_repos = array_merge($metapackage_path_repos, $this->getPathReposForType($this->getWorkspaceDirectory(), 'Plugin')); // Always add drupal/core as a path repo. $path_repos['drupal/core'] = $this->getWorkspaceDirectory() . '/core'; @@ -153,28 +160,47 @@ class ComposerProjectTemplatesTest extends BuildTestBase { $this->executeCommand("composer config --no-interaction repositories.$name path $path", $package_dir); $this->assertCommandSuccessful(); } + // Fix up drupal/core-recommended so that it requires a stable version + // of drupal/core rather than a dev version. + $core_recommended_dir = 'composer/Metapackage/CoreRecommended'; + $this->executeCommand("composer remove --no-interaction drupal/core --no-update", $core_recommended_dir); + $this->assertCommandSuccessful(); + $this->executeCommand("composer require --no-interaction drupal/core:^$simulated_stable_version --no-update", $core_recommended_dir); + $this->assertCommandSuccessful(); + // Add our vendor package repository to our SUT's repositories section. + // Call it "local" (although the name does not matter). $this->executeCommand("composer config --no-interaction repositories.local composer file://" . $vendor_packages_path, $package_dir); $this->assertCommandSuccessful(); $repository_path = $this->getWorkspaceDirectory() . '/test_repository/packages.json'; - $this->makeTestPackage($repository_path, $core_version); + $this->makeTestPackage($repository_path, $simulated_stable_version); + $installed_composer_json = $this->getWorkspaceDirectory() . '/testproject/composer.json'; $autoloader = $this->getWorkspaceDirectory() . '/testproject' . $docroot_dir . '/autoload.php'; $this->assertFileNotExists($autoloader); - $this->executeCommand("COMPOSER_HOME=$composer_home COMPOSER_ROOT_VERSION=$core_version composer create-project --no-ansi $project testproject $core_version -s dev -vv --repository $repository_path"); + // At the moment, we are only testing stable versions. If we used a + // non-stable version instead of $simulated_stable_version, then we would + // also need to pass the --stability flag to composer create-project. + $this->executeCommand("COMPOSER_HOME=$composer_home COMPOSER_ROOT_VERSION=$simulated_stable_version composer create-project --no-ansi $project testproject -vvv --repository $repository_path"); $this->assertCommandSuccessful(); // Ensure we used the project from our codebase. - $this->assertErrorOutputContains("Installing $project ($core_version): Symlinking from $package_dir"); + $this->assertErrorOutputContains("Installing $project ($simulated_stable_version): Symlinking from $package_dir"); // Ensure that we used drupal/core from our codebase. This probably means // that drupal/core-recommended was added successfully by the project. - $this->assertErrorOutputContains("Installing drupal/core ($core_version): Symlinking from"); + $this->assertErrorOutputContains("Installing drupal/core ($simulated_stable_version): Symlinking from"); // Verify that there is an autoloader. This is written by the scaffold // plugin, so its existence assures us that scaffolding happened. $this->assertFileExists($autoloader); + // Verify that the minimum stability in the installed composer.json file + // is 'stable' + $this->assertFileExists($installed_composer_json); + $composer_json_contents = file_get_contents($installed_composer_json); + $this->assertStringContainsString('"minimum-stability": "stable"', $composer_json_contents); + // In order to verify that Composer used the path repos for our project, we // have to get the requirements from the project composer.json so we can // reconcile our expectations. @@ -195,15 +221,31 @@ class ComposerProjectTemplatesTest extends BuildTestBase { // we still must check that their installed version matches // COMPOSER_CORE_VERSION. if (array_key_exists($package_name, $metapackage_path_repos)) { - $this->assertErrorOutputContains("Installing $package_name ($core_version)"); + $this->assertErrorOutputContains("Installing $package_name ($simulated_stable_version)"); } else { - $this->assertErrorOutputContains("Installing $package_name ($core_version): Symlinking from"); + $this->assertErrorOutputContains("Installing $package_name ($simulated_stable_version): Symlinking from"); } } } } + /** + * Assert that the VERSION constant in Drupal.php is the expected value. + * + * @param string $expectedVersion + * @param string $dir + */ + protected function assertDrupalVersion($expectedVersion, $dir) { + $drupal_php_path = $dir . '/core/lib/Drupal.php'; + $this->assertFileExists($drupal_php_path); + + // Read back the Drupal version that was set and assert it matches expectations. + $this->executeCommand("php -r 'include \"$drupal_php_path\"; print \Drupal::VERSION;'"); + $this->assertCommandSuccessful(); + $this->assertCommandOutputContains($expectedVersion); + } + /** * Creates a test package that points to the templates. * @@ -267,7 +309,10 @@ JSON; $full_path = "$root/$path"; // We are building a set of path repositories to projects in the vendor // directory, so we will skip any project that does not exist in vendor. - if (is_dir($full_path)) { + // Also skip the projects that are symlinked in vendor. These are in our + // metapackage. They will be represented as path repositories in the test + // project's composer.json. + if (is_dir($full_path) && !is_link($full_path)) { $packages['packages'][$name] = [ $version => [ "name" => $name,