From d72c58368b09e12fcba446c8e293e01a88424a9e Mon Sep 17 00:00:00 2001
From: catch <catch@35733.no-reply.drupal.org>
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 0a76fc717566..7f83591934cd 100644
--- a/composer/Composer.php
+++ b/composer/Composer.php
@@ -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();
+    $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 @@ 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()
+      ->files()
+      ->name('composer.json')
+      ->in("$root/composer/$subprojectType");
+  }
+
 }
diff --git a/composer/Template/README.txt b/composer/Template/README.txt
index 47c95f1c5cc5..31c316c19bfd 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 1576f93bea3b..eb17e6768c1d 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 9077e5665965..c407c84bf022 100644
--- a/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php
+++ b/core/tests/Drupal/BuildTests/Composer/Template/ComposerProjectTemplatesTest.php
@@ -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()
-      ->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 @@ public function testVerifyTemplateTestProviderIsAccurate() {
     $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 @@ 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';
     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 @@ 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';
     $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 @@ 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);
       $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 @@ public function testTemplateCreateProject($project, $package_dir, $docroot_dir)
         // 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 @@ 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,
-- 
GitLab