Commit d72c5836 authored by catch's avatar catch

Issue #3135247 by greg.1.anderson, alexpott, ridhimaabrol24, Mixologic,...

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
parent 7bb639fd
......@@ -5,7 +5,9 @@
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();
// 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 @@ public static function ensureComposerVersion() {
* @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()
......@@ -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`:
......@@ -1628,6 +1628,7 @@ svgz
......@@ -5,7 +5,6 @@
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()
->in($workspace_directory . '/composer/' . $subdir);
$path_repos = Composer::composerSubprojectPaths($workspace_directory, $subdir);
$data = [];
/* @var $path_repo \SplFileInfo */
......@@ -78,10 +74,7 @@ public function testVerifyTemplateTestProviderIsAccurate() {
$data = $this->provideTemplateCreateProject($root);
// Find all the templates.
$template_files = Finder::create()
->in($root . '/composer/Template');
$template_files = Composer::composerSubprojectPaths($root, 'Template');
$this->assertSame(count($template_files), count($data));
......@@ -114,6 +107,8 @@ public function testTemplateCreateProject($project, $package_dir, $docroot_dir)
// Make a working COMPOSER_HOME directory for setting global composer config
$composer_home = $this->getWorkspaceDirectory() . '/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 @@ public function testTemplateCreateProject($project, $package_dir, $docroot_dir)
// 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';
// Make a copy of the code to alter.
// Make a copy of the code to alter in the workspace directory.
// 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 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 @@ public function testTemplateCreateProject($project, $package_dir, $docroot_dir)
// 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 @@ public function testTemplateCreateProject($project, $package_dir, $docroot_dir)
$this->executeCommand("composer config --no-interaction repositories.$name path $path", $package_dir);
// 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->executeCommand("composer require --no-interaction drupal/core:^$simulated_stable_version --no-update", $core_recommended_dir);
// 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);
$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->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");
// 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.
// Verify that the minimum stability in the installed composer.json file
// is 'stable'
$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 @@ public function testTemplateCreateProject($project, $package_dir, $docroot_dir)
// we still must check that their installed version matches
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';
// Read back the Drupal version that was set and assert it matches expectations.
$this->executeCommand("php -r 'include \"$drupal_php_path\"; print \Drupal::VERSION;'");
* Creates a test package that points to the templates.
......@@ -267,7 +309,10 @@ protected function makeVendorPackage($repository_path) {
$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,
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment